Browse Source

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

# Conflicts:
#	vue/src/components/Menu.vue
yangrongze
hanyuqing 4 months ago
parent
commit
0dd7311b4b
  1. 196
      controller/OperationController.py
  2. 2
      controller/__init__.py
  3. 286
      service/OperationService.py
  4. 1
      util/neo4j_utils.py
  5. 104
      vue/src/api/data.js
  6. 109
      vue/src/components/Menu.vue
  7. 5
      vue/src/main.js
  8. 675
      vue/src/system/KGData.vue
  9. 9
      web_main.py

196
controller/OperationController.py

@ -8,6 +8,8 @@ from urllib.parse import unquote
# 实例化业务逻辑对象 # 实例化业务逻辑对象
operation_service = OperationService() operation_service = OperationService()
# --- 核心工具函数 ---
def create_response(status_code, data_dict): def create_response(status_code, data_dict):
""" """
统一响应格式封装强制使用 UTF-8 防止中文乱码 统一响应格式封装强制使用 UTF-8 防止中文乱码
@ -20,19 +22,63 @@ def create_response(status_code, data_dict):
def parse_request_body(req): def parse_request_body(req):
""" """
通用请求体解析器解决解析 bytes字符串或多重序列化的情况 解析器适配 Robyn 框架确保能准确拿到前端传来的 IDnodeIdname label
""" """
body = req.body
try: try:
body = getattr(req, "body", None)
if not body:
return {}
# 1. 处理 bytes 类型
if isinstance(body, (bytes, bytearray)): if isinstance(body, (bytes, bytearray)):
body = json.loads(body.decode('utf-8')) body = body.decode('utf-8')
# 2. 如果已经是字典
if isinstance(body, dict):
return body
# 3. 处理字符串
if isinstance(body, str): if isinstance(body, str):
body = json.loads(body) try:
return body if isinstance(body, dict) else {} data = json.loads(body)
# 处理双层 JSON 字符串转义的情况
if isinstance(data, str):
data = json.loads(data)
return data
except json.JSONDecodeError:
try:
from urllib.parse import parse_qs
params = parse_qs(body)
return {k: v[0] for k, v in params.items()}
except:
return {}
return {}
except Exception as e: except Exception as e:
print(f"Request Body Parse Error: {e}") print(f"Request Body Parse Error: {e}")
return {} return {}
def get_query_param(req, key, default=""):
"""
提取 URL 查询参数
"""
try:
data_source = getattr(req, "queries", None)
if data_source is None or (isinstance(data_source, dict) and not data_source):
data_source = getattr(req, "query_params", {})
if hasattr(data_source, "to_dict"):
data_source = data_source.to_dict()
val = data_source.get(key)
if val is None:
return default
raw_val = str(val[0]) if isinstance(val, list) else str(val)
return unquote(raw_val).strip()
except Exception as e:
print(f"Get Param Error ({key}): {e}")
return default
# --- 1. 获取全量动态标签 --- # --- 1. 获取全量动态标签 ---
@app.get("/api/kg/labels") @app.get("/api/kg/labels")
def get_labels(req): def get_labels(req):
@ -41,105 +87,125 @@ def get_labels(req):
return create_response(200, {"code": 200, "data": labels, "msg": "success"}) return create_response(200, {"code": 200, "data": labels, "msg": "success"})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return create_response(500, {"code": 500, "msg": f"获取标签失败: {str(e)}"}) return create_response(200, {"code": 500, "msg": f"获取标签失败: {str(e)}"})
# --- 2. 输入联想建议 --- # --- 2. 输入联想建议 ---
@app.get("/api/kg/node/suggest") @app.get("/api/kg/node/suggest")
def suggest_node(req): def suggest_node(req):
try: try:
query_data = getattr(req, "queries", getattr(req, "query_params", {})) clean_keyword = get_query_param(req, "keyword", "")
raw_keyword = query_data.get("keyword", "")
keyword = raw_keyword[0] if isinstance(raw_keyword, list) and raw_keyword else str(raw_keyword)
clean_keyword = unquote(keyword).strip()
suggestions = operation_service.suggest_nodes(clean_keyword) suggestions = operation_service.suggest_nodes(clean_keyword)
return create_response(200, {"code": 200, "data": suggestions, "msg": "success"}) return create_response(200, {"code": 200, "data": suggestions, "msg": "success"})
except Exception as e: except Exception as e:
traceback.print_exc() return create_response(200, {"code": 500, "msg": str(e)})
return create_response(500, {"code": 500, "msg": str(e)})
# --- 3. 获取分页节点列表 --- # --- 3. 获取分页节点列表 (无变动,Service已处理nodeId) ---
@app.get("/api/kg/nodes") @app.get("/api/kg/nodes")
def get_nodes(req): def get_nodes(req):
try: try:
query_data = getattr(req, "queries", getattr(req, "query_params", {})) name_raw = get_query_param(req, "name", "")
page = int(query_data.get("page", "1")) label_raw = get_query_param(req, "label", "")
page_size = int(query_data.get("pageSize", "20")) page_str = get_query_param(req, "page", "1")
size_str = get_query_param(req, "pageSize", "20")
page = int(page_str) if page_str.isdigit() else 1
page_size = int(size_str) if size_str.isdigit() else 20
res_data = operation_service.get_nodes_subset(page, page_size) name = name_raw if name_raw else None
label = label_raw if (label_raw and label_raw != "全部") else None
res_data = operation_service.get_nodes_subset(page, page_size, name=name, label=label)
return create_response(200, {"code": 200, "data": res_data, "msg": "success"}) return create_response(200, {"code": 200, "data": res_data, "msg": "success"})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return create_response(500, {"code": 500, "msg": f"获取节点列表失败: {str(e)}"}) return create_response(200, {"code": 500, "msg": f"获取节点失败: {str(e)}"})
# --- 4. 获取分页关系列表 --- # --- 4. 获取分页关系列表 ---
@app.get("/api/kg/relationships") @app.get("/api/kg/relationships")
def get_relationships(req): def get_relationships(req):
try: try:
query_data = getattr(req, "queries", getattr(req, "query_params", {})) source_raw = get_query_param(req, "source", "")
page = int(query_data.get("page", "1")) target_raw = get_query_param(req, "target", "")
page_size = int(query_data.get("pageSize", "20")) type_raw = get_query_param(req, "type", "")
page_str = get_query_param(req, "page", "1")
size_str = get_query_param(req, "pageSize", "20")
page = int(page_str) if page_str.isdigit() else 1
page_size = int(size_str) if size_str.isdigit() else 20
source = source_raw if source_raw else None
target = target_raw if target_raw else None
rel_type = type_raw if (type_raw and type_raw != "全部") else None
res_data = operation_service.get_relationships_subset(page, page_size) res_data = operation_service.get_relationships_subset(page, page_size, source, target, rel_type)
return create_response(200, {"code": 200, "data": res_data, "msg": "success"}) return create_response(200, {"code": 200, "data": res_data, "msg": "success"})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return create_response(500, {"code": 500, "msg": f"获取关系列表失败: {str(e)}"}) return create_response(200, {"code": 500, "msg": f"获取关系失败: {str(e)}"})
# --- 5. 新增节点 --- # --- 5. 新增节点 ---
@app.post("/api/kg/node/add") @app.post("/api/kg/node/add")
def add_node(req): def add_node(req):
try: try:
body = parse_request_body(req) body = parse_request_body(req)
# 增加 strip() 防止空格导致的匹配失败
label = str(body.get("label", "Drug")).strip() label = str(body.get("label", "Drug")).strip()
name = str(body.get("name", "")).strip() name = str(body.get("name", "")).strip()
# 注意:Service 里的 add_node 目前只接了 label 和 name,
# timestamp 由 Service 生成。如果以后需要接收前端传的 nodeId,
# 需在 Service 增加对应形参。目前保持兼容。
if not name: if not name:
return create_response(200, {"code": 400, "msg": "添加失败:节点名称不能为空"}) return create_response(200, {"code": 400, "msg": "名称不能为空"})
result = operation_service.add_node(label, name) result = operation_service.add_node(label, name)
return create_response(200, {"code": 200 if result["success"] else 400, "msg": result["msg"]}) return create_response(200, {
"code": 200 if result.get("success") else 400,
"msg": result.get("msg")
})
except Exception as e: 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)}"})
# --- 6. 修改节点 --- # --- 6. 修改节点 ---
@app.post("/api/kg/node/update") @app.post("/api/kg/node/update")
def update_node(req): def update_node(req):
try: try:
body = parse_request_body(req) body = parse_request_body(req)
# elementId 依然作为核心修改标识
node_id = body.get("id") node_id = body.get("id")
name = str(body.get("name", "")).strip() name = str(body.get("name", "")).strip()
label = str(body.get("label", "")).strip() label = str(body.get("label", "")).strip()
if not node_id or not name: if not node_id or not name:
return create_response(200, {"code": 400, "msg": "修改失败:参数缺失(ID或Name)"}) return create_response(200, {"code": 400, "msg": "参数缺失: 修改必须包含ID和名称"})
# 调用 Service,注意参数顺序:node_id, name, label
result = operation_service.update_node(node_id, name, label) result = operation_service.update_node(node_id, name, label)
return create_response(200, {"code": 200 if result["success"] else 400, "msg": result["msg"]}) return create_response(200, {
"code": 200 if result.get("success") else 400,
"msg": result.get("msg")
})
except Exception as e: except Exception as e:
traceback.print_exc() return create_response(200, {"code": 500, "msg": f"更新异常: {str(e)}"})
return create_response(200, {"code": 500, "msg": str(e)})
# --- 7. 新增关系 --- # --- 7. 新增关系 ---
@app.post("/api/kg/rel/add") @app.post("/api/kg/rel/add")
def add_relationship(req): def add_relationship(req):
try: try:
body = parse_request_body(req) body = parse_request_body(req)
# 强制格式化所有字符串,彻底杜绝数据类型导致的 MATCH 失败
source = str(body.get("source", "")).strip() source = str(body.get("source", "")).strip()
target = str(body.get("target", "")).strip() target = str(body.get("target", "")).strip()
rel_type = str(body.get("type", "")).strip() rel_type = str(body.get("type", "")).strip()
rel_label = str(body.get("label", "")).strip() # label 属性在 Neo4j 关系中常用于可视化展示
rel_label = str(body.get("label", "")).strip() or rel_type
if not all([source, target, rel_type]): if not all([source, target, rel_type]):
return create_response(200, {"code": 400, "msg": "添加失败:起始点、结束点和类型不能为空"}) return create_response(200, {"code": 400, "msg": "参数缺失: 起点、终点和类型为必填项"})
result = operation_service.add_relationship(source, target, rel_type, rel_label) result = operation_service.add_relationship(source, target, rel_type, rel_label)
return create_response(200, {"code": 200 if result["success"] else 400, "msg": result["msg"]}) return create_response(200, {
"code": 200 if result.get("success") else 400,
"msg": result.get("msg")
})
except Exception as e: 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)}"})
# --- 8. 修改关系 --- # --- 8. 修改关系 ---
@ -151,31 +217,35 @@ def update_rel(req):
source = str(body.get("source", "")).strip() source = str(body.get("source", "")).strip()
target = str(body.get("target", "")).strip() target = str(body.get("target", "")).strip()
rel_type = str(body.get("type", "")).strip() rel_type = str(body.get("type", "")).strip()
rel_label = str(body.get("label", "")).strip() rel_label = str(body.get("label", "")).strip() or rel_type
if not all([rel_id, source, target, rel_type]): if not rel_id:
return create_response(200, {"code": 400, "msg": "修改失败:必要参数(ID/Source/Target/Type)缺失"}) return create_response(200, {"code": 400, "msg": "修改失败:关系ID缺失"})
result = operation_service.update_relationship(rel_id, source, target, rel_type, rel_label) result = operation_service.update_relationship(rel_id, source, target, rel_type, rel_label)
return create_response(200, {"code": 200 if result["success"] else 400, "msg": result["msg"]}) return create_response(200, {
"code": 200 if result.get("success") else 400,
"msg": result.get("msg")
})
except Exception as e: except Exception as e:
traceback.print_exc() return create_response(200, {"code": 500, "msg": f"修改关系异常: {str(e)}"})
return create_response(200, {"code": 500, "msg": str(e)})
# --- 9. 删除节点 --- # --- 9. 删除节点 ---
@app.post("/api/kg/node/delete") @app.post("/api/kg/node/delete")
def delete_node(req): def delete_node(req):
try: try:
body = parse_request_body(req) body = parse_request_body(req)
node_id = body.get("id") node_id = body.get("id") # 这里传的是 elementId
if not node_id: if not node_id:
return create_response(200, {"code": 400, "msg": "删除失败:节点ID不能为空"}) return create_response(200, {"code": 400, "msg": "删除失败: 未指定节点系统ID"})
operation_service.delete_node(node_id) result = operation_service.delete_node(node_id)
return create_response(200, {"code": 200, "msg": "节点及其关联关系已成功删除"}) return create_response(200, {
"code": 200 if result.get("success") else 400,
"msg": result.get("msg")
})
except Exception as e: except Exception as e:
traceback.print_exc() return create_response(200, {"code": 500, "msg": f"删除节点异常: {str(e)}"})
return create_response(200, {"code": 500, "msg": str(e)})
# --- 10. 删除关系 --- # --- 10. 删除关系 ---
@app.post("/api/kg/rel/delete") @app.post("/api/kg/rel/delete")
@ -184,10 +254,26 @@ def delete_rel(req):
body = parse_request_body(req) body = parse_request_body(req)
rel_id = body.get("id") rel_id = body.get("id")
if not rel_id: if not rel_id:
return create_response(200, {"code": 400, "msg": "删除失败:关系ID不能为空"}) return create_response(200, {"code": 400, "msg": "删除失败: 未指定关系系统ID"})
result = operation_service.delete_relationship(rel_id)
return create_response(200, {
"code": 200 if result.get("success") else 400,
"msg": result.get("msg")
})
except Exception as e:
return create_response(200, {"code": 500, "msg": f"删除关系异常: {str(e)}"})
operation_service.delete_relationship(rel_id) # --- 11. 获取图谱全局统计数据 ---
return create_response(200, {"code": 200, "msg": "关系已成功从数据库移除"}) @app.get("/api/kg/stats")
def get_kg_stats(req):
try:
result = operation_service.get_kg_stats()
if result and result.get("success"):
return create_response(200, {"code": 200, "data": result.get("data"), "msg": "success"})
else:
msg = result.get("msg") if result else "未能获取统计数据"
return create_response(200, {"code": 400, "msg": msg})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return create_response(200, {"code": 500, "msg": str(e)}) return create_response(200, {"code": 500, "msg": f"统计数据异常: {str(e)}"})

