diff --git a/controller/GraphStyleController.py b/controller/GraphStyleController.py new file mode 100644 index 0000000..bcf89a8 --- /dev/null +++ b/controller/GraphStyleController.py @@ -0,0 +1,132 @@ +# controller/GraphStyleController.py +import json +from robyn import jsonify, Response +from app import app +from service.GraphStyleService import GraphStyleService + + +# --- 核心工具函数:解决乱码 --- +def create_response(status_code, data_dict): + """ + 统一响应格式封装,强制使用 UTF-8 防止中文乱码。 + """ + return Response( + status_code=status_code, + description=json.dumps(data_dict, ensure_ascii=False), + headers={"Content-Type": "application/json; charset=utf-8"} + ) + + +@app.post("/api/graph/style/save") +async def save_style_config(request): + """保存配置接口 - 升级版:支持分组名""" + try: + body = request.json() + canvas_name = body.get('canvas_name') + current_label = body.get('current_label') + styles = body.get('styles') + # 新增:接收分组名称(字符串) + group_name = body.get('group_name') + + if not all([canvas_name, current_label, styles]): + return create_response(200, {"code": 400, "msg": "参数不完整"}) + + # 调用 Service,逻辑内部处理:组名存在则用,不存在则建 + success = GraphStyleService.save_config(canvas_name, current_label, styles, 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)}"}) + + +@app.get("/api/graph/style/list/grouped") +async def get_grouped_style_list(request): + """获取【分组嵌套】格式的配置列表(用于右侧折叠面板)""" + try: + # 调用 Service 的嵌套聚合方法 + data = GraphStyleService.get_grouped_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.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": "查询成功"}) + except Exception as e: + 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') + + if not config_id: + return create_response(200, {"code": 400, "msg": "缺少ID"}) + + success = GraphStyleService.delete_config(config_id) + 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)}"}) + + +@app.post("/api/graph/style/group/delete") +async def delete_style_group(request): + """删除整个方案组及其下属所有配置""" + try: + body = request.json() + group_id = body.get('group_id') + + if not group_id: + return create_response(200, {"code": 400, "msg": "缺少分组ID"}) + + success = GraphStyleService.delete_group(group_id) + 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)}"}) + + +@app.post("/api/graph/style/batch_delete") +async def batch_delete_style(request): + """批量删除配置接口""" + try: + body = request.json() + config_ids = body.get('ids') + + if isinstance(config_ids, str): + try: + config_ids = json.loads(config_ids) + except: + pass + + if not config_ids or not isinstance(config_ids, list): + return create_response(200, {"code": 400, "msg": "参数格式错误"}) + + count = GraphStyleService.batch_delete_configs(config_ids) + 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)}"}) \ No newline at end of file diff --git a/controller/OperationController.py b/controller/OperationController.py index e2741cc..afe162a 100644 --- a/controller/OperationController.py +++ b/controller/OperationController.py @@ -24,30 +24,32 @@ def create_response(status_code, data_dict): def parse_request_body(req): """ - 解析器:适配 Robyn 框架,确保能准确拿到前端传来的 ID、nodeId、name 和 label。 + 解析器:适配 Robyn 框架。 + 针对前端 Vue3 + ElementPlus 的请求进行深度解析,确保获取 ID、nodeId、name 和 label。 """ try: body = getattr(req, "body", None) if not body: return {} - # 1. 处理 bytes 类型 + # 1. 处理 bytes 类型 (Robyn 常见的 body 类型) if isinstance(body, (bytes, bytearray)): body = body.decode('utf-8') - # 2. 如果已经是字典 + # 2. 如果已经是字典,直接返回 if isinstance(body, dict): return body - # 3. 处理字符串 + # 3. 处理字符串 (JSON 序列化后的字符串) if isinstance(body, str): try: data = json.loads(body) - # 处理双层 JSON 字符串转义的情况 + # 处理双层 JSON 序列化的情况 (有些前端框架会序列化两次) if isinstance(data, str): data = json.loads(data) return data except json.JSONDecodeError: + # 尝试解析 URL 编码格式 (application/x-www-form-urlencoded) try: from urllib.parse import parse_qs params = parse_qs(body) @@ -62,13 +64,15 @@ def parse_request_body(req): def get_query_param(req, key, default=""): """ - 提取 URL 查询参数。 + 提取 URL 查询参数。适配不同版本的 Robyn 参数存放位置。 """ try: + # 尝试从新版/旧版 Robyn 的不同属性中提取 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", {}) + # 适配 Robyn 特有的 Query 对象 if hasattr(data_source, "to_dict"): data_source = data_source.to_dict() @@ -76,6 +80,7 @@ def get_query_param(req, key, default=""): if val is None: return default + # 提取值并进行 URL 解码 raw_val = str(val[0]) if isinstance(val, list) else str(val) return unquote(raw_val).strip() except Exception as e: @@ -110,7 +115,7 @@ def get_labels(req): return create_response(200, {"code": 500, "msg": f"获取标签失败: {str(e)}"}) -# --- 新增:获取全量动态关系类型 (关系管理用) --- +# --- 新增:获取全量动态关系类型 --- @app.get("/api/kg/relationship-types") def get_rel_types(req): """ @@ -127,12 +132,23 @@ def get_rel_types(req): # --- 2. 输入联想建议 --- @app.get("/api/kg/node/suggest") def suggest_node(req): + """ + 联想词接口: + 支持 keyword 模糊搜索,同时支持 label 强过滤。 + """ try: + # 1. 提取前端传来的参数 clean_keyword = get_query_param(req, "keyword", "") - suggestions = operation_service.suggest_nodes(clean_keyword) + clean_label = get_query_param(req, "label", "") + + # 2. 调用 Service 层 + # 如果 label 为 "全部" 或空,Service 层会自动处理成全库建议 + suggestions = operation_service.suggest_nodes(clean_keyword, clean_label) + return create_response(200, {"code": 200, "data": suggestions, "msg": "success"}) except Exception as e: - return create_response(200, {"code": 500, "msg": str(e)}) + print(f"Suggest Interface Error: {e}") + return create_response(200, {"code": 500, "msg": f"联想接口异常: {str(e)}"}) # --- 3. 获取分页节点列表 --- @@ -147,8 +163,9 @@ def get_nodes(req): page = int(page_str) if page_str.isdigit() else 1 page_size = int(size_str) if size_str.isdigit() else 20 + # 清洗参数 name = name_raw if name_raw else None - label = label_raw if (label_raw and label_raw != "全部") else None + label = label_raw if (label_raw and label_raw not in ["全部", "", "null"]) 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"}) @@ -172,7 +189,7 @@ def get_relationships(req): 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 + rel_type = type_raw if (type_raw and type_raw not in ["全部", ""]) else None 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"}) @@ -206,7 +223,8 @@ def add_node(req): def update_node(req): try: body = parse_request_body(req) - node_id = body.get("id") + # 兼容两种写法:id (elementId) 或 nodeId (业务ID) + node_id = body.get("id") or body.get("nodeId") name = str(body.get("name", "")).strip() label = str(body.get("label", "")).strip() diff --git a/controller/__init__.py b/controller/__init__.py index 8104eb4..82bad8b 100644 --- a/controller/__init__.py +++ b/controller/__init__.py @@ -7,6 +7,7 @@ from .LoginController import * from .QAController import * from .RegisterController import * from .OperationController import * +from .GraphStyleController import * # 可选:如果控制器里定义了 blueprint,也可以在这里统一导出 # from .BuilderController import builder_bp # from .GraphController import graph_bp diff --git a/service/GraphStyleService.py b/service/GraphStyleService.py new file mode 100644 index 0000000..2156782 --- /dev/null +++ b/service/GraphStyleService.py @@ -0,0 +1,139 @@ +# service/GraphStyleService.py +import json +from util.mysql_utils import mysql_client + +class GraphStyleService: + @staticmethod + def save_config(canvas_name: str, current_label: str, styles_dict: dict, group_name: str = None) -> bool: + """ + 保存图谱样式配置(增强版:自动处理分组逻辑) + :param canvas_name: 画布显示名称 + :param current_label: 针对的标签名称 + :param styles_dict: 样式字典 + :param group_name: 分组名称(前端传来的字符串) + """ + # 1. 处理分组逻辑:查不到就建,查到了就用 + 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: + # 如果存在,直接使用已有 ID + target_group_id = existing_group[0]['id'] + else: + # 如果不存在,新建一个组 + create_group_sql = "INSERT INTO graph_style_groups (group_name) VALUES (%s)" + mysql_client.execute_update(create_group_sql, (group_name,)) + # 获取新生成的 ID + get_id_sql = "SELECT LAST_INSERT_ID() as last_id" + id_res = mysql_client.execute_query(get_id_sql) + target_group_id = id_res[0]['last_id'] if id_res else 1 + + # 2. 转换样式 JSON + config_json = json.dumps(styles_dict, ensure_ascii=False) + + # 3. 插入配置表(关联 target_group_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)) + return affected_rows > 0 + + @staticmethod + def get_grouped_configs() -> list: + """ + 核心优化:获取嵌套结构的方案列表 (Group -> Configs) + 用于前端右侧折叠面板展示 + """ + # 1. 查询所有方案组 + groups_sql = "SELECT id, group_name FROM graph_style_groups ORDER BY id ASC" + groups = mysql_client.execute_query(groups_sql) or [] + + # 2. 查询所有配置项 + 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 [] + + # 3. 内存聚合:将配置项塞进对应的组 + # 先处理配置项的 JSON 和 时间 + for conf in configs: + if conf.get('config_json'): + try: + conf['styles'] = json.loads(conf['config_json']) + except: + conf['styles'] = {} + del conf['config_json'] + + if conf.get('create_time') and not isinstance(conf['create_time'], str): + conf['create_time'] = conf['create_time'].strftime('%Y-%m-%d %H:%M:%S') + + # 组装数据结构 + result = [] + for g in groups: + # 找到属于该组的所有配置 + g_children = [c for c in configs if c['group_id'] == g['id']] + g['configs'] = g_children + # 增加一个前端控制开关用的字段 + g['expanded'] = False + result.append(g) + + return result + + @staticmethod + def get_all_configs() -> list: + """保持原有的扁平查询功能,仅增加 group_id 字段返回""" + sql = "SELECT id, group_id, canvas_name, current_label, config_json, create_time FROM graph_configs ORDER BY 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') + return rows + + @staticmethod + def delete_group(group_id: int) -> bool: + """ + 逻辑级联删除:删除方案组及其关联的所有配置 + """ + # 1. 删除组下的所有配置 + del_configs_sql = "DELETE FROM graph_configs WHERE group_id = %s" + mysql_client.execute_update(del_configs_sql, (group_id,)) + + # 2. 删除组本身 + 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 + + @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 + + @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)) + + @staticmethod + def get_group_list() -> list: + """单独获取方案名称列表,供前端下拉框使用""" + sql = "SELECT id, group_name FROM graph_style_groups ORDER BY create_time DESC" + return mysql_client.execute_query(sql) or [] \ No newline at end of file diff --git a/service/OperationService.py b/service/OperationService.py index 7200d81..206e741 100644 --- a/service/OperationService.py +++ b/service/OperationService.py @@ -76,8 +76,9 @@ class OperationService: 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() + conditions.append("$label IN labels(n)") where_clause = "WHERE " + " AND ".join(conditions) if conditions else "" @@ -164,18 +165,49 @@ class OperationService: traceback.print_exc() return {"items": [], "total": 0} - # --- 4. 联想建议 --- - def suggest_nodes(self, keyword: str): - if not keyword: return [] + # --- 4. 联想建议 --- + def suggest_nodes(self, keyword: str, label: str = None): + """ + 修复后的建议逻辑: + 1. 优化 Label 过滤语法,确保在 keyword 为空时也能根据 Label 返回数据。 + 2. 增加对空字符串的宽容处理。 + """ try: - kw = unquote(str(keyword)).strip() - 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}) - db_suggestions = [row["name"] for row in results if row["name"]] - suffix_suggestions = [f"{kw}片", f"{kw}胶囊", f"{kw}注射液"] - final_res = list(dict.fromkeys(db_suggestions + suffix_suggestions)) - return final_res[:15] - except: + kw = unquote(str(keyword or "")).strip() + lb = str(label).strip() if label and label not in ["全部", "", "null", "undefined"] else None + + # 如果既没有关键词也没有标签,直接返回空 + if not kw and not lb: + return [] + + params = {} + # 基础匹配语句,排除无意义节点 + match_clause = "MATCH (n)" + if lb: + # 动态构建标签匹配,使用 :`label` 语法更高效且准确 + match_clause = f"MATCH (n:`{lb}`)" + + conditions = ["n.name <> '未命名'"] + if kw: + conditions.append("n.name CONTAINS $kw") + params["kw"] = kw + + where_clause = "WHERE " + " AND ".join(conditions) + + # 查询数据库 + cypher = f"{match_clause} {where_clause} RETURN DISTINCT n.name as name LIMIT 15" + results = self.db.execute_read(cypher, params) + db_suggestions = [row["name"] for row in results if row.get("name")] + + # 如果依然没有结果,尝试去掉 Label 限制进行全库模糊匹配(保底逻辑) + if not db_suggestions and kw and lb: + fallback_cypher = "MATCH (n) WHERE n.name CONTAINS $kw AND n.name <> '未命名' RETURN DISTINCT n.name as name LIMIT 5" + fallback_res = self.db.execute_read(fallback_cypher, {"kw": kw}) + db_suggestions = [row["name"] for row in fallback_res if row.get("name")] + + return db_suggestions + except Exception as e: + print(f"Suggest Error Trace: {traceback.format_exc()}") return [] # --- 5. 节点管理 --- @@ -226,7 +258,7 @@ class OperationService: MATCH (n) WHERE elementId(n) = $id SET n.name = $name WITH n - REMOVE n:Drug:Disease:Symptom:Entity:Medicine:Check:Food + REMOVE n:Drug:Disease:Symptom:Entity:Medicine:Check:Food:Operation:CheckSubject:Complication:Diagnosis:Treatment:AdjuvantTherapy:adverseReactions:Department:DiseaseSite:RelatedDisease:RelatedSymptom:SpreadWay:Stage:Subject:SymptomAndSign:TreatmentPrograms:Type:Cause:Attribute:Indications:Ingredients:Pathogenesis:PathologicalType:Pathophysiology:Precautions:Prognosis:PrognosticSurvivalTime:DiseaseRatio:DrugTherapy:Infectious:MultipleGroups:DiseaseRate WITH n SET n:`{label}` RETURN n @@ -259,12 +291,7 @@ class OperationService: return ["Drug", "Disease", "Symptom"] # --- 6. 关系管理 --- - - # 新增:动态获取全库关系类型及其对应的中文映射 def get_all_relationship_types(self): - """ - 获取数据库中所有的关系类型 type 及其对应的中文 label 映射 - """ cypher = """ MATCH ()-[r]->() RETURN DISTINCT type(r) AS type, r.label AS label @@ -275,9 +302,7 @@ class OperationService: seen_types = set() for row in results: t_name = row["type"] - t_label = row["label"] if row.get("label") else t_name # 若没label属性则降级显示type - - # 去重逻辑:确保每个 type 只出现一次(以第一个发现的 label 为准) + t_label = row["label"] if row.get("label") else t_name if t_name not in seen_types: type_map.append({ "type": t_name, @@ -333,7 +358,6 @@ class OperationService: if result: return {"success": True, "msg": "添加成功"} return {"success": False, "msg": "关系创建失败"} - except Exception as e: traceback.print_exc() return {"success": False, "msg": f"数据库写入异常: {str(e)}"} diff --git a/vue/src/api/data.js b/vue/src/api/data.js index 05cd815..690b355 100644 --- a/vue/src/api/data.js +++ b/vue/src/api/data.js @@ -5,10 +5,7 @@ import request from '@/utils/request'; * 知识图谱管理接口 */ -// --- 存量数据 ID 自动修复 --- -/** - * 触发后端检查并修复 nodeId 为 0 或缺失的节点 - */ +// --- 存量数据 ID 自动修复 --- export function fixNodeIds() { return request({ url: '/api/kg/admin/fix-ids', @@ -24,7 +21,7 @@ export function getKgStats() { }) } -// --- 1. 获取全量动态标签 (用于节点管理下拉框) --- +// --- 1. 获取全量动态标签 --- export function getLabels() { return request({ url: '/api/kg/labels', @@ -32,11 +29,7 @@ export function getLabels() { }) } -// --- 新增:获取全量动态关系类型 (用于关系管理下拉框) --- -/** - * 从后端获取所有关系类型 type 及其对应的中文 label 映射 - * 返回格式示例: [{type: 'adverseReactions', label: '不良反应'}, ...] - */ +// --- 新增:获取全量动态关系类型 --- export function getRelationshipTypes() { return request({ url: '/api/kg/relationship-types', @@ -44,12 +37,21 @@ export function getRelationshipTypes() { }) } -// --- 2. 输入联想建议 --- -export function getNodeSuggestions(keyword) { +// --- 2. 输入联想建议 (重点修改位置) --- +/** + * 获取联想词 + * @param {string} keyword - 用户输入的文字 + * @param {string} label - 当前选择的标签 (用于过滤图二的列表) + */ +export function getNodeSuggestions(keyword, label) { return request({ url: '/api/kg/node/suggest', method: 'get', - params: { keyword } + // 关键点:将 label 传给后端 + params: { + keyword, + label + } }) } @@ -76,7 +78,7 @@ export function addNode(data) { return request({ url: '/api/kg/node/add', method: 'post', - data // 格式: { label, name } + data // { label, name } }) } @@ -94,7 +96,7 @@ export function addRelationship(data) { return request({ url: '/api/kg/rel/add', method: 'post', - data // 格式: { source, target, type, label } + data // { source, target, type, label } }) } @@ -103,7 +105,7 @@ export function updateRelationship(data) { return request({ url: '/api/kg/rel/update', method: 'post', - data // 格式: { id, source, target, type, label } + data // { id, source, target, type, label } }) } diff --git a/vue/src/api/style.js b/vue/src/api/style.js new file mode 100644 index 0000000..681f81f --- /dev/null +++ b/vue/src/api/style.js @@ -0,0 +1,96 @@ +// vue/src/api/style.js +import request from '@/utils/request'; + +/** + * 保存图谱样式配置 + * @param {Object} data { canvas_name, current_label, styles, group_name } + * 说明:group_name 为字符串,后端会自动判断是使用已有组还是新建组 + */ +export function saveGraphStyle(data) { + return request({ + url: '/api/graph/style/save', + method: 'post', + data: data, + headers: { + 'Content-Type': 'application/json' + } + }); +} + +/** + * 获取【分组嵌套】格式的样式配置列表 (核心新增) + * 用于右侧折叠面板渲染:Group -> Configs + */ +export function getGroupedGraphStyleList() { + return request({ + url: '/api/graph/style/list/grouped', + method: 'get' + }); +} + +/** + * 获取所有已存在的方案组名称列表 + * 用于保存配置弹窗中的下拉选择框 + */ +export function getGraphStyleGroups() { + return request({ + url: '/api/graph/style/groups', + method: 'get' + }); +} + +/** + * 获取所有图谱样式配置列表 + * 保留此接口用于兼容旧版逻辑或后台管理 + */ +export function getGraphStyleList() { + return request({ + url: '/api/graph/style/list', + method: 'get' + }); +} + +/** + * 删除整个方案组及其下属所有配置 + * @param {Number} group_id 分组ID + */ +export function deleteGraphStyleGroup(group_id) { + return request({ + url: '/api/graph/style/group/delete', + method: 'post', + data: { group_id }, + headers: { + 'Content-Type': 'application/json' + } + }); +} + +/** + * 删除指定的单个画布样式配置 + * @param {Number} id 配置ID + */ +export function deleteGraphStyle(id) { + return request({ + url: '/api/graph/style/delete', + method: 'post', + data: { id }, + headers: { + 'Content-Type': 'application/json' + } + }); +} + +/** + * 批量删除多个画布样式配置 + * @param {Object} payload { ids: [1, 2, 3] } + */ +export function batchDeleteGraphStyle(payload) { + return request({ + url: '/api/graph/style/batch_delete', + method: 'post', + data: payload, + headers: { + 'Content-Type': 'application/json' + } + }); +} \ No newline at end of file diff --git a/vue/src/system/GraphStyle.vue b/vue/src/system/GraphStyle.vue index 370f873..e640cb8 100644 --- a/vue/src/system/GraphStyle.vue +++ b/vue/src/system/GraphStyle.vue @@ -1,6 +1,6 @@ @@ -419,69 +656,98 @@ export default { display: flex; height: 100vh; background-color: #f8fafc; + overflow: hidden; } -/* 侧边面板 */ .control-panel { - width: 290px; + width: 300px; background: #ffffff; border-right: 1px solid #e2e8f0; - padding: 18px; + padding: 20px; display: flex; flex-direction: column; overflow-y: auto; - font-family: 'Microsoft YaHei', sans-serif; - box-shadow: 2px 0 10px rgba(0,0,0,0.03); + flex-shrink: 0; +} + +.config-list-panel { + width: 280px; + background: #ffffff; + border-left: 1px solid #e2e8f0; + padding: 18px; + display: flex; + flex-direction: column; + flex-shrink: 0; + position: relative; } -/* 标题样式调整 */ .panel-header-container { margin-bottom: 15px; } + .panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } + .header-line { height: 1px; background-color: #e2e8f0; width: 100%; } + .custom-title-style { font-size: 18px; font-weight: bold; color: #1157f3; } -/* 标签过滤器 */ .tag-filters { display: flex; - gap: 6px; - margin-bottom: 20px; - flex-wrap: wrap; -} -.tag-item { - padding: 4px 12px; - border-radius: 12px; - font-size: 11px; + flex-wrap: nowrap; + gap: 5px; + margin: 0 0 10px 0; + min-height: 40px; + overflow-x: auto; +} + +.tag-item-wrapper { + display: flex; + align-items: center; cursor: pointer; transition: all 0.2s; + padding: 0 0; + border-radius: 4px; + flex-shrink: 0; +} + +.tag-item-wrapper .color-dot { + width: 20px; + height: 10px; + border-radius: 6px; + margin-right: 4px; + opacity: 0.5; } -.clickable-tag { - background: #959390; - color: #ffffff; + +/* 将文字样式改为常驻加粗 */ +.tag-item-wrapper .tag-label-text { + font-size: 14px; + color: #111; + font-weight: bold; } -.tag-active { - background: #4a68db !important; + +/* 仅控制圆点的透明度/阴影 */ +.tag-item-wrapper.is-active .color-dot { + opacity: 1; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); } .section { margin-bottom: 25px; } -/* 分类标题 */ .section-title { display: flex; align-items: center; @@ -493,6 +759,7 @@ export default { position: relative; line-height: 1; } + .section-title::before { content: ""; position: absolute; @@ -503,8 +770,6 @@ export default { border-radius: 2px; } - -/* --- 找到 488 行附近,替换以下三个类 --- */ .form-group-flex { display: flex; align-items: center; @@ -512,15 +777,12 @@ export default { font-size: 14px; margin-bottom: 12px; color: #475569; - padding: 0 10px 0 0; } .checkbox-label { - width: 150px; flex-shrink: 0; text-align: left; - padding-left: 0; } .theme-checkbox { @@ -528,95 +790,273 @@ export default { width: 16px; height: 16px; cursor: pointer; - margin: 0; } -.slider-inline-group { +.form-group, .color-picker-item { display: flex; align-items: center; - gap: 10px; - font-size: 14px; margin-bottom: 12px; - color: #475569; + font-size: 14px; } -.slider-inline-group label { + +.form-group label, .color-picker-item label { width: 80px; flex-shrink: 0; } + +.form-group select, .form-group input[type="number"] { + flex: 1; + padding: 5px; + border: 1px solid #e2e8f0; + border-radius: 4px; +} + +.slider-wrapper { + flex: 1; + display: flex; + align-items: center; + gap: 10px; +} + .theme-slider { flex: 1; accent-color: #1559f3; - height: 4px; - cursor: pointer; } + .val-text-black { - color: #000000; + color: #000; font-weight: bold; - font-size: 12px; min-width: 35px; - text-align: right; + font-size: 12px; } .color-picker-border { - display: flex; - align-items: center; - justify-content: center; padding: 3px; border: 1px solid #e2e8f0; border-radius: 4px; - background-color: #fff; + display: flex; } + .square-picker { - -webkit-appearance: none; - width: 24px !important; - height: 24px !important; + width: 24px; + height: 24px; + cursor: pointer; + border: none; padding: 0; - border: 1px solid #cbd5e1; - border-radius: 2px; - background: none; +} + +.button-footer { + display: flex; + gap: 10px; + padding-top: 10px; +} + +.btn-confirm-save { + background: #1559f3; + color: #fff; + border: none; + flex: 1; + padding: 10px; + border-radius: 4px; cursor: pointer; + font-weight: bold; } -.square-picker::-webkit-color-swatch-wrapper { padding: 0; } -.square-picker::-webkit-color-swatch { border: none; border-radius: 2px; } -.form-group, .color-picker-item { +.btn-reset-style { + background: #fff; + color: #1559f3; + border: 1px solid #1559f3; + flex: 1; + padding: 10px; + border-radius: 4px; + cursor: pointer; +} + +.graph-container { + flex: 1; + background: #fff; +} + +.config-list { + flex: 1; + overflow-y: auto; + padding-bottom: 100px; +} + +.config-card { + display: flex; + justify-content: space-between; + padding: 12px; + background: #f8fafc; + margin-bottom: 10px; + border-radius: 8px; + cursor: pointer; + border: 1px solid #e2e8f0; + transition: all 0.2s; + position: relative; +} + +.config-card:hover { + border-color: #1559f3; +} + +.card-using { + border-color: #1559f3; + background: #eff6ff !important; + border-width: 1.5px; +} + +.card-checked { + border-left: 4px solid #ef4444; +} + +.checkbox-wrapper { + display: flex; + align-items: center; + margin-right: 12px; + padding-right: 8px; + border-right: 1px solid #e2e8f0; +} + +.config-checkbox { + width: 18px; + height: 18px; + accent-color: #ef4444; + cursor: pointer; +} + +.card-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.card-title-row { display: flex; align-items: center; + gap: 8px; +} + +.card-name { + font-weight: 600; + color: #1e293b; font-size: 14px; - margin-bottom: 12px; } -.form-group label, .color-picker-item label { - width: 80px; - color: #475569; + +.status-badge { + font-size: 10px; + background: #1559f3; + color: white; + padding: 1px 6px; + border-radius: 10px; } -.form-group select, .form-group input[type="number"] { - flex: 1; - padding: 6px; - border: 1px solid #e2e8f0; - border-radius: 6px; - background: #fcfcfc; - outline: none; + +.card-tag { + font-size: 12px; + color: #64748b; } -.button-footer { +.delete-icon { + color: #94a3b8; + padding: 4px; + border-radius: 4px; +} + +.delete-icon:hover { + color: #ef4444; + background: #fee2e2; +} + +.batch-actions-fixed { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: #ffffff; + padding: 15px; + border-top: 2px solid #e2e8f0; + box-shadow: 0 -4px 10px rgba(0, 0, 0, 0.05); display: flex; + flex-direction: column; gap: 10px; - margin-top: 10px; } -.button-footer button { + +.selection-info { + font-size: 13px; + color: #ef4444; + font-weight: bold; + text-align: center; +} + +.batch-button-group { + display: flex; + gap: 8px; +} + +.btn-batch-delete-final { + flex: 2; + padding: 10px; + background: #ef4444; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: bold; +} + +.btn-batch-delete-final:disabled { + background: #fca5a5; + cursor: not-allowed; +} + +.btn-clear-selection { flex: 1; padding: 10px; + background: #fff; + color: #64748b; + border: 1px solid #cbd5e1; border-radius: 6px; - font-size: 13px; cursor: pointer; - transition: all 0.2s; } -.btn-reset-view { background: #1559f3; color: white; border: none; } -.btn-reset-style { background: white; color: #1559f3; border: 1px solid #1559f3; } -.graph-container { +.empty-text { + text-align: center; + margin-top: 40px; + color: #94a3b8; +} + +.refresh-icon { + cursor: pointer; + color: #94a3b8; +} + +.refresh-icon:hover { + color: #1559f3; +} + +.group-header-slot { + display: flex; + align-items: center; + width: 100%; + padding-right: 10px; +} + +.group-name-text { flex: 1; - background: #ffffff; - position: relative; + font-weight: bold; + color: #334155; +} + +.group-del { + margin-left: 10px; + color: #94a3b8; +} + +.group-del:hover { + color: #ef4444; +} + +:deep(.el-collapse-item__header) { + padding: 0 10px; } \ No newline at end of file diff --git a/vue/src/system/KGData.vue b/vue/src/system/KGData.vue index b0d863e..eaeb38b 100644 --- a/vue/src/system/KGData.vue +++ b/vue/src/system/KGData.vue @@ -61,6 +61,7 @@ :fetch-suggestions="queryNodeSearch" placeholder="搜索节点名称..." clearable + :trigger-on-focus="true" @select="handleNodeSearch" @clear="handleNodeSearch" class="search-input" @@ -68,10 +69,14 @@
选择标签 - - + @@ -111,8 +116,10 @@ background layout="slot, sizes, prev, pager, next, jumper" :total="nodeTotal" + :page-sizes="[10, 20, 50, 100]" v-model:page-size="pageSize" v-model:current-page="nodePage" + @size-change="handleNodeSizeChange" @current-change="fetchNodes" > 共计 {{ nodeTotal.toLocaleString() }} 条数据 @@ -130,6 +137,7 @@ :fetch-suggestions="queryNodeSearch" placeholder="搜索起点..." clearable + :trigger-on-focus="true" @select="handleRelSearch" @clear="handleRelSearch" class="search-input" @@ -161,6 +169,7 @@ :fetch-suggestions="queryNodeSearch" placeholder="搜索终点..." clearable + :trigger-on-focus="true" @select="handleRelSearch" @clear="handleRelSearch" class="search-input" @@ -199,8 +208,10 @@ background layout="slot, sizes, prev, pager, next, jumper" :total="relTotal" + :page-sizes="[10, 20, 50, 100]" v-model:page-size="pageSize" v-model:current-page="relPage" + @size-change="handleRelSizeChange" @current-change="fetchRels" > 共计 {{ relTotal.toLocaleString() }} 条数据 @@ -255,11 +266,11 @@ + placeholder="请输入起点名称" :trigger-on-focus="true"/> + placeholder="请输入终点名称" :trigger-on-focus="true"/> { const map = {}; - // 1. 基础字典映射 for (const [chi, eng] of Object.entries(CHINESE_TO_ENGLISH_LABEL)) { map[eng] = chi; map[eng.toLowerCase()] = chi; } - // 2. 数据库动态获取的映射 (优先级更高) dynamicRelTypes.value.forEach(item => { map[item.type] = item.label; }); @@ -392,7 +399,6 @@ const relTotal = ref(0); const relPage = ref(1); const relSearch = reactive({source: '', target: '', type: ''}); -// --- 弹窗控制 --- const nodeDialogVisible = ref(false); const relDialogVisible = ref(false); const detailVisible = ref(false); @@ -400,18 +406,27 @@ const detailType = ref('node'); const currentDetail = ref({}); const isEdit = ref(false); -// --- 表单数据 --- const nodeForm = reactive({id: '', name: '', label: ''}); const relForm = reactive({id: '', source: '', target: '', type: '', label: ''}); -// --- 逻辑实现 --- +// --- 分页切换处理逻辑 --- +const handleNodeSizeChange = (val) => { + pageSize.value = val; + nodePage.value = 1; + fetchNodes(); +}; + +const handleRelSizeChange = (val) => { + pageSize.value = val; + relPage.value = 1; + fetchRels(); +}; +// --- 数据抓取逻辑 --- const fetchAllMetadata = async () => { - // 获取全量节点标签 getLabels().then(res => { if (res?.code === 200) dynamicLabels.value = res.data; }); - // 获取全量关系类型 getRelationshipTypes().then(res => { if (res?.code === 200) dynamicRelTypes.value = res.data; }); @@ -420,9 +435,7 @@ const fetchAllMetadata = async () => { const initDataGovernance = async () => { try { const res = await fixNodeIds(); - if (res?.code === 200 && activeName.value === 'first') { - fetchNodes(); - } + if (res?.code === 200 && activeName.value === 'first') fetchNodes(); } catch (e) { console.error(e); } @@ -444,7 +457,7 @@ const fetchNodes = async () => { page: nodePage.value, pageSize: pageSize.value, name: nodeSearch.name?.trim() || null, - label: (nodeSearch.label && nodeSearch.label !== "全部") ? nodeSearch.label : null + label: nodeSearch.label || null }); if (res?.code === 200) { nodeData.value = res.data.items; @@ -465,7 +478,7 @@ const fetchRels = async () => { pageSize: pageSize.value, source: relSearch.source?.trim() || null, target: relSearch.target?.trim() || null, - type: relSearch.type || null // 此时 type 已经是英文 + type: relSearch.type || null }); if (res?.code === 200) { relData.value = res.data.items; @@ -481,9 +494,9 @@ const fetchRels = async () => { const openNodeDialog = (row = null) => { isEdit.value = !!row; if (row) { - Object.assign(nodeForm, {id: row.id, name: row.name, label: row.labels?.[0] || 'Drug'}); + Object.assign(nodeForm, {id: row.id, name: row.name, label: row.labels?.[0] || ''}); } else { - Object.assign(nodeForm, {id: '', name: '', label: 'Drug'}); + Object.assign(nodeForm, {id: '', name: '', label: ''}); } nodeDialogVisible.value = true; }; @@ -501,6 +514,7 @@ const submitNode = async () => { fetchStats(); fetchAllMetadata(); // 刷新标签 preload(); + fetchAllMetadata(); } else { ElMessage.error(res?.msg || '操作失败'); } @@ -515,11 +529,7 @@ 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 || '' + id: row.id, source: row.source, target: row.target, type: row.type, label: row.label || '' }); } else { Object.assign(relForm, {id: '', source: '', target: '', type: '', label: ''}); @@ -538,7 +548,7 @@ const submitRel = async () => { relDialogVisible.value = false; fetchRels(); fetchStats(); - fetchAllMetadata(); // 刷新关系类型列表 + fetchAllMetadata(); } else { ElMessage.error(res?.msg || '提交失败'); } @@ -567,19 +577,48 @@ const handleDelete = (row, type) => { }); }; +// 节点查询处理 const handleNodeSearch = () => { nodePage.value = 1; fetchNodes(); }; + +// 节点标签变更逻辑:切换标签时清空输入框,并刷新列表 +const handleNodeLabelChange = () => { + nodeSearch.name = ''; // 清空之前的输入值,让联想列表重新加载 + nodePage.value = 1; + fetchNodes(); +}; + const handleRelSearch = () => { relPage.value = 1; fetchRels(); }; +/** + * 联想搜索建议逻辑修改: + * 重点:将当前 nodeSearch.label 传给后端 + */ const queryNodeSearch = async (queryString, cb) => { - if (!queryString) return cb([]); - const res = await getNodeSuggestions(queryString); - cb((res.data || []).map(n => ({value: n}))); + // 条件拦截:没有标签 且 没有输入内容 + if (!nodeSearch.label && !queryString?.trim()) { + return cb([]); + } + + try { + // 【核心改动】:传递 nodeSearch.label 到后端接口 + const res = await getNodeSuggestions(queryString || "", nodeSearch.label || null); + if (res?.code === 200) { + // 后端返回的是字符串数组,转成组件需要的对象数组格式 + const results = (res.data || []).map(n => ({value: n})); + cb(results); + } else { + cb([]); + } + } catch (e) { + console.error("联想查询失败", e); + cb([]); + } }; const handleView = (row, type) => { @@ -592,7 +631,7 @@ onMounted(() => { initDataGovernance(); fetchStats(); fetchNodes(); - fetchAllMetadata(); // 初始化加载所有标签和关系类型 + fetchAllMetadata(); });