Browse Source

工具栏复用+修改

mh
hanyuqing 3 months ago
parent
commit
3b53b2d7d1
  1. 117
      controller/GraphStyleController.py
  2. 223
      controller/OperationController.py
  3. 129
      controller/QAController.py
  4. 283
      service/GraphStyleService.py
  5. 519
      service/OperationService.py
  6. 1
      util/auth_interceptor.py
  7. 95
      vue/src/api/data.js
  8. 260
      vue/src/components/GraphToolbar.vue
  9. 281
      vue/src/system/GraphDemo.vue
  10. 280
      vue/src/system/GraphQA.vue
  11. 362
      vue/src/system/GraphStyle.vue
  12. 874
      vue/src/system/KGData.vue

117
controller/GraphStyleController.py

@ -1,9 +1,12 @@
# controller/GraphStyleController.py
import json
import logging
from robyn import jsonify, Response
from app import app
from service.GraphStyleService import GraphStyleService
# 配置日志记录
logger = logging.getLogger(__name__)
# --- 核心工具函数:解决乱码 ---
def create_response(status_code, data_dict):
@ -20,46 +23,76 @@ def create_response(status_code, data_dict):
@app.post("/api/graph/style/save")
async def save_style_config(request):
"""
保存配置接口 - 增强防跑偏版
逻辑
1. 如果 body 中包含 is_auto_save: true则强制忽略 group_name防止自动保存篡改归属
2. 如果是普通保存或移动则正常传递 group_name
保存配置接口 - 增强校验版
支持
1. 跨组精准移动 (id + target_group_id) - 优先级最高
2. 跨组名称移动 (id + group_name + is_auto_save: false)
3. 手动/自动保存 (id + is_auto_save: true/false)
4. 新建保存 ( id)
"""
try:
# 1. 解析请求体
body = request.json()
# 提取参数
config_id = body.get('id')
canvas_name = body.get('canvas_name')
current_label = body.get('current_label')
styles = body.get('styles')
# 核心改动:获取 group_name
# 核心改动点:接收精准 ID
target_group_id = body.get('target_group_id')
group_name = body.get('group_name')
# 增加一个前端标识:如果是实时同步(防抖保存),前端可以传这个字段
# 默认为 False,代表这是一次手动操作(可能是保存,也可能是移动)
is_auto_save = body.get('is_auto_save', False)
# 2. 基础参数校验
if not all([canvas_name, current_label, styles]):
return create_response(200, {"code": 400, "msg": "参数不完整"})
# 如果是自动保存模式,显式清空 group_name,强制 Service 进入“仅更新样式”逻辑
final_group_name = None if is_auto_save else group_name
# 将处理后的参数传给 Service 层
success = GraphStyleService.save_config(
canvas_name=canvas_name,
current_label=current_label,
styles_dict=styles,
group_name=final_group_name,
config_id=config_id
)
if success:
return create_response(200, {"code": 200, "msg": "操作成功"})
return create_response(200, {"code": 400, "msg": "参数不完整:缺失标签名或样式数据"})
# --- 核心逻辑分流 ---
# 情况 A:更新记录 (前端传了 ID)
if config_id:
# 判断动作类型用于日志和反馈
# 只要传了 target_group_id 或提供了 group_name 且非自动保存,就视为移动
is_moving = (target_group_id is not None) or (group_name is not None and not is_auto_save)
action_label = "移动" if is_moving else "更新"
# 修改点:将 target_group_id 显式传递给 Service 层
success = GraphStyleService.update_config(
config_id=int(config_id),
canvas_name=canvas_name,
current_label=current_label,
styles_dict=styles,
group_name=group_name,
target_group_id=target_group_id, # 确保这一行存在!
is_auto_save=is_auto_save
)
if success:
return create_response(200, {"code": 200, "msg": f"{action_label}操作完成"})
else:
return create_response(200,
{"code": 500, "msg": f"{action_label}失败,请确认配置是否存在或内容是否有变化"})
# 情况 B:新增记录 (前端未传 ID)
else:
return create_response(200, {"code": 500, "msg": "操作失败"})
success = GraphStyleService.create_config(
canvas_name=canvas_name,
current_label=current_label,
styles_dict=styles,
group_name=group_name
)
if success:
return create_response(200, {"code": 200, "msg": "新配置已创建成功"})
else:
return create_response(200, {"code": 500, "msg": "新建配置失败,请重试"})
except Exception as e:
return create_response(200, {"code": 500, "msg": f"系统异常: {str(e)}"})
logger.error(f"Controller 异常: {str(e)}", exc_info=True)
return create_response(200, {"code": 500, "msg": f"服务器内部错误: {str(e)}"})
@app.get("/api/graph/style/list/grouped")
@ -69,12 +102,13 @@ async def get_grouped_style_list(request):
data = GraphStyleService.get_grouped_configs()
return create_response(200, {"code": 200, "data": data, "msg": "查询成功"})
except Exception as e:
logger.error(f"查询异常: {str(e)}")
return create_response(200, {"code": 500, "msg": f"查询异常: {str(e)}"})
@app.post("/api/graph/style/group/apply")
async def apply_style_group(request):
"""应用全案"""
"""应用全案:一键切换当前激活的样式组"""
try:
body = request.json()
group_id = body.get('group_id')
@ -86,7 +120,7 @@ async def apply_style_group(request):
if success:
return create_response(200, {"code": 200, "msg": "方案已成功应用"})
else:
return create_response(200, {"code": 500, "msg": "应用全案失败"})
return create_response(200, {"code": 500, "msg": "应用全案失败,请检查方案是否存在"})
except Exception as e:
return create_response(200, {"code": 500, "msg": f"操作异常: {str(e)}"})
@ -112,7 +146,7 @@ async def set_default_style_group(request):
@app.get("/api/graph/style/groups")
async def get_group_names(request):
"""获取所有已存在的方案组列表"""
"""获取所有已存在的方案组列表(用于下拉选择)"""
try:
data = GraphStyleService.get_group_list()
return create_response(200, {"code": 200, "data": data, "msg": "查询成功"})
@ -120,19 +154,9 @@ async def get_group_names(request):
return create_response(200, {"code": 500, "msg": f"查询异常: {str(e)}"})
@app.get("/api/graph/style/list")
async def get_style_list(request):
"""获取原始扁平配置列表"""
try:
data = GraphStyleService.get_all_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/delete")
async def delete_style_config(request):
"""删除单条画布配置"""
"""删除单条配置记录"""
try:
body = request.json()
config_id = body.get('id')
@ -144,14 +168,14 @@ async def delete_style_config(request):
if success:
return create_response(200, {"code": 200, "msg": "删除成功"})
else:
return create_response(200, {"code": 500, "msg": "删除失败"})
return create_response(200, {"code": 404, "msg": "删除失败,配置可能已被删除"})
except Exception as e:
return create_response(200, {"code": 500, "msg": f"操作异常: {str(e)}"})
@app.post("/api/graph/style/group/delete")
async def delete_style_group(request):
"""删除整个方案组及其下属所有配置"""
"""级联删除整个方案组及其下属所有配置"""
try:
body = request.json()
group_id = body.get('group_id')
@ -161,7 +185,7 @@ async def delete_style_group(request):
success = GraphStyleService.delete_group(group_id)
if success:
return create_response(200, {"code": 200, "msg": "方案组已彻底删除"})
return create_response(200, {"code": 200, "msg": "方案组及关联配置已彻底删除"})
else:
return create_response(200, {"code": 500, "msg": "方案组删除失败"})
except Exception as e:
@ -175,6 +199,7 @@ async def batch_delete_style(request):
body = request.json()
config_ids = body.get('ids')
# 容错:处理前端可能以 JSON 字符串形式发送的列表
if isinstance(config_ids, str):
try:
config_ids = json.loads(config_ids)
@ -182,9 +207,13 @@ async def batch_delete_style(request):
pass
if not config_ids or not isinstance(config_ids, list):
return create_response(200, {"code": 400, "msg": "参数格式错误"})
return create_response(200, {"code": 400, "msg": "参数格式错误,请提供ID列表"})
count = GraphStyleService.batch_delete_configs(config_ids)
return create_response(200, {"code": 200, "msg": f"成功删除 {count} 条配置", "count": count})
return create_response(200, {
"code": 200,
"msg": f"成功删除 {count} 条配置数据",
"count": count
})
except Exception as e:
return create_response(200, {"code": 500, "msg": f"批量删除异常: {str(e)}"})

223
controller/OperationController.py

@ -9,15 +9,40 @@ from urllib.parse import unquote
operation_service = OperationService()
# --- 核心工具函数 ---
# 2. 新增/替换这个深度转换函数
def deep_convert(data):
"""
专门解决 Neo4j 对象序列化问题的工具函数
"""
if isinstance(data, dict):
return {k: deep_convert(v) for k, v in data.items()}
elif isinstance(data, list):
return [deep_convert(i) for i in data]
# 增加对数值类型的保护,其他的全部转为字符串
elif isinstance(data, (int, float, bool, type(None))):
return data
elif isinstance(data, str):
return data
# 如果是 Neo4j 的 ID、Long 或其他不可识别对象,一律强转字符串
else:
try:
return str(data)
except:
return None
# 3. 替换原来的 create_response
def create_response(status_code, data_dict):
"""
统一响应格式封装强制使用 UTF-8 防止中文乱码
统一响应格式封装
不再直接用 jsonify(data_dict)因为那处理不了嵌套的 Neo4j 对象
"""
# 第一步:清洗数据,把所有特殊对象转为标准 Python 类型
clean_data = deep_convert(data_dict)
# 第二步:手动序列化,确保中文不乱码,且 elementId 等长字符串不被截断
return Response(
status_code=status_code,
description=jsonify(data_dict),
description=json.dumps(clean_data, ensure_ascii=False),
headers={"Content-Type": "application/json; charset=utf-8"}
)
@ -333,4 +358,192 @@ def get_kg_stats(req):
return create_response(200, {"code": 400, "msg": msg})
except Exception as e:
traceback.print_exc()
return create_response(200, {"code": 500, "msg": f"统计数据异常: {str(e)}"})
return create_response(200, {"code": 500, "msg": f"统计数据异常: {str(e)}"})
# --- 12. 数据导出接口 ---
@app.get("/api/kg/export/nodes")
def export_nodes(req):
"""
节点导出接口全量导出满足筛选条件的节点
"""
try:
# 1. 提取筛选参数
name_raw = get_query_param(req, "name", "")
label_raw = get_query_param(req, "label", "")
# 2. 参数清洗
name = name_raw.strip() if name_raw and str(name_raw).lower() not in ["null", "undefined"] else None
label = label_raw.strip() if label_raw and label_raw not in ["全部", "", "null", "undefined"] else None
# 3. 调用 Service:移除 limit 参数,执行全量导出
result = operation_service.export_nodes_to_json(label=label, name=name)
if result.get("success"):
return create_response(200, {
"code": 200,
"data": result.get("data"),
"total": result.get("count", 0),
"msg": "success"
})
else:
return create_response(200, {"code": 500, "msg": result.get("msg", "获取导出数据失败")})
except Exception as e:
traceback.print_exc()
return create_response(200, {"code": 500, "msg": f"导出节点接口异常: {str(e)}"})
@app.get("/api/kg/export/relationships")
def export_relationships(req):
"""
关系导出接口全量导出满足筛选条件的关系
"""
try:
# 1. 提取筛选参数
source_raw = get_query_param(req, "source", "")
target_raw = get_query_param(req, "target", "")
type_raw = get_query_param(req, "type", "")
# 2. 参数清洗
source = source_raw.strip() if source_raw and str(source_raw).lower() not in ["null", "undefined"] else None
target = target_raw.strip() if target_raw and str(target_raw).lower() not in ["null", "undefined"] else None
rel_type = type_raw.strip() if type_raw and type_raw not in ["全部", "", "null", "undefined"] else None
# 3. 执行导出查询:移除 limit 参数,执行全量导出
result = operation_service.export_relationships_to_json(
source=source,
target=target,
rel_type=rel_type
)
if result.get("success"):
return create_response(200, {
"code": 200,
"data": result.get("data"),
"total": result.get("count", 0),
"msg": "success"
})
else:
return create_response(200, {
"code": 500,
"msg": result.get("msg", "获取导出关系失败")
})
except Exception as e:
traceback.print_exc()
return create_response(200, {
"code": 500,
"msg": f"导出关系接口异常: {str(e)}"
})
# --- 13. 批量导入核心接口 (预检 & 执行) ---
@app.post("/api/kg/import/nodes/precheck")
def import_nodes_precheck(req):
"""
预检接口接收前端上传的 nodes 数组进行冲突和有效性扫描
"""
try:
body = parse_request_body(req)
nodes = body.get("nodes", [])
if not nodes: return create_response(200, {"code": 400, "msg": "数据为空"})
result = operation_service.precheck_nodes_batch(nodes)
return create_response(200, {
"code": 200,
"data": {
"conflicts": result.get("conflicts", []),
"invalid": result.get("invalid", []), # 这里会包含因缺少 nodeId 而被过滤的数据
"summary": result.get("summary", {})
},
"msg": "预检完成"
})
except Exception as e:
return create_response(200, {"code": 500, "msg": f"预检异常: {str(e)}"})
@app.post("/api/kg/import/nodes/execute")
def import_nodes_execute(req):
"""
执行导入接口支持模式 mode (strict/skip/update)
"""
try:
body = parse_request_body(req)
nodes = body.get("nodes", [])
mode = body.get("mode", "skip")
if not nodes: return create_response(200, {"code": 400, "msg": "批次数据为空"})
result = operation_service.execute_node_import_batch(nodes, mode=mode)
if result.get("success"):
return create_response(200, {"code": 200, "msg": result.get("msg")})
else:
# 严格模式下如果因冲突失败,返回详情
return create_response(200, {
"code": 500,
"msg": result.get("msg"),
"data": {"conflicts": result.get("conflicts", [])}
})
except Exception as e:
return create_response(200, {"code": 500, "msg": f"执行异常: {str(e)}"})
# --- 关系导入核心修正 ---
@app.post("/api/kg/import/relationships/precheck")
def import_rels_precheck(req):
"""
修正说明适配 Service 层返回的 summary 结构确保前端准确识别冲突数
"""
try:
body = parse_request_body(req)
# 兼容 relationships 和 rels 两种写法
rels = body.get("relationships") or body.get("rels") or []
if not rels:
return create_response(200, {"code": 400, "msg": "预检数据为空"})
result = operation_service.precheck_rels_batch(rels)
return create_response(200, {
"code": 200,
"data": {
"conflicts": result.get("conflicts", []),
"invalid": result.get("invalid", []),
"summary": result.get("summary", {}) # 包含准确的 conflict 计数
},
"msg": "预检成功"
})
except Exception as e:
traceback.print_exc()
return create_response(200, {"code": 500, "msg": f"关系预检异常: {str(e)}"})
@app.post("/api/kg/import/relationships/execute")
def import_rels_execute(req):
"""
修正说明 Service 层生成的包含 elementId 的标准 Graph 数据透传回前端
"""
try:
body = parse_request_body(req)
rels = body.get("relationships") or body.get("rels") or []
mode = body.get("mode", "skip")
if not rels:
return create_response(200, {"code": 400, "msg": "执行数据为空"})
result = operation_service.execute_rel_import_batch(rels, mode=mode)
if result.get("success"):
return create_response(200, {
"code": 200,
"msg": result.get("msg"),
"data": result.get("data"), # 核心:返回标准嵌套结构的数组
"count": result.get("count", 0)
})
else:
return create_response(200, {"code": 500, "msg": result.get("msg")})
except Exception as e:
traceback.print_exc()
return create_response(200, {"code": 500, "msg": f"关系导入执行异常: {str(e)}"})

