Compare commits

...

21 Commits

Author SHA1 Message Date
hanyuqing 5367134eda 图谱展示修改 3 months ago
hanyuqing b32f1ca4c3 Merge branch 'hanyuqing' of http://124.70.32.114:3100/hanyuqing/KGPython into mh 3 months ago
hanyuqing 84cdf9ac0e all 3 months ago
hanyuqing 2f21a51546 Merge branch 'mh' of http://124.70.32.114:3100/hanyuqing/KGPython into hanyuqing 3 months ago
hanyuqing 9e1e1dc9d1 all 3 months ago
hanyuqing 34e439d953 Merge branch 'mh' of http://124.70.32.114:3100/hanyuqing/KGPython into hanyuqing 3 months ago
hanyuqing 2212e576bf all 3 months ago
hanyuqing 911c8946bb all 3 months ago
hanyuqing f030e0501c Merge branch 'mh' of http://124.70.32.114:3100/hanyuqing/KGPython into hanyuqing 3 months ago
hanyuqing 91572f3d10 all 3 months ago
hanyuqing 1c142d3567 Merge branch 'mh' of http://124.70.32.114:3100/hanyuqing/KGPython into hanyuqing 3 months ago
hanyuqing a804254e30 all 3 months ago
hanyuqing 4b371b6b14 Merge branch 'mh' of http://124.70.32.114:3100/hanyuqing/KGPython into hanyuqing 3 months ago
hanyuqing f6a90b3857 all 3 months ago
hanyuqing 8eb13bbf74 all 3 months ago
hanyuqing c2d3b34c67 Merge branch 'mh' of http://124.70.32.114:3100/hanyuqing/KGPython into hanyuqing 3 months ago
hanyuqing b1c54e4435 all 3 months ago
hanyuqing 49de7e9de1 Merge branch 'mh' of http://124.70.32.114:3100/hanyuqing/KGPython into hanyuqing 3 months ago
hanyuqing 8996d95fa2 all 3 months ago
hanyuqing a98947ebbb Merge branch 'mh' of http://124.70.32.114:3100/hanyuqing/KGPython into hanyuqing 3 months ago
hanyuqing fb130cb4ba all 3 months ago
  1. 58
      controller/GraphController.py
  2. 9
      controller/GraphStyleController.py
  3. 48
      python/1.py
  4. 66
      python/cmekg_aligner.py
  5. 36
      python/test_alignment.py
  6. 9
      service/GraphService.py
  7. 46
      service/GraphStyleService.py
  8. 59
      util/neo4j_utils.py
  9. 20
      vue/src/api/graph.js
  10. 7
      vue/src/api/style.js
  11. BIN
      vue/src/assets/下拉11.png
  12. BIN
      vue/src/assets/图标5.png
  13. 3
      vue/src/components/Menu.vue
  14. 12
      vue/src/system/GraphBuilder.vue
  15. 558
      vue/src/system/GraphDemo.vue
  16. 494
      vue/src/system/GraphQA.vue
  17. 222
      vue/src/system/GraphStyle.vue
  18. 238
      vue/src/system/KGData.vue

58
controller/GraphController.py

@ -11,7 +11,7 @@ from robyn import Robyn, jsonify, Response
from typing import Optional, List, Any, Dict
from service.GraphService import build_g6_subgraph_by_props, get_drug_names_from_neo4j, get_group_key, \
get_check_names_from_neo4j
get_check_names_from_neo4j, get_disease_names_from_neo4j
from util.neo4j_utils import Neo4jUtil
from util.neo4j_utils import neo4j_client
from util.redis_utils import set as redis_set, get as redis_get # 使用你已有的模块级 Redis 工
@ -19,7 +19,7 @@ from util.redis_utils import set as redis_set, get as redis_get # 使用你已
# 缓存键
DRUG_TREE_KEY = "cache:drug_tree"
CHECK_TREE_KEY = "cache:check_tree"
DISEASE_TREE_KEY = "cache:disease_tree" # 👈 新增
# ========================
# 🔥 启动时预加载数据(在 app 启动前执行)
# ========================
@ -41,7 +41,7 @@ def preload_data():
for key in all_keys:
if key in groups:
children = [{"label": name, "type": "Drug"} for name in sorted(groups[key])]
tree_data.append({"label": key, "type": "Drug", "children": children})
tree_data.append({"label": key, "children": children})
redis_set(DRUG_TREE_KEY, json.dumps(tree_data, ensure_ascii=False), ex=None)
@ -56,10 +56,23 @@ def preload_data():
for key in all_keys:
if key in groups:
children = [{"label": name, "type": "Check"} for name in sorted(groups[key])]
tree_data.append({"label": key, "type": "Check", "children": children})
tree_data.append({"label": key, "children": children})
redis_set(CHECK_TREE_KEY, json.dumps(tree_data, ensure_ascii=False), ex=None)
names = get_disease_names_from_neo4j()
groups = {}
for name in names:
key = get_group_key(name)
groups.setdefault(key, []).append(name)
tree_data = []
for key in all_keys:
if key in groups:
children = [{"label": name, "type": "Disease"} for name in sorted(groups[key])]
tree_data.append({"label": key, "children": children})
redis_set(DISEASE_TREE_KEY, json.dumps(tree_data, ensure_ascii=False), ex=None)
print("✅ 预加载完成!数据已写入 Redis 缓存。")
except Exception as e:
print(f"❌ 预加载失败: {e}", file=sys.stderr)
@ -135,6 +148,8 @@ def get_graph(req):
headers={"Content-Type": "text/plain; charset=utf-8"}
)
except Exception as e:
error_trace = traceback.format_exc()
print("Error in /api/tree:", error_trace)
return jsonify({"error": str(e)}), 500
@app.get("/api/drug-tree")
def get_drug_tree():
@ -229,8 +244,39 @@ def get_check_tree():
# description=json.dumps({"error": str(e)}, ensure_ascii=False),
# headers={"Content-Type": "application/json; charset=utf-8"}
# )
@app.get("/api/disease-tree")
def get_disease_tree():
return Response(
status_code=200,
description=redis_get(DISEASE_TREE_KEY),
headers={"Content-Type": "application/json; charset=utf-8"}
)
@app.get("/api/disease-depart-tree")
def get_department_disease_tree():
try:
tree_data = neo4j_client.get_department_disease_tree()
return Response(
status_code=200,
description=jsonify(tree_data),
headers={"Content-Type": "text/plain; charset=utf-8"}
)
except Exception as e:
error_trace = traceback.format_exc()
print("Error in /api/tree:", error_trace)
return jsonify({"error": str(e)}), 500
@app.get("/api/drug-subject-tree")
def get_drug_subject_tree():
try:
tree_data = neo4j_client.get_subject_drug_tree()
return Response(
status_code=200,
description=jsonify(tree_data),
headers={"Content-Type": "text/plain; charset=utf-8"}
)
except Exception as e:
error_trace = traceback.format_exc()
print("Error in /api/tree:", error_trace)
return jsonify({"error": str(e)}), 500
@app.get("/health")
def health():
print(redis_get(DRUG_TREE_KEY))

9
controller/GraphStyleController.py

@ -104,6 +104,15 @@ async def get_grouped_style_list(request):
except Exception as e:
logger.error(f"查询异常: {str(e)}")
return create_response(200, {"code": 500, "msg": f"查询异常: {str(e)}"})
@app.get("/api/graph/style/active")
async def get_active_style(request):
"""获取【分组嵌套】格式的配置列表(用于右侧折叠面板)"""
try:
# 调用 Service 的嵌套聚合方法,现在内部已包含 is_active/is_default 逻辑
data = GraphStyleService.get_active_configs()
return create_response(200, {"code": 200, "data": data, "msg": "查询成功"})
except Exception as e:
return create_response(200, {"code": 500, "msg": f"查询异常: {str(e)}"})
@app.post("/api/graph/style/group/apply")

48
python/1.py

@ -0,0 +1,48 @@
import json
UNIFIED_PROMPT_TEMPLATE = (
"你是一个医疗知识图谱构建专家。请从以下文本中:\n"
"1. 提取所有医学实体(去重),仅返回名称列表;\n"
"2. 在这些实体之间抽取高质量、术语化的语义关系三元组。\n\n"
"### 输出规则\n"
"- 实体类型无需标注,只输出名称字符串(如 \"慢性淋巴细胞白血病\")。\n"
"- 关系谓词必须是专业术语(2~6字),如:临床表现、诊断、相关疾病、禁忌症、治疗药物等。\n"
"- e1 和 e2 必须来自提取出的实体列表,且 e1 ≠ e2。\n"
"- 输出必须是纯 JSON,仅包含两个字段:\"entities\"(字符串列表)和 \"relations\"(对象列表,每个含 e1/r/e2)。\n"
"- 不要任何额外文本、解释或 Markdown。\n\n"
"文本:{input}\n\n输出:"
)
with open("test_data.jsonl", "r", encoding="utf-8") as fin, \
open("sft_messages_format.jsonl", "w", encoding="utf-8") as fout:
for line in fin:
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
input_text = item["input"]
output_obj = item["output"]
# system prompt 中的 {input} 占位符替换(可选,也可保留原样)
# 这里按你要求:system 保持模板不变,user 才放真实 input
system_content = UNIFIED_PROMPT_TEMPLATE # 不替换 {input}
user_content = input_text
# assistant content 必须是 JSON 字符串(带转义)
assistant_content = json.dumps(output_obj, ensure_ascii=False)
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": user_content},
{"role": "assistant", "content": assistant_content}
]
fout.write(json.dumps({"messages": messages}, ensure_ascii=False) + "\n")
except Exception as e:
print(f"处理出错: {e}")
continue
print("✅ 转换完成!文件已保存为 sft_messages_format.jsonl")

66
python/cmekg_aligner.py

@ -0,0 +1,66 @@
# cmekg_aligner.py
from difflib import SequenceMatcher
from typing import List, Dict, Optional, Tuple
class CMeKGAligner:
def __init__(self, uri, user, password):
from neo4j import GraphDatabase
self.driver = GraphDatabase.driver(uri, auth=(user, password))
def infer_type_by_name(self, name: str) -> str:
# 你的类型推断逻辑(保持不变)
if "" in name or "胶囊" in name or "注射" in name:
return "Drug"
# ... 其他规则
return "Unknown"
def find_entities_batch(self, terms: List[str]) -> Dict[str, Optional[Tuple[str, str]]]:
"""
批量对齐上万条实体仅一次数据库查询
:param terms: 原始术语列表允许重复
:return: {原始词: (标准名, 类型) None}
"""
if not terms:
return {}
# 1. 去重并保留顺序(可选)
unique_terms = list(dict.fromkeys(terms)) # 保持首次出现顺序
# 2. 一次性从 Neo4j 获取所有可能的候选实体
with self.driver.session() as session:
result = session.run(
"""
UNWIND $terms AS input_name
MATCH (e)
WHERE toLower(e.name) CONTAINS toLower(input_name)
OR toLower(input_name) CONTAINS toLower(e.name)
RETURN input_name, e.name AS std_name
""",
terms=unique_terms
)
# 构建 {input_name: [std_name1, std_name2, ...]}
candidates_map = {}
for record in result:
inp = record["input_name"]
std = record["std_name"]
if inp not in candidates_map:
candidates_map[inp] = []
candidates_map[inp].append(std)
# 3. 对每个输入词,从候选中选最相似的标准名
output = {}
for term in terms: # 遍历原始列表(含重复)
if term in output: # 已处理过(因重复)
continue
candidates = candidates_map.get(term, [])
if not candidates:
output[term] = None
else:
# 选与 term 最相似的标准名
best_std = max(candidates, key=lambda x: SequenceMatcher(None, term, x).ratio())
entity_type = self.infer_type_by_name(best_std)
output[term] = (best_std, entity_type)
return output

