You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

1505 lines
46 KiB

<template>
<div class="knowledge-graph-container">
<Menu :initial-active="4"/>
<div class="control-panel">
<div class="panel-header-container">
<div class="panel-header">
<span class="header-title custom-title-style">图谱样式控制</span>
<i class="el-icon-setting setting-icon" style="color: #94a3b8; cursor: pointer;"></i>
</div>
<div class="header-line"></div>
</div>
<div class="tag-filters">
<div
v-for="tag in ['疾病','药品','检查','症状','其他']"
:key="tag"
class="tag-pill"
:class="{ 'is-active': activeTags === tag }"
@click="handleTagClick(tag)"
>
{{ tag }}
</div>
</div>
<div class="section">
<div class="section-title">节点控制</div>
<div class="form-group-flex">
<label class="checkbox-label">显示节点标签</label>
<input v-model="nodeShowLabel" type="checkbox" class="theme-checkbox"/>
</div>
<div class="form-group">
<label>字体名称</label>
<select v-model="nodeFontFamily">
<option value="Microsoft YaHei, sans-serif">微软雅黑</option>
<option value="SimSun, serif">宋体(SimSun)</option>
<option value="SimHei, sans-serif">黑体(SimHei)</option>
<option value="Arial, sans-serif">Arial</option>
</select>
</div>
<div class="form-group">
<label>字体大小</label>
<div class="slider-wrapper">
<input v-model.number="nodeFontSize" type="range" min="10" max="24" step="1" class="theme-slider"/>
<span class="val-text-black">{{ nodeFontSize }}px</span>
</div>
</div>
<div class="color-picker-item">
<label>字体颜色</label>
<div class="color-picker-border">
<input v-model="nodeFontColor" type="color" class="square-picker"/>
</div>
</div>
<div class="form-group">
<label>图形</label>
<select v-model="nodeShape">
<option value="circle">圆形</option>
<option value="diamond">菱形</option>
<option value="triangle">三角形</option>
<option value="rect">矩形</option>
</select>
</div>
<div class="form-group">
<label>尺寸</label>
<input
:value="nodeSize"
type="number"
placeholder="30-100"
@blur="validateNodeSize($event)"
@keyup.enter="$event.target.blur()"
/>
</div>
<div class="color-picker-item">
<label>填充颜色</label>
<div class="color-picker-border">
<input v-model="nodeFill" type="color" class="square-picker"/>
</div>
</div>
<div class="color-picker-item">
<label>边框颜色</label>
<div class="color-picker-border">
<input v-model="nodeStroke" type="color" class="square-picker"/>
</div>
</div>
<div class="form-group">
<label>边框尺寸</label>
<input
:value="nodeLineWidth"
type="number"
placeholder="1-5"
@blur="validateNodeLineWidth($event)"
@keyup.enter="$event.target.blur()"
/>
</div>
</div>
<div class="section">
<div class="section-title">连边控制</div>
<div class="form-group-flex">
<label class="checkbox-label">显示连边标签</label>
<input v-model="edgeShowLabel" type="checkbox" class="theme-checkbox"/>
</div>
<div class="form-group-flex">
<label class="checkbox-label">显示端点箭头</label>
<input v-model="edgeEndArrow" type="checkbox" class="theme-checkbox"/>
</div>
<div class="form-group">
<label>字体名称</label>
<select v-model="edgeFontFamily">
<option value="Microsoft YaHei, sans-serif">微软雅黑</option>
<option value="SimSun, serif">宋体</option>
<option value="SimHei, sans-serif">黑体</option>
<option value="Arial, sans-serif">Arial</option>
</select>
</div>
<div class="form-group">
<label>字体大小</label>
<div class="slider-wrapper">
<input v-model.number="edgeFontSize" type="range" min="8" max="16" step="1" class="theme-slider"/>
<span class="val-text-black">{{ edgeFontSize }}px</span>
</div>
</div>
<div class="color-picker-item">
<label>字体颜色</label>
<div class="color-picker-border">
<input v-model="edgeFontColor" type="color" class="square-picker"/>
</div>
</div>
<div class="form-group">
<label>连边类型</label>
<select v-model="edgeType">
<option value="line">直线</option>
<option value="polyline">折线</option>
<option value="cubic">贝塞尔曲线</option>
<option value="quadratic">二次贝塞尔曲线</option>
</select>
</div>
<div class="form-group">
<label>线粗细</label>
<input
:value="edgeLineWidth"
type="number"
placeholder="1-5"
@blur="validateEdgeLineWidth($event)"
@keyup.enter="$event.target.blur()"
/>
</div>
<div class="color-picker-item">
<label>线条颜色</label>
<div class="color-picker-border">
<input v-model="edgeStroke" type="color" class="square-picker"/>
</div>
</div>
</div>
<div class="button-footer">
<button class="btn-confirm-save" @click="handleSaveClick">保存当前配置</button>
<button class="btn-reset-style" @click="resetStyle">重置样式</button>
</div>
</div>
<div ref="graphContainer" class="graph-container" id="container"></div>
<div class="config-list-panel">
<div class="panel-header-container">
<div class="panel-header">
<span class="header-title custom-title-style">图谱方案管理</span>
<div class="header-actions">
<i class="el-icon-refresh refresh-icon" title="刷新列表" @click="fetchConfigs"></i>
</div>
</div>
<div class="header-line"></div>
</div>
<div class="config-list">
<div v-if="styleGroups.length === 0" class="empty-text">暂无保存的方案</div>
<el-collapse v-model="activeCollapseNames">
<el-collapse-item v-for="group in styleGroups" :key="group.id" :name="group.id">
<template #title>
<div class="group-header-slot">
<input type="checkbox" :value="group.id" v-model="checkedGroupIds" @click.stop class="config-checkbox"
style="margin-right: 8px;"/>
<span class="group-name-text">
{{ group.group_name }}
</span>
<el-button
v-if="!group.is_active"
size="small"
type="primary"
plain
@click.stop="applyWholeGroup(group)">应用全案
</el-button>
<el-button
v-else
size="small"
type="info"
disabled
plain
@click.stop>已应用
</el-button>
<i class="el-icon-delete group-del" @click.stop="deleteGroup(group.id)"></i>
</div>
</template>
<el-dropdown
trigger="contextmenu"
placement="bottom-start"
v-for="item in group.configs"
:key="item.id"
style="width: 100%; display: block;"
>
<div
class="config-card"
:class="{
'card-using': usingConfigIds.includes(item.id),
'card-checked': checkedConfigIds.includes(item.id)
}"
@click="toggleApplyConfig(item)"
>
<div class="card-left">
<div class="checkbox-wrapper">
<input type="checkbox" :value="item.id" v-model="checkedConfigIds" @click.stop class="config-checkbox"/>
</div>
<div class="card-info">
<div class="card-title-row">
<span class="card-name">{{ item.canvas_name }}</span>
<span v-if="usingConfigIds.includes(item.id)" class="status-badge">已应用</span>
</div>
<span class="card-tag">标签: {{ item.current_label }}</span>
</div>
</div>
<div class="card-right">
<i class="el-icon-delete delete-icon" @click.stop="deleteSingleConfig(item.id)"></i>
</div>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled style="font-weight: bold; color: #333;">移动至方案:</el-dropdown-item>
<el-dropdown-item
v-for="targetGroup in styleGroups.filter(g => g.id !== group.id)"
:key="targetGroup.id"
@click="moveConfigToGroup(item, targetGroup)"
>
{{ targetGroup.group_name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-collapse-item>
</el-collapse>
</div>
<div class="batch-actions-fixed">
<div class="selection-info" v-if="checkedConfigIds.length > 0 || checkedGroupIds.length > 0">
已选中 {{ checkedConfigIds.length }} 项配置 / {{ checkedGroupIds.length }} 个方案
</div>
<div class="batch-button-group">
<button class="btn-batch-delete-final"
:disabled="checkedConfigIds.length === 0 && checkedGroupIds.length === 0"
@click="handleUnifiedBatchDelete">批量删除所选
</button>
<button v-if="checkedConfigIds.length > 0 || checkedGroupIds.length > 0" class="btn-clear-selection"
@click="clearSelection">清空已选
</button>
</div>
</div>
</div>
<el-dialog v-model="saveDialogVisible" title="保存样式至方案" width="400px">
<el-form label-width="80px">
<el-form-item label="所属方案" required>
<el-select
v-model="saveForm.group_name"
filterable
allow-create
default-first-option
placeholder="请选择或输入新方案名"
style="width: 100%"
>
<el-option v-for="g in existingGroups" :key="g.id" :label="g.group_name" :value="g.group_name"/>
</el-select>
</el-form-item>
<el-form-item label="配置名称" required>
<el-input v-model="saveForm.canvas_name" placeholder="请输入配置名称"/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="saveDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmSave">确定保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import {Graph} from '@antv/g6';
import Menu from "@/components/Menu.vue";
import {
saveGraphStyle,
getGroupedGraphStyleList,
getGraphStyleGroups,
deleteGraphStyle,
batchDeleteGraphStyle,
deleteGraphStyleGroup,
applyGraphStyleGroup, getGraphStyleActive
} from '@/api/style';
import {ElMessageBox, ElMessage} from 'element-plus';
import {markRaw} from 'vue';
const tagToLabelMap = {
'疾病': 'Disease', '症状': 'Symptom', '病因': 'Cause', '药品': 'Drug', '科室': 'Department', '检查': 'Check', '其他': 'Other'
};
const CORE_LABELS = ['Disease', 'Symptom', 'Drug', 'Check'];
const INITIAL_FILL_MAP = {
'Disease': '#EF4444', 'Drug': '#91cc75', 'Symptom': '#fac858', 'Check': '#336eee',
'Cause': '#59d1d4', 'Department': '#59d1d4', 'Other': '#59d1d4'
};
const INITIAL_STROKE_MAP = {
'Disease': '#B91C1C', 'Drug': '#047857', 'Symptom': '#B45309', 'Check': '#1D4ED8',
'Cause': '#40999b', 'Department': '#40999b', 'Other': '#40999b'
};
export default {
name: 'GraphDemo',
components: {Menu},
data() {
return {
activeTags: '疾病',
styleGroups: [],
existingGroups: [],
activeCollapseNames: [],
checkedConfigIds: [],
checkedGroupIds: [],
usingConfigIds: [],
saveDialogVisible: false,
saveForm: {group_name: '', canvas_name: ''},
tagStyles: {
'Disease': this.getInitialTagParams('Disease'),
'Symptom': this.getInitialTagParams('Symptom'),
'Cause': this.getInitialTagParams('Cause'),
'Drug': this.getInitialTagParams('Drug'),
'Department': this.getInitialTagParams('Department'),
'DiseaseSite': this.getInitialTagParams('DiseaseSite'),
'Check': this.getInitialTagParams('Check'),
'Other': this.getInitialTagParams('Other'),
},
nodeShowLabel: true,
nodeFontFamily: 'Microsoft YaHei, sans-serif',
nodeFontSize: 12,
nodeFontColor: '#ffffff',
nodeShape: 'circle',
nodeSize: 50,
nodeFill: '#EF4444',
nodeStroke: '#B91C1C',
nodeLineWidth: 2,
edgeShowLabel: true,
edgeEndArrow: true,
edgeFontFamily: 'Microsoft YaHei, sans-serif',
edgeFontSize: 10,
edgeFontColor: '#666666',
edgeType: 'quadratic',
edgeLineWidth: 2,
edgeStroke: '#EF4444',
defaultData: {
nodes: [
{id: "node1", data: {name: "霍乱", label: "Disease"}},
{id: "node2", data: {name: "腹泻", label: "Symptom"}},
{id: "node3", data: {name: "脱水", label: "Disease"}},
{id: "node4", data: {name: "呕吐", label: "Disease"}},
{id: "node5", data: {name: "由霍乱弧菌感染所致", label: "Cause"}},
{id: "node6", data: {name: "复方磺胺甲噁唑", label: "Drug"}},
{id: "node7", data: {name: "消化系统", label: "DiseaseSite"}},
{id: "node8", data: {name: "传染科", label: "Department"}},
{id: "node9", data: {name: "代谢性 酸中毒", label: "Disease"}},
{id: "node10", data: {name: "急性肾衰竭", label: "Disease"}},
{id: "node11", data: {name: "检查项目", label: "Check"}}
],
edges: [
{id: "e1", source: "node1", target: "node2", data: {relationship: {properties: {label: "症状与体征"}}}},
{id: "e2", source: "node1", target: "node3", data: {relationship: {properties: {label: "并发症"}}}},
{id: "e3", source: "node1", target: "node4", data: {relationship: {properties: {label: "并发症"}}}},
{id: "e4", source: "node1", target: "node5", data: {relationship: {properties: {label: "病因"}}}},
{id: "e5", source: "node1", target: "node6", data: {relationship: {properties: {label: "治疗方案"}}}},
{id: "e6", source: "node1", target: "node7", data: {relationship: {properties: {label: "病变部位"}}}},
{id: "e7", source: "node1", target: "node8", data: {relationship: {properties: {label: "科室"}}}},
{id: "e8", source: "node1", target: "node9", data: {relationship: {properties: {label: "并发症"}}}},
{id: "e9", source: "node1", target: "node10", data: {relationship: {properties: {label: "并发症"}}}},
{id: "e10", source: "node1", target: "node11", data: {relationship: {properties: {label: "检查"}}}}
]
}
}
},
watch: {
checkedGroupIds(newGroupIds) {
this.styleGroups.forEach(group => {
const isGroupChecked = newGroupIds.includes(group.id);
const childIds = group.configs.map(c => c.id);
if (isGroupChecked) {
this.checkedConfigIds = Array.from(new Set([...this.checkedConfigIds, ...childIds]));
} else {
this.checkedConfigIds = this.checkedConfigIds.filter(id => !childIds.includes(id));
}
});
},
nodeFill(newVal) {
this.edgeStroke = newVal;
},
nodeShowLabel: 'syncAndRefresh', nodeFontFamily: 'syncAndRefresh', nodeFontSize: 'syncAndRefresh',
nodeFontColor: 'syncAndRefresh', nodeShape: 'syncAndRefresh', nodeSize: 'syncAndRefresh',
nodeStroke: 'syncAndRefresh', nodeLineWidth: 'syncAndRefresh',
edgeShowLabel: 'syncAndRefresh', edgeEndArrow: 'syncAndRefresh', edgeFontFamily: 'syncAndRefresh',
edgeFontSize: 'syncAndRefresh', edgeFontColor: 'syncAndRefresh', edgeType: 'syncAndRefresh',
edgeLineWidth: 'syncAndRefresh', edgeStroke: 'syncAndRefresh'
},
created() {
this._graph = null;
this._nodeLabelMap = new Map();
},
mounted() {
this.$nextTick(() => {
setTimeout(() => {
this.initGraph();
this.fetchConfigs();
window.addEventListener('resize', this.handleResize);
}, 300);
});
},
beforeUnmount() {
if (this._graph) {
this._graph.destroy();
this._graph = null;
}
window.removeEventListener('resize', this.handleResize);
},
methods: {
validateNodeSize(event) {
const inputVal = event.target.value;
const val = parseInt(inputVal);
if (isNaN(val) || val < 30 || val > 100) {
ElMessage({ message: `节点尺寸请输入 30 到 100 之间的数字`, type: 'warning', duration: 1500 });
event.target.value = this.nodeSize;
return;
}
this.nodeSize = val;
this.syncAndRefresh();
},
validateNodeLineWidth(event) {
const inputVal = event.target.value;
const val = parseInt(inputVal);
if (isNaN(val) || val < 1 || val > 5) {
ElMessage({ message: `边框尺寸请输入 1 到 5 之间的数字`, type: 'warning', duration: 1500 });
event.target.value = this.nodeLineWidth;
return;
}
this.nodeLineWidth = val;
this.syncAndRefresh();
},
validateEdgeLineWidth(event) {
const inputVal = event.target.value;
const val = parseInt(inputVal);
if (isNaN(val) || val < 1 || val > 5) {
ElMessage({ message: `线条粗细请输入 1 到 5 之间的数字`, type: 'warning', duration: 1500 });
event.target.value = this.edgeLineWidth;
return;
}
this.edgeLineWidth = val;
this.syncAndRefresh();
},
getInitialTagParams(label) {
const fill = INITIAL_FILL_MAP[label] || '#59d1d4';
return {
nodeShowLabel: true, nodeFontFamily: 'Microsoft YaHei, sans-serif', nodeFontSize: 12, nodeFontColor: '#ffffff',
nodeShape: 'circle', nodeSize: 60, nodeFill: fill,
nodeStroke: INITIAL_STROKE_MAP[label] || '#40999b', nodeLineWidth: 2, edgeShowLabel: true, edgeEndArrow: true,
edgeFontFamily: 'Microsoft YaHei, sans-serif', edgeFontSize: 10, edgeFontColor: '#666666', edgeType: 'line',
edgeLineWidth: 2, edgeStroke: fill
};
},
handleTagClick(tag) {
this.activeTags = tag;
const label = tagToLabelMap[tag];
const style = this.tagStyles[label];
if (style) Object.assign(this, style);
},
syncAndRefresh() {
const label = tagToLabelMap[this.activeTags];
if (label) {
this.tagStyles[label] = {
nodeShowLabel: this.nodeShowLabel, nodeFontFamily: this.nodeFontFamily, nodeFontSize: this.nodeFontSize,
nodeFontColor: this.nodeFontColor, nodeShape: this.nodeShape, nodeSize: this.nodeSize,
nodeFill: this.nodeFill, nodeStroke: this.nodeStroke, nodeLineWidth: this.nodeLineWidth,
edgeShowLabel: this.edgeShowLabel, edgeEndArrow: this.edgeEndArrow, edgeFontFamily: this.edgeFontFamily,
edgeFontSize: this.edgeFontSize, edgeFontColor: this.edgeFontColor, edgeType: this.edgeType,
edgeLineWidth: this.edgeLineWidth, edgeStroke: this.edgeStroke
};
}
this.updateAllElements();
},
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,
layout: {type: 'radial', unitRadius: 100, preventOverlap: true, nodeSpacing: 50},
behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element', 'hover-activate'],
autoFit: 'center', animation: true
});
this._graph = markRaw(graph);
this.updateAllElements();
},
getEffectiveStyleKey(label) {
return CORE_LABELS.includes(label) ? label : 'Other';
},
updateAllElements() {
if (!this._graph) return;
const labelToAppliedConfigMap = {};
this.styleGroups.forEach(group => {
group.configs.forEach(conf => {
if (this.usingConfigIds.includes(conf.id)) {
const labelEn = tagToLabelMap[conf.current_label];
if (labelEn) labelToAppliedConfigMap[labelEn] = conf.styles;
}
});
});
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})`;
};
const nodes = this.defaultData.nodes.map(node => {
const rawLabel = node.data?.label || '';
const effectiveKey = this.getEffectiveStyleKey(rawLabel);
const s = labelToAppliedConfigMap[effectiveKey] || this.tagStyles[effectiveKey];
return {
...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',
}
};
});
const edges = this.defaultData.edges.map(edge => {
const sRawLabel = this._nodeLabelMap.get(edge.source);
const effectiveKey = this.getEffectiveStyleKey(sRawLabel);
const s = labelToAppliedConfigMap[effectiveKey] || this.tagStyles[effectiveKey] || this;
const strokeColor = hexToRgba(s.edgeStroke, 0.6);
return {
...edge, type: s.edgeType || 'line',
style: {
stroke: strokeColor,
lineWidth: this.safeNum(s.edgeLineWidth, 2),
endArrow: s.edgeEndArrow,
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
}
};
});
this._graph.setData({nodes, edges});
this._graph.render();
},
safeNum(val, defaultVal = 1) {
const n = Number(val);
return isNaN(n) ? defaultVal : n;
},
async fetchConfigs() {
try {
const res = await getGroupedGraphStyleList();
if (res.code === 200) {
// 1. 数据处理与【强制去重】
// 这里的逻辑确保了同一个配置 ID 在同一个分组内不会出现两次
this.styleGroups = res.data.map(group => {
const idSet = new Set();
const uniqueConfigs = [];
(group.configs || []).forEach(conf => {
if (!idSet.has(conf.id)) {
idSet.add(conf.id);
uniqueConfigs.push({
...conf,
// 安全解析 styles 字符串
styles: typeof conf.styles === 'string' ? JSON.parse(conf.styles) : conf.styles
});
} else {
console.warn(`检测到重复 ID: ${conf.id},已在前端过滤`);
}
});
return {
...group,
configs: uniqueConfigs
};
});
// 2. 初始加载时的应用逻辑(带排他性同步)
if (this.usingConfigIds.length === 0) {
const tempUsingIds = [];
const seenLabels = new Set(); // 记录已处理的标签(如:疾病、药品)
this.styleGroups.forEach(group => {
// 只处理后端标记为 active 的方案组
if (group.is_active) {
// 自动展开激活的折叠面板
if (!this.activeCollapseNames.includes(group.id)) {
this.activeCollapseNames.push(group.id);
}
// 遍历组内配置进行标签去重
group.configs.forEach(conf => {
if (!seenLabels.has(conf.current_label)) {
tempUsingIds.push(conf.id);
seenLabels.add(conf.current_label);
} else {
console.warn(`标签冲突拦截:方案【${group.group_name}】中的配置【${conf.canvas_name}】因标签【${conf.current_label}】已存在而被忽略`);
}
});
}
});
// 最终赋值
this.usingConfigIds = tempUsingIds;
}
// 3. 驱动图谱重新渲染
this.updateAllElements();
}
} catch (err) {
console.error("加载配置失败:", err);
ElMessage.error("获取方案列表失败");
}
},
async fetchGroupNames() {
const res = await getGraphStyleGroups();
if (res.code === 200) this.existingGroups = res.data;
},
toggleApplyConfig(item) {
const idx = this.usingConfigIds.indexOf(item.id);
if (idx > -1) {
this.usingConfigIds.splice(idx, 1);
} else {
// 同类标签排他性应用
this.styleGroups.forEach(g => {
g.configs.forEach(c => {
if (this.usingConfigIds.includes(c.id) && c.current_label === item.current_label) {
this.usingConfigIds = this.usingConfigIds.filter(id => id !== c.id);
}
});
});
this.usingConfigIds.push(item.id);
}
this.updateAllElements();
},
/**
* 新增功能:将配置移动到另一个方案
*/
async moveConfigToGroup(config, targetGroup) {
try {
// 1. 构建更新负载
const payload = {
id: config.id, // 确保有 ID,后端才会执行更新而非新增
canvas_name: config.canvas_name,
group_name: targetGroup.group_name, // 目标方案名
current_label: config.current_label,
styles: config.styles
};
const res = await saveGraphStyle(payload);
if (res.code === 200) {
ElMessage.success(`已成功移动至【${targetGroup.group_name}】`);
// 2. 【核心修复】手动清理本地 styleGroups 中的旧引用,避免 fetchConfigs 响应延迟导致的视觉重复
this.styleGroups = this.styleGroups.map(group => {
return {
...group,
// 过滤掉所有组里 ID 等于当前移动配置 ID 的项
configs: group.configs.filter(c => c.id !== config.id)
};
});
// 3. 重新获取最新的服务器状态
await this.fetchConfigs();
}
} catch (err) {
console.error("移动配置失败:", err);
ElMessage.error("移动操作失败,请重试");
}
},
/**
* 修改后的应用全案方法
* 逻辑:保留当前已手动选中的配置,新方案中冲突的配置不予应用
*/
async applyWholeGroup(group) {
try {
// 1. 【新增】定义必须包含的 5 个核心标签
const REQUIRED_TAGS = ['疾病', '症状', '药品', '检查', '其他'];
// 2. 【新增】获取当前方案组里已有的标签
const currentLabels = group.configs.map(conf => conf.current_label);
const hasTags = new Set(currentLabels);
// 3. 【新增】找出缺失的标签
const missingTags = REQUIRED_TAGS.filter(tag => !hasTags.has(tag));
// 4. 【新增】拦截逻辑:如果标签不全,弹出提示并返回
if (missingTags.length > 0) {
return ElMessageBox.alert(
`该方案配置不完整,无法应用。必须配齐 5 个核心标签。` +
`<br/>目前缺失:<b style="color: #f56c6c">${missingTags.join('')}</b>`,
'校验未通过',
{
confirmButtonText: '我知道了',
dangerouslyUseHTMLString: true,
type: 'warning'
}
);
}
// 获取当前正在使用的所有标签名用于外部排他
const currentlyUsingLabels = [];
this.styleGroups.forEach(g => {
g.configs.forEach(c => {
if (this.usingConfigIds.includes(c.id)) {
currentlyUsingLabels.push(c.current_label);
}
});
});
// 对新方案内部进行去重:如果方案内有多个同标签配置,只取第一个
const uniqueNewConfigs = [];
const seenLabelsInNewGroup = new Set();
group.configs.forEach(conf => {
if (!seenLabelsInNewGroup.has(conf.current_label)) {
uniqueNewConfigs.push(conf);
seenLabelsInNewGroup.add(conf.current_label);
}
});
// 过滤掉与当前已选标签冲突的配置(外部排他)
const filteredNewConfigIds = uniqueNewConfigs
.filter(newConf => !currentlyUsingLabels.includes(newConf.current_label))
.map(newConf => newConf.id);
if (filteredNewConfigIds.length === 0) {
return ElMessage.info("该方案中的标签配置已存在,无需重复应用");
}
// 追加 ID
this.usingConfigIds = [...this.usingConfigIds, ...filteredNewConfigIds];
// 调用后端接口更新状态
const res = await applyGraphStyleGroup(group.id);
if (res.code === 200) {
await this.fetchConfigs();
ElMessage.success(`方案【${group.group_name}】已应用,已自动过滤重复标签`);
}
} catch (err) {
console.error(err);
ElMessage.error("应用全案失败");
}
},
handleSaveClick() {
this.fetchGroupNames();
this.saveForm.canvas_name = `${this.activeTags}_${Date.now()}`;
this.saveDialogVisible = true;
},
async confirmSave() {
if (!this.saveForm.group_name || !this.saveForm.group_name.trim()) {
return ElMessage.warning("请选择或输入方案名称");
}
if (!this.saveForm.canvas_name || !this.saveForm.canvas_name.trim()) {
return ElMessage.warning("请输入配置名称");
}
const payload = {
canvas_name: this.saveForm.canvas_name.trim(),
group_name: this.saveForm.group_name.trim(),
current_label: this.activeTags,
styles: {...this.tagStyles[tagToLabelMap[this.activeTags]]}
};
const res = await saveGraphStyle(payload);
if (res.code === 200) {
ElMessage.success("保存成功");
this.saveDialogVisible = false;
this.saveForm.canvas_name = '';
this.saveForm.group_name = '';
this.resetAllTagsToDefault();
this.fetchConfigs();
}
},
resetAllTagsToDefault() {
Object.keys(this.tagStyles).forEach(labelKey => {
this.tagStyles[labelKey] = this.getInitialTagParams(labelKey);
});
this.activeTags = '疾病';
const diseaseInitial = this.getInitialTagParams('Disease');
Object.assign(this, diseaseInitial);
this.updateAllElements();
},
resetStyle() {
const labelEn = tagToLabelMap[this.activeTags];
const initial = this.getInitialTagParams(labelEn);
this.tagStyles[labelEn] = initial;
Object.assign(this, initial);
this.updateAllElements();
ElMessage.info(`已重置【${this.activeTags}】样式`);
},
// --- 修改点:单个配置删除增加判断 ---
async deleteSingleConfig(id) {
if (this.usingConfigIds.includes(id)) {
return ElMessage.error("该配置正在应用中,请取消应用或切换方案后再删除");
}
try {
await ElMessageBox.confirm('确定删除此配置吗?', '提示');
const res = await deleteGraphStyle(id);
if (res.code === 200) {
ElMessage.success("删除成功");
this.fetchConfigs();
}
} catch (err) {}
},
// --- 修改点:方案组删除增加判断 ---
async deleteGroup(groupId) {
const group = this.styleGroups.find(g => g.id === groupId);
if (!group) return;
// 1. 判断是否正在应用
const isGroupUsing = group.configs.some(c => this.usingConfigIds.includes(c.id));
if (isGroupUsing || group.is_active) {
return ElMessage.error("该方案中包含正在应用的配置,无法删除");
}
// 2. 判断是否是最后一个方案
if (this.styleGroups.length <= 1) {
return ElMessage.error("系统至少需保留一个方案,无法全部删除");
}
try {
await ElMessageBox.confirm('确定删除整个方案吗?', '提示');
const res = await deleteGraphStyleGroup(groupId);
if (res.code === 200) {
ElMessage.success("方案已删除");
this.fetchConfigs();
}
} catch (err) {}
},
// --- 修改点:批量删除增加核心判断逻辑 ---
async handleUnifiedBatchDelete() {
// 1. 基础判断
if (this.checkedConfigIds.length === 0 && this.checkedGroupIds.length === 0) {
return ElMessage.warning("请先勾选要删除的项目");
}
// 2. 正在应用状态判断
const isAnyCheckedConfigUsing = this.checkedConfigIds.some(id => this.usingConfigIds.includes(id));
const isAnyCheckedGroupUsing = this.styleGroups
.filter(g => this.checkedGroupIds.includes(g.id))
.some(g => g.configs.some(c => this.usingConfigIds.includes(c.id)));
if (isAnyCheckedConfigUsing || isAnyCheckedGroupUsing) {
return ElMessageBox.alert(
'选中的项目中包含“正在应用”的配置,请先取消应用后再执行删除操作。',
'无法执行删除',
{ type: 'error', confirmButtonText: '我知道了' }
);
}
// 3. 最小保留数判断 (针对方案组)
if (this.checkedGroupIds.length >= this.styleGroups.length && this.styleGroups.length > 0) {
return ElMessage.error("系统至少需要保留一个方案,请勿全部勾选删除");
}
try {
await ElMessageBox.confirm(
'确定执行批量删除吗?此操作不可恢复。',
'批量删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}
);
// 执行删除
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();
this.updateAllElements();
} catch (e) {
console.log("用户取消或删除失败", e);
}
},
clearSelection() {
this.checkedConfigIds = [];
this.checkedGroupIds = [];
},
handleResize() {
if (this._graph && this.$refs.graphContainer) {
this._graph.setSize(this.$refs.graphContainer.clientWidth, this.$refs.graphContainer.clientHeight);
}
}
}
}
</script>
<style scoped>
/* 精准控制“应用全案”按钮 */
.group-header-slot .el-button--primary.is-plain {
background-color: #1559f3 !important;
color: #ffffff !important;
border: none !important;
font-weight: bold;
}
.group-header-slot .el-button--primary.is-plain:hover {
opacity: 0.8;
background-color: #1559f3 !important;
color: #ffffff !important;
}
/* 精准控制弹窗中的“确定保存”按钮 */
.el-dialog__footer .el-button--primary {
background-color: #1559f3 !important;
border-color: #1559f3 !important;
color: #ffffff !important;
font-weight: bold;
}
.el-dialog__footer .el-button--primary:hover {
opacity: 0.8;
background-color: #1559f3 !important;
border-color: #1559f3 !important;
}
.control-panel::-webkit-scrollbar {
display: none !important;
}
.control-panel {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
.knowledge-graph-container {
display: flex;
height: 100vh;
background-color: #f3f3f3;
overflow: hidden;
}
.control-panel {
width: 250px;
background: #ffffff;
box-shadow: 4px 0 12px rgba(0, 0, 0, 0.08);
padding: 10px;
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
z-index: 5;
}
.config-list-panel {
width: 250px;
background: #ffffff;
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.08);
padding: 18px;
display: flex;
flex-direction: column;
flex-shrink: 0;
position: relative;
z-index: 5;
}
.panel-header-container {
margin-bottom: 10px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
border-bottom: 1px solid #E2E6F3;
}
/*.header-line {
height: 1px;
background-color: #e2e8f0;
width: 100%;
}*/
.custom-title-style {
font-size: 18px;
font-weight: bold;
color: #1157f3;
}
/* 标签过滤样式 */
.tag-filters {
display: flex;
flex-wrap: nowrap;
gap: 6px;
margin-bottom: 10px;
padding-bottom: 10px;
}
.tag-pill {
flex-shrink: 0;
padding: 1px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
color: #ffffff;
background-color: #959390;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
border: 1px solid transparent;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tag-pill.is-active {
background-color: #4a68db;
}
.section-title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: bold;
color: #334155;
margin-bottom: 12px;
padding-left: 12px;
position: relative;
line-height: 1;
}
.section-title::before {
content: "";
position: absolute;
left: 0;
width: 4px;
height: 15px;
background-color: #1559f3;
border-radius: 2px;
}
.form-group-flex {
display: flex;
align-items: center;
justify-content: flex-start;
font-size: 13px;
margin-bottom: 10px;
color: #475569;
}
.checkbox-label {
width: 80px;
flex-shrink: 0;
text-align: left;
margin-right: 8px;
}
.theme-checkbox {
accent-color: #1559f3;
width: 16px;
height: 16px;
cursor: pointer;
}
.form-group, .color-picker-item {
display: flex;
align-items: center;
margin-bottom: 10px;
font-size: 13px;
}
.form-group label, .color-picker-item label {
width: 80px;
flex-shrink: 0;
text-align: right;
margin-right: 8px;
}
.form-group select, .form-group input[type="number"] {
flex: 1;
padding: 5px;
border: 1px solid #e2e8f0;
border-radius: 4px;
width: 100px;
}
.slider-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.theme-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: #dde5f3;
border-radius: 10px;
outline: none;
}
.theme-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #1559f3;
border-radius: 50%;
cursor: pointer;
}
.val-text-black {
color: #000;
font-weight: bold;
font-size: 13px;
min-width: 35px;
}
.color-picker-border {
padding: 3px;
border: 1px solid #e2e8f0;
border-radius: 4px;
display: flex;
}
.square-picker {
width: 24px;
height: 24px;
cursor: pointer;
border: none;
padding: 0;
}
.button-footer {
}
.btn-confirm-save {
background: #1559f3;
color: #fff;
border: none;
flex: 1;
padding: 5px 14px;
border-radius: 4px;
cursor: pointer;
width: 100px;
font-size: 12px;
margin-right: 15px;
}
.btn-reset-style {
background: #fff;
color: #1559f3;
border: 1px solid #1559f3;
flex: 1;
padding: 5px 14px;
border-radius: 4px;
cursor: pointer;
width: 100px;
font-size: 12px;
}
.graph-container {
flex: 1;
background: #fff;
}
.config-list {
flex: 1;
overflow-y: auto;
padding-bottom: 100px;
scrollbar-width: none; /* 针对 Firefox */
-ms-overflow-style: none; /* 针对 IE 和 Edge */
}
:deep(.el-collapse-item__content) {
padding-left: 32px !important;
padding-right: 10px !important;
padding-bottom: 12px !important;
padding-top: 2px !important;
background-color: transparent !important;
}
.config-card {
display: flex;
align-items: center;
justify-content: space-between;
background-color:#f6f9fc !important;
padding: 7px 15px;
margin-bottom: 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #E2E5EA;
}
.config-card:hover {
background-color: #eff6ff !important;
outline: 1.5px solid #1559f3;
}
.card-using {
background-color: #eff6ff !important;
}
.card-checked {
border-left: 4px solid rgb(239, 68, 68) !important;
}
.card-left {
display: flex;
align-items: flex-start;
flex: 1;
min-width: 0;
gap: 8px;
}
.checkbox-wrapper {
display: flex;
align-items: center;
justify-content: flex-start;
margin-right: 0;
padding-right: 0;
border-right: none;
flex-shrink: 0;
height: auto;
margin-top: 4px;
}
.config-checkbox {
margin: 0;
cursor: pointer;
}
.card-info {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 0;
gap: 4px;
}
.card-title-row {
display: flex;
align-items: center;
line-height: 20px;
}
.card-name {
font-weight: 600;
color: #1e293b;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 4px;
}
.status-badge {
font-size: 10px;
background: #1559f3;
color: white;
padding: 0px 6px;
border-radius: 4px;
white-space: nowrap;
}
.card-right {
display: flex;
align-items: center;
height: 20px;
margin-left: 10px;
}
.delete-icon {
color: #94a3b8;
padding: 4px;
}
.delete-icon:hover {
color: #ef4444;
background: #fee2e2;
border-radius: 4px;
}
.batch-actions-fixed {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
padding: 15px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 10px;
}
.selection-info {
font-size: 13px;
color: #ef4444;
font-weight: bold;
text-align: center;
}
.batch-button-group {
display: flex;
gap: 8px;
}
.btn-batch-delete-final {
flex: 2;
padding: 10px;
background: #ef4444;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
}
.btn-clear-selection {
flex: 1;
padding: 10px;
background: #fff;
color: #64748b;
border: 1px solid #cbd5e1;
border-radius: 6px;
cursor: pointer;
}
.refresh-icon {
cursor: pointer;
color: #94a3b8;
}
.group-header-slot {
display: flex;
align-items: center;
width: 100%;
padding-right: 12px;
flex-wrap: nowrap;
}
.group-name-text {
flex: 1;
font-weight: 900;
font-size: 16px;
color: #0f172a;
margin-left: 4px;
text-align: left;
}
.group-del {
margin-left: 10px;
color: #94a3b8;
}
:deep(.el-collapse-item__header) {
padding: 0 10px;
height: 54px;
line-height: 54px;
background-color: #ffffff !important;
border-top: none !important;
}
:deep(.el-collapse-item__wrap) {
border-bottom: none;
background-color: transparent !important;
}
/* 统一对话框/消息框 */
:deep(.el-dialog__title),
:deep(.el-message-box__title) {
color: #000000 !important;
font-weight: 600 !important;
}
:deep(.el-dialog__header),
:deep(.el-message-box__header) {
text-align: left !important;
}
:deep(.el-dialog__footer .el-button:first-child),
:deep(.el-message-box__btns .el-button:first-child) {
background-color: #e6e6e6 !important;
border-color: #e6e6e6 !important;
color: #333 !important;
}
:deep(.el-message-box__btns .el-button--primary) {
background-color: #1559f3 !important;
border-color: #1559f3 !important;
}
:deep(.el-dialog) {
--el-color-primary: #ebf0ff !important;
--el-input-hover-border-color: #ebf0ff !important;
--el-input-focus-border-color: #ebf0ff !important;
--el-border-color-hover: #ebf0ff !important;
}
:deep(.el-dialog .el-input__wrapper) {
box-shadow: 0 0 0 1px #ebf0ff inset !important;
background-color: #ffffff !important;
}
:deep(.el-dialog .el-input.is-focus .el-input__wrapper),
:deep(.el-dialog .el-input__wrapper.is-focus),
:deep(.el-dialog .el-select .el-input__wrapper.is-focus),
:deep(.el-dialog .el-select:hover .el-input__wrapper) {
box-shadow: 0 0 0 1px #ebf0ff inset !important;
}
:deep(.el-dialog .el-input__inner) {
outline: none !important;
}
</style>
<style>
.el-message-box__header {
text-align: left !important;
padding-top: 15px !important;
}
.el-message-box__title {
color: #000000 !important;
font-weight: 500 !important;
font-size: 18px !important;
}
.el-message-box__btns .el-button--primary {
background-color: #1559f3 !important;
border-color: #1559f3 !important;
}
</style>