diff --git a/vue/src/components/GraphToolbar.vue b/vue/src/components/GraphToolbar.vue index b83a14d..437bd58 100644 --- a/vue/src/components/GraphToolbar.vue +++ b/vue/src/components/GraphToolbar.vue @@ -270,12 +270,12 @@ export default { 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 d7be885..294cc22 100644 --- a/vue/src/system/GraphDemo.vue +++ b/vue/src/system/GraphDemo.vue @@ -139,7 +139,6 @@ v-if="_graph" :graph="_graph" class="toolbar-position" - style="position: absolute; top: 40px; left: 750px; z-index: 1000; width: auto;" />
@@ -161,16 +160,15 @@ import { } from "@/api/graph" 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 { markRaw } from 'vue'; +import {a} from "vue-router/dist/devtools-EWN81iOl.mjs"; 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, @@ -212,41 +210,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: '药品', @@ -325,7 +323,7 @@ export default { this.loadDrugTreeData() this.loadCheckTreeData() this.loadDrugSubjectTreeData() - this.treeData = this.diseaseICD10Tree + this.treeData=this.diseaseICD10Tree await this.$nextTick(); try { await this.getDefault() @@ -367,7 +365,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, @@ -443,7 +441,7 @@ export default { }, beforeUnmount() { - if (this._graph != null) { + if (this._graph!=null){ this._graph.stopLayout(); this.clearGraphState(); this._graph.destroy() @@ -482,7 +480,7 @@ export default { return {}; } }, - async getDefault() { + async getDefault(){ const response = await getGraphStyleActive(); const data = response.data; if (!Array.isArray(data) || data.length === 0) { @@ -650,7 +648,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() { @@ -664,26 +662,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); } @@ -693,7 +691,7 @@ export default { }, // 切换某个类别的显示状态 - toggleCategory(key) { + toggleCategory (key){ if (this.visibleCategories.has(key)) { this.visibleCategories.delete(key) } else { @@ -748,28 +746,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() { @@ -829,15 +827,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) } @@ -853,21 +851,21 @@ 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) { - if (!this._graph) return; + formatData(data){ this._graph.stopLayout(); this.clearGraphState(); + // === 1. 构建 nodeId → label 映射 === const nodeIdToEnLabel = {}; data.nodes.forEach(node => { @@ -904,7 +902,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, @@ -922,6 +920,7 @@ export default { nodes: updatedNodes, edges: updatedEdges }; + this.buildNodeLabelMap(updatedNodes); this.updateGraph(updatedData) this.buildCategoryIndex(); @@ -949,7 +948,7 @@ export default { }, initGraph() { - if (this._graph != null) { + if (this._graph!=null){ this._graph.destroy() this._graph = null; } @@ -960,7 +959,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 +999,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 +1047,7 @@ export default { shadowBlur: 10, opacity: 1 }, - highlight: { + highlight:{ stroke: '#FF5722', lineWidth: 4, opacity: 1 @@ -1056,7 +1055,7 @@ export default { inactive: { opacity: 0.8 }, - normal: { + normal:{ opacity: 1 } @@ -1104,14 +1103,14 @@ export default { inactive: { opacity: 0.8 }, - normal: { + normal:{ opacity: 1 } }, }, - data: this.defaultData, + data:this.defaultData, }); this.$nextTick(() => { @@ -1169,10 +1168,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) @@ -1184,7 +1183,7 @@ export default { graph.fitCenter({ padding: 40, duration: 1000 }); } }); - this._graph = graph + this._graph = markRaw(graph) this._graph.setPlugins([ { type: 'tooltip', // 只对节点启用,边不显示tooltip @@ -1194,7 +1193,7 @@ export default { console.log(e) const edge = items[0]; // 当前悬停的边 if (!edge) return ''; - const data = items[0].data + const data=items[0].data const sourceId = edge.source; const targetId = edge.target; @@ -1205,7 +1204,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 `
${sourceName} — ${rel} —> ${targetName} @@ -1215,11 +1214,7 @@ export default { { type: 'toolbar', onClick: (id) => { - if (id === 'reset') { - // 1. 如果是重置,直接在本地执行方法 - this.localResetGraph(); - } else if (this.$refs.toolbarRef) { - // 2. 其他功能(放大、导出等)继续交给组件处理 + if (this.$refs.toolbarRef) { this.$refs.toolbarRef.handleToolbarAction(id); } }, @@ -1228,52 +1223,13 @@ export default { {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) { if (!this._graph) return this._graph.setData(data) @@ -1285,7 +1241,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, @@ -1303,7 +1259,6 @@ export default { this.defaultData = updatedData this.updateGraph(updatedData) }, - updateAllEdges() { if (!this._graph) return const updatedEdges = this.defaultData.edges.map(edge => ({ @@ -1474,7 +1429,6 @@ button:hover { } .graph-container { - flex: 1; background: #fff; width: 100%; height: 100%; @@ -1613,10 +1567,14 @@ button:hover { flex: 1; border: 1px dashed #e2e8f0; border-radius: 12px; + position: relative !important; /* 必须:作为工具栏参照物 */ + overflow: hidden; display: flex; align-items: center; justify-content: center; color: #ccc; + background: #fff; + padding: 0 !important; } @@ -1712,7 +1670,6 @@ button:hover { /deep/ .radio-check .el-radio__input.is-checked+.el-radio__label { color: #1890ff; } - /* 自定义下拉样式 */ .select-container { position: relative; @@ -1742,7 +1699,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; @@ -1759,4 +1716,16 @@ button:hover { background-color: #f5f7fa; } +:deep(.custom-graph-toolbar) { + position: absolute !important; /* 脱离文档流,解决占位问题 */ + top: 0 !important; + right: 0 !important; + z-index: 1000 !important; + + background: rgba(255, 255, 255, 0.9) !important; + padding: 4px 8px !important; + border-radius: 8px !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important; + border: 1px solid #ebeef5 !important +} \ No newline at end of file diff --git a/vue/src/system/GraphQA.vue b/vue/src/system/GraphQA.vue index badd374..82c90f4 100644 --- a/vue/src/system/GraphQA.vue +++ b/vue/src/system/GraphQA.vue @@ -63,6 +63,12 @@
+
@@ -77,11 +83,12 @@ import {qaAnalyze} from "@/api/qa"; import {Graph} from "@antv/g6"; import {getGraph} from "@/api/graph"; import {getGraphStyleActive} from "@/api/style"; +import GraphToolbar from '@/components/GraphToolbar.vue'; export default { name: 'GraghQA', - components: {Menu}, + components: {Menu,GraphToolbar}, data() { return { query:"", @@ -244,47 +251,85 @@ export default { } }, - handleSearch(){ - this.isSending=true - this.answers=[] - if (this._graph){ + handleSearch() { + this.isSending = true + this.answers = [] + if (this._graph) { this._graph.clear() } - let data={ - text:this.query + let data = { + text: this.query } - this.queryRecord=this.query - this.query="" + this.queryRecord = this.query + this.query = "" // this.answers=[{"answer":"糖尿病患者应避免高糖食物,如糖果、甜点、含糖饮料等,以防止血糖波动。", // "result":{"nodes":[{"id":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","label":"糖尿病","type":"疾病"},{"id":"89f9498b-7a83-4361-889b-f96dfdfb802c","label":"血糖波动","type":"症状"},{"id":"03201620-ba9f-4957-b7d3-f89c5a115e37","label":"高糖食物","type":"其他类型"},{"id":"b0bace0a-eedc-485c-90d3-0a5378dc5556","label":"糖果","type":"其他类型"},{"id":"ffc12d7b-60e5-4ffa-a945-a0769f6e1047","label":"甜点","type":"其他类型"},{"id":"a7e94ee7-072b-456f-bc0c-354545851c38","label":"含糖饮料","type":"其他类型"}],"edges":[{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"03201620-ba9f-4957-b7d3-f89c5a115e37","label":"关联"},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"b0bace0a-eedc-485c-90d3-0a5378dc5556","label":"关联"},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"ffc12d7b-60e5-4ffa-a945-a0769f6e1047","label":"关联"},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"a7e94ee7-072b-456f-bc0c-354545851c38","label":"关联"},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"89f9498b-7a83-4361-889b-f96dfdfb802c","label":"导致"}]}}] // this.initGraph(this.answers[0].result) // this.formatData(this.answers[0].result) - qaAnalyze(data).then(res=>{ - this.answers=res - if(this.answers.length>0){ + qaAnalyze(data).then(res => { + this.answers = res + if (this.answers.length > 0) { this.initGraph(this.answers[0].result) } - this.isSending=false - }) + this.isSending = false + }).catch(err => { + console.error('接口失败,启动保底方案', err); + const mockData = { + nodes: [ + {id: "node1", label: "霍乱", data: {type: "疾病"}}, + {id: "node2", label: "腹泻", data: {type: "症状"}}, + {id: "node3", label: "脱水", data: {type: "疾病"}}, + {id: "node4", label: "呕吐", data: {type: "疾病"}}, + {id: "node5", label: "霍乱弧菌", data: {type: "病因"}}, + {id: "node6", label: "复方磺胺", data: {type: "药品"}}, + ], + edges: [ + {id: "e1", source: "node1", target: "node2", data: {label: "典型症状"}}, + {id: "e2", source: "node1", target: "node3", data: {label: "并发症"}}, + {id: "e3", source: "node1", target: "node4", data: {label: "并发症"}}, + {id: "e4", source: "node1", target: "node5", data: {label: "致病菌"}}, + {id: "e5", source: "node1", target: "node6", data: {label: "推荐用药"}}, + ] + }; + this.answers = [{answer: "连接失败,显示预览版。", result: mockData}]; + this.initGraph(this.answers[0].result); + this.isSending = false; + }); }, formatData(data){ // this._graph.stopLayout(); // this.clearGraphState(); - // === 1. 构建 nodeId → label 映射 === - const nodeIdToEnLabel = {}; + const typeMap = { '疾病': 'Disease', '药品': 'Drug', '药物': 'Drug', '症状': 'Symptom', '检查': 'Check', '病因': 'Cause' }; + const getStandardLabel = (rawType) => typeMap[rawType] || rawType; + + const nodeIdToData = {}; data.nodes.forEach(node => { - nodeIdToEnLabel[node.id] = node.data.type; // e.g. "Disease" + nodeIdToData[node.id] = { + enLabel: getStandardLabel(node.data.type), + rawType: node.data.type + }; }); // === 2. 处理节点:根据自身 label 设置样式 === const updatedNodes = data.nodes.map(node => { - const enLabel = node.data.type; + const enLabel = getStandardLabel(node.data.type); const styleConf = this.parsedStyles[enLabel] || {}; + // 💡 颜色映射逻辑:如果 styleConf 没给颜色,则按类型分配 + let fColor = styleConf.nodeFill; + if (!fColor) { + if (node.data.type === '疾病') fColor = '#EF4444'; + else if (node.data.type === '药品' || node.data.type === '药物') fColor = '#91cc75'; + else if (node.data.type === '症状') fColor = '#fac858'; + else if (node.data.type === '检查') fColor = '#336eee'; + else fColor = this.nodeFill; + } return { ...node, type: styleConf.nodeShape || this.nodeShape, + data: { ...node.data, label: enLabel, name: node.label }, style: { + ...node.style, size: styleConf.nodeSize || this.nodeSize, - fill: styleConf.nodeFill || this.nodeFill, + fill: fColor, stroke: styleConf.nodeStroke || this.nodeStroke, lineWidth: styleConf.nodeLineWidth || this.nodeLineWidth, label: styleConf.nodeShowLabel !== undefined ? styleConf.nodeShowLabel : true, @@ -297,15 +342,25 @@ export default { // === 3. 处理边:根据 source 节点的 label 设置样式 === const updatedEdges = data.edges.map(edge => { - console.log(edge) - const sourceEnLabel = nodeIdToEnLabel[edge.source]; // e.g. "Disease" - const styleConf = this.parsedStyles[sourceEnLabel] || {}; + const sourceInfo = nodeIdToData[edge.source]; + const styleConf = this.parsedStyles[sourceInfo.enLabel] || {}; + + // 💡 边颜色逻辑:跟随源节点类型 + let eStroke = styleConf.edgeStroke; + if (!eStroke) { + if (sourceInfo.rawType === '疾病') eStroke = 'rgba(239, 68, 68, 0.4)'; + else if (sourceInfo.rawType === '药品' || sourceInfo.rawType === '药物') eStroke = 'rgba(145, 204, 117, 0.4)'; + else if (sourceInfo.rawType === '症状') eStroke = 'rgba(250, 200, 88, 0.4)'; + else eStroke = this.edgeStroke; + } return { ...edge, id: edge.data?.relationship?.id || edge.id, type: styleConf.edgeType ||this.edgeType, + data: { ...edge.data, label: edge.data?.label || "" }, style: { + ...edge.style, endArrow: styleConf.edgeEndArrow !== undefined ? styleConf.edgeEndArrow : true, stroke: styleConf.edgeStroke || this.edgeStroke, lineWidth: styleConf.edgeLineWidth || this.edgeLineWidth, @@ -318,41 +373,49 @@ export default { }); // === 4. 更新图数据 === - let updatedData = { - nodes: updatedNodes, - edges: updatedEdges - }; - - this.updateGraph(updatedData) + const pureData = JSON.parse(JSON.stringify({ nodes: updatedNodes, edges: updatedEdges })); + this.updateGraph(pureData); }, updateGraph(data) { - if (!this._graph) return - - this._graph.setData(data) - this._graph.render() + if (!this._graph) return; + this._graph.setData(data); + this._graph.render(); }, initGraph(data) { + const typeMap = { '疾病': 'Disease', '药品': 'Drug', '药物': 'Drug', '症状': 'Symptom', '检查': 'Check', '病因': 'Cause' }; + const getStandardLabel = (rawType) => typeMap[rawType] || rawType; if (this._graph!=null){ this._graph.destroy() this._graph = null; } - console.log(data) // === 1. 构建 nodeId → label 映射 === - const nodeIdToEnLabel = {}; + const nodeIdToData = {}; data.nodes.forEach(node => { - nodeIdToEnLabel[node.id] = node.data.type; // e.g. "Disease" + nodeIdToData[node.id] = { + enLabel: getStandardLabel(node.data.type), + rawType: node.data.type + }; }); - console.log(nodeIdToEnLabel) // === 2. 处理节点:根据自身 label 设置样式 === const updatedNodes = data.nodes.map(node => { - const enLabel = node.data.type; + const enLabel = getStandardLabel(node.data.type); const styleConf = this.parsedStyles[enLabel] || {}; + let fColor = styleConf.nodeFill; + if (!fColor) { + if (node.data.type === '疾病') fColor = '#EF4444'; + else if (node.data.type === '药品' || node.data.type === '药物') fColor = '#91cc75'; + else if (node.data.type === '症状') fColor = '#fac858'; + else if (node.data.type === '检查') fColor = '#336eee'; + else fColor = this.nodeFill; + } return { ...node, type: styleConf.nodeShape || this.nodeShape, + data: { ...node.data, label: enLabel, name: node.label }, style: { + ...node.style, size: styleConf.nodeSize || this.nodeSize, - fill: styleConf.nodeFill || this.nodeFill, + fill: fColor, stroke: styleConf.nodeStroke || this.nodeStroke, lineWidth: styleConf.nodeLineWidth || this.nodeLineWidth, label: styleConf.nodeShowLabel !== undefined ? styleConf.nodeShowLabel : true, @@ -365,17 +428,26 @@ export default { // === 3. 处理边:根据 source 节点的 label 设置样式 === const updatedEdges = data.edges.map(edge => { - console.log(edge) - const sourceEnLabel = nodeIdToEnLabel[edge.source]; // e.g. "Disease" - const styleConf = this.parsedStyles[sourceEnLabel] || {}; + const sourceInfo = nodeIdToData[edge.source]; + const styleConf = this.parsedStyles[sourceInfo.enLabel] || {}; + + let eStroke = styleConf.edgeStroke; + if (!eStroke) { + if (sourceInfo.rawType === '疾病') eStroke = 'rgba(239, 68, 68, 0.4)'; + else if (sourceInfo.rawType === '药品' || sourceInfo.rawType === '药物') eStroke = 'rgba(145, 204, 117, 0.4)'; + else if (sourceInfo.rawType === '症状') eStroke = 'rgba(250, 200, 88, 0.4)'; + else eStroke = this.edgeStroke; + } return { ...edge, id: edge.data?.relationship?.id || edge.id, type: styleConf.edgeType ||this.edgeType, + data: { ...edge.data, label: edge.data?.label || "" }, style: { + ...edge.style, endArrow: styleConf.edgeEndArrow !== undefined ? styleConf.edgeEndArrow : true, - stroke: styleConf.edgeStroke || this.edgeStroke, + stroke: eStroke, lineWidth: styleConf.edgeLineWidth || this.edgeLineWidth, label: styleConf.edgeShowLabel !== undefined ? styleConf.edgeShowLabel : false, labelFontSize: styleConf.edgeFontSize || this.edgeFontSize, @@ -402,14 +474,27 @@ export default { container, width, height, + plugins: [ + { + type: 'toolbar', + key: 'g6-toolbar', + onClick: (id) => { + if (this.$refs.toolbarRef) this.$refs.toolbarRef.handleToolbarAction(id); + }, + getItems: () => [ + { id: 'zoom-in', value: 'zoom-in', title: '放大' }, + { id: 'zoom-out', value: 'zoom-out', title: '缩小' }, + { id: 'auto-fit', value: 'auto-fit', title: '聚焦' }, + { id: 'export', value: 'export', title: '导出图谱' }, + ], + }, + ], layout: { type: 'force', // 力导向布局 gravity: 0.3, // 重力系数,控制节点聚集程度 repulsion: 500, // 排斥力 attraction: 20, // 吸引力 preventOverlap: true // 防止节点重叠 - - }, behaviors: [ 'zoom-canvas', 'drag-element', 'click-select','focus-element', { @@ -724,4 +809,39 @@ export default { .dot-2 { animation-delay: -0.2s; } .dot-3 { animation-delay: 0s; } .dot-4 { animation-delay: 0.2s; } + +/* 右侧图谱外层容器 */ +.knowledge-graph { + position: relative !important; /* 保持相对定位,作为工具栏的参照物 */ + flex: 1; + height: 100%; + background-color: white; + border-radius: 16px; + padding: 0 !important; + box-shadow: 0 4px 12px rgba(0,0,0,0.05); + overflow: hidden; + display: flex; +} + +/* 图谱画布容器 */ +.graph-container { + width: 100% !important; + height: 100% !important; + margin: 0 !important; + padding: 0 !important; +} + +/* 工具栏浮动修饰 */ +:deep(.custom-graph-toolbar) { + position: absolute !important; /* 必须绝对定位,不占物理高度 */ + top: 0 !important; + right: 0 !important; + z-index: 9999 !important; + display: inline-flex !important; + background: rgba(255, 255, 255, 0.9) !important; + padding: 5px !important; + border-radius: 8px !important; + border: 1px solid #ebeef5 !important; + box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1) !important; +} \ No newline at end of file diff --git a/vue/src/system/GraphStyle.vue b/vue/src/system/GraphStyle.vue index 5011d83..2f91029 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 @@
- +
@@ -961,7 +961,10 @@ export default { updateAllElements() { if (!this._graph) return; + // 1. 获取当前状态快照 const currentActiveLabelEn = tagToLabelMap[this.activeTags]; + + // 2. 收集已应用的配置方案 const labelToAppliedConfigMap = {}; this.styleGroups.forEach(group => { group.configs.forEach(conf => { @@ -972,13 +975,33 @@ export default { }); }); - const hexToRgba = (hex, opacity) => { - if (!hex) return 'rgba(182, 178, 178, 0.5)'; - if (hex.startsWith('rgba')) return hex; - let r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r}, ${g}, ${b}, ${opacity})`; + // 3. 💡 增强版颜色转换函数:完美兼容 Hex 和 RGB + const hexToRgba = (color, opacity) => { + if (!color) return `rgba(182, 178, 178, ${opacity})`; + // 如果已经是 rgba,直接返回 + if (color.startsWith('rgba')) return color; + // A. 处理 rgb(116, 239, 68) 格式 + if (color.startsWith('rgb')) { + // 将 rgb 替换为 rgba,并在结尾插入透明度 + return color.replace('rgb', 'rgba').replace(')', `, ${opacity})`); + } + // B. 处理 #ffffff 格式 + if (color.startsWith('#')) { + try { + let r = parseInt(color.slice(1, 3), 16), + g = parseInt(color.slice(3, 5), 16), + b = parseInt(color.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; + } catch (e) { + console.error('❌ Hex 转换失败:', color); + return color; + } + } + // C. 兜底返回原色 + return color; }; + // 4. 处理节点 (Nodes) const nodes = this.defaultData.nodes.map(node => { const rawLabel = node.data?.label || ''; const effectiveKey = this.getEffectiveStyleKey(rawLabel); @@ -996,46 +1019,85 @@ export default { } return { - ...node, type: s?.nodeShape || 'circle', + ...node, + type: s?.nodeShape || 'circle', style: { - size: this.safeNum(s?.nodeSize, 60), fill: s?.nodeFill, stroke: s?.nodeStroke, - lineWidth: this.safeNum(s?.nodeLineWidth, 2), labelText: s?.nodeShowLabel ? (node.data?.name || '') : '', - labelFill: s?.nodeFontColor || '#ffffff', labelFontSize: this.safeNum(s?.nodeFontSize, 12), - labelFontFamily: s?.nodeFontFamily || 'Microsoft YaHei', labelPlacement: 'center', labelWordWrap: true, - labelMaxWidth: '150%', labelMaxLines: 3, labelTextOverflow: 'ellipsis', labelTextAlign: 'center', + size: this.safeNum(s?.nodeSize, 60), + fill: s?.nodeFill, + stroke: s?.nodeStroke, + lineWidth: this.safeNum(s?.nodeLineWidth, 2), + labelText: s?.nodeShowLabel ? (node.data?.name || '') : '', + labelFill: s?.nodeFontColor || '#ffffff', + labelFontSize: this.safeNum(s?.nodeFontSize, 12), + labelFontFamily: s?.nodeFontFamily || 'Microsoft YaHei', + labelPlacement: 'center', + labelWordWrap: true, + labelMaxWidth: '150%', + labelMaxLines: 3, + labelTextOverflow: 'ellipsis', + labelTextAlign: 'center', } }; }); - const edges = this.defaultData.edges.map(edge => { + // 5. 处理连边 (Edges) + const edges = this.defaultData.edges.map((edge, index) => { const sRawLabel = this._nodeLabelMap.get(edge.source); const effectiveKey = this.getEffectiveStyleKey(sRawLabel); let s; + let sourceFrom = ''; + if (effectiveKey === currentActiveLabelEn) { + sourceFrom = '实时面板控制'; s = { - edgeType: this.edgeType, edgeStroke: this.edgeStroke, edgeLineWidth: this.edgeLineWidth, - edgeEndArrow: this.edgeEndArrow, edgeShowLabel: this.edgeShowLabel, - edgeFontColor: this.edgeFontColor, edgeFontSize: this.edgeFontSize, edgeFontFamily: this.edgeFontFamily + edgeType: this.edgeType, + edgeStroke: this.edgeStroke, + edgeLineWidth: this.edgeLineWidth, + edgeEndArrow: this.edgeEndArrow, + edgeShowLabel: this.edgeShowLabel, + edgeFontColor: this.edgeFontColor, + edgeFontSize: this.edgeFontSize, + edgeFontFamily: this.edgeFontFamily }; } else { + sourceFrom = '缓存/方案配置'; s = labelToAppliedConfigMap[effectiveKey] || this.tagStyles[effectiveKey]; } - const strokeColor = hexToRgba(s?.edgeStroke || '#EF4444', 0.6); + // 使用修正后的颜色读取逻辑 + const finalRawColor = s?.edgeStroke || this.edgeStroke || INITIAL_FILL_MAP[effectiveKey] || '#EF4444'; + const strokeColor = hexToRgba(finalRawColor, 0.6); + + if (index === 0) { + console.log('3. [连边抽检] 第一条边颜色详情:'); + console.log(' - 原始输入:', finalRawColor); + console.log(' - 转换输出:', strokeColor); + } + return { - ...edge, type: s?.edgeType || 'line', + ...edge, + type: s?.edgeType || 'line', style: { - stroke: strokeColor, lineWidth: this.safeNum(s?.edgeLineWidth, 2), endArrow: s?.edgeEndArrow, + stroke: strokeColor, + lineWidth: this.safeNum(s?.edgeLineWidth, 2), + endArrow: s?.edgeEndArrow !== undefined ? s.edgeEndArrow : true, labelText: s?.edgeShowLabel ? (edge.data?.relationship?.properties?.label || '') : '', - labelFill: s?.edgeFontColor || '#666', labelFontSize: this.safeNum(s?.edgeFontSize, 10), - labelFontFamily: s?.edgeFontFamily || 'Microsoft YaHei', labelBackground: true, - labelBackgroundFill: '#fff', labelBackgroundOpacity: 0.7 + labelFill: s?.edgeFontColor || '#666', + labelFontSize: this.safeNum(s?.edgeFontSize, 10), + labelFontFamily: s?.edgeFontFamily || 'Microsoft YaHei', + labelBackground: true, + labelBackgroundFill: '#fff', + labelBackgroundOpacity: 0.7 } }; }); - this._graph.setData({nodes, edges}); + + // 6. 提交渲染 + this._graph.setData({ nodes, edges }); this._graph.render(); + console.log('4. 图谱渲染指令已发出'); + console.groupEnd(); }, safeNum(val, defaultVal = 1) { const n = Number(val); @@ -1050,7 +1112,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); @@ -1103,27 +1165,19 @@ 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 = { @@ -1186,8 +1240,7 @@ 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); @@ -1774,7 +1827,6 @@ 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;