2
controller/__init__.py

@ -11,4 +11,4 @@ from .OperationController import *
# from .BuilderController import builder_bp # from .BuilderController import builder_bp
# from .GraphController import graph_bp # from .GraphController import graph_bp
# from .LoginController import login_bp # from .LoginController import login_bp

286
service/OperationService.py

@ -1,90 +1,152 @@
from util.neo4j_utils import neo4j_client import json
from urllib.parse import unquote
import traceback import traceback
import datetime
from urllib.parse import unquote
from util.neo4j_utils import neo4j_client
class OperationService: class OperationService:
def __init__(self): def __init__(self):
self.db = neo4j_client self.db = neo4j_client
# --- 1. 节点查询:去掉强制命名的逻辑 --- # --- 1. 全局统计接口 ---
def get_nodes_subset(self, page: int = 1, page_size: int = 20): def get_kg_stats(self):
skip_val = int((page - 1) * page_size)
limit_val = int(page_size)
# 去掉 Cypher 中的排序干扰,确保 ID 稳定
cypher = """
MATCH (n)
WITH count(n) AS total_count
MATCH (n)
RETURN elementId(n) AS id, labels(n) AS labels, n.name AS name, n.nodeId AS nodeId, total_count AS total
ORDER BY toInteger(n.nodeId) DESC
SKIP $skip LIMIT $limit
"""
try: try:
today_str = datetime.datetime.now().strftime('%Y-%m-%d')
cypher = """
CALL () {
MATCH (n) RETURN count(n) AS totalNodes
}
CALL () {
MATCH ()-[r]->() RETURN count(r) AS totalRels
}
CALL () {
MATCH (n) WHERE n.createTime STARTS WITH $today RETURN count(n) AS todayNodes
}
RETURN totalNodes, totalRels, todayNodes
"""
results = self.db.execute_read(cypher, {"today": today_str})
if results:
return {
"success": True,
"data": {
"totalNodes": results[0]['totalNodes'],
"totalRels": results[0]['totalRels'],
"todayNodes": results[0]['todayNodes']
}
}
return {"success": False, "msg": "未能获取统计数据"}
except Exception as e:
print(f"Stats Error: {e}")
return {"success": False, "data": {"totalNodes": 0, "totalRels": 0, "todayNodes": 0}}
# --- 2. 节点查询 ---
def get_nodes_subset(self, page=1, page_size=20, name=None, label=None):
try:
skip_val = (int(page) - 1) * int(page_size)
limit_val = int(page_size)
conditions = []
params = {"skip": skip_val, "limit": limit_val} params = {"skip": skip_val, "limit": limit_val}
if name:
decoded_name = unquote(str(name)).strip()
conditions.append("n.name CONTAINS $name")
params["name"] = decoded_name
if label and str(label).strip() and label not in ["全部", ""]:
conditions.append("ANY(l IN labels(n) WHERE l = $label)")
params["label"] = str(label).strip()
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
# 核心 Cypher:同时返回 elementId(用于后端操作) 和 nodeId(用于前端展示)
cypher = f"""
MATCH (n)
{where_clause}
WITH count(n) AS total_count
MATCH (n)
{where_clause}
RETURN elementId(n) AS id, labels(n) AS labels, n.name AS name, n.nodeId AS nodeId, total_count AS total
ORDER BY coalesce(n.createTime, '0000-00-00') DESC, toInteger(coalesce(n.nodeId, 0)) DESC
SKIP $skip LIMIT $limit
"""
raw_data = self.db.execute_read(cypher, params) raw_data = self.db.execute_read(cypher, params)
if not raw_data: return {"items": [], "total": 0} if not raw_data:
return {"items": [], "total": 0}
items = [] items = []
for item in raw_data: for item in raw_data:
items.append({ items.append({
"id": item["id"], "id": item["id"], # 后端操作用的 elementId
"nodeId": item.get("nodeId") or item["id"], # 前端展示用的业务ID,若无则降级
"labels": item["labels"], "labels": item["labels"],
# 修复:不再强转为“未命名”,保留真实状态 "name": item.get("name") or "N/A"
"name": item.get("name"),
"nodeId": item.get("nodeId") or 0
}) })
return {"items": items, "total": raw_data[0]['total']} return {"items": items, "total": raw_data[0]['total']}
except Exception as e: except Exception as e:
print(f"Service Error (Nodes): {e}") traceback.print_exc()
return {"items": [], "total": 0} return {"items": [], "total": 0}
# --- 2. 关系查询:去掉 coalesce 强制命名 --- # --- 3. 关系查询 ---
def get_relationships_subset(self, page: int = 1, page_size: int = 20): def get_relationships_subset(self, page=1, page_size=20, source=None, target=None, rel_type=None):
skip_val = int((page - 1) * page_size)
limit_val = int(page_size)
# 修复:移除 coalesce(..., "未知节点")
cypher = """
MATCH (a)-[r]->(b)
WITH count(r) AS total_count
MATCH (a)-[r]->(b)
RETURN elementId(r) as id,
type(r) as type,
r.label as label,
a.name as source,
b.name as target,
coalesce(r.createTime, 0) as createTime,
total_count
ORDER BY createTime DESC
SKIP $skip LIMIT $limit
"""
try: try:
skip_val = (int(page) - 1) * int(page_size)
limit_val = int(page_size)
conditions = []
params = {"skip": skip_val, "limit": limit_val} params = {"skip": skip_val, "limit": limit_val}
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("type(r) = $rel_type")
params["rel_type"] = str(rel_type).strip()
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
cypher = f"""
MATCH (a)-[r]->(b)
{where_clause}
WITH count(r) AS total_count
MATCH (a)-[r]->(b)
{where_clause}
RETURN elementId(r) as id,
type(r) as type,
r.label as label,
a.name as source,
b.name as target,
total_count
ORDER BY coalesce(r.createTime, '0000-00-00') DESC
SKIP $skip LIMIT $limit
"""
raw_data = self.db.execute_read(cypher, params) raw_data = self.db.execute_read(cypher, params)
if not raw_data: return {"items": [], "total": 0} if not raw_data:
return {"items": [], "total": 0}
items = [] items = []
for row in raw_data: for row in raw_data:
items.append({ items.append({
"id": row["id"], "id": row["id"],
"type": row["type"], "type": row["type"],
"label": row["label"] if row["label"] is not None else "", "label": row["label"] if row.get("label") is not None else "",
"source": row["source"], "source": row["source"],
"target": row["target"] "target": row["target"]
}) })
return {"items": items, "total": raw_data[0]['total_count']} return {"items": items, "total": raw_data[0]['total_count']}
except Exception as e: except Exception as e:
print(f"Service Error (Rels): {e}") traceback.print_exc()
return {"items": [], "total": 0} return {"items": [], "total": 0}
# --- 3. 联想建议:排除“未命名”干扰 --- # --- 4. 联想建议 ---
def suggest_nodes(self, keyword: str): def suggest_nodes(self, keyword: str):
if not keyword: return [] if not keyword: return []
try: try:
kw = unquote(str(keyword)).strip() kw = unquote(str(keyword)).strip()
# 增加过滤:不返回名为“未命名”的建议 cypher = "MATCH (n) WHERE n.name CONTAINS $kw AND n.name <> '未命名' RETURN DISTINCT n.name as name LIMIT 15"
cypher = """
MATCH (n)
WHERE n.name CONTAINS $kw AND n.name <> '未命名'
RETURN DISTINCT n.name as name LIMIT 15
"""
results = self.db.execute_read(cypher, {"kw": kw}) results = self.db.execute_read(cypher, {"kw": kw})
db_suggestions = [row["name"] for row in results if row["name"]] db_suggestions = [row["name"] for row in results if row["name"]]
suffix_suggestions = [f"{kw}", f"{kw}胶囊", f"{kw}注射液"] suffix_suggestions = [f"{kw}", f"{kw}胶囊", f"{kw}注射液"]
@ -93,88 +155,137 @@ class OperationService:
except: except:
return [] return []
# --- 4. 节点管理 --- # --- 5. 节点管理 ---
def add_node(self, label: str, name: str): def add_node(self, label: str, name: str):
try: try:
nm = str(name).strip() nm = unquote(str(name)).strip()
if not nm: return {"success": False, "msg": "名称不能为空"} if not nm: return {"success": False, "msg": "名称不能为空"}
create_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 查重
check_cypher = "MATCH (n) WHERE n.name = $name RETURN n LIMIT 1" check_cypher = "MATCH (n) WHERE n.name = $name RETURN n LIMIT 1"
existing = self.db.execute_read(check_cypher, {"name": nm}) existing = self.db.execute_read(check_cypher, {"name": nm})
if existing: if existing:
return {"success": False, "msg": f"添加失败:已存在名为 '{nm}' 的节点"} return {"success": False, "msg": f"添加失败:已存在名为 '{nm}' 的节点"}
create_cypher = f"CREATE (n:`{label}` {{name: $name, nodeId: timestamp()}}) RETURN elementId(n) as id" # nodeId 使用当前毫秒时间戳,确保其为业务可读的短ID
self.db.execute_write(create_cypher, {"name": nm}) create_cypher = f"""
return {"success": True, "msg": "添加成功"} CREATE (n:`{label}` {{
name: $name,
nodeId: timestamp(),
createTime: $createTime
}})
RETURN n
"""
# 使用 write_and_return 确保能拿到返回对象,从而判断成功
result = self.db.execute_write_and_return(create_cypher, {"name": nm, "createTime": create_time})
if result:
return {"success": True, "msg": "添加成功"}
return {"success": False, "msg": "节点创建失败"}
except Exception as e: except Exception as e:
return {"success": False, "msg": f"写入失败: {str(e)}"} return {"success": False, "msg": f"写入失败: {str(e)}"}
def update_node(self, node_id: str, name: str, label: str): def update_node(self, node_id: str, name: str, label: str):
try: try:
nm = str(name).strip() nm = unquote(str(name)).strip()
if not nm: return {"success": False, "msg": "名称不能为空"} if not nm: return {"success": False, "msg": "名称不能为空"}
# 排除自身查重
check_name = "MATCH (n) WHERE n.name = $name AND elementId(n) <> $id RETURN n LIMIT 1" check_name = "MATCH (n) WHERE n.name = $name AND elementId(n) <> $id RETURN n LIMIT 1"
existing = self.db.execute_read(check_name, {"name": nm, "id": node_id}) existing = self.db.execute_read(check_name, {"name": nm, "id": node_id})
if existing: if existing:
return {"success": False, "msg": f"修改失败:库中已有其他名为 '{nm}' 的节点"} return {"success": False, "msg": f"修改失败:库中已有其他名为 '{nm}' 的节点"}
# 修改标签需要先移除旧标签(Neo4j不支持直接覆盖所有标签)
cypher = f""" cypher = f"""
MATCH (n) WHERE elementId(n) = $id MATCH (n) WHERE elementId(n) = $id
SET n.name = $name, n.nodeId = timestamp() SET n.name = $name
WITH n WITH n
REMOVE n:Drug:Disease:Symptom:Entity REMOVE n:Drug:Disease:Symptom:Entity:Medicine:Check:Food
WITH n WITH n
SET n:`{label}` SET n:`{label}`
RETURN n RETURN n
""" """
result = self.db.execute_write(cypher, {"id": node_id, "name": nm}) result = self.db.execute_write_and_return(cypher, {"id": node_id, "name": nm})
return {"success": True, "msg": "节点修改成功"} if result else {"success": False, "msg": "找不到该节点"} if result:
return {"success": True, "msg": "节点修改成功"}
else:
return {"success": False, "msg": "找不到该节点或更新失败"}
except Exception as e: except Exception as e:
return {"success": False, "msg": str(e)} return {"success": False, "msg": str(e)}
def delete_node(self, node_id: str): def delete_node(self, node_id: str):
cypher = "MATCH (n) WHERE elementId(n) = $id DETACH DELETE n" try:
return self.db.execute_write(cypher, {"id": node_id}) # 使用 RETURN count(n) 来确认是否执行了删除
cypher = "MATCH (n) WHERE elementId(n) = $id DETACH DELETE n RETURN 1 as deleted"
result = self.db.execute_write_and_return(cypher, {"id": node_id})
if result:
return {"success": True, "msg": "删除成功"}
return {"success": False, "msg": "节点不存在或已被删除"}
except Exception as e:
return {"success": False, "msg": str(e)}
def get_all_labels(self): def get_all_labels(self):
cypher = "CALL db.labels()" cypher = "CALL db.labels()"
try: try:
results = self.db.execute_read(cypher) results = self.db.execute_read(cypher)
# 处理 Neo4j 返回的列表格式
labels = [list(row.values())[0] for row in results] labels = [list(row.values())[0] for row in results]
# 排除掉 Neo4j 默认的一些内部标签(如果有)
return labels if labels else ["Drug", "Disease", "Symptom"] return labels if labels else ["Drug", "Disease", "Symptom"]
except: except:
return ["Drug", "Disease", "Symptom"] return ["Drug", "Disease", "Symptom"]
# --- 5. 关系管理 --- # --- 6. 关系管理 ---
def add_relationship(self, source_name: str, target_name: str, rel_type: str, rel_label: str): def add_relationship(self, source_name: str, target_name: str, rel_type: str, rel_label: str):
try: try:
s, t, l = str(source_name).strip(), str(target_name).strip(), str(rel_label).strip() # 1. 数据清洗
s = unquote(str(source_name)).strip()
t = unquote(str(target_name)).strip()
l = str(rel_label).strip()
clean_rel_type = rel_type.strip().replace("`", "")
create_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 2. 预检:节点是否存在 (仿照 add_node 的 check_cypher)
# 使用 LIMIT 1 避免同名节点导致的笛卡尔积重复
check_nodes = """ check_nodes = """
OPTIONAL MATCH (a) WHERE a.name = $s OPTIONAL MATCH (a) WHERE a.name = $s
WITH a LIMIT 1
OPTIONAL MATCH (b) WHERE b.name = $t OPTIONAL MATCH (b) WHERE b.name = $t
WITH a, b LIMIT 1
RETURN a IS NOT NULL as hasA, b IS NOT NULL as hasB RETURN a IS NOT NULL as hasA, b IS NOT NULL as hasB
""" """
exists = self.db.execute_read(check_nodes, {"s": s, "t": t}) nodes_res = self.db.execute_read(check_nodes, {"s": s, "t": t})
if not exists or not exists[0]['hasA'] or not exists[0]['hasB']: if not nodes_res or not nodes_res[0]['hasA'] or not nodes_res[0]['hasB']:
err_msg = "添加失败: " err_msg = "添加失败: "
if not exists[0]['hasA']: err_msg += f"起始节点'{s}'不存在; " if not nodes_res[0]['hasA']: err_msg += f"起始节点'{s}'不存在; "
if not exists[0]['hasB']: err_msg += f"结束节点'{t}'不存在" if not nodes_res[0]['hasB']: err_msg += f"结束节点'{t}'不存在"
return {"success": False, "msg": err_msg} return {"success": False, "msg": err_msg}
cypher = f""" # 3. 查重:检查是否已存在相同关系 (仿照 add_node 的查重逻辑)
check_rel = f"MATCH (a {{name: $s}})-[r:`{clean_rel_type}`]->(b {{name: $t}}) RETURN r LIMIT 1"
existing_rel = self.db.execute_read(check_rel, {"s": s, "t": t})
if existing_rel:
return {"success": False, "msg": f"添加失败:已存在该关系"}
# 4. 写入:创建新关系
# 同样使用 WITH...LIMIT 1 确保即使有重名点也只连一对线
create_cypher = f"""
MATCH (a {{name: $s}}), (b {{name: $t}}) MATCH (a {{name: $s}}), (b {{name: $t}})
MERGE (a)-[r:`{rel_type}`]->(b) WITH a, b LIMIT 1
ON CREATE SET r.label = $l, r.createTime = timestamp() CREATE (a)-[r:`{clean_rel_type}` {{
ON MATCH SET r.label = $l, r.updateTime = timestamp() label: $l,
createTime: $create_time
}}]->(b)
RETURN r RETURN r
""" """
self.db.execute_write(cypher, {"s": s, "t": t, "l": l}) result = self.db.execute_write_and_return(create_cypher, {
return {"success": True, "msg": "操作成功"} "s": s, "t": t, "l": l, "create_time": create_time
})
if result:
return {"success": True, "msg": "添加成功"}
return {"success": False, "msg": "关系创建失败"}
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
@ -182,22 +293,31 @@ class OperationService:
def update_relationship(self, rel_id: str, source_name: str, target_name: str, rel_type: str, rel_label: str): def update_relationship(self, rel_id: str, source_name: str, target_name: str, rel_type: str, rel_label: str):
try: try:
s, t, l = str(source_name).strip(), str(target_name).strip(), str(rel_label).strip() s = unquote(str(source_name)).strip()
t = unquote(str(target_name)).strip()
l = str(rel_label).strip()
find_old = "MATCH (a)-[r]->(b) WHERE elementId(r) = $id RETURN type(r) as type, a.name as s, b.name as t" find_old = "MATCH (a)-[r]->(b) WHERE elementId(r) = $id RETURN type(r) as type, a.name as s, b.name as t"
old = self.db.execute_read(find_old, {"id": rel_id}) old = self.db.execute_read(find_old, {"id": rel_id})
if not old: return {"success": False, "msg": "修改失败:原关系不存在"} if not old: return {"success": False, "msg": "修改失败:原关系不存在"}
if old[0]['s'] != s or old[0]['t'] != t or old[0]['type'] != rel_type: # 如果只是修改 label,不修改节点和类型,则直接 SET
if old[0]['s'] == s and old[0]['t'] == t and old[0]['type'] == rel_type:
update_cypher = "MATCH ()-[r]->() WHERE elementId(r) = $id SET r.label = $l RETURN r"
self.db.execute_write_and_return(update_cypher, {"id": rel_id, "l": l})
return {"success": True, "msg": "修改成功"}
else:
# 涉及节点或类型变动,由于 Neo4j 不支持直接更改关系类型,需删掉重建
self.delete_relationship(rel_id) self.delete_relationship(rel_id)
return self.add_relationship(s, t, rel_type, l) return self.add_relationship(s, t, rel_type, l)
else:
update_cypher = "MATCH ()-[r]->() WHERE elementId(r) = $id SET r.label = $l, r.updateTime = timestamp() RETURN r"
self.db.execute_write(update_cypher, {"id": rel_id, "l": l})
return {"success": True, "msg": "修改成功"}
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return {"success": False, "msg": f"修改异常: {str(e)}"} return {"success": False, "msg": f"修改异常: {str(e)}"}
def delete_relationship(self, rel_id: str): def delete_relationship(self, rel_id: str):
cypher = "MATCH ()-[r]->() WHERE elementId(r) = $id DELETE r" try:
return self.db.execute_write(cypher, {"id": rel_id}) cypher = "MATCH ()-[r]->() WHERE elementId(r) = $id DELETE r RETURN 1"
result = self.db.execute_write_and_return(cypher, {"id": rel_id})
return {"success": True, "msg": "删除成功"} if result else {"success": False, "msg": "关系不存在"}
except Exception as e:
return {"success": False, "msg": f"删除失败: {str(e)}"}

