Browse Source

svg修改

mh
hanyuqing 3 months ago
parent
commit
8397cfff53
  1. 125
      vue/src/components/GraphToolbar.vue
  2. 371
      vue/src/system/GraphDemo.vue
  3. 214
      vue/src/system/GraphStyle.vue

125
vue/src/components/GraphToolbar.vue

@ -127,9 +127,14 @@ export default {
},
exportToSVGManual() {
const graph = this.graph;
if (!graph) return;
const graph = this._graph || this.graph;
if (!graph) {
this.$message.error("未找到图表实例");
return;
}
const { nodes, edges } = graph.getData();
// 1.
const bBox = graph.getCanvas().getRoot().getRenderBounds();
const padding = 60;
const minX = bBox.min[0] - padding;
@ -139,7 +144,6 @@ export default {
const vWidth = maxX - minX;
const vHeight = maxY - minY;
// 1.
const colorMap = {
'Disease': { fill: '#EF4444', stroke: '#B91C1C', edge: '#EF4444' },
'Drug': { fill: '#91cc75', stroke: '#047857', edge: '#91cc75' },
@ -148,39 +152,101 @@ export default {
'Other': { fill: '#59d1d4', stroke: '#40999b', edge: '#59d1d4' }
};
// 2.
const usedColors = new Set();
edges.forEach(edge => {
const s = nodes.find(n => n.id === edge.source);
const sourceType = s?.data?.label || 'Other';
const color = edge.style?.stroke || colorMap[sourceType]?.edge || '#cccccc';
usedColors.add(color);
});
let svgContent = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="${vWidth}" height="${vHeight}" viewBox="${minX} ${minY} ${vWidth} ${vHeight}">`;
// 3. Marker
svgContent += `<defs>`;
const colorList = Array.from(usedColors);
colorList.forEach((color, index) => {
svgContent += `
<marker id="arrow-${index}" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="${color}" />
</marker>`;
});
svgContent += `</defs>`;
svgContent += `<rect x="${minX}" y="${minY}" width="${vWidth}" height="${vHeight}" fill="#ffffff" />`;
// 2.
// 4.
edges.forEach(edge => {
const s = nodes.find(n => n.id === edge.source);
const t = nodes.find(n => n.id === edge.target);
if (!s || !t) return;
// ( style G6 )
const x1 = s.style?.x || 0;
const y1 = s.style?.y || 0;
const x2 = t.style?.x || 0;
const y2 = t.style?.y || 0;
const x1 = s.style?.x || 0, y1 = s.style?.y || 0;
const x2 = t.style?.x || 0, y2 = t.style?.y || 0;
const nodeRadius = (t.style?.size || 50) / 2;
// --- ---
// edge: { style: { stroke: (d) => ... } }
const sourceType = s.data?.label || 'Other'; // (Disease/Drug)
const strokeColor = colorMap[sourceType]?.edge || '#cccccc';
const sourceType = s.data?.label || 'Other';
const strokeColor = edge.style?.stroke || colorMap[sourceType]?.edge || '#cccccc';
const markerId = `arrow-${colorList.indexOf(strokeColor)}`;
let pathD = "";
let tx, ty;
const dx = x2 - x1, dy = y2 - y1;
const dist = Math.sqrt(dx * dx + dy * dy);
const nx = -dy / dist, ny = dx / dist;
const curveOffset = edge.style?.curveOffset || 40;
// --- 线 ---
if (edge.type === 'cubic' && dist > 0) {
//
const cp1X = x1 + dx / 3 + nx * curveOffset;
const cp1Y = y1 + dy / 3 + ny * curveOffset;
const cp2X = x1 + (dx * 2) / 3 + nx * curveOffset;
const cp2Y = y1 + (dy * 2) / 3 + ny * curveOffset;
const angle = Math.atan2(y2 - cp2Y, x2 - cp2X);
const realX2 = x2 - Math.cos(angle) * (nodeRadius + 1);
const realY2 = y2 - Math.sin(angle) * (nodeRadius + 1);
pathD = `M ${x1} ${y1} C ${cp1X} ${cp1Y} ${cp2X} ${cp2Y} ${realX2} ${realY2}`;
tx = (x1 + cp1X + cp2X + x2) / 4;
ty = (y1 + cp1Y + cp2Y + y2) / 4;
} else if (edge.type === 'quadratic' && dist > 0) {
//
const cpX = (x1 + x2) / 2 + nx * curveOffset;
const cpY = (y1 + y2) / 2 + ny * curveOffset;
const angle = Math.atan2(y2 - cpY, x2 - cpX);
const realX2 = x2 - Math.cos(angle) * (nodeRadius + 1);
const realY2 = y2 - Math.sin(angle) * (nodeRadius + 1);
pathD = `M ${x1} ${y1} Q ${cpX} ${cpY} ${realX2} ${realY2}`;
tx = (x1 + cpX * 2 + x2) / 4;
ty = (y1 + cpY * 2 + y2) / 4;
} else {
// 线
const angle = Math.atan2(y2 - y1, x2 - x1);
const realX2 = x2 - Math.cos(angle) * nodeRadius;
const realY2 = y2 - Math.sin(angle) * nodeRadius;
pathD = `M ${x1} ${y1} L ${realX2} ${realY2}`;
tx = (x1 + x2) / 2;
ty = (y1 + y2) / 2;
}
// 线 ( stroke-width )
svgContent += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${strokeColor}" stroke-width="2" opacity="0.4" />`;
svgContent += `<path d="${pathD}" stroke="${strokeColor}" stroke-width="2" fill="none" opacity="0.6" marker-end="url(#${markerId})" />`;
//
const labelText = edge.data?.relationship?.properties?.label || edge.data?.label || "";
//
const labelText = edge.data?.label || edge.label || "";
if (labelText) {
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
svgContent += `<text x="${mx}" y="${my - 4}" fill="#666666" font-size="10" font-family="Microsoft YaHei" text-anchor="middle">${labelText}</text>`;
svgContent += `<text x="${tx}" y="${ty - 8}" fill="#666666" font-size="10" font-family="Microsoft YaHei" text-anchor="middle">${labelText}</text>`;
}
});
// 3.
// 5.
nodes.forEach(node => {
const type = node.data?.label || 'Other';
const colors = colorMap[type] || colorMap['Other'];
@ -188,25 +254,28 @@ export default {
const x = node.style?.x || 0;
const y = node.style?.y || 0;
svgContent += `<circle cx="${x}" cy="${y}" r="${radius}" fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="1.5" />`;
svgContent += `<circle cx="${x}" cy="${y}" r="${radius}" fill="${node.style?.fill || colors.fill}" stroke="${node.style?.stroke || colors.stroke}" stroke-width="1.5" />`;
if (node.data?.name) {
svgContent += `<text x="${x}" y="${y}" fill="#ffffff" font-size="12" font-family="Microsoft YaHei" text-anchor="middle" dominant-baseline="middle">${node.data.name}</text>`;
const name = node.label || node.data?.name || "";
if (name) {
svgContent += `<text x="${x}" y="${y}" fill="#ffffff" font-size="11" font-family="Microsoft YaHei" text-anchor="middle" dominant-baseline="middle">${name}</text>`;
}
});
svgContent += `</svg>`;
// ...
// 6.
try {
const blob = new Blob([svgContent], {type: 'image/svg+xml;charset=utf-8'});
const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `矢量图谱_${Date.now()}.svg`;
link.download = `医疗图谱导出_${Date.now()}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
this.$message.success('全量矢量图导出成功');
this.$message.success('高精度矢量图导出成功');
} catch (err) {
this.$message.error('导出失败');
}

371
vue/src/system/GraphDemo.vue

@ -4,113 +4,113 @@
<Menu
:initial-active="0"
/>
<main class="main-body">
<header class="top-nav">
<div class="search-container">
<input v-model="searchKeyword" type="text" placeholder="搜索......" @keydown.enter="search"/>
<img src="@/assets/搜索.png" style="cursor: pointer" class="search-btn" @click="search"/>
</div>
<div class="legend-box">
<div v-for="(item, index) in legendItems" :key="index" class="legend-item">
<div
:style="{
<main class="main-body">
<header class="top-nav">
<div class="search-container">
<input v-model="searchKeyword" type="text" placeholder="搜索......" @keydown.enter="search"/>
<img src="@/assets/搜索.png" style="cursor: pointer" class="search-btn" @click="search"/>
</div>
<div class="legend-box">
<div v-for="(item, index) in legendItems" :key="index" class="legend-item">
<div
:style="{
backgroundColor: item.color,
opacity: visibleCategories.has(item.key) ? 1 : 0.3 //
}"
class="color-block"
@click="toggleCategory(item.key)"
></div>
<span style=" font-size: 12px;color: rgb(0, 0, 0);font-weight: 600;">{{ item.label }}</span>
class="color-block"
@click="toggleCategory(item.key)"
></div>
<span style=" font-size: 12px;color: rgb(0, 0, 0);font-weight: 600;">{{ item.label }}</span>
</div>
</div>
</header>
<section class="main-content">
<div class="disease-container">
<div class="disease-header" :style="headerStyle">
<div style="display: flex;align-items: center;">
<div class="d-title"><img :src="iconSrc" class="d-icon"/>
<span v-if="typeRadio=== 'Disease'">疾病信息</span>
<span v-if="typeRadio=== 'Drug'">药品信息</span>
<span v-if="typeRadio=== 'Check'">检查信息</span>
</div>
<div v-if="typeRadio=== 'Disease'" class="d-count" :style="headerLabel">{{diseaseCount.toLocaleString()}}</div>
<div v-if="typeRadio=== 'Drug'" class="d-count" :style="headerLabel">{{ drugCount.toLocaleString() }}</div>
<div v-if="typeRadio=== 'Check'" class="d-count" :style="headerLabel">{{checkCount.toLocaleString()}}</div>
</div>
</header>
<section class="main-content">
<div class="disease-container">
<div class="disease-header" :style="headerStyle">
<div style="display: flex;align-items: center;">
<div class="d-title"><img :src="iconSrc" class="d-icon"/>
<span v-if="typeRadio=== 'Disease'">疾病信息</span>
<span v-if="typeRadio=== 'Drug'">药品信息</span>
<span v-if="typeRadio=== 'Check'">检查信息</span>
</div>
<div v-if="typeRadio=== 'Disease'" class="d-count" :style="headerLabel">{{diseaseCount.toLocaleString()}}</div>
<div v-if="typeRadio=== 'Drug'" class="d-count" :style="headerLabel">{{ drugCount.toLocaleString() }}</div>
<div v-if="typeRadio=== 'Check'" class="d-count" :style="headerLabel">{{checkCount.toLocaleString()}}</div>
</div>
<div>
<!-- 替换开始自定义下拉选择器使用 div 实现 -->
<div class="select-container" @click="toggleDropdown">
<!-- 当前选中项显示 -->
<div class="select-display">
<div>
<!-- 替换开始自定义下拉选择器使用 div 实现 -->
<div class="select-container" @click="toggleDropdown">
<!-- 当前选中项显示 -->
<div class="select-display">
<span style="color: #000; background: rgb(255 255 255 / 64%);
padding: 2px 13px;
border-radius: 15px;">{{ currentTypeLabel }}</span>
<img src="../assets/下拉11.png" class="dropdown-icon" />
</div>
<!-- 下拉菜单 -->
<div v-show="isDropdownOpen" class="select-dropdown">
<div
v-for="item in typeOptions"
:key="item.value"
class="select-option"
@click.stop="selectType(item.value)"
>
{{ item.label }}
</div>
</div>
<img src="../assets/下拉11.png" class="dropdown-icon" />
</div>
<!-- 下拉菜单 -->
<div v-show="isDropdownOpen" class="select-dropdown">
<div
v-for="item in typeOptions"
:key="item.value"
class="select-option"
@click.stop="selectType(item.value)"
>
{{ item.label }}
</div>
<!-- 替换结束 -->
</div>
</div>
<!-- <div>-->
<!-- <el-radio-group v-model="typeRadio" @change="changeTree">-->
<!-- <el-radio-->
<!-- value="Disease"-->
<!-- :class="{'radio-disease': typeRadio === 'Disease'}"-->
<!-- >疾病</el-radio>-->
<!-- <el-radio-->
<!-- value="Drug"-->
<!-- :class="{'radio-drug': typeRadio === 'Drug'}"-->
<!-- >药品</el-radio>-->
<!-- <el-radio-->
<!-- value="Check"-->
<!-- :class="{'radio-check': typeRadio === 'Check'}"-->
<!-- >检查</el-radio>-->
<!-- </el-radio-group>-->
<!-- </div>-->
<div v-if="typeRadio === 'Disease'">
<el-radio-group v-model="DiseaseRadio" @change="changeTree">
<el-radio
value="ICD10"
:class="{'radio-disease': typeRadio === 'Disease'}"
>ICD10</el-radio>
<el-radio
value="Department"
:class="{'radio-disease': typeRadio === 'Disease'}"
>科室</el-radio>
<el-radio
value="SZM"
:class="{'radio-disease': typeRadio === 'Disease'}"
>首字母</el-radio>
</el-radio-group>
<!-- 替换结束 -->
</div>
</div>
<!-- <div>-->
<!-- <el-radio-group v-model="typeRadio" @change="changeTree">-->
<!-- <el-radio-->
<!-- value="Disease"-->
<!-- :class="{'radio-disease': typeRadio === 'Disease'}"-->
<!-- >疾病</el-radio>-->
<!-- <el-radio-->
<!-- value="Drug"-->
<!-- :class="{'radio-drug': typeRadio === 'Drug'}"-->
<!-- >药品</el-radio>-->
<!-- <el-radio-->
<!-- value="Check"-->
<!-- :class="{'radio-check': typeRadio === 'Check'}"-->
<!-- >检查</el-radio>-->
<!-- </el-radio-group>-->
<!-- </div>-->
<div v-if="typeRadio === 'Disease'">
<el-radio-group v-model="DiseaseRadio" @change="changeTree">
<el-radio
value="ICD10"
:class="{'radio-disease': typeRadio === 'Disease'}"
>ICD10</el-radio>
<el-radio
value="Department"
:class="{'radio-disease': typeRadio === 'Disease'}"
>科室</el-radio>
<el-radio
value="SZM"
:class="{'radio-disease': typeRadio === 'Disease'}"
>首字母</el-radio>
</el-radio-group>
</div>
<div v-if="typeRadio === 'Drug'">
<el-radio-group v-model="DrugRadio" @change="changeTree">
<el-radio
value="Subject"
:class="{'radio-drug': typeRadio === 'Drug'}"
>药物分类</el-radio>
<el-radio
value="SZM"
:class="{'radio-drug': typeRadio === 'Drug'}"
>首字母</el-radio>
</el-radio-group>
</div>
<div v-if="typeRadio === 'Drug'">
<el-radio-group v-model="DrugRadio" @change="changeTree">
<el-radio
value="Subject"
:class="{'radio-drug': typeRadio === 'Drug'}"
>药物分类</el-radio>
<el-radio
value="SZM"
:class="{'radio-drug': typeRadio === 'Drug'}"
>首字母</el-radio>
</el-radio-group>
</div>
<div class="disease-body">
</div>
<div class="disease-body">
<div class="tree">
<el-tree
@ -167,9 +167,10 @@ import GraphToolbar from "@/components/GraphToolbar.vue";
import Fuse from 'fuse.js';
import {ElMessage} from "element-plus";
import {getGraphStyleActive} from "@/api/style";
export default {
name: 'Display',
components: {Menu,GraphToolbar},
components: {Menu, GraphToolbar},
data() {
return {
_graph: null,
@ -211,41 +212,41 @@ export default {
children: 'children',
label: 'title' // el-tree
},
typeRadio:"Disease",
DiseaseRadio:"ICD10",
drugTree:[],
diseaseDepartTree:[],
diseaseICD10Tree:[],
diseaseSZMTree:[],
checkTree:[],
typeRadio: "Disease",
DiseaseRadio: "ICD10",
drugTree: [],
diseaseDepartTree: [],
diseaseICD10Tree: [],
diseaseSZMTree: [],
checkTree: [],
legendItems: [
{ key: 'Disease', label: '疾病', color: '#EF4444' },
{ key: 'Drug', label: '药品', color: '#91cc75' },
{ key: 'Check', label: '检查', color: '#336eee' },
{ key: 'Symptom', label: '症状', color: '#fac858' },
{ key: 'Other', label: '其他', color: '#59d1d4' }
{key: 'Disease', label: '疾病', color: '#EF4444'},
{key: 'Drug', label: '药品', color: '#91cc75'},
{key: 'Check', label: '检查', color: '#336eee'},
{key: 'Symptom', label: '症状', color: '#fac858'},
{key: 'Other', label: '其他', color: '#59d1d4'}
],
visibleCategories: new Set(), //
diseaseCount:0,
drugCount:0,
checkCount:0,
diseaseCount: 0,
drugCount: 0,
checkCount: 0,
originalNodeStyles: new Map(), //
originalEdgeStyles: new Map(), //
searchResults: {
nodes: [],
edges: []
},
searchKeyword:"",
drugSubjectTree:[],
DrugRadio:"Subject",
searchKeyword: "",
drugSubjectTree: [],
DrugRadio: "Subject",
isDropdownOpen: false,
typeOptions: [
{ value: 'Disease', label: '疾病' },
{ value: 'Drug', label: '药品' },
{ value: 'Check', label: '检查' }
{value: 'Disease', label: '疾病'},
{value: 'Drug', label: '药品'},
{value: 'Check', label: '检查'}
],
configs:[],
parsedStyles:{},
configs: [],
parsedStyles: {},
enToZhLabelMap: {
Disease: '疾病',
Drug: '药品',
@ -324,7 +325,7 @@ export default {
this.loadDrugTreeData()
this.loadCheckTreeData()
this.loadDrugSubjectTreeData()
this.treeData=this.diseaseICD10Tree
this.treeData = this.diseaseICD10Tree
await this.$nextTick();
try {
await this.getDefault()
@ -366,7 +367,7 @@ export default {
return {
...edge,
id: edge.data?.relationship?.id || edge.id,
type: styleConf.edgeType ||this.edgeType,
type: styleConf.edgeType || this.edgeType,
style: {
endArrow: styleConf.edgeEndArrow !== undefined ? styleConf.edgeEndArrow : true,
stroke: styleConf.edgeStroke || this.edgeStroke,
@ -442,7 +443,7 @@ export default {
},
beforeUnmount() {
if (this._graph!=null){
if (this._graph != null) {
this._graph.stopLayout();
this.clearGraphState();
this._graph.destroy()
@ -481,7 +482,7 @@ export default {
return {};
}
},
async getDefault(){
async getDefault() {
const response = await getGraphStyleActive();
const data = response.data;
if (!Array.isArray(data) || data.length === 0) {
@ -649,7 +650,7 @@ export default {
// 4
const firstMatch = nodeIds[0] || edgeIds[0];
if (firstMatch && (this._graph.hasNode(firstMatch) || this._graph.hasEdge(firstMatch))) {
this._graph.focusElement(firstMatch, { animation: true, duration: 600 });
this._graph.focusElement(firstMatch, {animation: true, duration: 600});
}
},
clearSearchHighlight() {
@ -663,26 +664,26 @@ export default {
this._graph.setElementState(id, 'normal', true);
});
},
getCount(){
getCount().then(res=>{
this.diseaseCount=res.Disease
this.drugCount=res.Drug
this.checkCount=res.Check
getCount() {
getCount().then(res => {
this.diseaseCount = res.Disease
this.drugCount = res.Drug
this.checkCount = res.Check
console.log(res)
})
},
buildCategoryIndex() {
const index = {};
if (this._graph!=null){
if (this._graph != null) {
const nodes = this._graph.getNodeData() //
nodes.forEach(node => {
console.log(node.data.label)
const category = node.data.label; // label
if(category=='Drug'||category=='Symptom'||
category=='Disease'||category=='Check'){
if (category == 'Drug' || category == 'Symptom' ||
category == 'Disease' || category == 'Check') {
if (!index[category]) index[category] = [];
index[category].push(node.id);
}else{
} else {
if (!index["Other"]) index["Other"] = [];
index["Other"].push(node.id);
}
@ -692,7 +693,7 @@ export default {
},
//
toggleCategory (key){
toggleCategory(key) {
if (this.visibleCategories.has(key)) {
this.visibleCategories.delete(key)
} else {
@ -747,28 +748,28 @@ export default {
}
},
changeTree(){
if(this.typeRadio=="Disease"){
if(this.DiseaseRadio=="ICD10") {
this.treeData=this.diseaseICD10Tree
changeTree() {
if (this.typeRadio == "Disease") {
if (this.DiseaseRadio == "ICD10") {
this.treeData = this.diseaseICD10Tree
}
if(this.DiseaseRadio=="Department") {
this.treeData=this.diseaseDepartTree
if (this.DiseaseRadio == "Department") {
this.treeData = this.diseaseDepartTree
}
if(this.DiseaseRadio=="SZM") {
this.treeData=this.diseaseSZMTree
if (this.DiseaseRadio == "SZM") {
this.treeData = this.diseaseSZMTree
}
}
if(this.typeRadio=="Drug") {
if(this.DrugRadio=="Subject") {
this.treeData=this.drugSubjectTree
if (this.typeRadio == "Drug") {
if (this.DrugRadio == "Subject") {
this.treeData = this.drugSubjectTree
}
if(this.DrugRadio=="SZM") {
this.treeData=this.drugTree
if (this.DrugRadio == "SZM") {
this.treeData = this.drugTree
}
}
if(this.typeRadio=="Check") {
this.treeData=this.checkTree
if (this.typeRadio == "Check") {
this.treeData = this.checkTree
}
},
async loadDiseaseICD10TreeData() {
@ -828,15 +829,15 @@ export default {
const response = await getGraph(data); // Promise
this.formatData(response)
}
if(data.level=="category"||
data.level=="subcategory"||
data.level=="diagnosis"){
data.type="Disease"
if (data.level == "category" ||
data.level == "subcategory" ||
data.level == "diagnosis") {
data.type = "Disease"
const response = await getGraph(data); // Promise
this.formatData(response)
}
if(data.type === "Disease"){
data.type="Disease"
if (data.type === "Disease") {
data.type = "Disease"
const response = await getGraph(data); // Promise
this.formatData(response)
}
@ -852,17 +853,17 @@ export default {
// 1.
this._graph.getNodeData().forEach(node => {
this._graph.setElementState(node.id,[]);
this._graph.setElementState(node.id, []);
});
this._graph.getEdgeData().forEach(edge => {
this._graph.setElementState(edge.id,[]);
this._graph.setElementState(edge.id, []);
});
// 2. pending
// clearTimeout
},
formatData(data){
formatData(data) {
this._graph.stopLayout();
this.clearGraphState();
@ -903,7 +904,7 @@ export default {
return {
...edge,
id: edge.data?.relationship?.id || edge.id,
type: styleConf.edgeType ||this.edgeType,
type: styleConf.edgeType || this.edgeType,
style: {
endArrow: styleConf.edgeEndArrow !== undefined ? styleConf.edgeEndArrow : true,
stroke: styleConf.edgeStroke || this.edgeStroke,
@ -949,7 +950,7 @@ export default {
},
initGraph() {
if (this._graph!=null){
if (this._graph != null) {
this._graph.destroy()
this._graph = null;
}
@ -960,7 +961,7 @@ export default {
console.log(this._nodeLabelMap)
const container = this.$refs.graphContainer;
if (!container) return;
if (container!=null){
if (container != null) {
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
console.log(width)
@ -1000,8 +1001,8 @@ export default {
easing: 'ease-in-out', //
},
},
behaviors: [ 'zoom-canvas', 'drag-element',
'click-select','focus-element', {
behaviors: ['zoom-canvas', 'drag-element',
'click-select', 'focus-element', {
type: 'hover-activate',
degree: 1,
},
@ -1048,7 +1049,7 @@ export default {
shadowBlur: 10,
opacity: 1
},
highlight:{
highlight: {
stroke: '#FF5722',
lineWidth: 4,
opacity: 1
@ -1056,7 +1057,7 @@ export default {
inactive: {
opacity: 0.8
},
normal:{
normal: {
opacity: 1
}
@ -1104,14 +1105,14 @@ export default {
inactive: {
opacity: 0.8
},
normal:{
normal: {
opacity: 1
}
},
},
data:this.defaultData,
data: this.defaultData,
});
this.$nextTick(() => {
@ -1169,10 +1170,10 @@ export default {
// });
graph.on('node:click', (evt) => {
const nodeItem = evt.target.id; //
let node=graph.getNodeData(nodeItem).data
let data={
label:node.name,
type:node.label
let node = graph.getNodeData(nodeItem).data
let data = {
label: node.name,
type: node.label
}
getGraph(data).then(response => {
console.log(response)
@ -1205,7 +1206,7 @@ export default {
const sourceName = sourceNode?.data.name || sourceId;
const targetName = targetNode?.data.name || targetId;
const rel = data.relationship.properties.label || '关联';
const rel = data.relationship.properties.label || '关联';
return `<div style="padding: 4px 8px; color: #b6b2b2; border-radius: 4px; font-size: 12px;">
${sourceName} ${rel} > ${targetName}
@ -1214,31 +1215,26 @@ export default {
},
{
type: 'toolbar',
onClick: (id) =>
{
onClick: (id) => {
if (id === 'reset') {
// 1.
this.localResetGraph();
}
else if (this.$refs.toolbarRef) {
} else if (this.$refs.toolbarRef) {
// 2.
this.$refs.toolbarRef.handleToolbarAction(id);
}
},
getItems: () => {
return [
{ id: 'zoom-in', value: 'zoom-in', title: '放大' },
{ id: 'zoom-out', value: 'zoom-out', title: '缩小' },
{ id: 'undo', value: 'undo', title: '撤销' },
{ id: 'redo', value: 'redo', title: '重做' },
{ id: 'auto-fit', value: 'auto-fit', title: '聚焦' },
{ id: 'reset', value: 'reset', title: '重置' },
{ id: 'export', value: 'export', title: '导出图谱' },
{id: 'zoom-in', value: 'zoom-in', title: '放大'},
{id: 'zoom-out', value: 'zoom-out', title: '缩小'},
{id: 'auto-fit', value: 'auto-fit', title: '聚焦'},
{id: 'reset', value: 'reset', title: '重置'},
{id: 'export', value: 'export', title: '导出图谱'},
];
},
},
// history
{ type: 'history', key: 'history' }])
])
}
},
localResetGraph() {
@ -1290,7 +1286,7 @@ export default {
const updatedNodes = this.defaultData.nodes.map(node => ({
...node,
type: this.nodeShape,
style:{
style: {
size: this.nodeSize,
fill: this.nodeFill,
stroke: this.nodeStroke,
@ -1548,6 +1544,7 @@ button:hover {
width: 16px;
}
/*.search-btn:hover {
background-color: rgba(34, 101, 244, 0.64);
border-radius: 50%;
@ -1709,7 +1706,6 @@ button:hover {
}
/* 自定义选中后的样式 */
/deep/ .radio-disease .el-radio__input.is-checked + .el-radio__label {
color: rgb(153, 10, 0);
@ -1722,6 +1718,7 @@ button:hover {
/deep/ .radio-check .el-radio__input.is-checked + .el-radio__label {
color: #1890ff;
}
/* 自定义下拉样式 */
.select-container {
position: relative;
@ -1751,7 +1748,7 @@ button:hover {
background: rgba(255, 255, 255, 0.64);
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
min-width: 80px;
color: #000;

214
vue/src/system/GraphStyle.vue

@ -53,7 +53,7 @@
<label>字体颜色</label>
<div class="color-picker-border">
<el-color-picker v-model="nodeFontColor" show-alpha class="square-picker"/>
<!-- <input v-model="nodeFontColor" type="color" class="square-picker"/>-->
<!-- <input v-model="nodeFontColor" type="color" class="square-picker"/>-->
</div>
</div>
@ -82,7 +82,7 @@
<label>填充颜色</label>
<div class="color-picker-border">
<el-color-picker v-model="nodeFill" show-alpha class="square-picker"/>
<!-- <input v-model="nodeFill" type="color" class="square-picker"/>-->
<!-- <input v-model="nodeFill" type="color" class="square-picker"/>-->
</div>
</div>
@ -90,7 +90,7 @@
<label>边框颜色</label>
<div class="color-picker-border">
<el-color-picker v-model="nodeStroke" show-alpha class="square-picker"/>
<!-- <input v-model="nodeStroke" type="color" class="square-picker"/>-->
<!-- <input v-model="nodeStroke" type="color" class="square-picker"/>-->
</div>
</div>
@ -140,7 +140,7 @@
<label>字体颜色</label>
<div class="color-picker-border">
<el-color-picker v-model="edgeFontColor" class="square-picker" show-alpha />
<!-- <input v-model="edgeFontColor" type="color" class="square-picker"/>-->
<!-- <input v-model="edgeFontColor" type="color" class="square-picker"/>-->
</div>
</div>
@ -169,7 +169,7 @@
<label>线条颜色</label>
<div class="color-picker-border">
<el-color-picker v-model="edgeStroke" class="square-picker" show-alpha />
<!-- <input v-model="edgeStroke" type="color" class="square-picker"/>-->
<!-- <input v-model="edgeStroke" type="color" class="square-picker"/>-->
</div>
</div>
</div>
@ -717,66 +717,130 @@ export default {
exportToSVGManual() {
const graph = this._graph;
if (!graph) return;
const { nodes, edges } = graph.getData();
const width = graph.getSize()[0];
const height = graph.getSize()[1];
// 1. SVG /
const usedColors = new Set();
edges.forEach(edge => { if (edge.style?.stroke) usedColors.add(edge.style.stroke); });
if (usedColors.size === 0) usedColors.add('#cccccc');
let svgContent = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`;
svgContent += `<rect width="100%" height="100%" fill="#ffffff" />`; //
// 2.
// 1.
svgContent += `<defs>`;
Array.from(usedColors).forEach((color, index) => {
const hexColor = color.includes('rgba') ? color.replace(/rgba?\((\d+),\s*(\d+),\s*(\d+).*/, 'rgb($1,$2,$3)') : color;
svgContent += `
<marker id="arrow-${index}" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="${hexColor}" />
</marker>`;
});
svgContent += `</defs>`;
svgContent += `<rect width="100%" height="100%" fill="#ffffff" />`;
// 2.
edges.forEach(edge => {
const sourceNode = nodes.find(n => n.id === edge.source);
const targetNode = nodes.find(n => n.id === edge.target);
if (!sourceNode || !targetNode) return;
const x1 = sourceNode.style.x || 0;
const y1 = sourceNode.style.y || 0;
const x2 = targetNode.style.x || 0;
const y2 = targetNode.style.y || 0;
const s = nodes.find(n => n.id === edge.source);
const t = nodes.find(n => n.id === edge.target);
if (!s || !t) return;
const x1 = s.style?.x || 0, y1 = s.style?.y || 0;
const x2 = t.style?.x || 0, y2 = t.style?.y || 0;
const nodeRadius = (t.style?.size || 60) / 2;
const style = edge.style || {};
const stroke = style.stroke || '#ccc';
const lineWidth = style.lineWidth || 1;
// 线
svgContent += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${lineWidth}" />`;
const colorIdx = Array.from(usedColors).indexOf(stroke);
const markerId = `arrow-${colorIdx !== -1 ? colorIdx : 0}`;
let pathD = "";
let tx, ty; //
const dx = x2 - x1, dy = y2 - y1;
const dist = Math.sqrt(dx * dx + dy * dy);
const nx = -dy / dist, ny = dx / dist;
// --- ---
if (edge.type === 'cubic') {
// 💡 线 (Cubic Bezier)
const offset = style.curveOffset || 40;
// G6
const cp1X = x1 + dx / 3 + nx * offset;
const cp1Y = y1 + dy / 3 + ny * offset;
const cp2X = x1 + (dx * 2) / 3 + nx * offset;
const cp2Y = y1 + (dy * 2) / 3 + ny * offset;
// cp2
const angle = Math.atan2(y2 - cp2Y, x2 - cp2X);
const realX2 = x2 - Math.cos(angle) * (nodeRadius + 1);
const realY2 = y2 - Math.sin(angle) * (nodeRadius + 1);
pathD = `M ${x1} ${y1} C ${cp1X} ${cp1Y} ${cp2X} ${cp2Y} ${realX2} ${realY2}`;
//
tx = (x1 + cp1X + cp2X + x2) / 4;
ty = (y1 + cp1Y + cp2Y + y2) / 4;
} else if (edge.type === 'quadratic') {
// 💡 线 (Quadratic Bezier)
const offset = style.curveOffset || 40;
const cpX = (x1 + x2) / 2 + nx * offset;
const cpY = (y1 + y2) / 2 + ny * offset;
const angle = Math.atan2(y2 - cpY, x2 - cpX);
const realX2 = x2 - Math.cos(angle) * (nodeRadius + 1);
const realY2 = y2 - Math.sin(angle) * (nodeRadius + 1);
pathD = `M ${x1} ${y1} Q ${cpX} ${cpY} ${realX2} ${realY2}`;
tx = (x1 + cpX * 2 + x2) / 4;
ty = (y1 + cpY * 2 + y2) / 4;
//
if (style.labelText) {
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
svgContent += `<text x="${mx}" y="${my}" fill="${style.labelFill || '#000'}" font-size="${style.labelFontSize || 10}" text-anchor="middle" dominant-baseline="middle">${style.labelText}</text>`;
} else {
// 💡 线 (Line)
const angle = Math.atan2(y2 - y1, x2 - x1);
const realX2 = x2 - Math.cos(angle) * nodeRadius;
const realY2 = y2 - Math.sin(angle) * nodeRadius;
pathD = `M ${x1} ${y1} L ${realX2} ${realY2}`;
tx = (x1 + x2) / 2;
ty = (y1 + y2) / 2;
}
// --- ---
const markerAttr = style.endArrow ? `marker-end="url(#${markerId})"` : "";
svgContent += `<path d="${pathD}" stroke="${stroke}" stroke-width="${style.lineWidth || 2}" fill="none" opacity="0.6" ${markerAttr} />`;
const label = edge.data?.relationship?.properties?.label || style.labelText || "";
if (style.edgeShowLabel !== false && label) {
svgContent += `<text x="${tx}" y="${ty - 8}" fill="${style.labelFill || '#666'}" font-size="${style.labelFontSize || 10}" font-family="Microsoft YaHei" text-anchor="middle">${label}</text>`;
}
});
// 3.
// 3.
nodes.forEach(node => {
const style = node.style || {};
const x = style.x || 0;
const y = style.y || 0;
const r = (style.size || 60) / 2;
const fill = style.fill || '#EF4444';
const stroke = style.stroke || '#B91C1C';
const lineWidth = style.lineWidth || 2;
//
svgContent += `<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="${lineWidth}" />`;
//
svgContent += `<circle cx="${style.x}" cy="${style.y}" r="${r}" fill="${style.fill}" stroke="${style.stroke}" stroke-width="${style.lineWidth || 2}" />`;
if (style.labelText) {
svgContent += `<text x="${x}" y="${y}" fill="${style.labelFill || '#fff'}" font-size="${style.labelFontSize || 12}" font-family="${style.labelFontFamily || 'Arial'}" text-anchor="middle" dominant-baseline="middle">${style.labelText}</text>`;
svgContent += `<text x="${style.x}" y="${style.y}" fill="${style.labelFill || '#fff'}" font-size="${style.labelFontSize || 12}" font-family="Microsoft YaHei" text-anchor="middle" dominant-baseline="middle">${style.labelText}</text>`;
}
});
svgContent += `</svg>`;
// 4.
// 4.
const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `矢量图谱_${Date.now()}.svg`;
link.download = `医疗图谱导出_${Date.now()}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
ElMessage.success(' SVG 导出成功');
},
initDraggableToolbar() {
@ -970,7 +1034,7 @@ export default {
}
};
});
this._graph.setData({ nodes, edges });
this._graph.setData({nodes, edges});
this._graph.render();
},
safeNum(val, defaultVal = 1) {
@ -986,7 +1050,7 @@ export default {
...conf,
styles: typeof conf.styles === 'string' ? JSON.parse(conf.styles) : conf.styles
}));
return { ...group, configs: uniqueConfigs };
return {...group, configs: uniqueConfigs};
});
const activeGroup = this.styleGroups.find(g => g.is_active);
@ -1005,7 +1069,9 @@ export default {
this.applyStylesToPanel(currentActiveConf.styles);
this.editingConfigId = currentActiveConf.id;
this.editingConfigLabel = currentActiveConf.current_label;
this.$nextTick(() => { this.isInitialEcho = false; });
this.$nextTick(() => {
this.isInitialEcho = false;
});
}
}
this.updateAllElements();
@ -1037,12 +1103,14 @@ export default {
const isLabelExist = group.configs.some(c => c.current_label === labelName && c.id !== excludeId);
if (isLabelExist) {
ElMessageBox.alert(`方案【${groupName}】中已存在【${labelName}】标签的配置。`, '校验失败', { type: 'error' }).catch(() => {});
ElMessageBox.alert(`方案【${groupName}】中已存在【${labelName}】标签的配置。`, '校验失败', {type: 'error'}).catch(() => {
});
return false;
}
if (group.configs.length >= 5 && !group.configs.some(c => c.id === excludeId)) {
ElMessageBox.alert(`方案【${groupName}】已满(上限5个)。`, '校验失败', { type: 'error' }).catch(() => {});
ElMessageBox.alert(`方案【${groupName}】已满(上限5个)。`, '校验失败', {type: 'error'}).catch(() => {
});
return false;
}
return true;
@ -1093,7 +1161,7 @@ export default {
canvas_name: this.saveForm.canvas_name.trim(),
group_name: this.saveForm.group_name.trim(),
current_label: this.activeTags,
styles: { ...this.tagStyles[labelEn] },
styles: {...this.tagStyles[labelEn]},
is_auto_save: false //
};
const res = await saveGraphStyle(payload);
@ -1118,7 +1186,8 @@ export default {
const missingTags = REQUIRED_TAGS.filter(tag => !new Set(currentLabels).has(tag));
if (missingTags.length > 0) {
this.isInitialEcho = false;
return ElMessageBox.alert(`该方案配置不完整,缺失:${missingTags.join('、')}`, '提示', { type: 'warning' }).catch(() => {});
return ElMessageBox.alert(`该方案配置不完整,缺失:${missingTags.join('、')}`, '提示', {type: 'warning'}).catch(() => {
});
}
this.usingConfigIds = group.configs.map(c => c.id);
const res = await applyGraphStyleGroup(group.id);
@ -1128,7 +1197,10 @@ export default {
if (group.configs.length > 0) this.handleEditConfig(group.configs[0]);
ElMessage.success(`已应用方案【${group.group_name}`);
}
} catch (err) { this.isInitialEcho = false; ElMessage.error("切换失败"); }
} catch (err) {
this.isInitialEcho = false;
ElMessage.error("切换失败");
}
},
resetStyle() {
@ -1148,8 +1220,12 @@ export default {
try {
await ElMessageBox.confirm('确定删除吗?', '提示');
const res = await deleteGraphStyle(id);
if (res.code === 200) { ElMessage.success("删除成功"); this.fetchConfigs(); }
} catch (err) { }
if (res.code === 200) {
ElMessage.success("删除成功");
this.fetchConfigs();
}
} catch (err) {
}
},
async deleteGroup(groupId) {
@ -1158,21 +1234,35 @@ export default {
try {
await ElMessageBox.confirm('确定删除全案吗?', '提示');
const res = await deleteGraphStyleGroup(groupId);
if (res.code === 200) { ElMessage.success("已删除"); this.fetchConfigs(); }
} catch (err) { }
if (res.code === 200) {
ElMessage.success("已删除");
this.fetchConfigs();
}
} catch (err) {
}
},
async handleUnifiedBatchDelete() {
if (this.checkedConfigIds.length === 0 && this.checkedGroupIds.length === 0) return;
try {
await ElMessageBox.confirm('确定批量删除吗?', '提示');
if (this.checkedGroupIds.length > 0) { for (const gid of this.checkedGroupIds) await deleteGraphStyleGroup(gid); }
if (this.checkedConfigIds.length > 0) { await batchDeleteGraphStyle({ ids: this.checkedConfigIds }); }
ElMessage.success("成功"); this.clearSelection(); this.fetchConfigs();
} catch (e) { }
if (this.checkedGroupIds.length > 0) {
for (const gid of this.checkedGroupIds) await deleteGraphStyleGroup(gid);
}
if (this.checkedConfigIds.length > 0) {
await batchDeleteGraphStyle({ids: this.checkedConfigIds});
}
ElMessage.success("成功");
this.clearSelection();
this.fetchConfigs();
} catch (e) {
}
},
clearSelection() { this.checkedConfigIds = []; this.checkedGroupIds = []; },
clearSelection() {
this.checkedConfigIds = [];
this.checkedGroupIds = [];
},
handleResize() {
if (this._graph && this.$refs.graphContainer) {
this._graph.setSize(this.$refs.graphContainer.clientWidth, this.$refs.graphContainer.clientHeight);
@ -1284,7 +1374,11 @@ export default {
padding-bottom: 10px;
margin-top: 10px;
}
:deep(.el-collapse){ --el-collapse-border-color: transparent;}
:deep(.el-collapse) {
--el-collapse-border-color: transparent;
}
.tag-pill {
flex-shrink: 0;
padding: 1px 10px;
@ -1459,8 +1553,8 @@ export default {
flex: 1;
overflow-y: auto;
padding-bottom: 100px;
scrollbar-width: none; /* 针对 Firefox */
-ms-overflow-style: none; /* 针对 IE 和 Edge */
scrollbar-width: none; /* 针对 Firefox */
-ms-overflow-style: none; /* 针对 IE 和 Edge */
}
:deep(.el-collapse-item__content) {
@ -1475,7 +1569,7 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
background-color:#f6f9fc !important;
background-color: #f6f9fc !important;
padding: 7px 15px;
margin-bottom: 10px;
border-radius: 6px;
@ -1517,6 +1611,7 @@ export default {
height: auto;
margin-top: 4px;
}
.config-checkbox {
margin: 0;
cursor: pointer;
@ -1679,6 +1774,7 @@ export default {
border-color: #e6e6e6 !important;
color: #333 !important;
}
:deep(.el-message-box__btns .el-button--primary) {
background-color: #1559f3 !important;
border-color: #1559f3 !important;

Loading…
Cancel
Save