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 1439871..294cc22 100644 --- a/vue/src/system/GraphDemo.vue +++ b/vue/src/system/GraphDemo.vue @@ -134,6 +134,12 @@
+
@@ -154,15 +160,18 @@ import { } from "@/api/graph" import {Graph, Tooltip} from '@antv/g6'; import Menu from "@/components/Menu.vue"; +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 {getGraphStyleActive} from "@/api/style"; export default { name: 'Display', - components: {Menu}, + components: {Menu,GraphToolbar}, data() { return { + _graph: null, G6: null, // 添加这个 // 节点样式 nodeShowLabel: true, @@ -1174,7 +1183,7 @@ export default { graph.fitCenter({ padding: 40, duration: 1000 }); } }); - this._graph = graph + this._graph = markRaw(graph) this._graph.setPlugins([ { type: 'tooltip', // 只对节点启用,边不显示tooltip @@ -1201,12 +1210,26 @@ export default { ${sourceName} — ${rel} —> ${targetName} `; }, - },]) + }, + { + type: 'toolbar', + onClick: (id) => { + if (this.$refs.toolbarRef) { + 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: 'export', value: 'export', title: '导出图谱'}, + ]; + }, + }, + ]) } - - }, - updateGraph(data) { if (!this._graph) return this._graph.setData(data) @@ -1236,7 +1259,6 @@ export default { this.defaultData = updatedData this.updateGraph(updatedData) }, - updateAllEdges() { if (!this._graph) return const updatedEdges = this.defaultData.edges.map(edge => ({ @@ -1407,7 +1429,6 @@ button:hover { } .graph-container { - flex: 1; background: #fff; width: 100%; height: 100%; @@ -1546,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; } @@ -1691,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 46c917c..2f91029 100644 --- a/vue/src/system/GraphStyle.vue +++ b/vue/src/system/GraphStyle.vue @@ -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 } }; }); + + // 6. 提交渲染 this._graph.setData({ nodes, edges }); this._graph.render(); + console.log('4. 图谱渲染指令已发出'); + console.groupEnd(); }, safeNum(val, defaultVal = 1) { const n = Number(val);