Browse Source

Merge branch 'mh' of http://124.70.32.114:3100/hanyuqing/KGPython into hanyuqing

# Conflicts:
#	vue/src/system/GraphDemo.vue
#	vue/src/system/GraphStyle.vue
hanyuqing
hanyuqing 3 months ago
parent
commit
7296db1d76
  1. 125
      vue/src/components/GraphToolbar.vue
  2. 229
      vue/src/system/GraphDemo.vue
  3. 403
      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('导出失败');
}

229
vue/src/system/GraphDemo.vue

@ -134,6 +134,13 @@
</div>
</div>
<div class="graph-viewport">
<GraphToolbar
ref="toolbarRef"
v-if="_graph"
:graph="_graph"
class="toolbar-position"
style="position: absolute; top: 40px; left: 750px; z-index: 1000; width: auto;"
/>
<div ref="graphContainer" class="graph-container" id="container"></div>
</div>
</section>
@ -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 `<div style="padding: 4px 8px; color: #b6b2b2; border-radius: 4px; font-size: 12px;">
${sourceName} ${rel} > ${targetName}
</div>`;
},
},])
},
{
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;

403
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>
@ -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 = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`;
// 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 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 += `<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.
nodes.forEach(node => {
const style = node.style || {};
const r = (style.size || 60) / 2;
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="${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.
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, // IDD
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 {
}
}
</script>
<style scoped>
/* 精准控制“应用全案”按钮 */
@ -1063,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;
@ -1238,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) {
@ -1254,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;
@ -1296,6 +1611,7 @@ export default {
height: auto;
margin-top: 4px;
}
.config-checkbox {
margin: 0;
cursor: pointer;
@ -1458,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;
@ -1487,6 +1804,22 @@ export default {
:deep(.el-dialog .el-input__inner) {
outline: none !important;
}
/* 强制覆盖 G6 工具栏样式 */
:deep(.g6-toolbar) {
height: 35px !important;
display: flex !important;
align-items: center !important;
padding: 0 10px !important;
}
:deep(.g6-toolbar-item) {
width: 20px !important;
height: 35px !important;
font-size: 15px !important;
margin: 0 4px !important;
}
</style>
<style>

Loading…
Cancel
Save