1
util/neo4j_utils.py

@ -48,6 +48,7 @@ class Neo4jUtil:
session.execute_write( session.execute_write(
lambda tx: tx.run(cypher, parameters=params).consume() lambda tx: tx.run(cypher, parameters=params).consume()
) )
print(cypher)
logger.debug(f"执行写操作: {cypher}") logger.debug(f"执行写操作: {cypher}")
def execute_read(self, cypher: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: def execute_read(self, cypher: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:

104
vue/src/api/data.js

@ -0,0 +1,104 @@
// src/api/data.js
import request from '@/utils/request';
/**
* 知识图谱管理接口
*/
// --- 0. 获取图谱全局统计数据 ---
export function getKgStats() {
return request({
url: '/api/kg/stats',
method: 'get'
})
}
// --- 1. 获取全量动态标签 ---
export function getLabels() {
return request({
url: '/api/kg/labels',
method: 'get'
})
}
// --- 2. 输入联想建议 ---
export function getNodeSuggestions(keyword) {
return request({
url: '/api/kg/node/suggest',
method: 'get',
params: { keyword }
})
}
// --- 3. 获取分页节点列表 ---
export function getNodesList(params) {
return request({
url: '/api/kg/nodes',
method: 'get',
params // 包含 page, pageSize, name, label
})
}
// --- 4. 获取分页关系列表 ---
export function getRelationshipsList(params) {
return request({
url: '/api/kg/relationships',
method: 'get',
params // 包含 page, pageSize, source, target, type
})
}
// --- 5. 新增节点 ---
export function addNode(data) {
return request({
url: '/api/kg/node/add',
method: 'post',
data // 格式: { label, name }
})
}
// --- 6. 修改节点 ---
export function updateNode(data) {
return request({
url: '/api/kg/node/update',
method: 'post',
data
})
}
// --- 7. 新增关系 ---
export function addRelationship(data) {
return request({
url: '/api/kg/rel/add',
method: 'post',
data // 格式: { source, target, type, label }
})
}
// --- 8. 修改关系 ---
export function updateRelationship(data) {
return request({
url: '/api/kg/rel/update',
method: 'post',
data // 格式: { id, source, target, type, label }
})
}
// --- 9. 删除节点 ---
export function deleteNode(id) {
return request({
url: '/api/kg/node/delete',
method: 'post',
// 建议封装成对象,以便后端 parse_request_body 统一处理
data: { id }
})
}
// --- 10. 删除关系 ---
export function deleteRelationship(id) {
return request({
url: '/api/kg/rel/delete',
method: 'post',
data: { id }
})
}

109
vue/src/components/Menu.vue

@ -1,13 +1,13 @@
<template> <template>
<div class="admin-layout" :class="{ <div class="admin-layout" :class="{
'is-collapsed': isCollapsed, 'is-collapsed': isCollapsed,
'is-expanded': !isCollapsed 'is-expanded': !isCollapsed
}"> }">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="header-content"> <div class="header-content">
<div class="header-icon-wrap"> <div class="header-icon-wrap">
<img src="../assets/logo1.png" class="logo-img"/> <img src="../assets/logo1.png" class="logo-img" />
</div> </div>
<div v-if="!isCollapsed" class="header-text-wrap"> <div v-if="!isCollapsed" class="header-text-wrap">
<div class="title-line">面向疾病预测的知识图谱</div> <div class="title-line">面向疾病预测的知识图谱</div>
@ -17,73 +17,60 @@
</div> </div>
<nav class="menu-nav"> <nav class="menu-nav">
<div <div v-for="(item, index) in menuItems" :key="index" class="menu-item"
v-for="(item, index) in menuItems" :class="{ 'is-active': activeIndex === index }" @click="handleMenuClick(index)">
:key="index"
class="menu-item"
:class="{ 'is-active': activeIndex === index }"
@click="handleMenuClick(index)"
>
<div class="highlight-box"> <div class="highlight-box">
<div class="menu-content-fixed"> <div class="menu-content-fixed">
<div class="menu-icon-wrapper"> <div class="menu-icon-wrapper">
<img :src="item.icon" class="menu-icon-img"/> <img :src="item.icon" class="menu-icon-img" />
</div> </div>
<span v-if="!isCollapsed" class="menu-text">{{ item.name }}</span> <span v-if="!isCollapsed" class="menu-text">{{ item.name }}</span>
</div> </div>
<img v-if="activeIndex === index && !isCollapsed" src="@/assets/右侧白色.png" class="active-tag"/> <img v-if="activeIndex === index && !isCollapsed" src="@/assets/右侧白色.png" class="active-tag" />
</div> </div>
</div> </div>
<div v-if="!isCollapsed" class="collapse-handle" @click="isCollapsed = true"> <div v-if="!isCollapsed" class="collapse-handle" @click="isCollapsed = true">
<img src="@/assets/收缩.png" class="collapse-icon-img"/> <img src="@/assets/收缩.png" class="collapse-icon-img" />
</div> </div>
</nav> </nav>
<div class="sidebar-footer" <div class="sidebar-footer" :style="{
:style="{ 'border-top': isCollapsed ? 'none' : '2px solid rgba(255, 255, 255, 0.1)'
'border-top': isCollapsed ? 'none' : '2px solid rgba(255, 255, 255, 0.1)' }">
}">
<div v-if="!isCollapsed" class="user-block"> <div v-if="!isCollapsed" class="user-block">
<img <img :src="userProfile.avatar" alt="用户头像" class="avatar" @click="handleProfile">
:src="userProfile.avatar"
alt="用户头像"
class="avatar"
@click="handleProfile"
>
<div class="info"> <div class="info">
<div class="name" @click="handleProfile">{{ userProfile.username }}</div> <div class="name" @click="handleProfile">{{ userProfile.username }}</div>
<div class="id">8866990099</div> <div class="id">8866990099</div>
</div> </div>
<div class="exit-wrap"> <div class="exit-wrap">
<img src="@/assets/退出.png" class="exit-icon" @click="handleLogout" alt="退出"/> <img src="@/assets/退出.png" class="exit-icon" @click="handleLogout" alt="退出" />
</div> </div>
</div> </div>
<div v-else class="expand-handle-circle" @click="isCollapsed = false"> <div v-else class="expand-handle-circle" @click="isCollapsed = false">
<img src="@/assets/打开.png" class="expand-icon-img-circle"/> <img src="@/assets/打开.png" class="expand-icon-img-circle" />
</div> </div>
</div> </div>
</aside> </aside>
</div> </div>
</template> </template>
<script setup> <script setup>
import {onMounted, ref} from 'vue'; import { onMounted, ref } from 'vue';
import {useRouter} from 'vue-router'; import { useRouter } from 'vue-router';
//
import { ElMessage } from 'element-plus';
import i1 from '@/assets/图标1.png'; import i1 from '@/assets/图标1.png';
import i2 from '@/assets/图标2.png'; import i2 from '@/assets/图标2.png';
import i3 from '@/assets/图标3.png'; import i3 from '@/assets/图标3.png';
import i4 from '@/assets/图标4.png'; import i4 from '@/assets/图标4.png';
import {getUserProfile} from "@/api/profile"; import { getUserProfile } from "@/api/profile";
const router = useRouter(); const router = useRouter();
// const activeIndex = ref(0);
// //
const props = defineProps({ const props = defineProps({
//
initialActive: { initialActive: {
type: Number, type: Number,
default: 0 default: 0
@ -92,10 +79,12 @@ const props = defineProps({
// //
const emit = defineEmits(['menu-click']); const emit = defineEmits(['menu-click']);
const userProfile = ref({ const userProfile = ref({
username: '用户', username: '用户',
avatar: '/resource/avatar/用户.png' avatar: '/resource/avatar/用户.png'
}); });
// //
const activeIndex = ref(props.initialActive); const activeIndex = ref(props.initialActive);
@ -103,65 +92,65 @@ const activeIndex = ref(props.initialActive);
const isCollapsed = ref(false); const isCollapsed = ref(false);
const menuItems = [ const menuItems = [
{name: '医疗知识图谱', path: '/kg-display', icon: i1}, { name: '医疗知识图谱', path: '/kg-display', icon: i1 },
{name: '知识图谱构建', path: '/kg-builder', icon: i2}, { name: '知识图谱构建', path: '/kg-builder', icon: i2 },
{name: '知识图谱问答', path: '/kg-qa', icon: i3}, { name: '知识图谱问答', path: '/kg-qa', icon: i3 },
{name: '知识图谱数据', path: '/kg-data', icon: i4}, { name: '知识图谱数据', path: '/kg-data', icon: i4 },
{name: '图谱样式工具', path: '/kg-style', icon: i4} { name: '图谱样式工具', path: '/kg-style', icon: i4 }
]; ];
const handleMenuClick = (i) => { const handleMenuClick = (i) => {
activeIndex.value = i; activeIndex.value = i;
router.push(menuItems[i].path); router.push(menuItems[i].path);
}; };
const handleProfile = () => { const handleProfile = () => {
// 使Vue Router
router.push('/profile'); router.push('/profile');
}; };
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('messages'); localStorage.removeItem('messages');
// 使Vue Router localStorage.removeItem('token'); // 退token
router.push('/login'); router.push('/login');
}; };
onMounted(async () => { onMounted(async () => {
try { try {
// localStoragetoken
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
console.log('Profile组件挂载,获取到的token:', token); console.log('Profile组件挂载,获取到的token:', token);
if (token) { if (token) {
// API
const response = await getUserProfile(token); const response = await getUserProfile(token);
if (response.success) { if (response.success) {
// let avatarUrl = response.user.avatar || '/resource/avatar/4.png';
// //
let avatarUrl = response.user.avatar || '/resource/avatar/用户.png';
if (avatarUrl.startsWith('/resource/')) {
avatarUrl = avatarUrl; // 使
}
console.log('设置头像URL:', avatarUrl);
userProfile.value = { userProfile.value = {
username: response.user.username, username: response.user.username,
avatar: avatarUrl avatar: avatarUrl
}; };
} else { } else {
// tokentoken // token
if (response.message && response.message.includes('登录')) { if (response.message && response.message.includes('登录')) {
localStorage.removeItem('token'); localStorage.removeItem('token');
ElMessage.warning('登录已过期,请重新登录');
} }
} }
} else { } else {
console.log('用户未登录'); console.log('用户未登录');
errorMessage.value = '用户未登录,请先登录'; // ElMessage errorMessage.value
ElMessage.info('您当前处于游客模式,请登录后操作');
} }
} catch (error) { } catch (error) {
console.error('获取用户信息失败:', error); console.error('获取用户信息失败:', error);
errorMessage.value = '获取用户信息时发生错误'; // ElMessage
ElMessage.error('无法连接到服务器,请检查网络');
} }
}); });
</script> </script>
<style scoped> <style scoped>
/* 样式部分保持不变,由于你提供的是完整样式,此处完全保留 */
.admin-layout { .admin-layout {
display: flex; display: flex;
height: 100vh; height: 100vh;
@ -175,7 +164,6 @@ onMounted(async () => {
flex-shrink: 0; flex-grow: 0; flex-shrink: 0; flex-grow: 0;
} }
/* --- 侧边栏 --- */
.sidebar { .sidebar {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -192,13 +180,12 @@ onMounted(async () => {
width: 4%; width: 4%;
} }
.admin-layout.is-expanded { .admin-layout.is-expanded {
width: 12%; width: 14%;
} }
.sidebar-header { .sidebar-header {
padding: 25px 0px 18px 0; padding: 25px 0px 18px 20px;
border-bottom: 2px solid rgba(255, 255, 255, 0.1); border-bottom: 2px solid rgba(255, 255, 255, 0.1);
display: flex; justify-content: flex-start;
justify-content: center;
} }
.header-content { .header-content {
@ -239,7 +226,6 @@ onMounted(async () => {
font-weight: 600; font-weight: 600;
} }
/* --- 菜单导航 --- */
.menu-nav { .menu-nav {
flex: 1; flex: 1;
padding-top: 10px; padding-top: 10px;
@ -333,7 +319,6 @@ onMounted(async () => {
width: 14px; width: 14px;
} }
/* --- 用户区域 (重点修改) --- */
.sidebar-footer { .sidebar-footer {
padding: 12px 0 20px 0; padding: 12px 0 20px 0;
border-top: 2px solid rgba(255, 255, 255, 0.1); border-top: 2px solid rgba(255, 255, 255, 0.1);
@ -378,7 +363,7 @@ onMounted(async () => {
font-size: 9px; font-size: 9px;
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
line-height: 1; line-height: 1;
margin-top: 6px; /* 通过增加上边距让id向下移动 */ margin-top: 6px;
} }
.exit-wrap { .exit-wrap {
@ -403,6 +388,4 @@ onMounted(async () => {
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
</style> </style>

5
vue/src/main.js

@ -4,8 +4,11 @@ import router from './router' // 👈 引入路由
// 引入 Element Plus // 引入 Element Plus
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' // 引入样式 import 'element-plus/dist/index.css' // 引入样式
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const app = createApp(App) const app = createApp(App)
app.use(router) // 👈 关键!注册 router 插件 app.use(router) // 👈 关键!注册 router 插件
app.use(ElementPlus) // 全局注册 app.use(ElementPlus, {
locale : zhCn,
}) // 全局注册
app.mount('#app') app.mount('#app')

675
vue/src/system/KGData.vue

@ -1,344 +1,479 @@
<template> <template>
<div style="padding: 20px;"> <div class="knowledge-graph-data-container">
<el-tabs v-model="activeName" @tab-click="handleTabClick"> <Menu :initial-active="3"/>
<el-tab-pane label="节点管理" name="first"> <main class="main-body">
<div style="margin-bottom: 15px; text-align: left;"> <div class="page-header">
<el-button type="primary" @click="openNodeDialog(null)">新增节点</el-button> <span class="header-decorator"></span>
<h2 class="header-title">知识图谱数据管理</h2>
</div>
<div class="stat-container">
<div class="custom-stat-card">
<div class="stat-inner">
<div class="stat-label">实体总数</div>
<div class="stat-value">{{ stats.totalNodes.toLocaleString() }}</div>
<div class="stat-desc">涵盖疾病药物症状等核心医疗要素</div>
</div>
</div>
<div class="custom-stat-card">
<div class="stat-inner">
<div class="stat-label">关系总数</div>
<div class="stat-value">{{ stats.totalRels.toLocaleString() }}</div>
<div class="stat-desc">包含禁忌并发症治疗等多种关联语义</div>
</div>
</div> </div>
<el-table v-loading="loading" :data="nodeData" border height="550" style="width: 100%"> <div class="custom-stat-card">
<el-table-column prop="id" label="ID" width="180" show-overflow-tooltip /> <div class="stat-inner">
<div class="stat-label">今日新增</div>
<el-table-column label="名称" min-width="150"> <div class="stat-value">{{ stats.todayNodes.toLocaleString() }}</div>
<template #default="scope"> <div class="stat-desc">24小时内系统自动爬取及人工审核增量</div>
<span :style="{ color: scope.row.name ? 'inherit' : '#909399' }"> </div>
{{ scope.row.name || 'N/A' }}
</span>
</template>
</el-table-column>
<el-table-column label="标签" width="150">
<template #default="scope">
<el-tag size="small" v-for="label in scope.row.labels" :key="label" style="margin-right: 5px;">{{ label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="scope">
<el-button type="primary" size="small" @click="openNodeDialog(scope.row)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDelete(scope.row, 'node')">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 15px; display: flex; justify-content: flex-end;">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="nodeTotal"
:page-sizes="[10, 20, 50, 100]"
v-model:page-size="pageSize"
v-model:current-page="nodePage"
@current-change="fetchNodes"
@size-change="handleSizeChange"
/>
</div> </div>
</el-tab-pane> </div>
<el-tab-pane label="关系管理" name="second"> <div class="data-content-wrapper">
<div style="margin-bottom: 15px; text-align: left;"> <div class="custom-folder-tabs">
<el-button type="primary" @click="openRelDialog(null)">新增关系</el-button> <div
class="folder-tab-item"
:class="{ active: activeName === 'first' }"
@click="activeName = 'first'; fetchNodes()"
>
节点管理
</div>
<div
class="folder-tab-item"
:class="{ active: activeName === 'second' }"
@click="activeName = 'second'; fetchRels()"
>
关系管理
</div>
</div> </div>
<el-table v-loading="loading" :data="relData" border height="550" style="width: 100%">
<el-table-column label="起始节点" min-width="150"> <div class="data-card-container">
<template #default="scope"> <div v-if="activeName === 'first'" class="tab-content-box animate-fade">
<span :style="{ color: scope.row.source ? 'inherit' : '#909399' }"> <div class="filter-bar">
{{ scope.row.source || 'N/A' }} <div class="filter-inputs">
</span> <div class="input-group-inline">
</template> <span class="filter-label-text">查询名称</span>
</el-table-column> <el-autocomplete
v-model="nodeSearch.name"
<el-table-column prop="type" label="关系类型" width="180" /> :fetch-suggestions="queryNodeSearch"
placeholder="搜索节点名称..."
<el-table-column label="结束节点" min-width="150"> clearable
<template #default="scope"> @select="handleNodeSearch"
<span :style="{ color: scope.row.target ? 'inherit' : '#909399' }"> @clear="handleNodeSearch"
{{ scope.row.target || 'N/A' }} class="search-input"
</span> />
</template> </div>
</el-table-column> <div class="input-group-inline">
<span class="filter-label-text">选择标签</span>
<el-table-column prop="label" label="描述" width="120" /> <el-select v-model="nodeSearch.label" placeholder="请选择标签" clearable @change="handleNodeSearch"
<el-table-column label="操作" width="160" fixed="right"> @clear="handleNodeSearch"
<template #default="scope"> class="search-select">
<el-button type="primary" size="small" @click="openRelDialog(scope.row)">编辑</el-button> <el-option label="全部" value="全部"/>
<el-button type="danger" size="small" @click="handleDelete(scope.row, 'rel')">删除</el-button> <el-option v-for="tag in dynamicLabels" :key="tag" :label="tag" :value="tag"/>
</template> </el-select>
</el-table-column> </div>
</el-table> </div>
<div style="margin-top: 15px; display: flex; justify-content: flex-end;"> <div class="filter-btns">
<el-pagination <el-button type="primary" class="btn-search-ref" @click="handleNodeSearch">搜索</el-button>
background <el-button class="btn-orange" @click="openNodeDialog(null)">+ 新增节点</el-button>
layout="total, sizes, prev, pager, next, jumper" </div>
:total="relTotal" </div>
:page-sizes="[10, 20, 50, 100]"
v-model:page-size="pageSize" <div class="table-shadow-wrapper table-compact">
v-model:current-page="relPage" <el-table v-loading="loading" :data="nodeData" class="ref-table" height="calc(100vh - 560px)">
@current-change="fetchRels" <el-table-column prop="nodeId" label="节点ID" width="180" align="center" show-overflow-tooltip/>
@size-change="handleSizeChange" <el-table-column label="实体类型" width="160" align="center">
/> <template #default="scope">
{{ scope.row.labels ? scope.row.labels[0] : 'Drug' }}
</template>
</el-table-column>
<el-table-column prop="name" label="实体名称" min-width="250" align="center" show-overflow-tooltip/>
<el-table-column label="操作" width="240" align="center">
<template #default="scope">
<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>
</el-table>
</div>
<div class="pagination-footer pagination-compact">
<el-pagination
background
layout="slot, sizes, prev, pager, next, jumper"
:total="nodeTotal"
v-model:page-size="pageSize"
v-model:current-page="nodePage"
@current-change="fetchNodes"
>
<span class="pagination-custom-text">共计 {{ nodeTotal.toLocaleString() }} 条数据</span>
</el-pagination>
</div>
</div>
<div v-else class="tab-content-box animate-fade">
<div class="filter-bar">
<div class="filter-inputs">
<div class="input-group-inline">
<span class="filter-label-text">起始节点</span>
<el-autocomplete
v-model="relSearch.source"
:fetch-suggestions="queryNodeSearch"
placeholder="搜索起点..."
clearable
@select="handleRelSearch"
@clear="handleRelSearch"
class="search-input"
/>
</div>
<div class="input-group-inline">
<span class="filter-label-text">结束节点</span>
<el-autocomplete
v-model="relSearch.target"
:fetch-suggestions="queryNodeSearch"
placeholder="搜索终点..."
clearable
@select="handleRelSearch"
@clear="handleRelSearch"
class="search-input"
/>
</div>
</div>
<div class="filter-btns">
<el-button type="primary" class="btn-search-ref" @click="handleRelSearch">查询</el-button>
<el-button class="btn-orange" @click="openRelDialog(null)">+ 新增关系</el-button>
</div>
</div>
<div class="table-shadow-wrapper table-compact">
<el-table v-loading="loading" :data="relData" class="ref-table" height="calc(100vh - 560px)">
<el-table-column prop="source" label="起始节点" min-width="200" align="center" show-overflow-tooltip/>
<el-table-column prop="type" label="关系类型" width="150" align="center">
<template #default="scope">
{{ scope.row.type }}
</template>
</el-table-column>
<el-table-column prop="target" label="结束节点" min-width="200" align="center" show-overflow-tooltip/>
<el-table-column label="操作" width="240" align="center">
<template #default="scope">
<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>
</el-table>
</div>
<div class="pagination-footer pagination-compact">
<el-pagination
background
layout="slot, sizes, prev, pager, next, jumper"
:total="relTotal"
v-model:page-size="pageSize"
v-model:current-page="relPage"
@current-change="fetchRels"
>
<span class="pagination-custom-text">共计 {{ relTotal.toLocaleString() }} 条数据</span>
</el-pagination>
</div>
</div>
</div> </div>
</el-tab-pane> </div>
</el-tabs> </main>
<el-dialog v-model="detailVisible" title="数据详情" width="550px" destroy-on-close header-class="bold-header">
<el-descriptions :column="1" border>
<el-descriptions-item label="系统 ID (ElementId)">{{ currentDetail.id }}</el-descriptions-item>
<template v-if="detailType === 'node'">
<el-descriptions-item label="业务 ID (NodeId)">{{ currentDetail.nodeId }}</el-descriptions-item>
<el-descriptions-item label="实体名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="所属标签">
<el-tag v-for="l in currentDetail.labels" :key="l" style="margin-right: 5px">{{ l }}</el-tag>
</el-descriptions-item>
</template>
<template v-else>
<el-descriptions-item label="起始节点">{{ currentDetail.source }}</el-descriptions-item>
<el-descriptions-item label="关系类型">{{ currentDetail.type }}</el-descriptions-item>
<el-descriptions-item label="关系标签">{{ currentDetail.label || '无' }}</el-descriptions-item>
<el-descriptions-item label="结束节点">{{ currentDetail.target }}</el-descriptions-item>
</template>
</el-descriptions>
</el-dialog>
<el-dialog v-model="nodeDialogVisible" :title="isEdit ? '修改节点' : '新增节点'" width="420px" destroy-on-close> <el-dialog v-model="nodeDialogVisible" :title="isEdit ? '修改节点' : '新增节点'" width="450px" class="custom-dialog" header-class="bold-header">
<el-form :model="nodeForm" label-width="80px" @submit.prevent> <el-form :model="nodeForm" label-width="90px" class="custom-form">
<el-form-item label="名称" required> <el-form-item label="名称" required>
<el-input v-model="nodeForm.name" placeholder="请输入节点名称" clearable /> <el-input v-model="nodeForm.name" placeholder="请输入实体名称" clearable/>
</el-form-item> </el-form-item>
<el-form-item label="标签" required> <el-form-item label="标签" required>
<el-select v-model="nodeForm.label" placeholder="请选择或输入标签" filterable allow-create style="width: 100%"> <el-select v-model="nodeForm.label" filterable allow-create placeholder="选择或输入标签" style="width: 100%">
<el-option v-for="tag in dynamicLabels" :key="tag" :label="tag" :value="tag" /> <el-option v-for="tag in dynamicLabels" :key="tag" :label="tag" :value="tag"/>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="nodeDialogVisible = false">取消</el-button> <div class="dialog-footer-wrap">
<el-button type="primary" native-type="button" :loading="submitting" @click="submitNode">确认</el-button> <el-button class="btn-cancel" @click="nodeDialogVisible = false">取消</el-button>
<el-button class="btn-confirm" type="primary" :loading="submitting" @click="submitNode">确认</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="relDialogVisible" :title="isEdit ? '修改关系信息' : '新增关系'" width="480px" destroy-on-close> <el-dialog v-model="relDialogVisible" :title="isEdit ? '修改关系' : '新增关系'" width="450px" class="custom-dialog" header-class="bold-header">
<el-form :model="relForm" label-width="100px" @submit.prevent> <el-form :model="relForm" label-width="90px" class="custom-form">
<el-form-item label="起始节点" required> <el-form-item label="起始节点" required>
<el-autocomplete <el-autocomplete v-model="relForm.source" :fetch-suggestions="queryNodeSearch" style="width:100%" placeholder="请输入起点名称"/>
v-model="relForm.source"
:fetch-suggestions="queryNodeSearch"
placeholder="搜索并选择起始节点"
style="width: 100%"
/>
</el-form-item> </el-form-item>
<el-form-item label="结束节点" required> <el-form-item label="结束节点" required>
<el-autocomplete <el-autocomplete v-model="relForm.target" :fetch-suggestions="queryNodeSearch" style="width:100%" placeholder="请输入终点名称"/>
v-model="relForm.target"
:fetch-suggestions="queryNodeSearch"
placeholder="搜索并选择结束节点"
style="width: 100%"
/>
</el-form-item> </el-form-item>
<el-form-item label="关系类型" required> <el-form-item label="关系类型" required>
<el-select v-model="relForm.type" placeholder="关系类别" style="width: 100%"> <el-input v-model="relForm.type" placeholder="例如:TREATS"/>
<el-option label="不良反应 (adverseReactions)" value="adverseReactions" />
<el-option label="治疗 (treats)" value="treats" />
<el-option label="包含 (contains)" value="contains" />
<el-option label="禁忌 (contraindicated)" value="contraindicated" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item label="关系描述"> <el-form-item label="关系显示名">
<el-input v-model="relForm.label" placeholder="例如:引起、属于、禁用于" /> <el-input v-model="relForm.label" placeholder="如果不填则同关系类型"/>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="relDialogVisible = false">取消</el-button> <div class="dialog-footer-wrap">
<el-button type="primary" native-type="button" :loading="submitting" @click="submitRel">确认</el-button> <el-button class="btn-cancel" @click="relDialogVisible = false">取消</el-button>
<el-button class="btn-confirm" type="primary" :loading="submitting" :disabled="submitting" @click="submitRel">确认</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import {ref, onMounted, reactive} from 'vue' import { ref, onMounted, reactive } from 'vue'
import axios from 'axios' import { ElMessage, ElMessageBox } from 'element-plus'
import {ElMessage, ElMessageBox} from 'element-plus' import Menu from '@/components/Menu.vue'
import {
getKgStats, getLabels, getNodeSuggestions, getNodesList, getRelationshipsList,
addNode, updateNode, addRelationship, updateRelationship, deleteNode, deleteRelationship
} from '@/api/data'
const BASE_URL = 'http://localhost:8088' // --- ---
const pageSize = ref(20) const pageSize = ref(20);
const activeName = ref('first');
const activeName = ref('first') const loading = ref(false);
const loading = ref(false) const submitting = ref(false); //
const submitting = ref(false) const stats = reactive({ totalNodes: 0, totalRels: 0, todayNodes: 0 });
// --- ---
const nodeData = ref([]); const nodeData = ref([]);
const nodeTotal = ref(0); const nodeTotal = ref(0);
const nodePage = ref(1) const nodePage = ref(1);
const nodeSearch = reactive({ name: '', label: '' });
const relData = ref([]); const relData = ref([]);
const relTotal = ref(0); const relTotal = ref(0);
const relPage = ref(1) const relPage = ref(1);
const relSearch = reactive({ source: '', target: '' });
const nodeDialogVisible = ref(false) // --- ---
const relDialogVisible = ref(false) const nodeDialogVisible = ref(false);
const isEdit = ref(false) const relDialogVisible = ref(false);
const dynamicLabels = ref([]) const detailVisible = ref(false);
const detailType = ref('node');
const currentDetail = ref({});
const isEdit = ref(false);
const dynamicLabels = ref([]);
const nodeForm = reactive({id: '', name: '', label: ''}) // --- ---
const relForm = reactive({id: '', source: '', target: '', type: 'adverseReactions', label: ''}) const nodeForm = reactive({ id: '', name: '', label: '' });
const relForm = reactive({ id: '', source: '', target: '', type: '', label: '' });
const handleSizeChange = (val) => { // --- ---
pageSize.value = val
activeName.value === 'first' ? fetchNodes() : fetchRels()
}
const fetchAllLabels = async () => { const fetchStats = async () => {
try {
const res = await axios.get(`${BASE_URL}/api/kg/labels`)
if (res.data.code === 200) dynamicLabels.value = res.data.data
} catch (e) {
console.error('获取标签失败:', e)
}
}
const queryNodeSearch = async (queryString, cb) => {
if (!queryString) return cb([])
try { try {
const res = await axios.get(`${BASE_URL}/api/kg/node/suggest`, {params: {keyword: queryString}}) const res = await getKgStats();
if (res.data.code === 200) { if (res?.code === 200) Object.assign(stats, res.data);
cb(res.data.data.map(name => ({value: name}))) } catch (e) { console.error("Stats Error", e); }
} else cb([]) };
} catch (e) {
cb([])
}
}
const openNodeDialog = (row = null) => {
fetchAllLabels()
isEdit.value = !!row
if (row) {
nodeForm.id = row.id
nodeForm.name = row.name || ''
nodeForm.label = row.labels ? row.labels[0] : ''
} else {
Object.assign(nodeForm, {id: '', name: '', label: 'Drug'})
}
nodeDialogVisible.value = true
}
const openRelDialog = (row = null) => {
isEdit.value = !!row
if (row) {
Object.assign(relForm, {
id: row.id,
source: row.source || '',
target: row.target || '',
type: row.type,
label: row.label || ''
})
} else {
Object.assign(relForm, {id: '', source: '', target: '', type: 'adverseReactions', label: ''})
}
relDialogVisible.value = true
}
const fetchNodes = async () => { const fetchNodes = async () => {
loading.value = true loading.value = true;
try { try {
const res = await axios.get(`${BASE_URL}/api/kg/nodes`, {params: {page: nodePage.value, pageSize: pageSize.value}}) const res = await getNodesList({
if (res.data.code === 200) { page: nodePage.value,
nodeData.value = res.data.data.items pageSize: pageSize.value,
nodeTotal.value = res.data.data.total name: nodeSearch.name?.trim() || null,
label: (nodeSearch.label && nodeSearch.label !== "全部") ? nodeSearch.label : null
});
if (res?.code === 200) {
nodeData.value = res.data.items;
nodeTotal.value = res.data.total;
} }
} finally { } catch (e) { ElMessage.error('加载节点失败'); }
loading.value = false finally { loading.value = false; }
} };
}
const fetchRels = async () => { const fetchRels = async () => {
loading.value = true loading.value = true;
relData.value = []
try { try {
const res = await axios.get(`${BASE_URL}/api/kg/relationships`, { const res = await getRelationshipsList({
params: { page: relPage.value, pageSize: pageSize.value } page: relPage.value,
}) pageSize: pageSize.value,
if (res.data.code === 200) { source: relSearch.source?.trim() || null,
relData.value = res.data.data.items target: relSearch.target?.trim() || null
relTotal.value = res.data.data.total });
if (res?.code === 200) {
relData.value = res.data.items;
relTotal.value = res.data.total;
} }
} finally { } catch (e) { ElMessage.error('加载关系失败'); }
loading.value = false finally { loading.value = false; }
};
const openNodeDialog = (row = null) => {
isEdit.value = !!row;
if (row) {
Object.assign(nodeForm, { id: row.id, name: row.name, label: row.labels?.[0] || '' });
} else {
Object.assign(nodeForm, { id: '', name: '', label: 'Drug' });
} }
} nodeDialogVisible.value = true;
};
const submitNode = async () => { const submitNode = async () => {
if (!nodeForm.name || !nodeForm.name.trim()) return ElMessage.warning('名称不能为空') if (!nodeForm.name?.trim()) return ElMessage.warning('名称不能为空');
submitting.value = true if (submitting.value) return; //
const url = isEdit.value ? `${BASE_URL}/api/kg/node/update` : `${BASE_URL}/api/kg/node/add`
const payload = {
name: nodeForm.name.trim(),
label: nodeForm.label
}
if (isEdit.value) payload.id = nodeForm.id
submitting.value = true;
try { try {
const res = await axios.post(url, payload) const res = isEdit.value ? await updateNode(nodeForm) : await addNode(nodeForm);
if (res.data.code === 200) { if (res?.code === 200) {
ElMessage.success(res.data.msg || '操作成功') ElMessage.success('操作成功');
nodeDialogVisible.value = false nodeDialogVisible.value = false;
fetchNodes() fetchNodes();
fetchStats();
} else { } else {
ElMessage.error(res.data.msg || '操作失败') ElMessage.error(res?.msg || '操作失败');
} }
} catch (e) { } catch (e) {
ElMessage.error('网络连接错误') ElMessage.error('接口响应异常');
} finally { } finally { submitting.value = false; }
submitting.value = false };
const openRelDialog = (row = null) => {
isEdit.value = !!row;
if (row) {
Object.assign(relForm, { id: row.id, source: row.source, target: row.target, type: row.type, label: row.label || '' });
} else {
Object.assign(relForm, { id: '', source: '', target: '', type: '', label: '' });
} }
} relDialogVisible.value = true;
};
const submitRel = async () => { const submitRel = async () => {
if (!relForm.source || !relForm.target) return ElMessage.warning('节点信息不完整') if (!relForm.source || !relForm.target || !relForm.type) return ElMessage.warning('必填项缺失');
submitting.value = true if (submitting.value) return; //
const url = isEdit.value ? `${BASE_URL}/api/kg/rel/update` : `${BASE_URL}/api/kg/rel/add`
const payload = {
source: String(relForm.source).trim(),
target: String(relForm.target).trim(),
type: relForm.type,
label: (relForm.label || '').trim()
}
if (isEdit.value) {
payload.id = relForm.id
}
submitting.value = true;
try { try {
const res = await axios.post(url, payload) const payload = isEdit.value ? { ...relForm } : {
if (res.data.code === 200) { source: relForm.source,
relDialogVisible.value = false target: relForm.target,
ElMessage.success(res.data.msg || '关系已同步') type: relForm.type,
fetchRels() label: relForm.label || relForm.type
};
const res = isEdit.value ? await updateRelationship(payload) : await addRelationship(payload);
if (res?.code === 200) {
ElMessage.success('提交成功');
relDialogVisible.value = false;
fetchRels();
fetchStats();
} else { } else {
ElMessage.warning(res.data.msg || '操作受限') ElMessage.error(res?.msg || '提交失败');
} }
} catch (e) { } catch (e) {
ElMessage.error('系统响应异常') ElMessage.error('网络连接异常');
} finally { } finally { submitting.value = false; }
submitting.value = false };
}
}
const handleDelete = (row, type) => { const handleDelete = (row, type) => {
const isNode = type === 'node' ElMessageBox.confirm('确认删除此项数据?', '警告', { type: 'warning' }).then(async () => {
const displayName = row.name || '未命名节点' const res = type === 'node' ? await deleteNode(row.id) : await deleteRelationship(row.id);
ElMessageBox.confirm( if (res?.code === 200) {
isNode ? `删除节点 "${displayName}" 会同步删除所有关联关系,确认吗?` : '确认删除该关系吗?', ElMessage.success('删除成功');
'风险提示', {type: 'error', confirmButtonText: '确定删除', cancelButtonText: '取消'} type === 'node' ? fetchNodes() : fetchRels();
).then(async () => { fetchStats();
try {
const res = await axios.post(`${BASE_URL}/api/kg/${isNode ? 'node' : 'rel'}/delete`, {id: row.id})
if (res.data.code === 200) {
ElMessage.success('已从数据库移除')
isNode ? (fetchNodes(), fetchRels()) : fetchRels()
}
} catch (e) {
ElMessage.error('操作执行失败')
} }
}).catch(() => { }).catch(() => {});
}) };
}
const handleNodeSearch = () => { nodePage.value = 1; fetchNodes(); };
const handleRelSearch = () => { relPage.value = 1; fetchRels(); };
const handleTabClick = (pane) => { const queryNodeSearch = async (queryString, cb) => {
pane.paneName === 'first' ? fetchNodes() : fetchRels() if (!queryString) return cb([]);
} const res = await getNodeSuggestions(queryString);
cb((res.data || []).map(n => ({ value: n })));
};
const handleView = (row, type) => {
detailType.value = type;
currentDetail.value = { ...row };
detailVisible.value = true;
};
onMounted(() => { onMounted(() => {
fetchNodes() fetchStats();
fetchAllLabels() fetchNodes();
}) getLabels().then(res => { if(res?.code === 200) dynamicLabels.value = res.data; });
</script> });
</script>
<style scoped>
.knowledge-graph-data-container { background-color: #f4f7fa; display: flex; height: 100vh; width: 100vw; }
.main-body { flex: 1; padding: 25px 40px; overflow-y: auto; }
.page-header { display: flex; align-items: center; margin-bottom: 25px; }
.header-decorator { width: 10px; height: 26px; background-color: #165dff; border-radius: 5px; margin-right: 15px; }
.header-title { font-size: 26px; font-weight: bold; color: #165dff; margin: 0; }
.stat-container { display: flex; gap: 80px; margin-bottom: 30px; }
.custom-stat-card { flex: 1; max-width: 280px; height: 200px; background: #ffffff; border-radius: 30px; padding: 0 35px; box-shadow: 0 0 40px 0px rgba(22, 93, 255, 0.12); 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; font-weight: 600; 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: 60px; }
.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: 40px 20px; box-shadow: 0 0 40px 0px rgba(22, 93, 255, 0.15); border: 1px solid #eff4ff; position: relative; z-index: 2; 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; 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, .search-select { width: 200px !important; }
.btn-search-ref { background: #165dff !important; border-radius: 8px; height: 38px; }
.btn-orange { background: #ffb142 !important; color: white !important; border-radius: 8px; height: 38px; border: none !important; }
.table-compact { border-radius: 16px; box-shadow: 0 4px 20px rgba(22, 93, 255, 0.08); overflow: hidden; }
.ref-table :deep(.el-table__header) th { background-color: #e8f0ff !important; color: #2869ff; font-weight: 700; }
.op-group { display: flex; gap: 8px; justify-content: center; }
.ref-op-btn { border: none !important; color: white !important; padding: 6px 14px !important; border-radius: 8px !important; }
.ref-op-btn.edit { background-color: #4379ff !important; }
.ref-op-btn.delete { background-color: #ff6060 !important; }
.ref-op-btn.view { background-color: #ffb142 !important; }
.pagination-footer { margin-top: 20px; display: flex; justify-content: flex-end; }
:deep(.bold-header) { padding: 20px 25px !important; margin-right: 0 !important; display: flex !important; justify-content: flex-start !important; }
:deep(.bold-header .el-dialog__title) { font-family: "Microsoft YaHei", sans-serif !important; font-weight: 900 !important; font-size: 22px !important; color: #000000 !important; }
.custom-form :deep(.el-form-item__label) { color: #767676 !important; font-weight: bold !important; }
.dialog-footer-wrap { display: flex; justify-content: flex-end; gap: 15px; padding: 10px 0; }
.btn-cancel { background-color: #e6e6e6 !important; border: none !important; color: #444 !important; padding: 10px 25px !important; font-weight: 500; }
.btn-confirm { background-color: #165dff !important; border: none !important; padding: 10px 25px !important; font-weight: 500; }
.animate-fade { animation: fadeIn 0.4s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
</style>

9
web_main.py

@ -5,10 +5,6 @@ import controller
from service.UserService import init_mysql_connection from service.UserService import init_mysql_connection
import os import os
# 开启全局跨域支持,允许所有来源访问
# 这将解决浏览器报 "CORS error" 或请求被拦截的问题
ALLOW_CORS(app, ["*"])
# 添加静态文件服务 # 添加静态文件服务
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
resource_dir = os.path.join(current_dir, "resource") resource_dir = os.path.join(current_dir, "resource")
@ -17,7 +13,4 @@ if os.path.exists(resource_dir):
print(f"静态资源目录已配置: {resource_dir}") print(f"静态资源目录已配置: {resource_dir}")
if __name__ == "__main__": if __name__ == "__main__":
# 启动服务 init_mysql_connection() and app.start(host="0.0.0.0", port=8088)
# 确保 init_mysql_connection 返回 True 或者去掉 and 逻辑以保证 start 执行
init_mysql_connection()
app.start(host="0.0.0.0", port=8088)
Loading…
Cancel
Save