129
controller/QAController.py

@ -9,48 +9,62 @@ from robyn import jsonify, Response
from app import app
from controller.client import client
import uuid
# --- 核心工具函数:解决元组返回错误及中文乱码 ---
def create_response(status_code, data_dict):
"""
统一响应格式封装
1. 确保返回的是 Robyn 预期的 Response 对象
2. description 必须是字符串json.dumps 结果
3. 强制使用 UTF-8 防止中文乱码
"""
return Response(
status_code=status_code,
description=json.dumps(data_dict, ensure_ascii=False),
headers={"Content-Type": "application/json; charset=utf-8"}
)
def convert_to_g6_format(data):
entities = data["entities"]
relations = data["relations"]
entities = data.get("entities", [])
relations = data.get("relations", [])
# 创建实体名称到唯一ID的映射
name_to_id = {}
nodes = []
for ent in entities:
name = ent["n"]
if name not in name_to_id:
name = ent.get("n")
if name and name not in name_to_id:
node_id = str(uuid.uuid4())
name_to_id[name] = node_id
nodes.append({
"id": node_id,
"label": name,
"data": {
"type": ent["t"] # 可用于 G6 的节点样式区分
"type": ent.get("t") # 用于 G6 的节点样式区分
}
})
# 构建边,并为每条边生成唯一 ID
edges = []
for rel in relations:
e1 = rel["e1"]
e2 = rel["e2"]
r = rel["r"]
e1 = rel.get("e1")
e2 = rel.get("e2")
r = rel.get("r")
source_id = name_to_id.get(e1)
target_id = name_to_id.get(e2)
if source_id and target_id:
edge_id = str(uuid.uuid4()) # 👈 为边生成唯一 ID
edge_id = str(uuid.uuid4())
edges.append({
"id": edge_id, # ✅ 添加 id 字段
"id": edge_id,
"source": source_id,
"target": target_id,
"label": r, # G6 支持直接使用 label(非必须放 data)
"label": r,
"data": {
"label": r # 保留 data.label 便于扩展
"label": r
}
})
else:
@ -60,85 +74,88 @@ def convert_to_g6_format(data):
"nodes": nodes,
"edges": edges
}
@app.post("/api/qa/analyze")
async def analyze(request):
body = request.json()
input_text = body.get("text", "").strip()
if not input_text:
return jsonify({"error": "缺少 text 字段"}), 400
# 使用 create_response 统一返回格式,避免使用 jsonify 可能带来的元组嵌套问题
return create_response(400, {"error": "缺少 text 字段"})
try:
# 直接转发到大模型服务(假设它返回 { "task_id": "xxx" })
# 1. 提取实体
resp = await client.post(
"/getEntity",
json={"text": input_text},
timeout=1800.0 # 30分钟
timeout=1800.0
)
qaList = []
if resp.status_code == 202 or resp.status_code == 200:
qaList = []
if resp.status_code in (200, 202):
resp_json = resp.json()
resp_json_data = resp_json.get("data",{})
resp_json_data = json.loads(resp_json_data)
# 处理字符串形式的 data 字段
resp_json_data = resp_json.get("data", "{}")
if isinstance(resp_json_data, str):
resp_json_data = json.loads(resp_json_data)
entities = resp_json_data.get("entities", [])
print(entities)
data = []
print(f"提取到的实体: {entities}")
# 查询 Neo4j 邻居(此逻辑保留,虽目前未直接放入 qaList,可能用于后续扩展)
for name in entities:
neighbors =neo4j_client.find_neighbors_with_relationshipsAI(
neo4j_client.find_neighbors_with_relationshipsAI(
node_label=None,
direction="both",
node_properties={"name": name},
rel_type=None
)
data.append({
name:neighbors
})
resp = await client.post(
# 2. 问答代理获取答案列表
resp_agent = await client.post(
"/question_agent",
json={"neo4j_data": [],
"text": input_text},
timeout=1800.0 # 30分钟
json={"neo4j_data": [], "text": input_text},
timeout=1800.0
)
resp_data = resp.json()
inner_data = json.loads(resp_data["data"])
# 第二步:获取 json 数组
items = inner_data["json"]
# 第三步:按 sort 排序(虽然所有都是 0.9,但为了通用性还是排序)
# 如果 sort 相同,可以保留原始顺序(使用 stable sort),或按 xh 排序等
sorted_items = sorted(items, key=lambda x: x["sort"], reverse=True)
resp_data = resp_agent.json()
inner_data = json.loads(resp_data["data"])
items = inner_data.get("json", [])
# 第四步:取前5个
# 按权重排序取前5
sorted_items = sorted(items, key=lambda x: x.get("sort", 0), reverse=True)
top5 = sorted_items[:5]
# 3. 对每个答案提取关系图谱
for item in top5:
resp = await client.post(
resp_ext = await client.post(
"/extract_entities_and_relations",
json={"text": item['answer']},
timeout=1800.0 # 30分钟
timeout=1800.0
)
if resp.status_code in (200, 202):
result = resp.json()
print(result)
if resp_ext.status_code in (200, 202):
result = resp_ext.json()
g6_data = convert_to_g6_format(result)
print(g6_data)
qaList.append({
"answer": item["answer"],
"result": g6_data,
})
print(f"xh: {item['xh']}, answer: {item['answer']}, sort: {item['sort']}")
print(resp.json())
return Response(
status_code=200,
description=jsonify(qaList),
headers={"Content-Type": "text/plain; charset=utf-8"}
)
})
print(f"处理成功 xh: {item.get('xh')}, sort: {item.get('sort')}")
# --- 修复点:使用 create_response 返回解析后的数组 ---
return create_response(200, qaList)
else:
return jsonify({
return create_response(resp.status_code, {
"error": "提交失败",
"detail": resp.text
}), resp.status_code
})
except Exception as e:
error_trace = traceback.format_exc()
print("❌ 发生异常:")
print(error_trace)
return jsonify({"error": str(e),"traceback": error_trace}), 500
return create_response(500, {"error": str(e), "traceback": error_trace})

283
service/GraphStyleService.py

@ -1,191 +1,218 @@
# service/GraphStyleService.py
import json
import logging
from util.mysql_utils import mysql_client
# 配置日志
logger = logging.getLogger(__name__)
class GraphStyleService:
@staticmethod
def save_config(canvas_name: str, current_label: str, styles_dict: dict, group_name: str = None, config_id: int = None) -> bool:
def _get_or_create_group(group_name: str) -> int:
"""内部辅助方法:获取或创建方案组 ID"""
if not group_name or group_name.strip() == "":
group_name = "默认方案"
group_name = group_name.strip()
# 1. 查询是否存在
check_sql = "SELECT id FROM graph_style_groups WHERE group_name = %s LIMIT 1"
existing = mysql_client.execute_query(check_sql, (group_name,))
if existing:
return int(existing[0]['id'])
# 2. 不存在则插入
insert_sql = "INSERT INTO graph_style_groups (group_name, is_active, is_default) VALUES (%s, %s, %s)"
mysql_client.execute_update(insert_sql, (group_name, False, False))
# 3. 获取新生成的 ID
final_check = mysql_client.execute_query(check_sql, (group_name,))
return int(final_check[0]['id']) if final_check else 1
@staticmethod
def create_config(canvas_name: str, current_label: str, styles_dict: dict, group_name: str = None) -> bool:
"""【纯新增】用于另存为或初始保存"""
config_json = json.dumps(styles_dict, ensure_ascii=False)
target_group_id = GraphStyleService._get_or_create_group(group_name)
sql = """
INSERT INTO graph_configs (canvas_name, current_label, config_json, group_id)
VALUES (%s, %s, %s, %s)
"""
保存图谱样式配置修复版防止自动保存导致的分组乱跑
affected_rows = mysql_client.execute_update(sql, (canvas_name, current_label, config_json, target_group_id))
return affected_rows > 0
@staticmethod
def update_config(config_id: int, canvas_name: str, current_label: str, styles_dict: dict,
group_name: str = None, is_auto_save: bool = False, target_group_id: int = None) -> bool:
"""
# 2. 转换样式 JSON
config_json = json.dumps(styles_dict, ensure_ascii=False)
核心更新逻辑支持精准 ID 移动优化了逻辑优先级判断
"""
if not config_id:
logger.error("更新失败:缺少 config_id")
return False
# 3. 【核心修改点】:区分 更新 还是 新建
if config_id:
# --- 更新逻辑 ---
# 如果带了 ID,我们要极其谨慎地处理 group_id,防止在自动保存时被误改
# A. 如果调用者明确传了 group_name,说明是“移动”或“初次保存到某组”
if group_name and group_name.strip() != "":
# 检查/创建 目标方案组
check_group_sql = "SELECT id FROM graph_style_groups WHERE group_name = %s LIMIT 1"
existing_group = mysql_client.execute_query(check_group_sql, (group_name,))
if existing_group:
target_group_id = existing_group[0]['id']
else:
create_group_sql = "INSERT INTO graph_style_groups (group_name, is_active, is_default) VALUES (%s, %s, %s)"
mysql_client.execute_update(create_group_sql, (group_name, False, False))
target_group_id = mysql_client.execute_query("SELECT LAST_INSERT_ID() as last_id")[0]['last_id']
# 执行带分组更新的 SQL
sql = """
UPDATE graph_configs
SET canvas_name = %s, current_label = %s, config_json = %s, group_id = %s
WHERE id = %s
"""
affected_rows = mysql_client.execute_update(sql, (canvas_name, current_label, config_json, target_group_id, config_id))
config_json_str = json.dumps(styles_dict, ensure_ascii=False)
try:
# --- 步骤 1:查询当前数据库状态 ---
curr_sql = "SELECT group_id, canvas_name, current_label, config_json FROM graph_configs WHERE id = %s"
current_data = mysql_client.execute_query(curr_sql, (config_id,))
if not current_data:
logger.warning(f"更新失败:找不到 ID 为 {config_id} 的配置")
return False
curr_row = current_data[0]
old_group_id = int(curr_row['group_id'])
# --- 步骤 2:确定目标组 ID (调整优先级) ---
# 优先级 1: 只要传了 target_group_id,就说明是移动操作,优先级最高
if target_group_id is not None:
final_group_id = int(target_group_id)
logger.info(f"【移动模式】配置 {config_id}: 强制设定目标组 ID 为 {final_group_id}")
# 优先级 2: 自动保存模式下,锁定 group_id 不允许变动
elif is_auto_save:
final_group_id = old_group_id
logger.debug(f"【自保模式】配置 {config_id}: 锁定原组 ID {final_group_id}")
# 优先级 3: 传了 group_name 但没传 target_group_id (旧版移动逻辑)
elif group_name:
final_group_id = GraphStyleService._get_or_create_group(group_name)
logger.info(f"【名称模式】配置 {config_id}: 根据名称 [{group_name}] 获得 ID {final_group_id}")
# 兜底:保持不变
else:
# B. 如果没有传 group_name,说明是“实时自动保存”,严禁修改 group_id
# 这样即使前端变量乱了,数据库的分组也不会变
sql = """
final_group_id = old_group_id
# --- 步骤 3:差异比对 ---
# 增加对数据一致性的判定
has_changed = (
int(final_group_id) != old_group_id or
canvas_name != curr_row['canvas_name'] or
current_label != curr_row['current_label'] or
config_json_str != curr_row['config_json']
)
if not has_changed:
logger.info(
f"配置 {config_id} 内容无变化 (最终目标ID:{final_group_id}, 原ID:{old_group_id}),跳过数据库更新")
return True
# --- 步骤 4:执行更新 ---
sql = """
UPDATE graph_configs
SET canvas_name = %s, current_label = %s, config_json = %s
SET group_id = %s, canvas_name = %s, current_label = %s, config_json = %s
WHERE id = %s
"""
affected_rows = mysql_client.execute_update(sql, (canvas_name, current_label, config_json, config_id))
else:
# --- 新建逻辑 ---
# 新建时必须有组名,默认“默认方案”
if not group_name or group_name.strip() == "":
group_name = "默认方案"
check_group_sql = "SELECT id FROM graph_style_groups WHERE group_name = %s LIMIT 1"
existing_group = mysql_client.execute_query(check_group_sql, (group_name,))
if existing_group:
target_group_id = existing_group[0]['id']
else:
create_group_sql = "INSERT INTO graph_style_groups (group_name, is_active, is_default) VALUES (%s, %s, %s)"
mysql_client.execute_update(create_group_sql, (group_name, False, False))
target_group_id = mysql_client.execute_query("SELECT LAST_INSERT_ID() as last_id")[0]['last_id']
params = (final_group_id, canvas_name, current_label, config_json_str, config_id)
sql = """
INSERT INTO graph_configs (canvas_name, current_label, config_json, group_id)
VALUES (%s, %s, %s, %s)
"""
affected_rows = mysql_client.execute_update(sql, (canvas_name, current_label, config_json, target_group_id))
affected_rows = mysql_client.execute_update(sql, params)
return affected_rows > 0
if affected_rows > 0:
logger.info(f"更新成功,ID: {config_id}, 归属组已变更为: {final_group_id}")
return True
else:
logger.error(f"数据库更新执行成功但受影响行数为 0,ID: {config_id}")
return False
except Exception as e:
logger.error(f"Service 层更新异常: {str(e)}", exc_info=True)
return False
@staticmethod
def get_grouped_configs() -> list:
"""
获取嵌套结构的方案列表按默认/激活状态排序
"""
groups_sql = """
SELECT id, group_name, is_active, is_default
FROM graph_style_groups
ORDER BY is_default DESC, id ASC
"""
"""获取嵌套结构的方案列表"""
groups_sql = "SELECT id, group_name, is_active, is_default FROM graph_style_groups ORDER BY is_default DESC, id ASC"
groups = mysql_client.execute_query(groups_sql) or []
configs_sql = "SELECT id, group_id, canvas_name, current_label, config_json, create_time FROM graph_configs"
configs = mysql_client.execute_query(configs_sql) or []
# 格式化配置数据
for conf in configs:
if conf.get('config_json'):
try:
conf['styles'] = json.loads(conf['config_json'])
except:
conf['styles'] = {}
conf['styles'] = json.loads(conf['config_json']) if conf.get('config_json') else {}
# 保持 key 简洁
if 'config_json' in conf:
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')
result = []
# 组装嵌套结构
for g in groups:
g['is_active'] = bool(g['is_active'])
g['is_default'] = bool(g['is_default'])
g_children = [c for c in configs if c['group_id'] == g['id']]
g['configs'] = g_children
g['configs'] = [c for c in configs if c['group_id'] == g['id']]
g['expanded'] = g['is_active']
result.append(g)
return result
return groups
@staticmethod
def apply_group_all(group_id: int) -> bool:
"""应用全案:设置激活状态"""
"""切换当前激活的方案组"""
try:
reset_sql = "UPDATE graph_style_groups SET is_active = %s"
mysql_client.execute_update(reset_sql, (False,))
apply_sql = "UPDATE graph_style_groups SET is_active = %s WHERE id = %s"
affected_rows = mysql_client.execute_update(apply_sql, (True, group_id))
# 重置所有组的激活状态
mysql_client.execute_update("UPDATE graph_style_groups SET is_active = %s", (False,))
# 激活目标组
affected_rows = mysql_client.execute_update(
"UPDATE graph_style_groups SET is_active = %s WHERE id = %s",
(True, group_id)
)
return affected_rows > 0
except Exception as e:
print(f"Apply group error: {e}")
logger.error(f"应用全案异常: {str(e)}")
return False
@staticmethod
def set_default_group(group_id: int) -> bool:
"""设为系统初始默认方案"""
"""设为默认方案"""
try:
reset_sql = "UPDATE graph_style_groups SET is_default = %s"
mysql_client.execute_update(reset_sql, (False,))
set_sql = "UPDATE graph_style_groups SET is_default = %s WHERE id = %s"
affected_rows = mysql_client.execute_update(set_sql, (True, group_id))
mysql_client.execute_update("UPDATE graph_style_groups SET is_default = %s", (False,))
affected_rows = mysql_client.execute_update(
"UPDATE graph_style_groups SET is_default = %s WHERE id = %s",
(True, group_id)
)
return affected_rows > 0
except Exception as e:
print(f"Set default error: {e}")
logger.error(f"设置默认方案异常: {str(e)}")
return False
@staticmethod
def get_all_configs() -> list:
"""获取扁平查询"""
sql = """
SELECT c.id, c.group_id, c.canvas_name, c.current_label, c.config_json, c.create_time, g.is_active
FROM graph_configs c
LEFT JOIN graph_style_groups g ON c.group_id = g.id
ORDER BY c.create_time DESC
"""
rows = mysql_client.execute_query(sql)
if not rows: return []
for row in rows:
if row.get('config_json'):
try:
row['styles'] = json.loads(row['config_json'])
except:
row['styles'] = {}
del row['config_json']
if row.get('create_time') and not isinstance(row['create_time'], str):
row['create_time'] = row['create_time'].strftime('%Y-%m-%d %H:%M:%S')
row['is_active'] = bool(row.get('is_active', False))
return rows
@staticmethod
def delete_group(group_id: int) -> bool:
"""级联删除"""
del_configs_sql = "DELETE FROM graph_configs WHERE group_id = %s"
mysql_client.execute_update(del_configs_sql, (group_id,))
del_group_sql = "DELETE FROM graph_style_groups WHERE id = %s"
affected_rows = mysql_client.execute_update(del_group_sql, (group_id,))
return affected_rows > 0
"""级联删除组及其下的所有配置"""
try:
# 先删配置,再删组(如果没设外键级联)
mysql_client.execute_update("DELETE FROM graph_configs WHERE group_id = %s", (group_id,))
affected_rows = mysql_client.execute_update("DELETE FROM graph_style_groups WHERE id = %s", (group_id,))
return affected_rows > 0
except Exception as e:
logger.error(f"删除方案组异常: {str(e)}")
return False
@staticmethod
def delete_config(config_id: int) -> bool:
"""删除配置"""
sql = "DELETE FROM graph_configs WHERE id = %s"
affected_rows = mysql_client.execute_update(sql, (config_id,))
return affected_rows > 0
"""删除单个配置"""
try:
affected_rows = mysql_client.execute_update("DELETE FROM graph_configs WHERE id = %s", (config_id,))
return affected_rows > 0
except Exception as e:
logger.error(f"删除配置异常: {str(e)}")
return False
@staticmethod
def batch_delete_configs(config_ids: list) -> int:
"""批量删除"""
if not config_ids: return 0
try:
clean_ids = [int(cid) for cid in config_ids if str(cid).isdigit()]
except: return 0
if not clean_ids: return 0
placeholders = ', '.join(['%s'] * len(clean_ids))
sql = f"DELETE FROM graph_configs WHERE id IN ({placeholders})"
return mysql_client.execute_update(sql, tuple(clean_ids))
placeholders = ', '.join(['%s'] * len(config_ids))
sql = f"DELETE FROM graph_configs WHERE id IN ({placeholders})"
return mysql_client.execute_update(sql, tuple(config_ids))
except Exception as e:
logger.error(f"批量删除异常: {str(e)}")
return 0
@staticmethod
def get_group_list() -> list:
"""获取方案列表"""
"""简单的方案名称列表"""
sql = "SELECT id, group_name, is_active, is_default FROM graph_style_groups ORDER BY is_default DESC, id DESC"
return mysql_client.execute_query(sql) or []

519
service/OperationService.py

@ -11,6 +11,96 @@ class OperationService:
def __init__(self):
self.db = neo4j_client
# --- 0. 内部辅助工具:格式标准化 ---
def _format_node_data(self, node):
"""
统一转换前端传来的平铺或嵌套 JSON 格式
输出标准结构: { "labels": [...], "properties": {...} }
"""
# 1. 提取并标准化 labels
raw_labels = node.get("labels") or node.get("label")
if isinstance(raw_labels, str):
labels = [raw_labels]
elif isinstance(raw_labels, list):
labels = raw_labels
else:
labels = []
# 2. 提取并标准化 properties
if "properties" in node and isinstance(node["properties"], dict):
# 嵌套格式:直接取属性字典
props = node["properties"]
else:
# 平铺格式:打包除特殊键外的所有键值对
props = {
k: v for k, v in node.items()
if k not in ["label", "labels", "identity", "elementId"]
}
return {"labels": labels, "properties": props}
def _normalize_rel_data(self, item):
"""
参考节点导入的逻辑统一转换关系数据
支持标准嵌套 JSON平铺 JSON以及带有 properties 包装的格式
"""
def clean_str(val):
return str(val).strip() if val is not None else None
# 1. 尝试识别标准嵌套结构 (start/end 对象)
if isinstance(item.get("start"), dict) and isinstance(item.get("end"), dict):
s_node = item["start"]
e_node = item["end"]
# 使用获取属性的通用逻辑 ( properties 优先 )
s_props = s_node.get("properties") if isinstance(s_node.get("properties"), dict) else s_node
e_props = e_node.get("properties") if isinstance(e_node.get("properties"), dict) else e_node
# 提取标签 (取第一个标签用于精确匹配)
s_label = s_node.get("labels", [""])[0] if s_node.get("labels") else ""
e_label = e_node.get("labels", [""])[0] if e_node.get("labels") else ""
# 获取关系信息
rel_obj = {}
if item.get("segments") and len(item["segments"]) > 0:
rel_obj = item["segments"][0].get("relationship", {})
else:
rel_obj = item.get("relationship", {})
r_props = rel_obj.get("properties") if isinstance(rel_obj.get("properties"), dict) else rel_obj
return {
"source_name": clean_str(s_props.get("name")),
"source_label": s_label,
"target_name": clean_str(e_props.get("name")),
"target_label": e_label,
"rel_type": clean_str(rel_obj.get("type")),
"rel_label": clean_str(r_props.get("label") or r_props.get("name") or "")
}
# 2. 扁平格式适配
alias_map = {
"source": ["source_name", "source", "start_name", "起点"],
"target": ["target_name", "target", "end_name", "终点"],
"type": ["rel_type", "type", "relationship"],
"label": ["rel_label", "label", "关系标签"]
}
def find_value(keys):
for k in keys:
val = item.get(k)
if val and not isinstance(val, dict): return val
return None
return {
"source_name": clean_str(find_value(alias_map["source"])),
"source_label": clean_str(item.get("source_label") or ""),
"target_name": clean_str(find_value(alias_map["target"])),
"target_label": clean_str(item.get("target_label") or ""),
"rel_type": clean_str(find_value(alias_map["type"])),
"rel_label": clean_str(find_value(alias_map["label"])) or ""
}
# --- 0. 数据修复工具 ---
def fix_all_missing_node_ids(self):
try:
@ -392,86 +482,417 @@ class OperationService:
return {"success": False, "msg": f"删除失败: {str(e)}"}
# --- 7. 导出功能 ---
def export_nodes_to_json(self, label=None, name=None):
"""
按照条件导出节点确保包含 identity, elementId, labels, properties 等所有原始字段
"""
def export_nodes_to_json(self, label=None, name=None): # 删除了参数中的 limit=20
try:
conditions = []
params = {}
# 构建过滤条件(复用查询逻辑,但去掉分页)
if name:
if name and str(name).strip() and name not in ["null", "undefined"]:
params["name"] = unquote(str(name)).strip()
conditions.append("n.name CONTAINS $name")
lb_clause = ""
if label and label not in ["全部", ""]:
# 为了保证原生对象的完整性,这里直接 MATCH 标签
lb_clause = f":`{label}`"
if label and str(label).strip() and label not in ["全部", "", "null", "undefined"]:
params["export_label"] = str(label).strip()
label_cypher = f":`{label}`"
else:
label_cypher = ""
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
# 注意:这里 RETURN n,返回的是整个节点对象
cypher = f"MATCH (n{lb_clause}) {where_clause} RETURN n"
raw_data = self.db.execute_read(cypher, params)
# 彻底移除 limit_clause
cypher = f"""
MATCH (n{label_cypher})
{where_clause}
RETURN elementId(n) AS elementId,
labels(n) AS labels,
properties(n) AS properties
"""
export_items = []
for row in raw_data:
node = row['n']
# 核心逻辑:提取 Neo4j 节点对象的所有原生属性
node_data = {
"identity": node.id, # 对应你截图中的 identity (旧版 ID)
"elementId": node.element_id, # 对应你截图中的 elementId (新版 ID)
"labels": list(node.labels),
"properties": dict(node.items())
}
export_items.append(node_data)
with self.db.driver.session() as session:
result = session.run(cypher, params)
for index, row in enumerate(result):
export_items.append({
"identity": index,
"elementId": row.get("elementId"),
"labels": row.get("labels"),
"properties": row.get("properties")
})
return {"success": True, "data": export_items}
return {"success": True, "data": export_items, "count": len(export_items)}
except Exception as e:
traceback.print_exc()
return {"success": False, "msg": f"导出节点失败: {str(e)}"}
def export_relationships_to_json(self, source=None, target=None, rel_type=None):
"""
按照条件导出关系确保包含起始/结束节点信息及完整属性
"""
def export_relationships_to_json(self, source=None, target=None, rel_type=None): # 删除了参数中的 limit=20
try:
conditions = []
params = {}
if source:
params["source"] = unquote(str(source)).strip()
conditions.append("a.name CONTAINS $source")
if target:
params["target"] = unquote(str(target)).strip()
conditions.append("b.name CONTAINS $target")
if rel_type and rel_type not in ["全部", ""]:
conditions.append(f"type(r) = $rel_type")
params["rel_type"] = rel_type
if rel_type and str(rel_type).strip() and rel_type not in ["全部", "", "null", "undefined"]:
params["rel_type"] = str(rel_type).strip()
conditions.append("type(r) = $rel_type")
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
# 返回关系对象 r 以及起止节点的 elementId 以便追溯
cypher = f"MATCH (a)-[r]->(b) {where_clause} RETURN r, elementId(a) as startNode, elementId(b) as endNode"
raw_data = self.db.execute_read(cypher, params)
# 彻底移除 limit_clause
cypher = f"""
MATCH (a)-[r]->(b)
{where_clause}
RETURN
{{
elementId: elementId(a),
labels: labels(a),
properties: properties(a)
}} AS start_node,
{{
elementId: elementId(b),
labels: labels(b),
properties: properties(b)
}} AS end_node,
{{
type: type(r),
properties: properties(r),
elementId: elementId(r),
startNodeElementId: elementId(a),
endNodeElementId: elementId(b)
}} AS rel_info
"""
export_items = []
for row in raw_data:
rel = row['r']
rel_data = {
"identity": rel.id,
"elementId": rel.element_id,
"type": rel.type,
"startNodeElementId": row['startNode'],
"endNodeElementId": row['endNode'],
"properties": dict(rel.items())
}
export_items.append(rel_data)
with self.db.driver.session() as session:
result = session.run(cypher, params)
for index, record in enumerate(result):
s = record["start_node"]
e = record["end_node"]
r = record["rel_info"]
node_id_base = index * 2
s["identity"] = node_id_base
e["identity"] = node_id_base + 1
r["identity"] = index
r["start"] = s["identity"]
r["end"] = e["identity"]
export_items.append({
"start": s,
"end": e,
"segments": [{"start": s, "relationship": r, "end": e}],
"length": 1.0
})
return {"success": True, "data": export_items, "count": len(export_items)}
except Exception as e:
traceback.print_exc()
return {"success": False, "msg": f"导出关系失败: {str(e)}"}
# --- 8. 节点导入核心功能 ---
def precheck_nodes_batch(self, nodes_batch):
"""
全量预检针对一批数据检查格式无效性nodeId冲突name+label冲突
"""
conflicts = []
invalid_data = []
valid_nodes = []
# 1. 内存清洗:先转换格式,再严格校验“三要素”
for index, raw_node in enumerate(nodes_batch):
# 格式标准化 (处理平铺/嵌套)
node = self._format_node_data(raw_node)
props = node["properties"]
name = props.get("name")
labels = node["labels"]
node_id = props.get("nodeId") # 关键:获取 nodeId
# 严格判定逻辑:name、labels、nodeId 缺一不可
if not name or not labels or node_id is None:
reasons = []
if not name: reasons.append("缺少 name")
if not labels: reasons.append("缺少 label")
if node_id is None: reasons.append("缺少 nodeId")
invalid_data.append({
"index": index,
"name": name or "未知",
"reason": " | ".join(reasons)
})
continue
valid_nodes.append(node)
if not valid_nodes:
return {"success": True, "conflicts": conflicts, "invalid": invalid_data}
# 2. 批量数据库比对 (查询潜在冲突)
all_node_ids = [n["properties"]["nodeId"] for n in valid_nodes]
all_names = [n["properties"]["name"] for n in valid_nodes]
# 查询 nodeId 冲突
db_id_map = {}
if all_node_ids:
id_results = self.db.execute_read(
"MATCH (n) WHERE n.nodeId IN $ids RETURN n.nodeId as nodeId, n.name as name", {"ids": all_node_ids})
db_id_map = {row["nodeId"]: row for row in id_results}
# 查询 name+label 冲突
db_name_set = set()
if all_names:
name_results = self.db.execute_read(
"MATCH (n) WHERE n.name IN $names RETURN n.name as name, labels(n) as labels", {"names": all_names})
db_name_set = {f"{row['name']}_{lbl}" for row in name_results for lbl in row['labels']}
# 3. 组装冲突报告
for node in valid_nodes:
p = node["properties"]
n_id, name, labels = p["nodeId"], p["name"], node["labels"]
# 优先级 1: nodeId 冲突
if n_id in db_id_map:
conflicts.append({
"name": name, "label": labels[0], "nodeId": n_id,
"reason": f"业务ID冲突: 已存在 nodeId={n_id}",
"type": "nodeId_duplicate"
})
continue
# 优先级 2: name + label 冲突
for lbl in labels:
if f"{name}_{lbl}" in db_name_set:
conflicts.append({
"name": name, "label": lbl, "nodeId": n_id,
"reason": f"逻辑主键冲突: {lbl} 下已存在名称 '{name}'",
"type": "logic_key_duplicate"
})
break
return {
"success": True,
"conflicts": conflicts,
"invalid": invalid_data,
"summary": {"total": len(nodes_batch), "valid": len(valid_nodes), "conflict": len(conflicts)}
}
def execute_node_import_batch(self, nodes_batch, mode="skip"):
try:
formatted_batch = [self._format_node_data(n) for n in nodes_batch]
# 获取当前时间的标准字符串格式,确保与手动添加的节点一致
current_time_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if mode == "strict":
check = self.precheck_nodes_batch(nodes_batch)
if check.get("conflicts"):
return {"success": False, "msg": "严格模式下发现冲突,停止导入", "conflicts": check["conflicts"]}
label_groups = {}
for node in formatted_batch:
lbls = node.get("labels")
if not lbls or len(lbls) == 0: continue
lbl = lbls[0]
if lbl not in label_groups: label_groups[lbl] = []
props = node.get("properties")
if props and props.get("name"):
label_groups[lbl].append(props)
total_imported = 0
with self.db.driver.session() as session:
for lbl, batch_props in label_groups.items():
if not batch_props: continue
# 关键修改:将 datetime() 替换为传入的 $now 字符串
if mode == "update":
cypher = f"""
UNWIND $batch AS props
MERGE (n:`{lbl}` {{name: props.name}})
ON CREATE SET n = props, n.createTime = $now
ON MATCH SET n += props, n.updateTime = $now
RETURN count(n) as cnt
"""
elif mode == "skip":
cypher = f"""
UNWIND $batch AS props
MERGE (n:`{lbl}` {{name: props.name}})
ON CREATE SET n = props, n.createTime = $now
RETURN count(n) as cnt
"""
else:
cypher = f"""
UNWIND $batch AS props
CREATE (n:`{lbl}`)
SET n = props, n.createTime = $now
RETURN count(n) as cnt
"""
res = session.run(cypher, {"batch": batch_props, "now": current_time_str})
record = res.single()
if record: total_imported += record["cnt"]
return {"success": True, "msg": f"成功处理 {total_imported} 个节点", "count": total_imported}
except Exception as e:
traceback.print_exc()
return {"success": False, "msg": f"批次导入异常: {str(e)}"}
def precheck_rels_batch(self, rels_batch):
"""
关系导入预检 - 修复版
"""
conflicts = [] # 关系已存在
invalid = [] # 节点不存在或格式错误
# 注意:这里不再使用 valid_to_check 这种模糊中间变量
# 统计真正可以执行导入的数量
actual_valid_count = 0
for raw_item in rels_batch:
item = self._normalize_rel_data(raw_item)
# 1. 基础格式校验
if not item["source_name"] or not item["target_name"] or not item["rel_type"]:
invalid.append({
"source": item.get("source_name") or "未知",
"target": item.get("target_name") or "未知",
"reason": "格式错误:缺少必要字段"
})
continue
# 2. 数据库存在性校验
cypher = f"""
OPTIONAL MATCH (s {{name: $s_name}})
OPTIONAL MATCH (t {{name: $t_name}})
OPTIONAL MATCH (s)-[r:`{item['rel_type']}`]->(t)
RETURN s IS NOT NULL as hasS, t IS NOT NULL as hasT, r IS NOT NULL as hasR
"""
res = self.db.execute_read(cypher, {"s_name": item["source_name"], "t_name": item["target_name"]})
if not res:
continue
rec = res[0]
if not rec["hasS"] or not rec["hasT"]:
# 关键修复:节点不存在,属于 invalid
invalid.append({
"source": item["source_name"],
"target": item["target_name"],
"reason": f"节点不存在(起点:{'' if rec['hasS'] else '×'}, 终点:{'' if rec['hasT'] else '×'})"
})
elif rec["hasR"]:
# 关系已存在,属于冲突
conflicts.append({
"source": item["source_name"],
"target": item["target_name"],
"type": item["rel_type"],
"reason": "关系已存在"
})
else:
# 只有走到这里,才是真正的有效数据
actual_valid_count += 1
return {
"success": True,
"conflicts": conflicts,
"invalid": invalid,
"summary": {
"total": len(rels_batch),
"valid": actual_valid_count, # 真正能导进去的数量
"conflict": len(conflicts),
"invalid": len(invalid)
}
}
def execute_rel_import_batch(self, rels_batch, mode="skip"):
"""
执行导入加入 Label 辅助匹配确保 ElementId 100% 捕获
"""
try:
now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
final_results = []
with self.db.driver.session() as session:
for raw_item in rels_batch:
item = self._normalize_rel_data(raw_item)
# 基础检查:没有起点或终点名称直接跳过
if not item["source_name"] or not item["target_name"] or not item["rel_type"]:
continue
# 动态构建 Cypher:如果有 Label 则带上 Label,匹配更精准
s_label_cypher = f":`{item['source_label']}`" if item['source_label'] else ""
t_label_cypher = f":`{item['target_label']}`" if item['target_label'] else ""
op = "ON MATCH SET r.label = $label" if mode == "update" else ""
cypher = f"""
MATCH (s{s_label_cypher} {{name: $s_name}})
MATCH (t{t_label_cypher} {{name: $t_name}})
MERGE (s)-[r:`{item['rel_type']}`]->(t)
ON CREATE SET r.label = $label, r.createTime = $now
{op}
RETURN s, t, r,
id(s) as s_id, id(t) as t_id, id(r) as r_id,
elementId(s) as s_eid, elementId(t) as t_eid, elementId(r) as r_eid
"""
res = session.run(cypher, {
"s_name": item["source_name"],
"t_name": item["target_name"],
"label": item["rel_label"],
"now": now
})
return {"success": True, "data": export_items}
record = res.single()
if record:
s_node, t_node, r_rel = record["s"], record["t"], record["r"]
# 严格按照你要求的“理想格式”拼装
graph_item = {
"start": {
"identity": record["s_id"],
"labels": list(s_node.labels),
"properties": dict(s_node),
"elementId": str(record["s_eid"])
},
"end": {
"identity": record["t_id"],
"labels": list(t_node.labels),
"properties": dict(t_node),
"elementId": str(record["t_eid"])
},
"segments": [{
"start": {
"identity": record["s_id"],
"labels": list(s_node.labels),
"properties": dict(s_node),
"elementId": str(record["s_eid"])
},
"relationship": {
"identity": record["r_id"],
"start": record["s_id"],
"end": record["t_id"],
"type": r_rel.type,
"properties": dict(r_rel),
"elementId": str(record["r_eid"]),
"startNodeElementId": str(record["s_eid"]),
"endNodeElementId": str(record["t_eid"])
},
"end": {
"identity": record["identity"] if "identity" in t_node else record["t_id"], # 备选方案
"labels": list(t_node.labels),
"properties": dict(t_node),
"elementId": str(record["t_eid"])
}
}],
"length": 1.0
}
final_results.append(graph_item)
return {"success": True, "data": final_results, "count": len(final_results)}
except Exception as e:
traceback.print_exc()
return {"success": False, "msg": f"导出关系失败: {str(e)}"}
return {"success": False, "msg": f"入执行失败: {str(e)}"}

1
util/auth_interceptor.py

@ -10,6 +10,7 @@ PUBLIC_PATHS = [
'/api/register',
'/api/checkUsername',
'/resource',
'/api/kg/export',
]

95
vue/src/api/data.js

@ -125,4 +125,99 @@ export function deleteRelationship(id) {
method: 'post',
data: { id }
})
}
// --- 11. 导出接口---
/**
* 导出节点数据到 JSON
* @param {object} params - 包含 name, label
*/
export function exportNodes(params) {
return request({
url: '/api/kg/export/nodes',
method: 'get',
params,
timeout: 60000
})
}
/**
* 导出关系数据到 JSON
* @param {object} params - 包含 source, target, type
*/
export function exportRelationships(params) {
return request({
url: '/api/kg/export/relationships',
method: 'get',
params,
timeout: 60000
})
}
// --- 12. 导入接口 ---
/**
* 节点导入预检
* @param {Array} nodes - 全量节点列表前端解析 JSON 后的数组
* @returns {Promise} - 返回冲突列表和汇总报告
*/
export function precheckNodes(nodes) {
return request({
url: '/api/kg/import/nodes/precheck',
method: 'post',
data: { nodes },
// 预检 10 万条数据涉及大量内存比对,建议超时设为 2 分钟
timeout: 120000
})
}
/**
* 执行节点批量导入
* @param {Array} nodes - 当前批次的节点数据 (建议 5000 条一包)
* @param {string} mode - 导入模式: 'skip' (忽略), 'update' (更新), 'strict' (严格)
*/
export function executeImportNodes(nodes, mode = 'skip') {
return request({
url: '/api/kg/import/nodes/execute',
method: 'post',
data: {
nodes,
mode
},
// 单个批次处理建议超时 1 分钟
timeout: 60000
})
}
// --- 13. 关系导入接口 ---
/**
* 关系导入预检
* @param {Array} relationships - 前端解析后的关系数组 (兼容嵌套和扁平格式)
*/
export function precheckRelationships(relationships) {
return request({
url: '/api/kg/import/relationships/precheck',
method: 'post',
data: { relationships },
timeout: 120000 // 关系预检涉及双向节点查询,超时时间设长
})
}
/**
* 执行关系批量导入
* @param {Array} relationships - 当前批次的关系数据
* @param {string} mode - 'skip' (忽略已存在), 'update' (覆盖已存在属性)
*/
export function executeImportRelationships(relationships, mode = 'skip') {
return request({
url: '/api/kg/import/relationships/execute',
method: 'post',
data: {
relationships,
mode
},
timeout: 60000
})
}

260
vue/src/components/GraphToolbar.vue

@ -0,0 +1,260 @@
<template>
<div ref="toolbarContainer" class="custom-graph-toolbar"></div>
</template>
<script>
import { ElMessageBox, ElMessage } from 'element-plus';
export default {
name: 'GraphToolbar',
props: ['graph'],
data() {
return {
observer: null //
};
},
watch: {
graph: {
handler(newGraph) {
if (newGraph) {
this.$nextTick(() => {
// DOM
setTimeout(() => this.mountG6Toolbar(), 200);
});
}
},
immediate: true
}
},
beforeUnmount() {
//
if (this.observer) this.observer.disconnect();
},
methods: {
mountG6Toolbar() {
const container = this.$refs.toolbarContainer;
const g6ToolbarElement = document.querySelector('.g6-toolbar');
// 1
console.log('===[Toolbar Check Start]===');
console.log('1. Graph 实例状态:', !!this.graph);
console.log('2. Vue 容器 (ref):', !!container);
console.log('3. 是否抓取到原生 G6 DOM:', !!g6ToolbarElement);
if (!this.graph || !container || !g6ToolbarElement) {
console.warn('!!! [Toolbar] 挂载终止:由于缺少以上必要条件,搬家逻辑未执行。');
return;
}
// 1. G6 DOM Vue
container.appendChild(g6ToolbarElement);
// 2.
const lockStyle = () => {
g6ToolbarElement.style.setProperty('position', 'static', 'important');
g6ToolbarElement.style.setProperty('inset', 'unset', 'important');
g6ToolbarElement.style.setProperty('display', 'flex', 'important');
g6ToolbarElement.style.setProperty('height', '100%', 'important');
g6ToolbarElement.style.setProperty('background', 'transparent', 'important');
g6ToolbarElement.style.setProperty('box-shadow', 'none', 'important');
g6ToolbarElement.style.setProperty('border', 'none', 'important');
g6ToolbarElement.style.setProperty('width', 'auto', 'important');
};
// 3. Resize G6
if (this.observer) this.observer.disconnect();
this.observer = new MutationObserver(() => lockStyle());
this.observer.observe(g6ToolbarElement, {attributes: true, attributeFilter: ['style']});
//
lockStyle();
// 4. 使 capture
const stopEvent = (e) => e.stopPropagation();
['mousedown', 'click', 'dblclick', 'contextmenu', 'wheel'].forEach(type => {
g6ToolbarElement.addEventListener(type, stopEvent);
});
},
handleToolbarAction(id) {
if (!this.graph) return;
const historyPlugin = this.graph.getPluginInstance('history');
switch (id) {
case 'zoom-in':
this.graph.zoomBy(1.2);
break;
case 'zoom-out':
this.graph.zoomBy(0.8);
break;
case 'undo':
if (historyPlugin?.canUndo()) historyPlugin.undo();
break;
case 'redo':
if (historyPlugin?.canRedo()) historyPlugin.redo();
break;
case 'auto-fit':
this.graph.fitView();
break;
case 'reset':
break;
case 'export':
this.handleExportClick();
break;
}
},
async handleExportClick() {
ElMessageBox.confirm('请选择您要导出的图片格式:', '导出图谱', {
confirmButtonText: '导出为 PNG',
cancelButtonText: '导出为 SVG',
distinguishCancelAndClose: true,
type: 'info',
draggable: true,
}).then(async () => {
try {
const dataURL = await this.graph.toDataURL({type: 'image/png', backgroundColor: '#ffffff'});
const link = document.createElement('a');
link.href = dataURL;
link.download = `图谱_${Date.now()}.png`;
link.click();
ElMessage.success('PNG 导出成功');
} catch (e) {
ElMessage.error('PNG 导出失败');
}
}).catch((action) => {
if (action === 'cancel') this.exportToSVGManual();
});
},
exportToSVGManual() {
const graph = this.graph;
if (!graph) return;
const { nodes, edges } = graph.getData();
const bBox = graph.getCanvas().getRoot().getRenderBounds();
const padding = 60;
const minX = bBox.min[0] - padding;
const minY = bBox.min[1] - padding;
const maxX = bBox.max[0] + padding;
const maxY = bBox.max[1] + padding;
const vWidth = maxX - minX;
const vHeight = maxY - minY;
// 1.
const colorMap = {
'Disease': { fill: '#EF4444', stroke: '#B91C1C', edge: '#EF4444' },
'Drug': { fill: '#91cc75', stroke: '#047857', edge: '#91cc75' },
'Check': { fill: '#336eee', stroke: '#1D4ED8', edge: '#336eee' },
'Symptom': { fill: '#fac858', stroke: '#B45309', edge: '#fac858' },
'Other': { fill: '#59d1d4', stroke: '#40999b', edge: '#59d1d4' }
};
let svgContent = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="${vWidth}" height="${vHeight}" viewBox="${minX} ${minY} ${vWidth} ${vHeight}">`;
svgContent += `<rect x="${minX}" y="${minY}" width="${vWidth}" height="${vHeight}" fill="#ffffff" />`;
// 2.
edges.forEach(edge => {
const s = nodes.find(n => n.id === edge.source);
const t = nodes.find(n => n.id === edge.target);
if (!s || !t) return;
// ( style G6 )
const x1 = s.style?.x || 0;
const y1 = s.style?.y || 0;
const x2 = t.style?.x || 0;
const y2 = t.style?.y || 0;
// --- ---
// edge: { style: { stroke: (d) => ... } }
const sourceType = s.data?.label || 'Other'; // (Disease/Drug)
const strokeColor = colorMap[sourceType]?.edge || '#cccccc';
// 线 ( stroke-width )
svgContent += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${strokeColor}" stroke-width="2" opacity="0.4" />`;
//
const labelText = edge.data?.relationship?.properties?.label || edge.data?.label || "";
if (labelText) {
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
svgContent += `<text x="${mx}" y="${my - 4}" fill="#666666" font-size="10" font-family="Microsoft YaHei" text-anchor="middle">${labelText}</text>`;
}
});
// 3.
nodes.forEach(node => {
const type = node.data?.label || 'Other';
const colors = colorMap[type] || colorMap['Other'];
const radius = (node.style?.size || 50) / 2;
const x = node.style?.x || 0;
const y = node.style?.y || 0;
svgContent += `<circle cx="${x}" cy="${y}" r="${radius}" fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="1.5" />`;
if (node.data?.name) {
svgContent += `<text x="${x}" y="${y}" fill="#ffffff" font-size="12" font-family="Microsoft YaHei" text-anchor="middle" dominant-baseline="middle">${node.data.name}</text>`;
}
});
svgContent += `</svg>`;
// ...
try {
const blob = new Blob([svgContent], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `矢量图谱_${Date.now()}.svg`;
link.click();
URL.revokeObjectURL(url);
this.$message.success('全量矢量图导出成功');
} catch (err) {
this.$message.error('导出失败');
}
}
}
}
</script>
<style scoped>
.custom-graph-toolbar {
display: inline-flex !important;
width: auto !important;
min-width: 100px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
align-items: center;
height: 42px !important;
padding: 0 4px;
z-index: 1001;
pointer-events: auto;
}
:deep(.g6-toolbar) {
display: inline-flex !important;
height: 35px !important;
width: auto !important;
align-items: center !important;
background: transparent !important;
}
:deep(.g6-toolbar-item) {
width: 20px !important;
height: 35px !important;
font-size: 15px !important;
margin: 0 2px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 6px;
cursor: pointer;
color: #838383 !important;
transition: all 0.2s;
}
:deep(.g6-toolbar-item:hover) {
background: #f1f5f9 !important;
color: #1e293b !important;
}
</style>

281
vue/src/system/GraphDemo.vue

@ -4,83 +4,90 @@
<Menu
:initial-active="0"
/>
<main class="main-body">
<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"/>
</div>
<div class="legend-box">
<div v-for="(item, index) in legendItems" :key="index" class="legend-item">
<div
:style="{
<main class="main-body">
<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"/>
</div>
<div class="legend-box">
<div v-for="(item, index) in legendItems" :key="index" class="legend-item">
<div
:style="{
backgroundColor: item.color,
opacity: visibleCategories.has(item.key) ? 1 : 0.3 //
}"
class="color-block"
@click="toggleCategory(item.key)"
></div>
<span style=" font-size: 12px;color: rgb(0, 0, 0);font-weight: 600;">{{ item.label }}</span>
</div>
class="color-block"
@click="toggleCategory(item.key)"
></div>
<span style=" font-size: 12px;color: rgb(0, 0, 0);font-weight: 600;">{{ item.label }}</span>
</div>
</div>
</header>
<section class="main-content">
<div class="disease-container">
<div class="disease-header" :style="headerStyle">
<div class="d-title"><img :src="iconSrc" class="d-icon"/>
<span v-if="typeRadio=== 'Disease'">疾病信息</span>
<span v-if="typeRadio=== 'Drug'">药品信息</span>
<span v-if="typeRadio=== 'Check'">检查信息</span>
</div>
</header>
<section class="main-content">
<div class="disease-container">
<div class="disease-header" :style="headerStyle">
<div class="d-title"><img :src="iconSrc" class="d-icon"/>
<span v-if="typeRadio=== 'Disease'">疾病信息</span>
<span v-if="typeRadio=== 'Drug'">药品信息</span>
<span v-if="typeRadio=== 'Check'">检查信息</span>
</div>
<div v-if="typeRadio=== 'Disease'" class="d-count" :style="headerLabel">{{diseaseCount.toLocaleString()}}</div>
<div v-if="typeRadio=== 'Drug'" class="d-count" :style="headerLabel">{{ drugCount.toLocaleString() }}</div>
<div v-if="typeRadio=== 'Check'" class="d-count" :style="headerLabel">{{checkCount.toLocaleString()}}</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 class="disease-body">
<div class="tree">
<el-tree
:data="treeData"
:props="treeProps"
accordion
expand-on-click-node
:expand-on-click-node="false"
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div v-if="typeRadio=== 'Disease'" class="d-count" :style="headerLabel">{{diseaseCount.toLocaleString()}}</div>
<div v-if="typeRadio=== 'Drug'" class="d-count" :style="headerLabel">{{ drugCount.toLocaleString() }}</div>
<div v-if="typeRadio=== 'Check'" class="d-count" :style="headerLabel">{{checkCount.toLocaleString()}}</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 class="disease-body">
<div class="tree">
<el-tree
:data="treeData"
:props="treeProps"
accordion
expand-on-click-node
:expand-on-click-node="false"
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<span class="code">{{ data.code }}</span>
<el-tooltip :content="data.label" placement="top">
<span class="label">{{ data.label }}</span>
</el-tooltip>
</span>
</template>
</el-tree>
</div>
</div>
</div>
<div class="graph-viewport">
<div ref="graphContainer" class="graph-container" id="container"></div>
</template>
</el-tree>
</div>
</section>
</main>
</div>
</div>
<div class="graph-viewport">
<GraphToolbar
ref="toolbarRef"
v-if="_graph"
:graph="_graph"
class="toolbar-position"
style="position: absolute; top: 40px; left: 750px; z-index: 1000; width: auto;"
/>
<div ref="graphContainer" class="graph-container" id="container"></div>
</div>
</section>
</main>
<!-- 图谱容器 -->
</div>
</template>
@ -90,13 +97,16 @@ import {getCheckTree, getCount, getDrugTree, getGraph, getTestGraphData} from "@
import {Graph, Tooltip} from '@antv/g6';
import Menu from "@/components/Menu.vue";
import {a} from "vue-router/dist/devtools-EWN81iOl.mjs";
import GraphToolbar from "@/components/GraphToolbar.vue";
import Fuse from 'fuse.js';
import {ElMessage} from "element-plus";
export default {
name: 'Display',
components: {Menu},
components: {Menu,GraphToolbar},
data() {
return {
_graph: null,
G6: null, //
//
nodeShowLabel: true,
@ -257,7 +267,7 @@ export default {
edges: updatedEdges
}
this.defaultData = updatedData
setTimeout(() => {
setTimeout(() => {
this.initGraph();
this.buildCategoryIndex();
window.addEventListener('resize', this.handleResize);
@ -735,11 +745,10 @@ export default {
},
autoFit: {
type: 'center', // 'view' 'center'
type: 'center',
options: {
// 'view'
when: 'always', // 'overflow'() 'always'()
direction: 'both', // 'x''y' 'both'
when: 'first',
direction: 'both',
},
animation: {
//
@ -747,8 +756,8 @@ export default {
easing: 'ease-in-out', //
},
},
behaviors: [ 'zoom-canvas', 'drag-element',
'click-select','focus-element', {
behaviors: ['zoom-canvas', 'drag-element',
'click-select', 'focus-element', {
type: 'hover-activate',
degree: 1,
},
@ -795,7 +804,7 @@ export default {
shadowBlur: 10,
opacity: 1
},
highlight:{
highlight: {
stroke: '#FF5722',
lineWidth: 4,
opacity: 1
@ -803,7 +812,7 @@ export default {
inactive: {
opacity: 0.8
},
normal:{
normal: {
opacity: 1
}
@ -851,14 +860,14 @@ export default {
inactive: {
opacity: 0.8
},
normal:{
normal: {
opacity: 1
}
},
},
data:this.defaultData,
data: this.defaultData,
});
this.$nextTick(() => {
@ -916,23 +925,23 @@ export default {
// });
graph.on('node:click', (evt) => {
const nodeItem = evt.target.id; //
let node=graph.getNodeData(nodeItem).data
let data={
label:node.name,
type:node.label
let node = graph.getNodeData(nodeItem).data
let data = {
label: node.name,
type: node.label
}
getGraph(data).then(response=>{
getGraph(data).then(response => {
console.log(response)
this.formatData(response)
}); // Promise
});
graph.once('afterlayout', () => {
if (!graph.destroyed) {
graph.fitCenter({ padding: 40, duration: 1000 });
graph.fitCenter({padding: 40, duration: 1000});
}
});
this._graph = graph
this._graph.setPlugins([ {
this._graph.setPlugins([{
type: 'tooltip',
// tooltip
enable: (e) => e.targetType === 'edge',
@ -941,7 +950,7 @@ export default {
console.log(e)
const edge = items[0]; //
if (!edge) return '';
const data=items[0].data
const data = items[0].data
const sourceId = edge.source;
const targetId = edge.target;
@ -952,16 +961,78 @@ export default {
const sourceName = sourceNode?.data.name || sourceId;
const targetName = targetNode?.data.name || targetId;
const rel = data.relationship.properties.label || '关联';
const rel = data.relationship.properties.label || '关联';
return `<div style="padding: 4px 8px; color: #b6b2b2; border-radius: 4px; font-size: 12px;">
${sourceName} ${rel} > ${targetName}
</div>`;
},
},])
},
{
type: 'toolbar',
onClick: (id) =>
{
if (id === 'reset') {
// 1.
this.localResetGraph();
}
else if (this.$refs.toolbarRef) {
// 2.
this.$refs.toolbarRef.handleToolbarAction(id);
}
},
getItems: () => {
return [
{ id: 'zoom-in', value: 'zoom-in', title: '放大' },
{ id: 'zoom-out', value: 'zoom-out', title: '缩小' },
{ id: 'undo', value: 'undo', title: '撤销' },
{ id: 'redo', value: 'redo', title: '重做' },
{ id: 'auto-fit', value: 'auto-fit', title: '聚焦' },
{ id: 'reset', value: 'reset', title: '重置' },
{ id: 'export', value: 'export', title: '导出图谱' },
];
},
},
// history
{ type: 'history', key: 'history' }])
}
},
localResetGraph() {
// 1.
if (!this._graph) return;
if (!this.defaultData || !this.defaultData.nodes) {
this.$message.warning("未找到可重置的数据源");
return;
}
try {
// 2.
const canvas = this._graph.getCanvas();
if (canvas && typeof canvas.setCursor === 'function') {
canvas.setCursor('default');
}
// 3.
// DOM EventBoundary
this._graph.destroy();
this._graph = null;
// 4.
this.$nextTick(() => {
// initGraph this.defaultData
this.initGraph();
// 5. destroy
this.buildCategoryIndex();
this.$message.success("图谱已重置");
});
} catch (err) {
console.error('重置图谱失败:', err);
//
// location.reload();
}
},
updateGraph(data) {
@ -975,7 +1046,7 @@ export default {
const updatedNodes = this.defaultData.nodes.map(node => ({
...node,
type: this.nodeShape,
style:{
style: {
size: this.nodeSize,
fill: this.nodeFill,
stroke: this.nodeStroke,
@ -998,7 +1069,7 @@ export default {
if (!this._graph) return
const updatedEdges = this.defaultData.edges.map(edge => ({
...edge,
type:this.edgeType,
type: this.edgeType,
style: {
endArrow: this.edgeEndArrow,
stroke: this.edgeStroke,
@ -1276,12 +1347,14 @@ button:hover {
padding: 0 15px;
justify-content: flex-start;
}
.d-title{
.d-title {
display: flex;
align-items: center;
font-size: 13px;
}
.d-count{
.d-count {
font-size: 9px;
background-color: #5989F0;
border-radius: 7px;
@ -1328,19 +1401,21 @@ button:hover {
text-align: left;
font-size: 12px;
}
.disease-body{
.disease-body {
width: 360px;
overflow: scroll;
height: 74vh;
}
/* 隐藏滚动条,但允许滚动 */
.disease-body {
/* Firefox */
scrollbar-width: none; /* 'auto' | 'thin' | 'none' */
/* Webkit (Chrome, Safari, Edge) */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
padding-bottom: 9px;
}
@ -1358,12 +1433,12 @@ button:hover {
/deep/ .radio-drug .el-radio__input.is-checked .el-radio__inner {
background: #52c41a; /* 检查的颜色 */
border-color:#52c41a;
border-color: #52c41a;
}
/deep/ .radio-check .el-radio__input.is-checked .el-radio__inner {
background: #1890ff; /* 药品的颜色 */
border-color:#1890ff;
border-color: #1890ff;
}
@ -1376,25 +1451,25 @@ button:hover {
/deep/ .radio-drug .el-radio__input.is-checked .el-radio__inner:hover {
background: #52c41a; /* 检查的颜色 */
border-color:#52c41a;
border-color: #52c41a;
}
/deep/ .radio-check .el-radio__input.is-checked .el-radio__inner:hover {
background: #1890ff; /* 药品的颜色 */
border-color:#1890ff;
border-color: #1890ff;
}
/* 自定义选中后的样式 */
/deep/ .radio-disease .el-radio__input.is-checked+.el-radio__label {
/deep/ .radio-disease .el-radio__input.is-checked + .el-radio__label {
color: rgb(153, 10, 0);
}
/deep/ .radio-drug .el-radio__input.is-checked+.el-radio__label {
/deep/ .radio-drug .el-radio__input.is-checked + .el-radio__label {
color: #52c41a;
}
/deep/ .radio-check .el-radio__input.is-checked+.el-radio__label {
/deep/ .radio-check .el-radio__input.is-checked + .el-radio__label {
color: #1890ff;
}
</style>

280
vue/src/system/GraphQA.vue

@ -33,7 +33,7 @@
<div class="answer-item" style=" background-color: #165DFF;
border-color: #0066cc;
color: #fff; height: auto;max-height: 3vw">
{{queryRecord}}
{{queryRecord}}
</div>
</div>
<h2 class="section-title">问答结果</h2>
@ -63,6 +63,12 @@
<!-- 右侧知识图谱 -->
<div class="knowledge-graph">
<GraphToolbar
v-if="_graph"
:graph="_graph"
ref="toolbarRef"
/>
<div ref="graphContainer" class="graph-container" id="container"></div>
</div>
</div>
@ -76,13 +82,15 @@ import Menu from "@/components/Menu.vue";
import {qaAnalyze} from "@/api/qa";
import {Graph} from "@antv/g6";
import {getGraph} from "@/api/graph";
import GraphToolbar from '@/components/GraphToolbar.vue';
export default {
name: 'GraghQA',
components: {Menu},
components: {Menu,GraphToolbar},
data() {
return {
_graph: null,
query:"",
answers:[],
selected:0,
@ -203,35 +211,78 @@ export default {
}
},
handleSearch(){
this.isSending=true
this.answers=[]
if (this._graph){
this._graph.clear()
}
let data={
text:this.query
handleSearch() {
alert('方法触发成功!');
console.log('--- 1. 发起搜索,参数为:', this.query);
this.isSending = true;
this.answers = [];
if (this._graph) {
this._graph.clear();
}
this.queryRecord=this.query
this.query=""
// this.answers=[{"answer":"尿",
// "result":{"nodes":[{"id":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","label":"尿","type":""},{"id":"89f9498b-7a83-4361-889b-f96dfdfb802c","label":"","type":""},{"id":"03201620-ba9f-4957-b7d3-f89c5a115e37","label":"","type":""},{"id":"b0bace0a-eedc-485c-90d3-0a5378dc5556","label":"","type":""},{"id":"ffc12d7b-60e5-4ffa-a945-a0769f6e1047","label":"","type":""},{"id":"a7e94ee7-072b-456f-bc0c-354545851c38","label":"","type":""}],"edges":[{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"03201620-ba9f-4957-b7d3-f89c5a115e37","label":""},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"b0bace0a-eedc-485c-90d3-0a5378dc5556","label":""},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"ffc12d7b-60e5-4ffa-a945-a0769f6e1047","label":""},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"a7e94ee7-072b-456f-bc0c-354545851c38","label":""},{"source":"e0a8410b-c5ee-47d4-acbe-b7ae5f111fd4","target":"89f9498b-7a83-4361-889b-f96dfdfb802c","label":""}]}}]
// this.initGraph(this.answers[0].result)
// this.formatData(this.answers[0].result)
qaAnalyze(data).then(res=>{
this.answers=res
if(this.answers.length>0){
this.initGraph(this.answers[0].result)
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
})
this.isSending = false;
}).catch(err => {
console.error('--- 2. 接口失败,启动保底方案 ---', err);
const mockData = {
nodes: [
{ id: "node1", label: "霍乱", data: { type: "疾病" } },
{ id: "node2", label: "腹泻", data: { type: "症状" } },
{ id: "node3", label: "脱水", data: { type: "疾病" } },
{ id: "node4", label: "呕吐", data: { type: "疾病" } },
{ id: "node5", label: "霍乱弧菌", data: { type: "病因" } },
{ id: "node6", label: "复方磺胺", data: { type: "药品" } },
],
edges: [
{ id: "e1", source: "node1", target: "node2", data: { label: "典型症状" } },
{ id: "e2", source: "node1", target: "node3", data: { label: "并发症" } },
{ id: "e3", source: "node1", target: "node4", data: { label: "并发症" } },
{ id: "e4", source: "node1", target: "node5", data: { label: "致病菌" } },
{ id: "e5", source: "node1", target: "node6", data: { label: "推荐用药" } },
]
};
//
this.answers = [{
answer: "后端接口连接失败,当前显示的是预览版图谱。",
result: mockData
}];
//
this.initGraph(this.answers[0].result);
this.isSending = false;
this.$message.warning("已切换至离线预览模式");
});
},
formatData(data){
// this._graph.stopLayout();
// this.clearGraphState();
formatData(data) {
const typeMap = {
'疾病': 'Disease',
'药品': 'Drug',
'药物': 'Drug',
'症状': 'Symptom',
'检查': 'Check'
};
const updatedEdges = data.edges.map(edge => ({
...edge,
type: this.edgeType,
type: this.edgeType,
data: {
...edge.data,
label: edge.data?.label || ""
},
style: {
endArrow: this.edgeEndArrow,
stroke: this.edgeStroke,
@ -240,28 +291,42 @@ export default {
labelFontSize: this.edgeFontSize,
labelFontFamily: this.edgeFontFamily,
labelFill: this.edgeFontColor,
},
}))
const updatedNodes = data.nodes.map(node => ({
...node,
type: this.nodeShape,
style:{
size: this.nodeSize,
lineWidth: this.nodeLineWidth,
label: this.nodeShowLabel,
labelFontSize: this.nodeFontSize,
labelFontFamily: this.nodeFontFamily,
labelFill: this.nodeFontColor,
opacity: 1,
}
}))
}));
//
const updatedNodes = data.nodes.map(node => {
// 1. 使
const englishLabel = typeMap[node.data?.type] || 'Other';
// 2. return
return {
...node,
type: this.nodeShape,
data: {
...node.data,
label: englishLabel, // (GraphToolbar colorMap)
name: node.label // (GraphToolbar node.data.name)
},
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,
}
};
});
const updatedData = {
nodes: updatedNodes,
edges: updatedEdges
}
};
this.updateGraph(updatedData)
this.updateGraph(updatedData);
},
updateGraph(data) {
if (!this._graph) return
@ -269,14 +334,39 @@ export default {
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("图谱已重置");
});
},
initGraph(data) {
console.log('--- 触发了 initGraph ---', data);
if (this._graph!=null){
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,
@ -288,19 +378,29 @@ export default {
},
}))
const updatedNodes = data.nodes.map(node => ({
...node,
type: this.nodeShape,
style:{
size: this.nodeSize,
lineWidth: this.nodeLineWidth,
label: this.nodeShowLabel,
labelFontSize: this.nodeFontSize,
labelFontFamily: this.nodeFontFamily,
labelFill: this.nodeFontColor,
opacity: 1,
}
}))
const typeMap = { '疾病': 'Disease', '药品': 'Drug', '药物': 'Drug', '症状': 'Symptom', '检查': 'Check' };
const updatedNodes = data.nodes.map(node => {
const englishLabel = typeMap[node.data?.type] || 'Other';
return {
...node,
type: this.nodeShape,
data: {
...node.data,
label: englishLabel, //
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,
}
};
});
const updatedData = {
nodes: updatedNodes,
edges: updatedEdges
@ -317,6 +417,32 @@ export default {
container,
width,
height,
plugins: [
{
type: 'toolbar',
key: 'g6-toolbar',
onClick: (id) => {
if (id === 'reset') {
this.localResetGraph(); //
} else if (this.$refs.toolbarRef) {
// GraphToolbar.vue //
this.$refs.toolbarRef.handleToolbarAction(id);
}
},
getItems: () => {
return [
{ id: 'zoom-in', value: 'zoom-in', title: '放大' },
{ id: 'zoom-out', value: 'zoom-out', title: '缩小' },
{ id: 'undo', value: 'undo', title: '撤销' },
{ id: 'redo', value: 'redo', title: '重做' },
{ id: 'auto-fit', value: 'auto-fit', title: '聚焦' },
{ id: 'reset', value: 'reset', title: '重置' },
{ id: 'export', value: 'export', title: '导出图谱' },
];
},
},
{ type: 'history', key: 'history' }, //
],
layout: {
type: 'force', //
gravity: 0.3, //
@ -330,6 +456,10 @@ export default {
'click-select','focus-element', {
type: 'hover-activate',
degree: 1,
enable: (e) =>
{
return e.target && e.target.id && e.action !== 'drag';
}
},
{
type: 'drag-canvas',
@ -388,9 +518,7 @@ export default {
edge: {
style: {
labelText: (d) => {
console.log(d)
return d.data.label},
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)';
@ -452,8 +580,6 @@ export default {
};
</script>
<style scoped>
.medical-qa-container {
padding: 25px;
background-color: #F6F9FF;
@ -630,9 +756,10 @@ export default {
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
} 40% {
transform: scale(1.0);
}
}
40% {
transform: scale(1.0);
}
}
/* 让每个圆点的动画延迟出现,形成连续效果 */
@ -640,4 +767,29 @@ export default {
.dot-2 { animation-delay: -0.2s; }
.dot-3 { animation-delay: 0s; }
.dot-4 { animation-delay: 0.2s; }
.knowledge-graph {
position: relative; /* 必须!否则工具栏会飘走 */
flex: 1;
height: 100%;
background-color: white;
border-radius: 16px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
overflow: hidden; /* 保证工具栏不超出边界 */
}
/* 确保图谱容器铺满 */
.graph-container {
width: 100%;
height: 100%;
}
:deep(.custom-graph-toolbar) {
position: absolute !important; /* 强制绝对定位 */
top: 30px !important;
right: 30px !important;
z-index: 9999 !important; /* 确保层级最高 */
display: inline-flex !important;
}
</style>

362
vue/src/system/GraphStyle.vue

@ -462,31 +462,22 @@ 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 => {
@ -497,14 +488,11 @@ 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;
@ -524,32 +512,22 @@ 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;
@ -561,46 +539,43 @@ export default {
edgeFontSize: this.edgeFontSize, edgeFontColor: this.edgeFontColor, edgeType: this.edgeType,
edgeLineWidth: this.edgeLineWidth, edgeStroke: this.edgeStroke
};
//
this.tagStyles[labelEn] = currentStyle;
this.updateAllElements();
//
//
if (this.editingConfigId) {
if (this.saveTimer) clearTimeout(this.saveTimer);
const currentEditId = this.editingConfigId;
this.saveTimer = setTimeout(async () => {
try {
let targetConf = null;
let groupName = null;
// 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
for (const group of this.styleGroups) {
targetConf = group.configs.find(c => c.id === currentEditId);
if (targetConf) {
groupName = group.group_name;
const conf = group.configs.find(c => c.id === currentEditId);
if (conf) {
conf.styles = JSON.parse(JSON.stringify(currentStyle));
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) {
@ -638,7 +613,6 @@ export default {
edgeLineWidth: 2, edgeStroke: fill
};
},
handleTagClick(tag) {
if (this.editingConfigId && this.editingConfigLabel !== tag) {
ElMessageBox.confirm(
@ -654,18 +628,13 @@ 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));
}
@ -674,26 +643,222 @@ export default {
this.updateAllElements();
},
// --- XML ---
async handleExportClick() {
if (!this._graph) return;
ElMessageBox.confirm(
'请选择您要导出的图片格式:',
'导出图谱',
{
confirmButtonText: '导出为 PNG',
cancelButtonText: '导出为 SVG',
distinguishCancelAndClose: true,
type: 'info',
draggable: true,
}
).then(async () => {
try {
// PNG 姿
const dataURL = await this._graph.toDataURL({
type: 'image/png',
backgroundColor: '#ffffff'
});
const link = document.createElement('a');
link.href = dataURL;
link.download = `图谱_${Date.now()}.png`;
link.click();
ElMessage.success('PNG 导出成功');
} catch (e) {
ElMessage.error('PNG 导出失败');
}
}).catch((action) => {
if (action === 'cancel') {
this.exportToSVGManual();
}
});
},
exportToSVGManual() {
const graph = this._graph;
const { nodes, edges } = graph.getData();
const width = graph.getSize()[0];
const height = graph.getSize()[1];
// 1. SVG /
let svgContent = `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`;
svgContent += `<rect width="100%" height="100%" fill="#ffffff" />`; //
// 2.
edges.forEach(edge => {
const sourceNode = nodes.find(n => n.id === edge.source);
const targetNode = nodes.find(n => n.id === edge.target);
if (!sourceNode || !targetNode) return;
const x1 = sourceNode.style.x || 0;
const y1 = sourceNode.style.y || 0;
const x2 = targetNode.style.x || 0;
const y2 = targetNode.style.y || 0;
const style = edge.style || {};
const stroke = style.stroke || '#ccc';
const lineWidth = style.lineWidth || 1;
// 线
svgContent += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${lineWidth}" />`;
//
if (style.labelText) {
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2;
svgContent += `<text x="${mx}" y="${my}" fill="${style.labelFill || '#000'}" font-size="${style.labelFontSize || 10}" text-anchor="middle" dominant-baseline="middle">${style.labelText}</text>`;
}
});
// 3.
nodes.forEach(node => {
const style = node.style || {};
const x = style.x || 0;
const y = style.y || 0;
const r = (style.size || 60) / 2;
const fill = style.fill || '#EF4444';
const stroke = style.stroke || '#B91C1C';
const lineWidth = style.lineWidth || 2;
//
svgContent += `<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="${lineWidth}" />`;
//
if (style.labelText) {
svgContent += `<text x="${x}" y="${y}" fill="${style.labelFill || '#fff'}" font-size="${style.labelFontSize || 12}" font-family="${style.labelFontFamily || 'Arial'}" text-anchor="middle" dominant-baseline="middle">${style.labelText}</text>`;
}
});
svgContent += `</svg>`;
// 4.
const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `矢量图谱_${Date.now()}.svg`;
link.click();
URL.revokeObjectURL(url);
ElMessage.success(' SVG 导出成功');
},
initDraggableToolbar() {
const toolbar = document.querySelector('.draggable-toolbar');
if (!toolbar) return;
let isDragging = false;
let startPos = { x: 0, y: 0 };
let offset = { x: 0, y: 0 };
const onMouseDown = (e) => {
if (e.target.closest('.g6-toolbar-item')) return;
isDragging = true;
startPos = { x: e.clientX, y: e.clientY };
offset.x = e.clientX - toolbar.offsetLeft;
offset.y = e.clientY - toolbar.offsetTop;
toolbar.style.transition = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault();
};
const onMouseMove = (e) => {
if (!isDragging) return;
let left = e.clientX - offset.x;
let top = e.clientY - offset.y;
left = Math.max(10, Math.min(window.innerWidth - toolbar.offsetWidth - 10, left));
top = Math.max(10, Math.min(window.innerHeight - toolbar.offsetHeight - 10, top));
toolbar.style.left = left + 'px';
toolbar.style.top = top + 'px';
toolbar.style.right = 'auto';
};
const onMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
toolbar.addEventListener('mousedown', onMouseDown);
},
initGraph() {
const container = this.$refs.graphContainer;
if (!container || container.clientWidth === 0) return;
this.defaultData.nodes.forEach(n => this._nodeLabelMap.set(n.id, n.data?.label));
if (this._graph) this._graph.destroy();
const graph = new Graph({
container, width: container.clientWidth, height: container.clientHeight || 600,
container,
width: container.clientWidth,
height: container.clientHeight || 600,
layout: { type: 'radial', unitRadius: 100, preventOverlap: true, nodeSpacing: 50 },
behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element', 'hover-activate'],
autoFit: 'center', animation: false
autoFit: 'center',
animation: false,
plugins: [
{ type: 'history', key: 'history' },
{
type: 'toolbar',
position: 'top-right',
className: 'g6-toolbar draggable-toolbar',
style: { marginRight: '320px', marginTop: '10px', cursor: 'move', zIndex: 999 },
onClick: (id) => {
const historyPlugin = this._graph.getPluginInstance('history');
switch (id) {
case 'zoom-in': this._graph.zoomBy(1.2); break;
case 'zoom-out': this._graph.zoomBy(0.8); break;
case 'undo':
if (historyPlugin && historyPlugin.canUndo()) historyPlugin.undo();
break;
case 'redo':
if (historyPlugin && historyPlugin.canRedo()) historyPlugin.redo();
break;
case 'auto-fit': this._graph.fitView(); break;
case 'reset':
const currentData = this._graph.getData();
const cleanNodes = currentData.nodes.map(node => {
const { x, y, ...rest } = node;
if (rest.style) {
delete rest.style.x;
delete rest.style.y;
}
return rest;
});
this._graph.zoomTo(1);
this._graph.translateTo([container.clientWidth / 2, container.clientHeight / 2]);
this._graph.setData({
nodes: cleanNodes,
edges: currentData.edges
});
this._graph.layout().then(() => {
this._graph.fitCenter();
});
ElMessage.success("已重置图谱位置");
break;
case 'export': this.handleExportClick(); break;
}
},
getItems: () => {
return [
{ id: 'zoom-in', value: 'zoom-in', title: '放大' },
{ id: 'zoom-out', value: 'zoom-out', title: '缩小' },
{ id: 'undo', value: 'undo', title: '撤销' },
{ id: 'redo', value: 'redo', title: '重做' },
{ id: 'auto-fit', value: 'auto-fit', title: '聚焦' },
{ id: 'reset', value: 'reset', title: '重置' },
{ id: 'export', value: 'export', title: '导出图谱' },
];
}
}
]
});
this._graph = markRaw(graph);
this.updateAllElements();
this.$nextTick(() => {
setTimeout(this.initDraggableToolbar, 800);
});
},
getEffectiveStyleKey(label) {
return CORE_LABELS.includes(label) ? label : 'Other';
},
updateAllElements() {
if (!this._graph) return;
const currentActiveLabelEn = tagToLabelMap[this.activeTags];
const labelToAppliedConfigMap = {};
this.styleGroups.forEach(group => {
@ -704,7 +869,6 @@ export default {
}
});
});
const hexToRgba = (hex, opacity) => {
if (!hex) return 'rgba(182, 178, 178, 0.5)';
if (hex.startsWith('rgba')) return hex;
@ -715,7 +879,6 @@ 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 = {
@ -727,7 +890,6 @@ export default {
} else {
s = labelToAppliedConfigMap[effectiveKey] || this.tagStyles[effectiveKey];
}
return {
...node, type: s?.nodeShape || 'circle',
style: {
@ -739,11 +901,9 @@ 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 = {
@ -754,7 +914,6 @@ export default {
} else {
s = labelToAppliedConfigMap[effectiveKey] || this.tagStyles[effectiveKey];
}
const strokeColor = hexToRgba(s?.edgeStroke || '#EF4444', 0.6);
return {
...edge, type: s?.edgeType || 'line',
@ -785,7 +944,6 @@ 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);
@ -795,7 +953,6 @@ 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;
@ -807,9 +964,7 @@ export default {
}
this.updateAllElements();
}
} catch (err) {
console.error("加载配置失败:", err);
}
} catch (err) { console.error("加载配置失败:", err); }
},
async fetchGroupNames() {
const res = await getGraphStyleGroups();
@ -819,69 +974,83 @@ 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(() => {});
ElMessageBox.alert(`方案【${groupName}】中已存在【${labelName}】标签的配置。`, '校验失败', { type: 'error' }).catch(() => {});
return false;
}
if (group.configs.length >= 5 && !group.configs.some(c => c.id === excludeId)) {
ElMessageBox.alert(`方案【${groupName}的配置已满(上限5个),无法添加`, '校验失败', { type: 'error' }).catch(() => {});
ElMessageBox.alert(`方案【${groupName}】已满(上限5个)。`, '校验失败', { type: 'error' }).catch(() => {});
return false;
}
return true;
},
async moveConfigToGroup(config, targetGroup) {
// 1.
if (config.group_id === targetGroup.id) {
return ElMessage.info("该配置已在该方案中");
}
if (!this.validateGroupConstraint(targetGroup.group_name, config.current_label, config.id)) return;
try {
// 2. Payload target_group_id
const payload = {
id: config.id, canvas_name: config.canvas_name, group_name: targetGroup.group_name,
current_label: config.current_label, styles: config.styles, is_auto_save: false
id: config.id,
target_group_id: targetGroup.id, // IDD
canvas_name: config.canvas_name,
current_label: config.current_label,
styles: config.styles,
is_auto_save: false
};
const res = await saveGraphStyle(payload);
if (res.code === 200) {
ElMessage.success(`已移动至【${targetGroup.group_name}`);
await this.fetchConfigs();
await this.fetchConfigs(); //
} else {
ElMessage.error(res.msg || "移动失败");
}
} catch (err) { ElMessage.error("操作失败"); }
} catch (err) {
ElMessage.error("移动操作异常");
}
},
handleSaveClick() {
this.fetchGroupNames();
this.saveForm.canvas_name = `${this.activeTags}_${Date.now()}`;
this.saveForm.group_name = '';
this.saveDialogVisible = true;
},
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;
const labelEn = tagToLabelMap[this.activeTags];
const payload = {
canvas_name: this.saveForm.canvas_name.trim(), group_name: this.saveForm.group_name.trim(),
current_label: this.activeTags, styles: { ...this.tagStyles[labelEn] }, is_auto_save: false
canvas_name: this.saveForm.canvas_name.trim(),
group_name: this.saveForm.group_name.trim(),
current_label: this.activeTags,
styles: { ...this.tagStyles[labelEn] },
is_auto_save: false //
};
const res = await saveGraphStyle(payload);
if (res.code === 200) {
ElMessage.success("保存成功");
this.saveDialogVisible = false;
this.editingConfigId = null;
this.editingConfigLabel = '';
await this.fetchConfigs();
}
},
async applyWholeGroup(group) {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.isInitialEcho = true;
@ -899,13 +1068,11 @@ 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);
@ -917,7 +1084,6 @@ export default {
this.syncAndRefresh();
});
},
async deleteSingleConfig(id) {
if (this.usingConfigIds.includes(id)) return ElMessage.error("应用中无法删除");
try {
@ -926,7 +1092,6 @@ 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("应用中无法删除");
@ -936,7 +1101,6 @@ 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 {
@ -946,7 +1110,6 @@ export default {
ElMessage.success("成功"); this.clearSelection(); this.fetchConfigs();
} catch (e) { }
},
clearSelection() { this.checkedConfigIds = []; this.checkedGroupIds = []; },
handleResize() {
if (this._graph && this.$refs.graphContainer) {
@ -956,6 +1119,7 @@ export default {
}
}
</script>
<style scoped>
/* 精准控制“应用全案”按钮 */
@ -1482,20 +1646,24 @@ export default {
:deep(.el-dialog .el-input__inner) {
outline: none !important;
}
</style>
<style>
.el-message-box__header {
text-align: left !important;
padding-top: 15px !important;
/* 强制覆盖 G6 工具栏样式 */
:deep(.g6-toolbar) {
height: 35px !important;
display: flex !important;
align-items: center !important;
padding: 0 10px !important;
}
.el-message-box__title {
color: #000000 !important;
font-weight: 500 !important;
font-size: 18px !important;
:deep(.g6-toolbar-item) {
width: 20px !important;
height: 35px !important;
font-size: 15px !important;
margin: 0 4px !important;
}
</style>
<style>
.el-message-box__btns .el-button--primary {
background-color: #1559f3 !important;
border-color: #1559f3 !important;

874
vue/src/system/KGData.vue

File diff suppressed because it is too large
Loading…
Cancel
Save