36
python/test_alignment.py

@ -0,0 +1,36 @@
# batch_test.py
from cmekg_aligner import CMeKGAligner
aligner = CMeKGAligner(
uri="bolt://localhost:7687",
user="neo4j",
password="your_password"
)
# 模拟上万条数据(实际可从文件读取)
with open("input_terms.txt", "r", encoding="utf-8") as f:
terms = [line.strip() for line in f if line.strip()]
print(f"🔍 开始批量对齐 {len(terms)} 条实体...")
results = aligner.find_entities_batch(terms)
# 输出结果
for term in terms[:10]: # 只打印前10条示例
res = results[term]
if res:
print(f"'{term}''{res[0]}', {res[1]}")
else:
print(f"'{term}' → 未匹配")
# 可选:保存到 CSV
import csv
with open("batch_alignment_result.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["原始词", "标准名", "类型"])
for term in terms:
res = results[term]
if res:
writer.writerow([term, res[0], res[1]])
else:
writer.writerow([term, "", ""])

9
service/GraphService.py

@ -118,6 +118,10 @@ def build_g6_subgraph_by_props(
center_nodes = neo4j_util.find_nodes_with_element_id(node_label, node_properties)
if center_nodes:
n = center_nodes[0]
print("sssssssss")
print(center_nodes)
if "labels" in n and n["labels"]:
n["label"] = n["labels"][0]
node_dict[n["id"]] = n
nodes = list(node_dict.values())
@ -149,6 +153,11 @@ def get_check_names_from_neo4j():
names.append(name)
print(f"[DEBUG] Loaded {len(names)} check names from Neo4j") # 打印实际数量
return names
def get_disease_names_from_neo4j():
cypher = "MATCH (d:Disease) RETURN d.name AS name"
results = neo4j_client.execute_read(cypher)
return [record["name"] for record in results if record.get("name")]
def get_group_key(name: str) -> str:
if not name or not isinstance(name, str):
return "其他"

46
service/GraphStyleService.py

@ -148,6 +148,52 @@ class GraphStyleService:
return groups
@staticmethod
def get_active_configs() -> list:
"""
获取 is_active = 1 的方案组及其配置项
返回长度为 0 1 的列表因为通常只有一个激活组
"""
# 1. 查询 is_active = 1 的组(按 id 排序,取第一个以防有多个)
group_sql = """
SELECT id, group_name, is_active, is_default
FROM graph_style_groups
WHERE is_active = 1
ORDER BY id ASC
LIMIT 1
"""
groups = mysql_client.execute_query(group_sql) or []
if not groups:
return [] # 没有激活的组
group = groups[0]
group_id = group['id']
# 2. 查询该组下的所有配置
configs_sql = """
SELECT id, group_id, canvas_name, current_label, config_json, create_time
FROM graph_configs
WHERE group_id = %s
"""
configs = mysql_client.execute_query(configs_sql, (group_id,)) or []
# 3. 处理配置项
for conf in configs:
if conf.get('config_json'):
try:
conf['styles'] = json.loads(conf['config_json'])
except (TypeError, ValueError):
conf['styles'] = {}
del conf['config_json']
if conf.get('create_time') and not isinstance(conf['create_time'], str):
conf['create_time'] = conf['create_time'].strftime('%Y-%m-%d %H:%M:%S')
# 4. 组装结果
group['configs'] = configs
return [group] # 返回单元素列表,保持接口兼容性
@staticmethod
def apply_group_all(group_id: int) -> bool:
"""切换当前激活的方案组"""
try:

59
util/neo4j_utils.py

@ -141,8 +141,8 @@ class Neo4jUtil:
params = {f"prop_{k}": v for k, v in properties.items()}
cypher += f" WHERE {where_clause}"
cypher += " RETURN elementId(n) AS id, n{.*} AS props"
# cypher += " RETURN elementId(n) AS id, n{.*} AS props"
cypher += " RETURN elementId(n) AS id, labels(n) AS labels, n{.*} AS props"
raw = self.execute_read(cypher, params)
return [self._merge_id_and_props(row) for row in raw]
@ -598,10 +598,15 @@ class Neo4jUtil:
# ==================== 内部辅助方法 ====================
def _merge_id_and_props(self, row: Dict[str, Any]) -> Dict[str, Any]:
"""合并 id 和 props"""
props = dict(row.get("props", {}))
props["id"] = row["id"]
return props
"""
elementIdlabels props 合并为一个扁平字典
"""
result = {
"id": row["id"],
"labels": row["labels"], # 保留标签列表,如 ["Disease"]
**row["props"] # 展开所有属性(name, nodeId 等)
}
return result
def _enrich_relationship(self, row: Dict[str, Any]) -> Dict[str, Any]:
"""格式化关系结果"""
@ -650,6 +655,48 @@ class Neo4jUtil:
conditions.append(f"{var}.`{k}` = ${param_key}")
params[param_key] = v
return " AND ".join(conditions), params
def get_department_disease_tree(self):
"""
查询所有科室及其关联的疾病返回 el-tree 格式数据
"""
cypher = """
MATCH (d:Department)--(dis:Disease)
RETURN d.name AS dept_name, collect(dis.name) AS diseases
ORDER BY d.name
"""
results = self.execute_read(cypher)
tree = []
for record in results:
dept_node = {
"label": record["dept_name"],
"type": "Department",
"children": [{"label": name,"type": "Disease"} for name in record["diseases"]]
}
tree.append(dept_node)
return tree
def get_subject_drug_tree(self):
"""
查询所有药物分类Subject及其关联的药物Drug返回 el-tree 格式数据
"""
cypher = """
MATCH (s:Subject)--(d:Drug)
RETURN s.name AS subject_name, collect(d.name) AS drugs
ORDER BY s.name
"""
results = self.execute_read(cypher)
tree = []
for record in results:
subject_node = {
"label": record["subject_name"],
"type": "Subject",
"children": [{"label": name, "type": "Drug"} for name in record["drugs"]]
}
tree.append(subject_node)
return tree
# ==================== 全局单例实例(自动初始化)====================
neo4j_client = Neo4jUtil(
uri=NEO4J_URI,

20
vue/src/api/graph.js

@ -47,3 +47,23 @@ export function getCheckTree() {
method: 'get'
});
}
export function getDiseaseDepartTree() {
return request({
url: '/api/disease-depart-tree',
method: 'get'
});
}
export function getDiseaseTree() {
return request({
url: '/api/disease-tree',
method: 'get'
});
}
export function getDrugSubjectTree() {
return request({
url: '/api/drug-subject-tree',
method: 'get'
});
}

7
vue/src/api/style.js

@ -72,6 +72,13 @@ export function getGraphStyleGroups() {
});
}
export function getGraphStyleActive() {
return request({
url: '/api/graph/style/active',
method: 'get'
});
}
/**
* 获取所有图谱样式配置列表
* 保留此接口用于兼容旧版逻辑或后台管理

BIN
vue/src/assets/下拉11.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 B

BIN
vue/src/assets/图标5.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

3
vue/src/components/Menu.vue

@ -65,6 +65,7 @@ import i1 from '@/assets/图标1.png';
import i2 from '@/assets/图标2.png';
import i3 from '@/assets/图标3.png';
import i4 from '@/assets/图标4.png';
import i5 from '@/assets/图标5.png';
import { getUserProfile } from "@/api/profile";
const router = useRouter();
@ -102,7 +103,7 @@ const menuItems = [
{ name: '知识图谱构建', path: '/kg-builder', icon: i2 },
{ name: '知识图谱问答', path: '/kg-qa', icon: i3 },
{ name: '知识图谱数据', path: '/kg-data', icon: i4 },
{ name: '图谱样式工具', path: '/kg-style', icon: i4 }
{ name: '图谱样式工具', path: '/kg-style', icon: i5 }
];
const handleMenuClick = (i) => {

12
vue/src/system/GraphBuilder.vue

@ -20,12 +20,12 @@
<div class="wordName" style="text-align: left;margin-bottom: 1px;font-size: 11px;line-height: 14px;">{{msg.file.name}}</div>
<div style=" align-items: center;height: 18px;font-size: 10px; line-height: 12px;display: flex;">{{msg.file.size}}</div>
</div>
<img
src="../assets/close.svg"
alt="close"
class="close-btn"
@click="removeFile"
/>
<!-- <img-->
<!-- src="../assets/close.svg"-->
<!-- alt="close"-->
<!-- class="close-btn"-->
<!-- @click="removeFile"-->
<!-- />-->
</div>
<div v-if="msg.role === 'user'" style="display: flex;align-items: flex-start; justify-content: flex-end;">

558
vue/src/system/GraphDemo.vue

@ -8,7 +8,7 @@
<header class="top-nav">
<div class="search-container">
<input v-model="searchKeyword" type="text" placeholder="搜索......" @keydown.enter="search"/>
<img src="@/assets/搜索.png" class="search-btn" @click="search"/>
<img src="@/assets/搜索.png" style="cursor: pointer" class="search-btn" @click="search"/>
</div>
<div class="legend-box">
<div v-for="(item, index) in legendItems" :key="index" class="legend-item">
@ -24,10 +24,10 @@
</div>
</div>
</header>
<section class="main-content">
<div class="disease-container">
<div class="disease-header" :style="headerStyle">
<div style="display: flex;align-items: center;">
<div class="d-title"><img :src="iconSrc" class="d-icon"/>
<span v-if="typeRadio=== 'Disease'">疾病信息</span>
<span v-if="typeRadio=== 'Drug'">药品信息</span>
@ -38,20 +38,77 @@
<div v-if="typeRadio=== 'Check'" class="d-count" :style="headerLabel">{{checkCount.toLocaleString()}}</div>
</div>
<div>
<el-radio-group v-model="typeRadio" @change="changeTree">
<!-- 替换开始自定义下拉选择器使用 div 实现 -->
<div class="select-container" @click="toggleDropdown">
<!-- 当前选中项显示 -->
<div class="select-display">
<span style="color: #000; background: rgb(255 255 255 / 64%);
padding: 2px 13px;
border-radius: 15px;">{{ currentTypeLabel }}</span>
<img src="../assets/下拉11.png" class="dropdown-icon" />
</div>
<!-- 下拉菜单 -->
<div v-show="isDropdownOpen" class="select-dropdown">
<div
v-for="item in typeOptions"
:key="item.value"
class="select-option"
@click.stop="selectType(item.value)"
>
{{ item.label }}
</div>
</div>
</div>
<!-- 替换结束 -->
</div>
</div>
<!-- <div>-->
<!-- <el-radio-group v-model="typeRadio" @change="changeTree">-->
<!-- <el-radio-->
<!-- value="Disease"-->
<!-- :class="{'radio-disease': typeRadio === 'Disease'}"-->
<!-- >疾病</el-radio>-->
<!-- <el-radio-->
<!-- value="Drug"-->
<!-- :class="{'radio-drug': typeRadio === 'Drug'}"-->
<!-- >药品</el-radio>-->
<!-- <el-radio-->
<!-- value="Check"-->
<!-- :class="{'radio-check': typeRadio === 'Check'}"-->
<!-- >检查</el-radio>-->
<!-- </el-radio-group>-->
<!-- </div>-->
<div v-if="typeRadio === 'Disease'">
<el-radio-group v-model="DiseaseRadio" @change="changeTree">
<el-radio
value="Disease"
value="ICD10"
:class="{'radio-disease': typeRadio === 'Disease'}"
>疾病</el-radio>
>ICD10</el-radio>
<el-radio
value="Drug"
value="Department"
:class="{'radio-disease': typeRadio === 'Disease'}"
>科室</el-radio>
<el-radio
value="SZM"
:class="{'radio-disease': typeRadio === 'Disease'}"
>首字母</el-radio>
</el-radio-group>
</div>
<div v-if="typeRadio === 'Drug'">
<el-radio-group v-model="DrugRadio" @change="changeTree">
<el-radio
value="Subject"
:class="{'radio-drug': typeRadio === 'Drug'}"
>药品</el-radio>
>药物分类</el-radio>
<el-radio
value="Check"
:class="{'radio-check': typeRadio === 'Check'}"
>检查</el-radio>
value="SZM"
:class="{'radio-drug': typeRadio === 'Drug'}"
>首字母</el-radio>
</el-radio-group>
</div>
<div class="disease-body">
@ -93,7 +150,15 @@
</template>
<script>
import {getCheckTree, getCount, getDrugTree, getGraph, getTestGraphData} from "@/api/graph"
import {
getCheckTree,
getCount,
getDiseaseDepartTree,
getDiseaseTree, getDrugSubjectTree,
getDrugTree,
getGraph,
getTestGraphData
} from "@/api/graph"
import {Graph, Tooltip} from '@antv/g6';
import Menu from "@/components/Menu.vue";
import {a} from "vue-router/dist/devtools-EWN81iOl.mjs";
@ -101,6 +166,7 @@ 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,GraphToolbar},
@ -146,8 +212,11 @@ export default {
label: 'title' // el-tree
},
typeRadio:"Disease",
DiseaseRadio:"ICD10",
drugTree:[],
diseaseTree:[],
diseaseDepartTree:[],
diseaseICD10Tree:[],
diseaseSZMTree:[],
checkTree:[],
legendItems: [
{ key: 'Disease', label: '疾病', color: '#EF4444' },
@ -166,10 +235,31 @@ export default {
nodes: [],
edges: []
},
searchKeyword:""
searchKeyword:"",
drugSubjectTree:[],
DrugRadio:"Subject",
isDropdownOpen: false,
typeOptions: [
{ value: 'Disease', label: '疾病' },
{ value: 'Drug', label: '药品' },
{ value: 'Check', label: '检查' }
],
configs:[],
parsedStyles:{},
enToZhLabelMap: {
Disease: '疾病',
Drug: '药品',
Check: '检查',
Symptom: '症状',
Other: '其他'
}
}
},
computed: {
currentTypeLabel() {
const option = this.typeOptions.find(opt => opt.value === this.typeRadio);
return option ? option.label : '请选择';
},
//
headerStyle() {
let backgroundColor = '#fff'; //
@ -225,47 +315,76 @@ export default {
// #790800
},
async mounted() {
this.visibleCategories = new Set(this.legendItems.map(i => i.key));
await this.loadDiseaseTreeData()
await this.loadDiseaseICD10TreeData()
this.loadDiseaseDepartTreeData()
this.loadDiseaseSZMTreeData()
this.getCount()
this.loadDrugTreeData()
this.loadCheckTreeData()
this.treeData=this.diseaseTree
this.loadDrugSubjectTreeData()
this.treeData=this.diseaseICD10Tree
await this.$nextTick();
try {
await this.getDefault()
const response = await getTestGraphData(); // Promise
const updatedNodes = response.nodes.map(node => ({
// === 1. nodeId label ===
const nodeIdToEnLabel = {};
response.nodes.forEach(node => {
nodeIdToEnLabel[node.id] = node.data.label; // e.g. "Disease"
});
// === 2. label ===
const updatedNodes = response.nodes.map(node => {
const enLabel = node.data.label;
const zhLabel = this.enToZhLabelMap[enLabel] || '其他'; // 退
const styleConf = this.parsedStyles[zhLabel] || {};
return {
...node,
type: this.nodeShape,
type: styleConf.nodeShape || this.nodeShape,
style: {
size: this.nodeSize,
fill: this.nodeFill,
stroke: this.nodeStroke,
lineWidth: this.nodeLineWidth,
label: this.nodeShowLabel,
labelFontSize: this.nodeFontSize,
labelFontFamily: this.nodeFontFamily,
labelFill: this.nodeFontColor,
size: styleConf.nodeSize || this.nodeSize,
fill: styleConf.nodeFill || this.nodeFill,
stroke: styleConf.nodeStroke || this.nodeStroke,
lineWidth: styleConf.nodeLineWidth || this.nodeLineWidth,
label: styleConf.nodeShowLabel !== undefined ? styleConf.nodeShowLabel : true,
labelFontSize: styleConf.nodeFontSize || this.nodeFontSize,
labelFontFamily: styleConf.nodeFontFamily || this.nodeFontFamily,
labelFill: styleConf.nodeFontColor || this.nodeFontColor
}
}))
const updatedEdges = response.edges.map(edge => ({
};
});
// === 3. source label ===
const updatedEdges = response.edges.map(edge => {
console.log(edge)
const sourceEnLabel = nodeIdToEnLabel[edge.source]; // e.g. "Disease"
const sourceZhLabel = this.enToZhLabelMap[sourceEnLabel] || '其他';
const styleConf = this.parsedStyles[sourceZhLabel] || {};
return {
...edge,
id: edge.data.relationship.id,
type: this.edgeType,
id: edge.data?.relationship?.id || edge.id,
type: styleConf.edgeType ||this.edgeType,
style: {
endArrow: this.edgeEndArrow,
stroke: this.edgeStroke,
lineWidth: this.edgeLineWidth,
label: this.edgeShowLabel,
labelFontSize: this.edgeFontSize,
labelFontFamily: this.edgeFontFamily,
labelFill: this.edgeFontColor,
},
}))
const updatedData = {
endArrow: styleConf.edgeEndArrow !== undefined ? styleConf.edgeEndArrow : true,
stroke: styleConf.edgeStroke || this.edgeStroke,
lineWidth: styleConf.edgeLineWidth || this.edgeLineWidth,
label: styleConf.edgeShowLabel !== undefined ? styleConf.edgeShowLabel : false,
labelFontSize: styleConf.edgeFontSize || this.edgeFontSize,
labelFontFamily: styleConf.edgeFontFamily || this.edgeFontFamily,
labelFill: styleConf.edgeFontColor || this.edgeFontColor
}
};
});
// === 4. ===
let updatedData = {
nodes: updatedNodes,
edges: updatedEdges
}
};
console.log(updatedData)
this.defaultData = updatedData
setTimeout(() => {
this.initGraph();
@ -275,6 +394,50 @@ export default {
} catch (error) {
console.error('加载图谱数据失败:', error);
}
// try {
// await this.getDefault()
// const response = await getTestGraphData(); // Promise
// const updatedNodes = response.nodes.map(node => ({
// ...node,
// type: this.nodeShape,
// style:{
// size: this.nodeSize,
// fill: this.nodeFill,
// stroke: this.nodeStroke,
// lineWidth: this.nodeLineWidth,
// label: this.nodeShowLabel,
// labelFontSize: this.nodeFontSize,
// labelFontFamily: this.nodeFontFamily,
// labelFill: this.nodeFontColor,
// }
// }))
// const updatedEdges = response.edges.map(edge => ({
// ...edge,
// id: edge.data.relationship.id,
// type: this.edgeType,
// style: {
// endArrow: this.edgeEndArrow,
// stroke: this.edgeStroke,
// lineWidth: this.edgeLineWidth,
// label: this.edgeShowLabel,
// labelFontSize: this.edgeFontSize,
// labelFontFamily: this.edgeFontFamily,
// labelFill: this.edgeFontColor,
// },
// }))
// const updatedData = {
// nodes: updatedNodes,
// edges: updatedEdges
// }
// this.defaultData = updatedData
// setTimeout(() => {
// this.initGraph();
// this.buildCategoryIndex();
// window.addEventListener('resize', this.handleResize);
// }, 1000);
// } catch (error) {
// console.error(':', error);
// }
},
@ -310,6 +473,49 @@ export default {
edgeFontFamily: 'updateAllEdges',
},
methods: {
safeParseStyles(stylesStr) {
try {
return JSON.parse(stylesStr || '{}');
} catch (e) {
console.warn('Failed to parse styles:', stylesStr);
return {};
}
},
async getDefault(){
const response = await getGraphStyleActive();
const data = response.data;
if (!Array.isArray(data) || data.length === 0) {
this.configs = [];
this.parsedStyles = {};
return;
}
// is_active=1
const activeGroup = data[0];
this.configs = Array.isArray(activeGroup.configs) ? activeGroup.configs : [];
// label -> style
const styleMap = {};
this.configs.forEach(config => {
const label = config.current_label;
styleMap[label] = this.safeParseStyles(config.styles);
});
this.parsedStyles = styleMap;
console.log(this.parsedStyles)
},
//
toggleDropdown() {
this.isDropdownOpen = !this.isDropdownOpen;
},
//
selectType(value) {
if (this.typeRadio !== value) {
this.typeRadio = value;
this.changeTree(); //
}
this.isDropdownOpen = false;
},
search() {
const keyword = this.searchKeyword?.trim();
if (!keyword) {
@ -543,53 +749,59 @@ export default {
changeTree(){
if(this.typeRadio=="Disease"){
this.treeData=this.diseaseTree
if(this.DiseaseRadio=="ICD10") {
this.treeData=this.diseaseICD10Tree
}
if(this.DiseaseRadio=="Department") {
this.treeData=this.diseaseDepartTree
}
if(this.DiseaseRadio=="SZM") {
this.treeData=this.diseaseSZMTree
}
}
if(this.typeRadio=="Drug") {
if(this.DrugRadio=="Subject") {
this.treeData=this.drugSubjectTree
}
if(this.DrugRadio=="SZM") {
this.treeData=this.drugTree
}
}
if(this.typeRadio=="Check") {
this.treeData=this.checkTree
}
},
loadTreeNode(node, resolve, data) {
//
let apiCall;
if (this.typeRadio === 'Disease') {
// ICD-10 level chapter, section
// code id
apiCall = () => this.loadDiseaseTreeData();
} else if (this.typeRadio === 'Drug') {
apiCall = () => this.loadDrugTreeData(); // API
} else if (this.typeRadio === 'Check') {
apiCall = () => this.loadCheckTreeData();
}
if (apiCall) {
apiCall()
.then(children => {
// children labelcode/id
// []
resolve(children || []);
})
.catch(err => {
console.error('加载子节点失败:', err);
resolve([]); //
});
} else {
resolve([]);
}
},
async loadDiseaseTreeData() {
async loadDiseaseICD10TreeData() {
try {
const res = await fetch('/icd10.json')
if (!res.ok) throw new Error('Failed to load JSON')
this.diseaseTree = await res.json()
console.log(this.treeData)
this.diseaseICD10Tree = await res.json()
} catch (error) {
console.error('加载 ICD-10 数据失败:', error)
this.$message.error('加载编码数据失败,请检查文件路径')
}
},
async loadDiseaseDepartTreeData() {
try {
const res = await getDiseaseDepartTree()
this.diseaseDepartTree = res
} catch (error) {
}
},
async loadDiseaseSZMTreeData() {
try {
const res = await getDiseaseTree()
this.diseaseSZMTree = res
} catch (error) {
}
},
async loadDrugSubjectTreeData() {
try {
const res = await getDrugSubjectTree()
this.drugSubjectTree = res
} catch (error) {
}
},
async loadDrugTreeData() {
try {
const res = await getDrugTree()
@ -623,6 +835,11 @@ export default {
const response = await getGraph(data); // Promise
this.formatData(response)
}
if(data.type === "Disease"){
data.type="Disease"
const response = await getGraph(data); // Promise
this.formatData(response)
}
},
buildNodeLabelMap(nodes) {
this._nodeLabelMap = new Map();
@ -648,37 +865,63 @@ export default {
formatData(data){
this._graph.stopLayout();
this.clearGraphState();
const updatedEdges = data.edges.map(edge => ({
...edge,
type: this.edgeType,
style: {
endArrow: this.edgeEndArrow,
stroke: this.edgeStroke,
lineWidth: this.edgeLineWidth,
label: this.edgeShowLabel,
labelFontSize: this.edgeFontSize,
labelFontFamily: this.edgeFontFamily,
labelFill: this.edgeFontColor,
},
}))
const updatedNodes = data.nodes.map(node => ({
// === 1. nodeId label ===
const nodeIdToEnLabel = {};
data.nodes.forEach(node => {
nodeIdToEnLabel[node.id] = node.data.label; // e.g. "Disease"
});
// === 2. label ===
const updatedNodes = data.nodes.map(node => {
const enLabel = node.data.label;
const zhLabel = this.enToZhLabelMap[enLabel] || '其他'; // 退
const styleConf = this.parsedStyles[zhLabel] || {};
return {
...node,
type: this.nodeShape,
type: styleConf.nodeShape || this.nodeShape,
style: {
size: this.nodeSize,
lineWidth: this.nodeLineWidth,
label: this.nodeShowLabel,
labelFontSize: this.nodeFontSize,
labelFontFamily: this.nodeFontFamily,
labelFill: this.nodeFontColor,
opacity: 1,
size: styleConf.nodeSize || this.nodeSize,
fill: styleConf.nodeFill || this.nodeFill,
stroke: styleConf.nodeStroke || this.nodeStroke,
lineWidth: styleConf.nodeLineWidth || this.nodeLineWidth,
label: styleConf.nodeShowLabel !== undefined ? styleConf.nodeShowLabel : true,
labelFontSize: styleConf.nodeFontSize || this.nodeFontSize,
labelFontFamily: styleConf.nodeFontFamily || this.nodeFontFamily,
labelFill: styleConf.nodeFontColor || this.nodeFontColor
}
}))
const updatedData = {
};
});
// === 3. source label ===
const updatedEdges = data.edges.map(edge => {
console.log(edge)
const sourceEnLabel = nodeIdToEnLabel[edge.source]; // e.g. "Disease"
const sourceZhLabel = this.enToZhLabelMap[sourceEnLabel] || '其他';
const styleConf = this.parsedStyles[sourceZhLabel] || {};
return {
...edge,
id: edge.data?.relationship?.id || edge.id,
type: styleConf.edgeType ||this.edgeType,
style: {
endArrow: styleConf.edgeEndArrow !== undefined ? styleConf.edgeEndArrow : true,
stroke: styleConf.edgeStroke || this.edgeStroke,
lineWidth: styleConf.edgeLineWidth || this.edgeLineWidth,
label: styleConf.edgeShowLabel !== undefined ? styleConf.edgeShowLabel : false,
labelFontSize: styleConf.edgeFontSize || this.edgeFontSize,
labelFontFamily: styleConf.edgeFontFamily || this.edgeFontFamily,
labelFill: styleConf.edgeFontColor || this.edgeFontColor
}
};
});
// === 4. ===
let updatedData = {
nodes: updatedNodes,
edges: updatedEdges
}
};
this.buildNodeLabelMap(updatedNodes);
this.updateGraph(updatedData)
this.buildCategoryIndex();
@ -745,10 +988,11 @@ export default {
},
autoFit: {
type: 'center',
type: 'center', // 'view' 'center'
options: {
when: 'first',
direction: 'both',
// 'view'
when: 'always', // 'overflow'() 'always'()
direction: 'both', // 'x''y' 'both'
},
animation: {
//
@ -772,27 +1016,27 @@ export default {
node: {
style: {
fill: (d) => {
const label = d.data?.label;
if (label === 'Disease') return '#EF4444'; //
if (label === 'Drug') return '#91cc75'; // 绿
if (label === 'Symptom') return '#fac858'; //
if (label === 'Check') return '#336eee'; //
return '#59d1d4'; //
},
stroke: (d) => {
const label = d.data?.label;
if (label === 'Disease') return '#B91C1C';
if (label === 'Drug') return '#047857';
if (label === 'Check') return '#1D4ED8'; //
if (label === 'Symptom') return '#B45309';
return '#40999b';
},
// fill: (d) => {
// const label = d.data?.label;
// if (label === 'Disease') return '#EF4444'; //
// if (label === 'Drug') return '#91cc75'; // 绿
// if (label === 'Symptom') return '#fac858'; //
// if (label === 'Check') return '#336eee'; //
// return '#59d1d4'; //
// },
// stroke: (d) => {
// const label = d.data?.label;
// if (label === 'Disease') return '#B91C1C';
// if (label === 'Drug') return '#047857';
// if (label === 'Check') return '#1D4ED8'; //
// if (label === 'Symptom') return '#B45309';
// return '#40999b';
// },
labelText: (d) => d.data.name,
labelPlacement: 'center',
labelWordWrap: true,
labelMaxWidth: '150%',
labelMaxLines: 3,
labelMaxWidth: '100%',
labelMaxLines: 2,
labelTextOverflow: 'ellipsis',
labelTextAlign: 'center',
opacity: 1
@ -822,16 +1066,16 @@ export default {
edge: {
style: {
labelText: (d) => d.data.relationship.properties.label,
stroke: (d) => {
// target label
const targetLabel = this._nodeLabelMap.get(d.source); // d.target ID
// target
if (targetLabel === 'Disease') return 'rgba(239,68,68,0.5)';
if (targetLabel === 'Drug') return 'rgba(145,204,117,0.5)';
if (targetLabel === 'Symptom') return 'rgba(250,200,88,0.5)';
if (targetLabel === 'Check') return 'rgba(51,110,238,0.5)'; //
return 'rgba(89,209,212,0.5)'; // default
},
// stroke: (d) => {
// // target label
// const targetLabel = this._nodeLabelMap.get(d.source); // d.target ID
// // target
// if (targetLabel === 'Disease') return 'rgba(239,68,68,0.5)';
// if (targetLabel === 'Drug') return 'rgba(145,204,117,0.5)';
// if (targetLabel === 'Symptom') return 'rgba(250,200,88,0.5)';
// if (targetLabel === 'Check') return 'rgba(51,110,238,0.5)'; //
// return 'rgba(89,209,212,0.5)'; // default
// },
// labelFill: (d) => {
// // target label
// const targetLabel = this._nodeLabelMap.get(d.target); // d.target ID
@ -1302,8 +1546,13 @@ button:hover {
.search-btn {
width: 16px;
}
}
/*.search-btn:hover {
background-color: rgba(34, 101, 244, 0.64);
border-radius: 50%;
padding: 2px 0;
}*/
.legend-box {
display: flex;
gap: 20px;
@ -1335,7 +1584,7 @@ button:hover {
border: 1px solid #f0f0f0;
border-radius: 12px;
overflow: hidden;
max-height: 85vh;
}
.disease-header {
@ -1344,8 +1593,8 @@ button:hover {
color: #fff;
display: flex;
align-items: center;
padding: 0 15px;
justify-content: flex-start;
padding: 0 2px 0px 15px;
justify-content: space-between;
}
.d-title {
@ -1460,6 +1709,7 @@ button:hover {
}
/* 自定义选中后的样式 */
/deep/ .radio-disease .el-radio__input.is-checked + .el-radio__label {
color: rgb(153, 10, 0);
@ -1472,4 +1722,50 @@ button:hover {
/deep/ .radio-check .el-radio__input.is-checked + .el-radio__label {
color: #1890ff;
}
/* 自定义下拉样式 */
.select-container {
position: relative;
cursor: pointer;
user-select: none;
}
.select-display {
display: flex;
align-items: center;
padding: 0 12px;
height: 25px;
border-radius: 20px;
font-size: 12px;
}
.dropdown-icon {
width: 23px;
margin-left: 6px;
}
.select-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
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);
z-index: 10;
min-width: 80px;
color: #000;
padding: 3px 0px;
}
.select-option {
padding: 7px 12px;
font-size: 13px;
text-align: center;
}
.select-option:hover {
background-color: #f5f7fa;
}
</style>

494
vue/src/system/GraphQA.vue

@ -81,10 +81,9 @@
import Menu from "@/components/Menu.vue";
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, GraphToolbar },
@ -94,7 +93,7 @@ export default {
query: "",
answers: [],
selected: 0,
//
//
nodeShowLabel: true,
nodeFontSize: 12,
nodeFontColor: '#fff',
@ -105,7 +104,7 @@ export default {
nodeLineWidth: 2,
nodeFontFamily: 'Microsoft YaHei, sans-serif',
//
//
edgeShowLabel: true,
edgeFontSize: 10,
edgeFontColor: '#666666',
@ -115,51 +114,77 @@ export default {
edgeEndArrow: true,
edgeFontFamily: 'Microsoft YaHei, sans-serif',
queryRecord: "",
isSending:false
isSending: false,
configs: [],
parsedStyles: {},
enToZhLabelMap: {
Disease: '疾病',
Drug: '药品',
Check: '检查',
Symptom: '症状',
Other: '其他'
}
};
},
// =============== 👇 ===============
beforeRouteLeave(to, from, next) {
this.saveDataToLocalStorage();
next(); //
next();
},
// =======================================================================
mounted() {
// =============== 👇 localStorage ===============
async mounted() {
await this.getDefault();
this.restoreDataFromLocalStorage();
// =======================================================================
// this.answers=[]
//
if (this.answers.length > 0) {
this.initGraph(this.answers[0].result);
// console.log(this.answers[0].result)
this.initGraph(this.answers[this.selected].result);
}
},
beforeUnmount() {
// =============== 👇==============
this.saveDataToLocalStorage();
// beforeunload
window.removeEventListener('beforeunload', this.handleBeforeUnload);
// =======================================================================
},
created() {
// =============== 👇/ ===============
window.addEventListener('beforeunload', this.handleBeforeUnload);
// =======================================================================
},
methods: {
safeParseStyles(stylesStr) {
try {
return JSON.parse(stylesStr || '{}');
} catch (e) {
console.warn('Failed to parse styles:', stylesStr);
return {};
}
},
async getDefault() {
const response = await getGraphStyleActive();
const data = response.data;
if (!Array.isArray(data) || data.length === 0) {
this.configs = [];
this.parsedStyles = {};
return;
}
const activeGroup = data[0];
this.configs = Array.isArray(activeGroup.configs) ? activeGroup.configs : [];
const styleMap = {};
this.configs.forEach(config => {
const label = config.current_label;
styleMap[label] = this.safeParseStyles(config.styles);
});
this.parsedStyles = styleMap;
},
buildNodeLabelMap(nodes) {
this._nodeLabelMap = new Map();
nodes.forEach(node => {
this._nodeLabelMap.set(node.id, node.data?.type || 'default');
});
},
// =============== 👇 ===============
saveDataToLocalStorage() {
try {
localStorage.setItem('graphQA_queryRecord', this.queryRecord);
@ -169,72 +194,53 @@ export default {
console.warn('⚠️ 无法保存到 localStorage:', e);
}
},
// =======================================================================
// =============== 👇 ===============
restoreDataFromLocalStorage() {
try {
const savedQuery = localStorage.getItem('graphQA_queryRecord');
const savedAnswers = localStorage.getItem('graphQA_answers');
if (savedQuery !== null) {
this.queryRecord = savedQuery;
}
if (savedQuery !== null) this.queryRecord = savedQuery;
if (savedAnswers !== null) {
this.answers = JSON.parse(savedAnswers);
// selected
if (this.answers.length > 0) {
this.selected = Math.min(this.selected, this.answers.length - 1);
}
}
console.log('✅ 数据已从 localStorage 恢复');
} catch (e) {
console.warn('⚠️ 无法从 localStorage 恢复数据:', e);
//
localStorage.removeItem('graphQA_queryRecord');
localStorage.removeItem('graphQA_answers');
}
},
// =======================================================================
// =============== 👇/ ===============
handleBeforeUnload(event) {
this.saveDataToLocalStorage();
//
event.preventDefault();
event.returnValue = ''; //
event.returnValue = '';
},
selectGraph(index) {
this.selected=index
this.selected = index;
if (this.answers.length > 0) {
this.formatData(this.answers[index].result)
this.formatData(this.answers[index].result);
}
},
handleSearch() {
alert('方法触发成功!');
console.log('--- 1. 发起搜索,参数为:', this.query);
this.isSending = true;
this.answers = [];
if (this._graph) {
this._graph.clear();
}
if (this._graph) this._graph.clear();
let data = { text: this.query };
this.queryRecord = this.query;
this.query = "";
// 1.
qaAnalyze(data).then(res => {
console.log('--- 2. 接口响应成功 ---');
this.answers = res;
if (this.answers && this.answers.length > 0) {
this.initGraph(this.answers[0].result);
}
this.isSending = false;
}).catch(err => {
console.error('--- 2. 接口失败,启动保底方案 ---', err);
console.error('接口失败,启动保底方案', err);
const mockData = {
nodes: [
{ id: "node1", label: "霍乱", data: { type: "疾病" } },
@ -243,7 +249,6 @@ export default {
{ id: "node4", label: "呕吐", data: { type: "疾病" } },
{ id: "node5", label: "霍乱弧菌", data: { type: "病因" } },
{ id: "node6", label: "复方磺胺", data: { type: "药品" } },
],
edges: [
{ id: "e1", source: "node1", target: "node2", data: { label: "典型症状" } },
@ -253,100 +258,103 @@ export default {
{ id: "e5", source: "node1", target: "node6", data: { label: "推荐用药" } },
]
};
//
this.answers = [{
answer: "后端接口连接失败,当前显示的是预览版图谱。",
result: mockData
}];
//
this.answers = [{ answer: "连接失败,显示预览版。", result: mockData }];
this.initGraph(this.answers[0].result);
this.isSending = false;
this.$message.warning("已切换至离线预览模式");
});
},
formatData(data) {
const typeMap = {
'疾病': 'Disease',
'药品': 'Drug',
'药物': 'Drug',
'症状': 'Symptom',
'检查': 'Check'
const typeMap = { '疾病': 'Disease', '药品': 'Drug', '药物': 'Drug', '症状': 'Symptom', '检查': 'Check', '病因': 'Cause' };
const getStandardLabel = (rawType) => typeMap[rawType] || rawType;
const nodeIdToData = {};
data.nodes.forEach(node => {
nodeIdToData[node.id] = {
enLabel: getStandardLabel(node.data.type),
rawType: node.data.type
};
});
const updatedEdges = data.edges.map(edge => ({
...edge,
type: this.edgeType,
data: {
...edge.data,
label: edge.data?.label || ""
},
style: {
endArrow: this.edgeEndArrow,
stroke: this.edgeStroke,
lineWidth: this.edgeLineWidth,
label: this.edgeShowLabel,
labelFontSize: this.edgeFontSize,
labelFontFamily: this.edgeFontFamily,
labelFill: this.edgeFontColor,
},
}));
//
const updatedNodes = data.nodes.map(node => {
// 1. 使
const englishLabel = typeMap[node.data?.type] || 'Other';
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;
}
// 2. return
return {
...node,
type: this.nodeShape,
data: {
...node.data,
label: englishLabel, // (GraphToolbar colorMap)
name: node.label // (GraphToolbar node.data.name)
},
type: styleConf.nodeShape || this.nodeShape,
data: { ...node.data, label: enLabel, name: node.label },
style: {
...node.style, // style x, y
size: this.nodeSize,
lineWidth: this.nodeLineWidth,
label: this.nodeShowLabel,
labelFontSize: this.nodeFontSize,
labelFontFamily: this.nodeFontFamily,
labelFill: this.nodeFontColor,
opacity: 1,
...node.style,
size: styleConf.nodeSize || this.nodeSize,
fill: fColor,
stroke: styleConf.nodeStroke || this.nodeStroke,
lineWidth: styleConf.nodeLineWidth || this.nodeLineWidth,
label: styleConf.nodeShowLabel !== undefined ? styleConf.nodeShowLabel : true,
labelFontSize: styleConf.nodeFontSize || this.nodeFontSize,
labelFontFamily: styleConf.nodeFontFamily || this.nodeFontFamily,
labelFill: styleConf.nodeFontColor || this.nodeFontColor
}
};
});
const updatedData = {
nodes: updatedNodes,
edges: updatedEdges
const updatedEdges = data.edges.map(edge => {
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,
style: {
...edge.style,
endArrow: styleConf.edgeEndArrow !== undefined ? styleConf.edgeEndArrow : true,
stroke: eStroke,
lineWidth: styleConf.edgeLineWidth || this.edgeLineWidth,
label: styleConf.edgeShowLabel !== undefined ? styleConf.edgeShowLabel : false,
labelFontSize: styleConf.edgeFontSize || this.edgeFontSize,
labelFontFamily: styleConf.edgeFontFamily || this.edgeFontFamily,
labelFill: styleConf.edgeFontColor || this.edgeFontColor
},
data: { ...edge.data, label: edge.data?.label || "" },
};
});
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()
updateGraph(data) {
if (!this._graph) return;
this._graph.setData(data);
this._graph.render();
},
localResetGraph() {
if (!this._graph) return;
// 1.
const currentResult = this.answers[this.selected]?.result;
if (!currentResult) return;
// 2. EventBoundary
this._graph.destroy();
this._graph = null;
// 3. initGraph
// initGraph render
this.$nextTick(() => {
this.initGraph(currentResult);
this.$message.success("图谱已重置");
@ -354,65 +362,95 @@ export default {
},
initGraph(data) {
console.log('--- 触发了 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.destroy();
this._graph = null;
}
const updatedEdges = data.edges.map(edge => ({
...edge,
type: this.edgeType,
data: {
...edge.data,
label: edge.data?.label || ""
},
style: {
endArrow: this.edgeEndArrow,
stroke: this.edgeStroke,
lineWidth: this.edgeLineWidth,
label: this.edgeShowLabel,
labelFontSize: this.edgeFontSize,
labelFontFamily: this.edgeFontFamily,
labelFill: this.edgeFontColor,
const nodeIdToData = {};
data.nodes.forEach(node => {
nodeIdToData[node.id] = {
enLabel: getStandardLabel(node.data.type),
rawType: node.data.type
};
});
},
}))
const typeMap = { '疾病': 'Disease', '药品': 'Drug', '药物': 'Drug', '症状': 'Symptom', '检查': 'Check' };
const updatedNodes = data.nodes.map(node => {
const englishLabel = typeMap[node.data?.type] || 'Other';
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: this.nodeShape,
data: {
...node.data,
label: englishLabel, //
name: node.label //
},
type: styleConf.nodeShape || this.nodeShape,
data: { ...node.data, label: enLabel, name: node.label },
style: {
...node.style,
size: this.nodeSize,
lineWidth: this.nodeLineWidth,
label: this.nodeShowLabel,
labelFontSize: this.nodeFontSize,
labelFontFamily: this.nodeFontFamily,
labelFill: this.nodeFontColor,
opacity: 1,
size: styleConf.nodeSize || this.nodeSize,
fill: fColor,
stroke: styleConf.nodeStroke || this.nodeStroke,
lineWidth: styleConf.nodeLineWidth || this.nodeLineWidth,
label: styleConf.nodeShowLabel !== undefined ? styleConf.nodeShowLabel : true,
labelFontSize: styleConf.nodeFontSize || this.nodeFontSize,
labelFontFamily: styleConf.nodeFontFamily || this.nodeFontFamily,
labelFill: styleConf.nodeFontColor || this.nodeFontColor
}
};
});
const updatedEdges = data.edges.map(edge => {
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: eStroke,
lineWidth: styleConf.edgeLineWidth || this.edgeLineWidth,
label: styleConf.edgeShowLabel !== undefined ? styleConf.edgeShowLabel : false,
labelFontSize: styleConf.edgeFontSize || this.edgeFontSize,
labelFontFamily: styleConf.edgeFontFamily || this.edgeFontFamily,
labelFill: styleConf.edgeFontColor || this.edgeFontColor
}
};
});
const updatedData = {
const finalData = JSON.parse(JSON.stringify({
nodes: updatedNodes,
edges: updatedEdges
}
this.buildNodeLabelMap(updatedNodes);
}));
this.buildNodeLabelMap(finalData.nodes);
const container = this.$refs.graphContainer;
console.log(container)
if (container != null) {
const width = container.clientWidth || 800;
const height = container.clientHeight || 600;
console.log(width)
console.log(height)
const graph = new Graph({
container,
width,
@ -422,15 +460,10 @@ export default {
type: 'toolbar',
key: 'g6-toolbar',
onClick: (id) => {
if (id === 'reset') {
this.localResetGraph(); //
} else if (this.$refs.toolbarRef) {
// GraphToolbar.vue //
this.$refs.toolbarRef.handleToolbarAction(id);
}
if (id === 'reset') this.localResetGraph();
else if (this.$refs.toolbarRef) this.$refs.toolbarRef.handleToolbarAction(id);
},
getItems: () => {
return [
getItems: () => [
{ id: 'zoom-in', value: 'zoom-in', title: '放大' },
{ id: 'zoom-out', value: 'zoom-out', title: '缩小' },
{ id: 'undo', value: 'undo', title: '撤销' },
@ -438,57 +471,36 @@ export default {
{ id: 'auto-fit', value: 'auto-fit', title: '聚焦' },
{ id: 'reset', value: 'reset', title: '重置' },
{ id: 'export', value: 'export', title: '导出图谱' },
];
},
],
},
{ type: 'history', key: 'history' }, //
{ type: 'history', key: 'history' },
],
layout: {
type: 'force', //
gravity: 0.3, //
repulsion: 500, //
attraction: 20, //
preventOverlap: true //
},
behaviors: [ 'zoom-canvas', 'drag-element',
'click-select','focus-element', {
type: 'force',
gravity: 0.3,
repulsion: 500,
attraction: 20,
preventOverlap: true
},
behaviors: [
'zoom-canvas', 'drag-element', 'click-select', 'focus-element',
{
type: 'hover-activate',
degree: 1,
enable: (e) =>
{
return e.target && e.target.id && e.action !== 'drag';
}
enable: (e) => e.target && e.target.id && e.action !== 'drag'
},
{
type: 'drag-canvas',
enable: (event) => event.shiftKey === false,
},
{
type: 'brush-select',
},
{ type: 'brush-select' },
],
node: {
style: {
fill: (d) => {
const label = d.data?.type;
if (label === '疾病') return '#EF4444'; //
if (label === '药品'||label === '药物') return '#91cc75'; // 绿
if (label === '症状') return '#fac858'; //
if (label === '检查') return '#336eee'; //
return '#59d1d4'; //
},
stroke: (d) => {
const label = d.data?.type;
if (label === '疾病') return '#B91C1C';
if (label === '药品'||label === '药物') return '#047857';
if (label === '检查') return '#1D4ED8'; //
if (label === '症状') return '#B45309';
return '#40999b';
},
fill: (d) => d.style?.fill,
stroke: (d) => d.style?.stroke,
size: (d) => d.style?.size,
lineWidth: (d) => d.style?.lineWidth,
labelText: (d) => d.label,
labelPlacement: 'center',
labelWordWrap: true,
@ -496,54 +508,37 @@ export default {
labelMaxLines: 3,
labelTextOverflow: 'ellipsis',
labelTextAlign: 'center',
labelFill: (d) => d.style?.labelFill,
labelFontSize: (d) => d.style?.labelFontSize,
labelFontFamily: (d) => d.style?.labelFontFamily,
opacity: 1
},
state: {
active: {
lineWidth: 2,
shadowColor: '#ffffff',
shadowBlur: 10,
fill: (d) => d.style?.fill,
stroke: (d) => d.style?.stroke,
lineWidth: 3,
opacity: 1
},
inactive: {
opacity: 0.3
},
inactive: { opacity: 0.3 },
normal: {
fill: (d) => d.style?.fill,
stroke: (d) => d.style?.stroke,
opacity: 1
}
},
},
edge: {
style: {
labelText: (d) => {return d.data.label},
stroke: (d) => {
const targetLabel = this._nodeLabelMap.get(d.source); // d.target ID
if (targetLabel === '疾病') return 'rgba(239,68,68,0.5)';
if (targetLabel === '药品'||targetLabel === '药物') return 'rgba(145,204,117,0.5)';
if (targetLabel === '症状') return 'rgba(250,200,88,0.5)';
if (targetLabel === '检查') return 'rgba(51,110,238,0.5)'; //
return 'rgba(89,209,212,0.5)'; // default
},
// labelFill: (d) => {
// // target label
// const targetLabel = this._nodeLabelMap.get(d.target); // d.target ID
// // target
//
// if (targetLabel === 'Disease') return '#ff4444';
// if (targetLabel === 'Drug') return '#2f9b70';
// if (targetLabel === 'Symptom') return '#f89775';
// return '#6b91ff'; // default
// }
stroke: (d) => d.style?.stroke,
lineWidth: (d) => d.style?.lineWidth,
endArrow: (d) => d.style?.endArrow,
labelText: (d) => d.data?.label,
labelFill: (d) => d.style?.labelFill,
labelFontSize: (d) => d.style?.labelFontSize,
},
state: {
selected: {
stroke: '#1890FF',
lineWidth: 2,
},
selected: { stroke: '#1890FF', lineWidth: 2 },
highlight: {
halo: true,
haloStroke: '#1890FF',
@ -552,31 +547,19 @@ export default {
lineWidth: 3,
opacity: 1
},
inactive: {
opacity: 0.3
inactive: { opacity: 0.3 },
normal: { opacity: 1 }
},
normal:{
opacity: 1
}
},
},
data:updatedData,
data: finalData,
});
graph.render();
this._graph = graph
this._graph?.fitView()
this._graph = graph;
this._graph?.fitView();
}
},
},
};
</script>
<style scoped>
@ -756,8 +739,7 @@ export default {
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
} 40% {
transform: scale(1.0);
}
}

222
vue/src/system/GraphStyle.vue

@ -32,7 +32,7 @@
</div>
<div class="form-group">
<label>字体名称:</label>
<label>字体名称</label>
<select v-model="nodeFontFamily">
<option value="Microsoft YaHei, sans-serif">微软雅黑</option>
<option value="SimSun, serif">宋体SimSun</option>
@ -42,7 +42,7 @@
</div>
<div class="form-group">
<label>字体大小:</label>
<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>
@ -50,14 +50,15 @@
</div>
<div class="color-picker-item">
<label>字体颜色:</label>
<label>字体颜色</label>
<div class="color-picker-border">
<input v-model="nodeFontColor" type="color" class="square-picker"/>
<el-color-picker v-model="nodeFontColor" show-alpha class="square-picker"/>
<!-- <input v-model="nodeFontColor" type="color" class="square-picker"/>-->
</div>
</div>
<div class="form-group">
<label>图形:</label>
<label>图形</label>
<select v-model="nodeShape">
<option value="circle">圆形</option>
<option value="diamond">菱形</option>
@ -67,7 +68,7 @@
</div>
<div class="form-group">
<label>尺寸:</label>
<label>尺寸</label>
<input
:value="nodeSize"
type="number"
@ -78,21 +79,23 @@
</div>
<div class="color-picker-item">
<label>填充颜色:</label>
<label>填充颜色</label>
<div class="color-picker-border">
<input v-model="nodeFill" type="color" class="square-picker"/>
<el-color-picker v-model="nodeFill" show-alpha class="square-picker"/>
<!-- <input v-model="nodeFill" type="color" class="square-picker"/>-->
</div>
</div>
<div class="color-picker-item">
<label>边框颜色:</label>
<label>边框颜色</label>
<div class="color-picker-border">
<input v-model="nodeStroke" type="color" class="square-picker"/>
<el-color-picker v-model="nodeStroke" show-alpha class="square-picker"/>
<!-- <input v-model="nodeStroke" type="color" class="square-picker"/>-->
</div>
</div>
<div class="form-group">
<label>边框尺寸:</label>
<label>边框尺寸</label>
<input
:value="nodeLineWidth"
type="number"
@ -116,7 +119,7 @@
</div>
<div class="form-group">
<label>字体名称:</label>
<label>字体名称</label>
<select v-model="edgeFontFamily">
<option value="Microsoft YaHei, sans-serif">微软雅黑</option>
<option value="SimSun, serif">宋体</option>
@ -126,7 +129,7 @@
</div>
<div class="form-group">
<label>字体大小:</label>
<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>
@ -134,14 +137,15 @@
</div>
<div class="color-picker-item">
<label>字体颜色:</label>
<label>字体颜色</label>
<div class="color-picker-border">
<input v-model="edgeFontColor" type="color" class="square-picker"/>
<el-color-picker v-model="edgeFontColor" class="square-picker" show-alpha />
<!-- <input v-model="edgeFontColor" type="color" class="square-picker"/>-->
</div>
</div>
<div class="form-group">
<label>连边类型:</label>
<label>连边类型</label>
<select v-model="edgeType">
<option value="line">直线</option>
<option value="polyline">折线</option>
@ -151,7 +155,7 @@
</div>
<div class="form-group">
<label>线粗细:</label>
<label>线粗细</label>
<input
:value="edgeLineWidth"
type="number"
@ -162,9 +166,10 @@
</div>
<div class="color-picker-item">
<label>线条颜色:</label>
<label>线条颜色</label>
<div class="color-picker-border">
<input v-model="edgeStroke" type="color" class="square-picker"/>
<el-color-picker v-model="edgeStroke" class="square-picker" show-alpha />
<!-- <input v-model="edgeStroke" type="color" class="square-picker"/>-->
</div>
</div>
</div>
@ -321,7 +326,7 @@ import {
deleteGraphStyle,
batchDeleteGraphStyle,
deleteGraphStyleGroup,
applyGraphStyleGroup
applyGraphStyleGroup, getGraphStyleActive
} from '@/api/style';
import { ElMessageBox, ElMessage } from 'element-plus';
import { markRaw } from 'vue';
@ -381,7 +386,7 @@ export default {
edgeFontFamily: 'Microsoft YaHei, sans-serif',
edgeFontSize: 10,
edgeFontColor: '#666666',
edgeType: 'line',
edgeType: 'quadratic',
edgeLineWidth: 2,
edgeStroke: '#EF4444',
defaultData: {
@ -462,22 +467,31 @@ export default {
methods: {
handleEditConfig(item) {
if (this.saveTimer) clearTimeout(this.saveTimer);
// 退
if (this.editingConfigId === item.id) {
this.exitEditingMode();
return;
}
this.isInitialEcho = true;
this.editingConfigId = item.id;
this.editingConfigLabel = item.current_label;
this.activeTags = item.current_label;
const s = item.styles;
if (!s) {
this.isInitialEcho = false;
return;
}
//
this.applyStylesToPanel(s);
const labelEn = tagToLabelMap[item.current_label];
if (labelEn) this.tagStyles[labelEn] = JSON.parse(JSON.stringify(s));
// usingConfigIds
if (!this.usingConfigIds.includes(item.id)) {
this.styleGroups.forEach(g => {
g.configs.forEach(c => {
@ -488,11 +502,14 @@ export default {
});
this.usingConfigIds.push(item.id);
}
this.updateAllElements();
this.$nextTick(() => {
setTimeout(() => { this.isInitialEcho = false; }, 100);
});
},
//
applyStylesToPanel(s) {
this.nodeShowLabel = s.nodeShowLabel;
this.nodeFontFamily = s.nodeFontFamily;
@ -512,22 +529,32 @@ export default {
this.edgeLineWidth = s.edgeLineWidth;
this.edgeStroke = s.edgeStroke;
},
// 退
exitEditingMode() {
this.editingConfigId = null;
this.editingConfigLabel = '';
//
const labelEn = tagToLabelMap[this.activeTags];
const defaultStyle = this.getInitialTagParams(labelEn);
this.isInitialEcho = true;
// 1. UI
this.applyStylesToPanel(defaultStyle);
// 2. 使
this.tagStyles[labelEn] = JSON.parse(JSON.stringify(defaultStyle));
this.$nextTick(() => {
this.isInitialEcho = false;
this.updateAllElements();
});
ElMessage.info("已退出编辑模式,控制栏已恢复默认");
},
syncAndRefresh() {
if (this.isInitialEcho) return;
const labelEn = tagToLabelMap[this.activeTags];
if (!labelEn) return;
@ -539,6 +566,8 @@ export default {
edgeFontSize: this.edgeFontSize, edgeFontColor: this.edgeFontColor, edgeType: this.edgeType,
edgeLineWidth: this.edgeLineWidth, edgeStroke: this.edgeStroke
};
//
this.tagStyles[labelEn] = currentStyle;
this.updateAllElements();
@ -549,33 +578,35 @@ export default {
const currentEditId = this.editingConfigId;
this.saveTimer = setTimeout(async () => {
try {
// ID
const payload = {
id: currentEditId, // ID UPDATE
styles: currentStyle, //
is_auto_save: true, // GroupID
// canvas_name current_label 便
canvas_name: this.saveForm.canvas_name || '自动保存',
current_label: this.activeTags
};
console.log(`%c[自动保存] 配置ID: ${currentEditId}`, "color: #E6A23C", payload);
await saveGraphStyle(payload);
// UI
let targetConf = null;
let groupName = null;
for (const group of this.styleGroups) {
const conf = group.configs.find(c => c.id === currentEditId);
if (conf) {
conf.styles = JSON.parse(JSON.stringify(currentStyle));
targetConf = group.configs.find(c => c.id === currentEditId);
if (targetConf) {
groupName = group.group_name;
break;
}
}
if (targetConf) {
const payload = {
id: currentEditId,
canvas_name: targetConf.canvas_name,
current_label: targetConf.current_label,
group_name: groupName,
styles: currentStyle,
is_auto_save: true
};
await saveGraphStyle(payload);
targetConf.styles = JSON.parse(JSON.stringify(currentStyle));
}
} catch (err) {
console.error("同步失败:", err);
}
}, 800);
}
},
validateNodeSize(event) {
const val = parseInt(event.target.value);
if (isNaN(val) || val < 30 || val > 100) {
@ -613,6 +644,7 @@ export default {
edgeLineWidth: 2, edgeStroke: fill
};
},
handleTagClick(tag) {
if (this.editingConfigId && this.editingConfigLabel !== tag) {
ElMessageBox.confirm(
@ -628,13 +660,18 @@ export default {
this.performTagSwitch(tag);
}
},
performTagSwitch(tag) {
this.activeTags = tag;
const labelEn = tagToLabelMap[tag];
//
const style = this.editingConfigId ? this.tagStyles[labelEn] : this.getInitialTagParams(labelEn);
if (style) {
this.isInitialEcho = true;
this.applyStylesToPanel(style);
// 退
if (!this.editingConfigId) {
this.tagStyles[labelEn] = JSON.parse(JSON.stringify(style));
}
@ -859,6 +896,7 @@ export default {
},
updateAllElements() {
if (!this._graph) return;
const currentActiveLabelEn = tagToLabelMap[this.activeTags];
const labelToAppliedConfigMap = {};
this.styleGroups.forEach(group => {
@ -869,6 +907,7 @@ export default {
}
});
});
const hexToRgba = (hex, opacity) => {
if (!hex) return 'rgba(182, 178, 178, 0.5)';
if (hex.startsWith('rgba')) return hex;
@ -879,6 +918,7 @@ export default {
const nodes = this.defaultData.nodes.map(node => {
const rawLabel = node.data?.label || '';
const effectiveKey = this.getEffectiveStyleKey(rawLabel);
let s;
if (effectiveKey === currentActiveLabelEn) {
s = {
@ -890,6 +930,7 @@ export default {
} else {
s = labelToAppliedConfigMap[effectiveKey] || this.tagStyles[effectiveKey];
}
return {
...node, type: s?.nodeShape || 'circle',
style: {
@ -901,9 +942,11 @@ export default {
}
};
});
const edges = this.defaultData.edges.map(edge => {
const sRawLabel = this._nodeLabelMap.get(edge.source);
const effectiveKey = this.getEffectiveStyleKey(sRawLabel);
let s;
if (effectiveKey === currentActiveLabelEn) {
s = {
@ -914,6 +957,7 @@ export default {
} else {
s = labelToAppliedConfigMap[effectiveKey] || this.tagStyles[effectiveKey];
}
const strokeColor = hexToRgba(s?.edgeStroke || '#EF4444', 0.6);
return {
...edge, type: s?.edgeType || 'line',
@ -944,6 +988,7 @@ export default {
}));
return { ...group, configs: uniqueConfigs };
});
const activeGroup = this.styleGroups.find(g => g.is_active);
if (activeGroup) {
this.usingConfigIds = activeGroup.configs.map(c => c.id);
@ -953,6 +998,7 @@ export default {
this.tagStyles[labelEn] = JSON.parse(JSON.stringify(conf.styles));
}
});
const currentActiveConf = activeGroup.configs.find(c => c.current_label === this.activeTags);
if (currentActiveConf) {
this.isInitialEcho = true;
@ -964,7 +1010,9 @@ export default {
}
this.updateAllElements();
}
} catch (err) { console.error("加载配置失败:", err); }
} catch (err) {
console.error("加载配置失败:", err);
}
},
async fetchGroupNames() {
const res = await getGraphStyleGroups();
@ -974,26 +1022,32 @@ export default {
const idx = this.usingConfigIds.indexOf(item.id);
if (idx > -1) {
this.usingConfigIds.splice(idx, 1);
if (this.editingConfigId === item.id) this.exitEditingMode();
if (this.editingConfigId === item.id) {
this.exitEditingMode(); // 退退
}
} else {
this.handleEditConfig(item);
}
this.updateAllElements();
},
validateGroupConstraint(groupName, labelName, excludeId = null) {
const group = this.styleGroups.find(g => g.group_name === groupName);
if (!group) return true;
const isLabelExist = group.configs.some(c => c.current_label === labelName && c.id !== excludeId);
if (isLabelExist) {
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(() => {});
return false;
}
return true;
},
async moveConfigToGroup(config, targetGroup) {
// 1.
if (config.group_id === targetGroup.id) {
@ -1030,6 +1084,7 @@ export default {
this.saveForm.group_name = '';
this.saveDialogVisible = true;
},
async confirmSave() {
if (!this.saveForm.group_name?.trim() || !this.saveForm.canvas_name?.trim()) return ElMessage.warning("请完善名称");
if (!this.validateGroupConstraint(this.saveForm.group_name.trim(), this.activeTags)) return;
@ -1051,6 +1106,7 @@ export default {
await this.fetchConfigs();
}
},
async applyWholeGroup(group) {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.isInitialEcho = true;
@ -1068,11 +1124,13 @@ export default {
const res = await applyGraphStyleGroup(group.id);
if (res.code === 200) {
await this.fetchConfigs();
//
if (group.configs.length > 0) this.handleEditConfig(group.configs[0]);
ElMessage.success(`已应用方案【${group.group_name}`);
}
} catch (err) { this.isInitialEcho = false; ElMessage.error("切换失败"); }
},
resetStyle() {
const labelEn = tagToLabelMap[this.activeTags];
const initial = this.getInitialTagParams(labelEn);
@ -1084,6 +1142,7 @@ export default {
this.syncAndRefresh();
});
},
async deleteSingleConfig(id) {
if (this.usingConfigIds.includes(id)) return ElMessage.error("应用中无法删除");
try {
@ -1092,6 +1151,7 @@ export default {
if (res.code === 200) { ElMessage.success("删除成功"); this.fetchConfigs(); }
} catch (err) { }
},
async deleteGroup(groupId) {
const group = this.styleGroups.find(g => g.id === groupId);
if (!group || group.is_active) return ElMessage.error("应用中无法删除");
@ -1101,6 +1161,7 @@ export default {
if (res.code === 200) { ElMessage.success("已删除"); this.fetchConfigs(); }
} catch (err) { }
},
async handleUnifiedBatchDelete() {
if (this.checkedConfigIds.length === 0 && this.checkedGroupIds.length === 0) return;
try {
@ -1110,6 +1171,7 @@ export default {
ElMessage.success("成功"); this.clearSelection(); this.fetchConfigs();
} catch (e) { }
},
clearSelection() { this.checkedConfigIds = []; this.checkedGroupIds = []; },
handleResize() {
if (this._graph && this.$refs.graphContainer) {
@ -1167,7 +1229,7 @@ export default {
}
.control-panel {
width: 260px;
width: 250px;
background: #ffffff;
box-shadow: 4px 0 12px rgba(0, 0, 0, 0.08);
padding: 10px;
@ -1179,7 +1241,7 @@ export default {
}
.config-list-panel {
width: 320px;
width: 250px;
background: #ffffff;
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.08);
padding: 18px;
@ -1191,14 +1253,14 @@ export default {
}
.panel-header-container {
margin-bottom: 15px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 10px;
border-bottom: 1px solid #E2E6F3;
}
/*.header-line {
@ -1220,11 +1282,12 @@ export default {
gap: 6px;
margin-bottom: 10px;
padding-bottom: 10px;
margin-top: 10px;
}
:deep(.el-collapse){ --el-collapse-border-color: transparent;}
.tag-pill {
flex-shrink: 0;
padding: 0 10px;
padding: 1px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
@ -1243,14 +1306,11 @@ export default {
background-color: #4a68db;
}
.section {
margin-bottom: 25px;
}
.section-title {
display: flex;
align-items: center;
font-size: 16px;
font-size: 14px;
font-weight: bold;
color: #334155;
margin-bottom: 12px;
@ -1264,7 +1324,7 @@ export default {
position: absolute;
left: 0;
width: 4px;
height: 16px;
height: 15px;
background-color: #1559f3;
border-radius: 2px;
}
@ -1273,15 +1333,16 @@ export default {
display: flex;
align-items: center;
justify-content: flex-start;
font-size: 14px;
margin-bottom: 12px;
font-size: 13px;
margin-bottom: 10px;
color: #475569;
}
.checkbox-label {
width: 150px;
width: 80px;
flex-shrink: 0;
text-align: left;
margin-right: 8px;
}
.theme-checkbox {
@ -1294,20 +1355,25 @@ export default {
.form-group, .color-picker-item {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
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: none;
border-radius: 4px;
width: 100px;
box-shadow: 0 0 0 2px #EBF0FF;
outline: none;
}
.slider-wrapper {
@ -1339,14 +1405,11 @@ export default {
.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;
}
@ -1360,9 +1423,6 @@ export default {
}
.button-footer {
display: flex;
gap: 10px;
padding-top: 10px;
}
.btn-confirm-save {
@ -1370,10 +1430,12 @@ export default {
color: #fff;
border: none;
flex: 1;
padding: 10px;
padding: 5px 14px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
width: 100px;
font-size: 12px;
margin-right: 15px;
}
.btn-reset-style {
@ -1381,9 +1443,11 @@ export default {
color: #1559f3;
border: 1px solid #1559f3;
flex: 1;
padding: 10px;
padding: 5px 14px;
border-radius: 4px;
cursor: pointer;
width: 100px;
font-size: 12px;
}
.graph-container {
@ -1411,14 +1475,13 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background-color: #f3f3f3 !important;
margin-bottom: 6px;
background-color:#f6f9fc !important;
padding: 7px 15px;
margin-bottom: 10px;
border-radius: 6px;
cursor: pointer;
border: none !important;
transition: all 0.2s;
border-bottom: 1px solid #d1d5db !important;
border: 1px solid #E2E5EA;
}
.config-card:hover {
@ -1428,17 +1491,15 @@ export default {
.card-using {
background-color: #eff6ff !important;
outline: 1.5px solid #1559f3;
}
.card-checked {
border-left: 4px solid #ef4444 !important;
border-left: 4px solid rgb(239, 68, 68) !important;
}
.card-left {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 0;
@ -1454,6 +1515,7 @@ export default {
border-right: none;
flex-shrink: 0;
height: auto;
margin-top: 4px;
}
.config-checkbox {
margin: 0;
@ -1663,9 +1725,21 @@ export default {
}
</style>
<style>
.el-message-box__header {
text-align: left !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>

238
vue/src/system/KGData.vue

@ -104,6 +104,7 @@
<div class="op-group">
<el-button class="ref-op-btn edit" @click="openNodeDialog(scope.row)">编辑</el-button>
<el-button class="ref-op-btn delete" @click="handleDelete(scope.row, 'node')">删除</el-button>
<!-- <el-button class="ref-op-btn view" @click="handleView(scope.row, 'node')">详情</el-button>-->
</div>
</template>
</el-table-column>
@ -197,6 +198,7 @@
<div class="op-group">
<el-button class="ref-op-btn edit" @click="openRelDialog(scope.row)">编辑</el-button>
<el-button class="ref-op-btn delete" @click="handleDelete(scope.row, 'rel')">删除</el-button>
<!-- <el-button class="ref-op-btn view" @click="handleView(scope.row, 'rel')">详情</el-button>-->
</div>
</template>
</el-table-column>
@ -376,6 +378,9 @@
/>
</el-select>
</el-form-item>
<!-- <el-form-item label="显示名称">-->
<!-- <el-input v-model="relForm.label" placeholder="用于前端展示的中文名称"/>-->
<!-- </el-form-item>-->
</el-form>
<template #footer>
<div class="dialog-footer-wrap">
@ -416,15 +421,19 @@ const CHINESE_TO_ENGLISH_LABEL = {
"感染性": "Infectious", "关联实体": "RelatedTo", "人群分组": "MultipleGroups", "发病率": "DiseaseRate"
};
//
const dynamicRelTypes = ref([]);
const dynamicLabels = ref([]);
//
const ENGLISH_TO_CHINESE = computed(() => {
const map = {};
// 1.
for (const [chi, eng] of Object.entries(CHINESE_TO_ENGLISH_LABEL)) {
map[eng] = chi;
map[eng.toLowerCase()] = chi;
}
// 2. ()
dynamicRelTypes.value.forEach(item => {
map[item.type] = item.label;
});
@ -462,6 +471,7 @@ const detailType = ref('node');
const currentDetail = ref({});
const isEdit = ref(false);
// --- ---
const nodeForm = reactive({id: '', name: '', label: ''});
const relForm = reactive({id: '', source: '', target: '', type: '', label: ''});
@ -485,16 +495,20 @@ const handleNodeSizeChange = (val) => {
nodePage.value = 1;
fetchNodes();
};
const handleRelSizeChange = (val) => {
pageSize.value = val;
relPage.value = 1;
fetchRels();
};
// --- ---
const fetchAllMetadata = async () => {
//
getLabels().then(res => {
if (res?.code === 200) dynamicLabels.value = res.data;
});
//
getRelationshipTypes().then(res => {
if (res?.code === 200) dynamicRelTypes.value = res.data;
});
@ -522,8 +536,10 @@ const fetchNodes = async () => {
loading.value = true;
try {
const res = await getNodesList({
page: nodePage.value, pageSize: pageSize.value,
name: nodeSearch.name?.trim() || null, label: nodeSearch.label || null
page: nodePage.value,
pageSize: pageSize.value,
name: nodeSearch.name?.trim() || null,
label: nodeSearch.label || null
});
if (res?.code === 200) {
nodeData.value = res.data.items || res.data || [];
@ -599,6 +615,9 @@ const submitNode = async () => {
nodeDialogVisible.value = false;
fetchNodes();
fetchStats();
fetchAllMetadata(); //
preload();
fetchAllMetadata();
fetchAllMetadata();
if (typeof preload === 'function') preload();
} else {
@ -666,21 +685,28 @@ const handleDelete = (row, type) => {
});
};
//
//
const handleNodeSearch = () => {
nodePage.value = 1;
fetchNodes();
};
//
const handleNodeLabelChange = () => {
nodeSearch.name = '';
nodeSearch.name = ''; //
nodePage.value = 1;
fetchNodes();
};
const handleRelSearch = () => {
relPage.value = 1;
fetchRels();
};
/**
* 联想搜索建议逻辑修改
* 重点将当前 nodeSearch.label 传给后端
*/
const queryNodeSearch = async (queryString, cb) => {
const currentLabel = activeName.value === 'first' ? nodeSearch.label : null;
if (!currentLabel && !queryString?.trim()) return cb([]);
@ -692,6 +718,7 @@ const queryNodeSearch = async (queryString, cb) => {
cb([]);
}
} catch (e) {
console.error("联想查询失败", e);
cb([]);
}
};
@ -858,173 +885,28 @@ onMounted(() => {
</script>
<style scoped>
.knowledge-graph-data-container {
background-color: #f4f7fa;
display: flex;
height: 100vh;
width: 100vw;
}
.main-body {
flex: 1;
padding: 25px 40px;
overflow: auto;
height: 100vh;
}
.page-header {
display: flex;
align-items: center;
margin-bottom: 25px;
}
.header-decorator {
width: 10px;
height: 22px;
background-color: #165dff;
border-radius: 5px;
margin-right: 10px;
}
.header-title {
font-size: 22px;
font-weight: bold;
color: #165dff;
margin: 0;
}
.stat-container {
display: flex;
gap: 5%;
margin-bottom: 30px;
}
.custom-stat-card {
flex: 1;
max-width: 25%;
height: 200px;
background: #ffffff;
border-radius: 40px;
padding: 0 35px;
box-shadow: 2px -1px 14px 9px #EBF1FF;
border: 1px solid #ffffff;
display: flex;
align-items: center;
transition: transform 0.3s ease;
}
.stat-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.stat-label {
color: #636364;
font-size: 21px;
margin-bottom: 15px;
}
.stat-value {
color: #165dff;
font-size: 48px;
font-weight: 800;
margin-bottom: 15px;
line-height: 1;
}
.stat-desc {
color: #999;
font-size: 14px;
line-height: 1.4;
}
.data-content-wrapper {
margin-top: 20px;
display: flex;
flex-direction: column;
}
.custom-folder-tabs {
display: flex;
padding-left: 40px;
}
.folder-tab-item {
padding: 8px 20px;
font-size: 12px;
color: #86909c;
cursor: pointer;
background-color: #ecf2ff;
border: 1px solid #dcdfe6;
border-bottom: none;
border-radius: 8px 8px 0 0;
}
.folder-tab-item.active {
background-color: #f1f6ff !important;
color: #2869ff;
font-weight: bold;
border: 2px solid #6896ff;
border-bottom: 2px solid #ffffff;
margin-bottom: -1px;
z-index: 3;
}
.data-card-container {
background: #ffffff;
border-radius: 30px;
padding: 20px 20px;
box-shadow: 2px -1px 14px 4px #E1EAFF;
border: 1px solid #eff4ff;
position: relative;
z-index: 4;
}
.filter-bar {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 20px;
}
.filter-inputs {
display: flex;
gap: 35px;
flex-wrap: nowrap;
margin-right: 20px;
}
.input-group-inline {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
white-space: nowrap;
}
.filter-label-text {
font-size: 14px;
color: #165dff;
font-weight: 600;
flex-shrink: 0;
}
.search-input, .input-group-inline :deep(.el-input) {
width: 200px !important;
box-shadow: 0 0 0 2px #EBF0FF;
border: none;
border-radius: 5px;
}
.search-input, .search-select {
width: 200px !important;
box-shadow: 0 0 0 2px #EBF0FF;
border: none;
border-radius: 5px;
}
.knowledge-graph-data-container { background-color: #f4f7fa; display: flex; height: 100vh; width: 100vw; }
.main-body { flex: 1; padding: 25px 40px; overflow: auto;height: 100vh; }
.page-header { display: flex; align-items: center; margin-bottom: 25px; }
.header-decorator { width: 10px; height: 22px; background-color: #165dff; border-radius: 5px; margin-right: 10px; }
.header-title { font-size: 22px; font-weight: bold; color: #165dff; margin: 0; }
.stat-container { display: flex; gap: 5%; margin-bottom: 30px; }
.custom-stat-card { flex: 1; max-width: 25%; height: 200px; background: #ffffff; border-radius: 40px; padding: 0 35px; box-shadow: 2px -1px 14px 9px #EBF1FF; border: 1px solid #ffffff; display: flex; align-items: center; transition: transform 0.3s ease; }
.stat-inner { display: flex; flex-direction: column; align-items: flex-start; width: 100%; }
.stat-label { color: #636364; font-size: 21px; margin-bottom: 15px; }
.stat-value { color: #165dff; font-size: 48px; font-weight: 800; margin-bottom: 15px; line-height: 1; }
.stat-desc { color: #999; font-size: 14px; line-height: 1.4; }
.data-content-wrapper { margin-top: 20px; display: flex; flex-direction: column; }
.custom-folder-tabs { display: flex; padding-left: 40px; }
.folder-tab-item { padding: 8px 20px; font-size: 12px; color: #86909c; cursor: pointer; background-color: #ecf2ff; border: 1px solid #dcdfe6; border-bottom: none; border-radius: 8px 8px 0 0; }
.folder-tab-item.active { background-color: #f1f6ff !important; color: #2869ff; font-weight: bold; border: 2px solid #6896ff; border-bottom: 2px solid #ffffff; margin-bottom: -1px; z-index: 3; }
.data-card-container { background: #ffffff; border-radius: 30px; padding: 20px 20px; box-shadow: 2px -1px 14px 4px #E1EAFF; border: 1px solid #eff4ff; position: relative; z-index: 4; }
.filter-bar { display: flex; justify-content: flex-end; align-items: center; margin-bottom: 20px;gap: 20px }
.filter-inputs { display: flex; gap: 35px; flex-wrap: nowrap;}
.input-group-inline { display: flex; align-items: center; gap: 12px; flex-shrink: 0; white-space: nowrap; }
.filter-label-text { font-size: 14px; color: #165dff; font-weight: 600; flex-shrink: 0; }
.search-input,.input-group-inline :deep(.el-input) { width: 200px !important;box-shadow: 0 0 0 2px #EBF0FF;border: none;border-radius: 5px; }
.search-input, .search-select { width: 200px !important;box-shadow: 0 0 0 2px #EBF0FF;border: none;border-radius: 5px; }
.input-group-inline :deep(.el-select__wrapper){
box-shadow: none !important;
}
@ -1357,18 +1239,7 @@ onMounted(() => {
min-height: 300px;
}
.filter-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.filter-inputs {
display: flex;
gap: 35px;
flex-wrap: nowrap;
}
.input-group-inline {
display: flex;
@ -1385,9 +1256,6 @@ onMounted(() => {
flex-shrink: 0;
}
.search-input, .search-select {
width: 160px !important;
}
.btn-search-ref {
background: #165dff !important;

Loading…
Cancel
Save