diff --git a/vue/src/components/GraphToolbar.vue b/vue/src/components/GraphToolbar.vue index 9ae4173..b83a14d 100644 --- a/vue/src/components/GraphToolbar.vue +++ b/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 = ``; + + // 3. 动态生成 Marker 定义 + svgContent += ``; + const colorList = Array.from(usedColors); + colorList.forEach((color, index) => { + svgContent += ` + + + `; + }); + svgContent += ``; + svgContent += ``; - // 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 += ``; + svgContent += ``; - // 渲染边文字 - 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 += `${labelText}`; + svgContent += `${labelText}`; } }); - // 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 += ``; + svgContent += ``; - if (node.data?.name) { - svgContent += `${node.data.name}`; + const name = node.label || node.data?.name || ""; + if (name) { + svgContent += `${name}`; } }); svgContent += ``; - // 保存文件逻辑保持不变... + // 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('导出失败'); } diff --git a/vue/src/system/GraphDemo.vue b/vue/src/system/GraphDemo.vue index 1439871..fe1b291 100644 --- a/vue/src/system/GraphDemo.vue +++ b/vue/src/system/GraphDemo.vue @@ -134,6 +134,13 @@
+
@@ -155,14 +162,18 @@ import { import {Graph, Tooltip} from '@antv/g6'; import Menu from "@/components/Menu.vue"; import {a} from "vue-router/dist/devtools-EWN81iOl.mjs"; +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}, + components: {Menu, GraphToolbar}, data() { return { + _graph: null, G6: null, // 添加这个 // 节点样式 nodeShowLabel: true, @@ -201,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: '药品', @@ -314,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() @@ -356,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, @@ -432,7 +443,7 @@ export default { }, beforeUnmount() { - if (this._graph!=null){ + if (this._graph != null) { this._graph.stopLayout(); this.clearGraphState(); this._graph.destroy() @@ -471,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) { @@ -639,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() { @@ -653,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); } @@ -682,7 +693,7 @@ export default { }, // 切换某个类别的显示状态 - toggleCategory (key){ + toggleCategory(key) { if (this.visibleCategories.has(key)) { this.visibleCategories.delete(key) } else { @@ -737,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() { @@ -818,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) } @@ -842,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(); @@ -893,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, @@ -939,7 +950,7 @@ export default { }, initGraph() { - if (this._graph!=null){ + if (this._graph != null) { this._graph.destroy() this._graph = null; } @@ -950,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) @@ -990,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, }, @@ -1038,7 +1049,7 @@ export default { shadowBlur: 10, opacity: 1 }, - highlight:{ + highlight: { stroke: '#FF5722', lineWidth: 4, opacity: 1 @@ -1046,7 +1057,7 @@ export default { inactive: { opacity: 0.8 }, - normal:{ + normal: { opacity: 1 } @@ -1094,14 +1105,14 @@ export default { inactive: { opacity: 0.8 }, - normal:{ + normal: { opacity: 1 } }, }, - data:this.defaultData, + data: this.defaultData, }); this.$nextTick(() => { @@ -1159,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) @@ -1195,16 +1206,73 @@ 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 `
${sourceName} — ${rel} —> ${targetName}
`; }, - },]) + }, + { + type: 'toolbar', + onClick: (id) => { + if (id === 'reset') { + // 1. 如果是重置,直接在本地执行方法 + this.localResetGraph(); + } 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: 'auto-fit', value: 'auto-fit', title: '聚焦'}, + {id: 'reset', value: 'reset', title: '重置'}, + {id: 'export', value: 'export', title: '导出图谱'}, + ]; + }, + }, + ]) } + }, + localResetGraph() { + // 1. 检查图表实例和数据是否存在 + if (!this._graph) return; + if (!this.defaultData || !this.defaultData.nodes) { + this.$message.warning("未找到可重置的数据源"); + return; + } + try { + // 2. 彻底清理鼠标指针状态 + const canvas = this._graph.getCanvas(); + if (canvas && typeof canvas.setCursor === 'function') { + canvas.setCursor('default'); + } + + // 3. 彻底销毁当前图谱实例 + // 销毁会移除所有 DOM 节点和事件监听,彻底解决 EventBoundary 问题 + this._graph.destroy(); + this._graph = null; + + // 4. 重新初始化 + this.$nextTick(() => { + // initGraph 内部会自动读取 this.defaultData + this.initGraph(); + + // 5. 重新构建分类索引(因为 destroy 也会清空之前的交互索引) + this.buildCategoryIndex(); + + this.$message.success("图谱已重置"); + }); + } catch (err) { + console.error('重置图谱失败:', err); + // 降级处理:如果销毁失败,尝试刷新页面(可选) + // location.reload(); + } }, updateGraph(data) { @@ -1218,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, @@ -1645,6 +1713,7 @@ button:hover { /deep/ .radio-check .el-radio__input.is-checked+.el-radio__label { color: #1890ff; } + /* 自定义下拉样式 */ .select-container { position: relative; @@ -1674,7 +1743,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; diff --git a/vue/src/system/GraphStyle.vue b/vue/src/system/GraphStyle.vue index 1c502f3..5011d83 100644 --- a/vue/src/system/GraphStyle.vue +++ b/vue/src/system/GraphStyle.vue @@ -53,7 +53,7 @@
- +
@@ -82,7 +82,7 @@
- +
@@ -90,7 +90,7 @@
- +
@@ -140,7 +140,7 @@
- +
@@ -169,7 +169,7 @@
- +
@@ -571,9 +571,10 @@ export default { this.tagStyles[labelEn] = currentStyle; this.updateAllElements(); - // 只有在编辑模式下才同步到数据库 + // 只有在“编辑模式”下才触发后端自动同步 if (this.editingConfigId) { if (this.saveTimer) clearTimeout(this.saveTimer); + const currentEditId = this.editingConfigId; this.saveTimer = setTimeout(async () => { try { @@ -679,19 +680,280 @@ export default { this.updateAllElements(); }, + // --- 第三种方法:XML 模板映射法 --- + async handleExportClick() { + if (!this._graph) return; + ElMessageBox.confirm( + '请选择您要导出的图片格式:', + '导出图谱', + { + confirmButtonText: '导出为 PNG', + cancelButtonText: '导出为 SVG', + distinguishCancelAndClose: true, + type: 'info', + draggable: true, + } + ).then(async () => { + try { + // PNG 导出保持官方推荐姿势 + const dataURL = await this._graph.toDataURL({ + type: 'image/png', + backgroundColor: '#ffffff' + }); + const link = document.createElement('a'); + link.href = dataURL; + link.download = `图谱_${Date.now()}.png`; + link.click(); + ElMessage.success('PNG 导出成功'); + } catch (e) { + ElMessage.error('PNG 导出失败'); + } + }).catch((action) => { + if (action === 'cancel') { + this.exportToSVGManual(); + } + }); + }, + + exportToSVGManual() { + const graph = this._graph; + if (!graph) return; + const { nodes, edges } = graph.getData(); + const width = graph.getSize()[0]; + const height = graph.getSize()[1]; + + 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 = ``; + + // 1. 定义动态颜色箭头 + svgContent += ``; + 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 += ` + + + `; + }); + svgContent += ``; + + svgContent += ``; + + // 2. 渲染连边 + 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; + + 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 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; + + } 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 += ``; + + const label = edge.data?.relationship?.properties?.label || style.labelText || ""; + if (style.edgeShowLabel !== false && label) { + svgContent += `${label}`; + } + }); + + // 3. 渲染节点 + nodes.forEach(node => { + const style = node.style || {}; + const r = (style.size || 60) / 2; + svgContent += ``; + if (style.labelText) { + svgContent += `${style.labelText}`; + } + }); + + svgContent += ``; + + // 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`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, + + initDraggableToolbar() { + const toolbar = document.querySelector('.draggable-toolbar'); + if (!toolbar) return; + let isDragging = false; + let startPos = { x: 0, y: 0 }; + let offset = { x: 0, y: 0 }; + const onMouseDown = (e) => { + if (e.target.closest('.g6-toolbar-item')) return; + isDragging = true; + startPos = { x: e.clientX, y: e.clientY }; + offset.x = e.clientX - toolbar.offsetLeft; + offset.y = e.clientY - toolbar.offsetTop; + toolbar.style.transition = 'none'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + e.preventDefault(); + }; + const onMouseMove = (e) => { + if (!isDragging) return; + let left = e.clientX - offset.x; + let top = e.clientY - offset.y; + left = Math.max(10, Math.min(window.innerWidth - toolbar.offsetWidth - 10, left)); + top = Math.max(10, Math.min(window.innerHeight - toolbar.offsetHeight - 10, top)); + toolbar.style.left = left + 'px'; + toolbar.style.top = top + 'px'; + toolbar.style.right = 'auto'; + }; + const onMouseUp = () => { + isDragging = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + toolbar.addEventListener('mousedown', onMouseDown); + }, + initGraph() { const container = this.$refs.graphContainer; if (!container || container.clientWidth === 0) return; this.defaultData.nodes.forEach(n => this._nodeLabelMap.set(n.id, n.data?.label)); if (this._graph) this._graph.destroy(); + const graph = new Graph({ - container, width: container.clientWidth, height: container.clientHeight || 600, + container, + width: container.clientWidth, + height: container.clientHeight || 600, layout: { type: 'radial', unitRadius: 100, preventOverlap: true, nodeSpacing: 50 }, behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element', 'hover-activate'], - autoFit: 'center', animation: false + autoFit: 'center', + animation: false, + plugins: [ + { type: 'history', key: 'history' }, + { + type: 'toolbar', + position: 'top-right', + className: 'g6-toolbar draggable-toolbar', + style: { marginRight: '320px', marginTop: '10px', cursor: 'move', zIndex: 999 }, + onClick: (id) => { + const historyPlugin = this._graph.getPluginInstance('history'); + switch (id) { + case 'zoom-in': this._graph.zoomBy(1.2); break; + case 'zoom-out': this._graph.zoomBy(0.8); break; + case 'undo': + if (historyPlugin && historyPlugin.canUndo()) historyPlugin.undo(); + break; + case 'redo': + if (historyPlugin && historyPlugin.canRedo()) historyPlugin.redo(); + break; + case 'auto-fit': this._graph.fitView(); break; + case 'reset': + const currentData = this._graph.getData(); + const cleanNodes = currentData.nodes.map(node => { + const { x, y, ...rest } = node; + if (rest.style) { + delete rest.style.x; + delete rest.style.y; + } + return rest; + }); + this._graph.zoomTo(1); + this._graph.translateTo([container.clientWidth / 2, container.clientHeight / 2]); + this._graph.setData({ + nodes: cleanNodes, + edges: currentData.edges + }); + this._graph.layout().then(() => { + this._graph.fitCenter(); + }); + ElMessage.success("已重置图谱位置"); + break; + case 'export': this.handleExportClick(); break; + } + }, + 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: '导出图谱' }, + ]; + } + } + ] }); this._graph = markRaw(graph); this.updateAllElements(); + this.$nextTick(() => { + setTimeout(this.initDraggableToolbar, 800); + }); }, getEffectiveStyleKey(label) { return CORE_LABELS.includes(label) ? label : 'Other'; @@ -772,7 +1034,7 @@ export default { } }; }); - this._graph.setData({ nodes, edges }); + this._graph.setData({nodes, edges}); this._graph.render(); }, safeNum(val, defaultVal = 1) { @@ -788,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); @@ -807,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(); @@ -839,35 +1103,53 @@ 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; }, async moveConfigToGroup(config, targetGroup) { + // 1. 拦截无效移动 + if (config.group_id === targetGroup.id) { + return ElMessage.info("该配置已在该方案中"); + } + if (!this.validateGroupConstraint(targetGroup.group_name, config.current_label, config.id)) return; + try { + // 2. 构造 Payload:直接传入 target_group_id const payload = { - id: config.id, canvas_name: config.canvas_name, group_name: targetGroup.group_name, - current_label: config.current_label, styles: config.styles, is_auto_save: false + id: config.id, + target_group_id: targetGroup.id, // 新增:直接传 ID,最准确D + canvas_name: config.canvas_name, + current_label: config.current_label, + styles: config.styles, + is_auto_save: false }; + const res = await saveGraphStyle(payload); if (res.code === 200) { ElMessage.success(`已移动至【${targetGroup.group_name}】`); - await this.fetchConfigs(); + await this.fetchConfigs(); // 刷新列表 + } else { + ElMessage.error(res.msg || "移动失败"); } - } catch (err) { ElMessage.error("操作失败"); } + } catch (err) { + ElMessage.error("移动操作异常"); + } }, - handleSaveClick() { this.fetchGroupNames(); this.saveForm.canvas_name = `${this.activeTags}_${Date.now()}`; + this.saveForm.group_name = ''; this.saveDialogVisible = true; }, @@ -876,13 +1158,19 @@ export default { if (!this.validateGroupConstraint(this.saveForm.group_name.trim(), this.activeTags)) return; const labelEn = tagToLabelMap[this.activeTags]; const payload = { - canvas_name: this.saveForm.canvas_name.trim(), group_name: this.saveForm.group_name.trim(), - current_label: this.activeTags, styles: { ...this.tagStyles[labelEn] }, is_auto_save: false + canvas_name: this.saveForm.canvas_name.trim(), + group_name: this.saveForm.group_name.trim(), + current_label: this.activeTags, + styles: {...this.tagStyles[labelEn]}, + is_auto_save: false // 标记为手动保存 }; const res = await saveGraphStyle(payload); if (res.code === 200) { ElMessage.success("保存成功"); this.saveDialogVisible = false; + this.editingConfigId = null; + this.editingConfigLabel = ''; + await this.fetchConfigs(); } }, @@ -898,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); @@ -908,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() { @@ -928,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) { @@ -938,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); @@ -961,6 +1271,7 @@ export default { } } +