Browse Source

all

hanyuqing
hanyuqing 3 months ago
parent
commit
8c3d6487f7
  1. 23
      controller/GraphController.py
  2. 6
      util/neo4j_utils.py
  3. 8
      vue/node_modules/.package-lock.json
  4. 14
      vue/package-lock.json
  5. 1
      vue/package.json
  6. 6
      vue/src/api/graph.js
  7. 270
      vue/src/system/GraphDemo.vue
  8. 25
      vue/src/system/KGData.vue

23
controller/GraphController.py

@ -1,5 +1,6 @@
import json
import sys
import traceback
from datetime import datetime
import httpx
@ -84,6 +85,27 @@ def get_data():
)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.get("/api/getCount")
def get_data():
try:
diseaselen = neo4j_client.count_nodes_by_label(label="Disease")
druglen = neo4j_client.count_nodes_by_label(label="Drug")
checklen = neo4j_client.count_nodes_by_label(label="Check")
data={
"Disease":diseaselen,
"Drug":druglen,
"Check":checklen
}
return Response(
status_code=200,
description=jsonify(data),
headers={"Content-Type": "text/plain; charset=utf-8"}
)
except Exception as e:
error_trace = traceback.format_exc()
print(error_trace)
return jsonify({"error": str(e)}), 500
@app.post("/api/getGraph")
def get_graph(req):
try:
@ -110,6 +132,7 @@ def get_graph(req):
return jsonify({"error": str(e)}), 500
@app.get("/api/drug-tree")
def get_drug_tree():
print(redis_get(DRUG_TREE_KEY))
return Response(
status_code=200,
description=redis_get(DRUG_TREE_KEY),

6
util/neo4j_utils.py

@ -103,6 +103,12 @@ class Neo4jUtil:
raw = self.execute_read(cypher)
return [self._merge_id_and_props(row) for row in raw]
def count_nodes_by_label(self, label: str) -> int:
"""返回指定标签的节点数量"""
# 安全校验(强烈建议保留)
cypher = f"MATCH (n:`{label}`) RETURN count(n) AS total"
result = self.execute_read(cypher)
return result[0]["total"] # Neo4j 返回结果通常是列表,取第一个记录
from typing import Optional, Dict, Any, List
def find_nodes_with_element_id(

8
vue/node_modules/.package-lock.json

@ -7915,6 +7915,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"engines": {
"node": ">=10"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",

14
vue/package-lock.json

@ -12,6 +12,7 @@
"axios": "^1.13.2",
"core-js": "^3.8.3",
"element-plus": "^2.13.0",
"fuse.js": "^7.1.0",
"vue": "^3.2.13",
"vue-router": "^4.6.4"
},
@ -7957,6 +7958,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"engines": {
"node": ">=10"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -19214,6 +19223,11 @@
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
},
"fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="
},
"gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",

1
vue/package.json

@ -12,6 +12,7 @@
"axios": "^1.13.2",
"core-js": "^3.8.3",
"element-plus": "^2.13.0",
"fuse.js": "^7.1.0",
"vue": "^3.2.13",
"vue-router": "^4.6.4"
},

6
vue/src/api/graph.js

@ -13,7 +13,13 @@ export function getTestGraphData() {
});
}
export function getCount() {
return request({
url: '/api/getCount',
method: 'get'
});
}
export function getGraph(data) {
return request({
url: '/api/getGraph',

270
vue/src/system/GraphDemo.vue

@ -7,8 +7,8 @@
<main class="main-body">
<header class="top-nav">
<div class="search-container">
<input type="text" placeholder="搜索......"/>
<img src="@/assets/搜索.png" class="search-btn"/>
<input v-model="searchKeyword" type="text" placeholder="搜索......" @keydown.enter="search"/>
<img src="@/assets/搜索.png" class="search-btn" @click="search"/>
</div>
<div class="legend-box">
<div v-for="(item, index) in legendItems" :key="index" class="legend-item">
@ -33,7 +33,9 @@
<span v-if="typeRadio=== 'Drug'">药品信息</span>
<span v-if="typeRadio=== 'Check'">检查信息</span>
</div>
<div class="d-count" :style="headerLabel">12</div>
<div v-if="typeRadio=== 'Disease'" class="d-count" :style="headerLabel">{{diseaseCount.toLocaleString()}}</div>
<div v-if="typeRadio=== 'Drug'" class="d-count" :style="headerLabel">{{ drugCount.toLocaleString() }}</div>
<div v-if="typeRadio=== 'Check'" class="d-count" :style="headerLabel">{{checkCount.toLocaleString()}}</div>
</div>
<div>
<el-radio-group v-model="typeRadio" @change="changeTree">
@ -57,6 +59,7 @@
<el-tree
:data="treeData"
:props="treeProps"
accordion
:expand-on-click-node="false"
@node-click="handleNodeClick"
>
@ -82,11 +85,12 @@
</template>
<script>
import {getCheckTree, getDrugTree, getGraph, getTestGraphData} from "@/api/graph"
import { Graph } from '@antv/g6';
import {getCheckTree, getCount, getDrugTree, getGraph, getTestGraphData} from "@/api/graph"
import {Graph, Tooltip} from '@antv/g6';
import Menu from "@/components/Menu.vue";
import {a} from "vue-router/dist/devtools-EWN81iOl.mjs";
import Fuse from 'fuse.js';
export default {
name: 'Display',
components: {Menu},
@ -142,6 +146,16 @@ export default {
{ key: 'Other', label: '其他', color: '#59d1d4' }
],
visibleCategories: new Set(), //
diseaseCount:0,
drugCount:0,
checkCount:0,
originalNodeStyles: new Map(), //
originalEdgeStyles: new Map(), //
searchResults: {
nodes: [],
edges: []
},
searchKeyword:""
}
},
computed: {
@ -202,6 +216,9 @@ export default {
async mounted() {
this.visibleCategories = new Set(this.legendItems.map(i => i.key));
await this.loadDiseaseTreeData()
this.getCount()
this.loadDrugTreeData()
this.loadCheckTreeData()
this.treeData=this.diseaseTree
await this.$nextTick();
try {
@ -241,8 +258,6 @@ export default {
this.defaultData = updatedData
setTimeout(() => {
this.initGraph();
this.loadDrugTreeData()
this.loadCheckTreeData()
this.buildCategoryIndex();
window.addEventListener('resize', this.handleResize);
}, 1000);
@ -251,6 +266,7 @@ export default {
}
},
beforeUnmount() {
if (this._graph!=null){
this._graph.stopLayout();
@ -283,6 +299,161 @@ export default {
edgeFontFamily: 'updateAllEdges',
},
methods: {
search() {
const keyword = this.searchKeyword?.trim();
if (!keyword) {
this.clearSearchHighlight();
return;
}
const nodeData = this._graph?.getNodeData() || [];
const edgeData = this._graph?.getEdgeData() || [];
// === 1. ===
const nodeSearchItems = nodeData.map(node => ({
id: node.id,
name: node.data?.name || '',
label: node.data?.label || ''
}));
// === 2. ===
const edgeSearchItems = edgeData.map(edge => {
const sourceName = this._nodeLabelMap.get(edge.source) || '';
const targetName = this._nodeLabelMap.get(edge.target) || '';
const relLabel = edge.data?.relationship?.properties?.label || '';
const fullText = [sourceName, relLabel, targetName].filter(Boolean).join(' ');
return {
id: edge.id,
sourceId: edge.source,
targetId: edge.target,
fullText
};
});
// === 3. Fuse ===
const fuseOptions = {
includeScore: true,
threshold: 0.4,
ignoreLocation: true,
tokenize: true, //
keys: ['name', 'label']
};
const edgeFuseOptions = {
includeScore: true,
threshold: 0.5,
ignoreLocation: true,
tokenize: true,
keys: ['fullText']
};
const nodeFuse = new Fuse(nodeSearchItems, fuseOptions);
const edgeFuse = new Fuse(edgeSearchItems, edgeFuseOptions);
//
const directNodeIds = nodeFuse.search(keyword).map(r => r.item.id);
const matchedEdgesFromSearch = edgeFuse.search(keyword).map(r => r.item);
const matchedEdgeIdsFromSearch = matchedEdgesFromSearch.map(e => e.id);
// === 4. ===
const nodeSet = new Set(directNodeIds);
const additionalEdgeIds = [];
edgeData.forEach(edge => {
// source target
if (nodeSet.has(edge.source) && nodeSet.has(edge.target)) {
additionalEdgeIds.push(edge.id);
}
});
// ID
const allEdgeIds = Array.from(new Set([
...matchedEdgeIdsFromSearch,
...additionalEdgeIds
]));
// === 5. + ===
const finalNodeIds = new Set(directNodeIds);
matchedEdgesFromSearch.forEach(edge => {
finalNodeIds.add(edge.sourceId);
finalNodeIds.add(edge.targetId);
});
// additionalEdge directNodeIds
// === 6. ===
this.highlightSearchResults(
Array.from(finalNodeIds),
allEdgeIds
);
},
highlightSearchResults(nodeIds, edgeIds) {
if (!this._graph) return;
const allNodes = this._graph.getNodeData().map(n => n.id);
const allEdges = this._graph.getEdgeData().map(e => e.id);
// 1 search
allNodes.forEach(id => {
this._graph.setElementState(id, 'active', false);
this._graph.setElementState(id, 'inactive', false);
});
allEdges.forEach(id => {
this._graph.setElementState(id, 'active', false);
this._graph.setElementState(id, 'inactive', false);
});
// 2
nodeIds.forEach(id => {
if (this._graph.hasNode(id)) {
this._graph.setElementState(id, 'active', true);
}
});
edgeIds.forEach(id => {
if (this._graph.hasEdge(id)) {
this._graph.setElementState(id, 'active', true);
}
});
// 3
const matchedNodeSet = new Set(nodeIds);
const matchedEdgeSet = new Set(edgeIds);
allNodes.forEach(id => {
if (!matchedNodeSet.has(id)) {
this._graph.setElementState(id, 'inactive', true);
}
});
allEdges.forEach(id => {
if (!matchedEdgeSet.has(id)) {
this._graph.setElementState(id, 'inactive', true);
}
});
// 4
const firstMatch = nodeIds[0] || edgeIds[0];
if (firstMatch && (this._graph.hasNode(firstMatch) || this._graph.hasEdge(firstMatch))) {
this._graph.focusElement(firstMatch, { animation: true, duration: 600 });
}
},
clearSearchHighlight() {
if (!this._graph) return;
const allNodes = this._graph.getNodeData().map(n => n.id);
const allEdges = this._graph.getEdgeData().map(e => e.id);
// search
[...allNodes, ...allEdges].forEach(id => {
this._graph.setElementState(id, 'normal', true);
});
},
getCount(){
getCount().then(res=>{
this.diseaseCount=res.Disease
this.drugCount=res.Drug
this.checkCount=res.Check
console.log(res)
})
},
buildCategoryIndex() {
const index = {};
if (this._graph!=null){
@ -362,7 +533,6 @@ export default {
changeTree(){
if(this.typeRadio=="Disease"){
this.treeData=this.diseaseTree
}
if(this.typeRadio=="Drug") {
this.treeData=this.drugTree
@ -383,7 +553,6 @@ export default {
} else if (this.typeRadio === 'Check') {
apiCall = () => this.loadCheckTreeData();
}
if (apiCall) {
apiCall()
.then(children => {
@ -465,8 +634,6 @@ export default {
formatData(data){
this._graph.stopLayout();
this.clearGraphState();
// data.nodes = data.nodes.slice(0, 1000);
// data.edges = data.edges.slice(0, 0);
const updatedEdges = data.edges.map(edge => ({
...edge,
type: this.edgeType,
@ -500,6 +667,7 @@ export default {
}
this.buildNodeLabelMap(updatedNodes);
this.updateGraph(updatedData)
this.buildCategoryIndex();
},
getLevelLabel(level) {
const map = {
@ -562,6 +730,19 @@ export default {
// barnesHut:true,
},
autoFit: {
type: 'center', // 'view' 'center'
options: {
// 'view'
when: 'always', // 'overflow'() 'always'()
direction: 'both', // 'x''y' 'both'
},
animation: {
//
duration: 1000, // ()
easing: 'ease-in-out', //
},
},
behaviors: [ 'zoom-canvas', 'drag-element',
'click-select','focus-element', {
type: 'hover-activate',
@ -610,8 +791,13 @@ export default {
shadowBlur: 10,
opacity: 1
},
highlight:{
stroke: '#FF5722',
lineWidth: 4,
opacity: 1
},
inactive: {
opacity: 0.3
opacity: 0.8
},
normal:{
opacity: 1
@ -659,7 +845,7 @@ export default {
opacity: 1
},
inactive: {
opacity: 0.3
opacity: 0.8
},
normal:{
opacity: 1
@ -670,16 +856,11 @@ export default {
},
data:this.defaultData,
});
console.log("Container width:", container.clientWidth);
console.log("Container height:", container.clientHeight);
console.log("Container scroll position before:", container.scrollTop, container.scrollLeft);
this.$nextTick(() => {
graph.render();
// graph.fitView()
graph.fitView({ padding: 30, duration: 1000, easing: 'ease-in' });
// graph.fitView({ padding: 30, duration: 1000, easing: 'ease-in' });
});
console.log("Container scroll position after:", container.scrollTop, container.scrollLeft);
@ -747,7 +928,33 @@ export default {
}
});
this._graph = graph
this._graph.setPlugins([ {
type: 'tooltip',
// tooltip
enable: (e) => e.targetType === 'edge',
getContent: (e, items) => {
console.log(items)
console.log(e)
const edge = items[0]; //
if (!edge) return '';
const data=items[0].data
const sourceId = edge.source;
const targetId = edge.target;
//
const sourceNode = this._graph.getNodeData(sourceId);
console.log(sourceNode)
const targetNode = this._graph.getNodeData(targetId);
const sourceName = sourceNode?.data.name || sourceId;
const targetName = targetNode?.data.name || targetId;
const rel = data.relationship.properties.label || '关联';
return `<div style="padding: 4px 8px; color: #b6b2b2; border-radius: 4px; font-size: 12px;">
${sourceName} ${rel} > ${targetName}
</div>`;
},
},])
}
@ -1144,9 +1351,6 @@ button:hover {
border-color: rgb(153, 10, 0);
}
.el-radio__inner:hover {
border-color: rgb(153, 10, 0);
}
/deep/ .radio-drug .el-radio__input.is-checked .el-radio__inner {
background: #52c41a; /* 检查的颜色 */
@ -1158,6 +1362,26 @@ button:hover {
border-color:#1890ff;
}
/* 动态给每个 el-radio 设置不同的背景颜色 */
/deep/ .radio-disease .el-radio__input.is-checked .el-radio__inner:hover {
background: rgb(153, 10, 0); /* 疾病的颜色 */
border-color: rgb(153, 10, 0);
}
/deep/ .radio-drug .el-radio__input.is-checked .el-radio__inner:hover {
background: #52c41a; /* 检查的颜色 */
border-color:#52c41a;
}
/deep/ .radio-check .el-radio__input.is-checked .el-radio__inner:hover {
background: #1890ff; /* 药品的颜色 */
border-color:#1890ff;
}
/* 自定义选中后的样式 */
/deep/ .radio-disease .el-radio__input.is-checked+.el-radio__label {
color: rgb(153, 10, 0);

25
vue/src/system/KGData.vue

@ -209,7 +209,7 @@
</div>
</main>
<el-dialog v-model="detailVisible" title="数据详情" width="550px" destroy-on-close header-class="bold-header">
<el-dialog v-model="detailVisible" title="数据详情" style=" border-radius: 7px;" 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'">
@ -228,7 +228,7 @@
</el-descriptions>
</el-dialog>
<el-dialog v-model="nodeDialogVisible" :title="isEdit ? '修改节点' : '新增节点'" width="450px" class="custom-dialog"
<el-dialog v-model="nodeDialogVisible" :title="isEdit ? '修改节点' : '新增节点'" width="530px" class="custom-dialog"
header-class="bold-header">
<el-form :model="nodeForm" label-width="90px" class="custom-form">
<el-form-item label="名称" required>
@ -248,9 +248,9 @@
</template>
</el-dialog>
<el-dialog v-model="relDialogVisible" :title="isEdit ? '修改关系' : '新增关系'" width="450px" class="custom-dialog"
<el-dialog v-model="relDialogVisible" :title="isEdit ? '修改关系' : '新增关系'" width="530px" class="custom-dialog"
header-class="bold-header">
<el-form :model="relForm" label-width="90px" class="custom-form">
<el-form :model="relForm" label-width="120px" class="custom-form">
<el-form-item label="起始节点" required>
<el-autocomplete v-model="relForm.source" :fetch-suggestions="queryNodeSearch" style="width:100%"
placeholder="请输入起点名称"/>
@ -611,7 +611,7 @@ onMounted(() => {
.input-group-inline :deep(.el-select__wrapper){
box-shadow: none !important;
}
:deep(.el-autocomplete .el-input__wrapper){
:deep(.input-group-inline .el-autocomplete .el-input__wrapper){
box-shadow: none !important;
}
@ -625,22 +625,21 @@ onMounted(() => {
.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; }
:deep(.bold-header) { 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;padding:5px;margin-bottom: 10px; font-size: 19px !important;}
.custom-form :deep(.el-form-item__label) { color: #606266 !important;font-size: 16px !important; }
.dialog-footer-wrap { display: flex; justify-content: flex-end; gap: 15px; }
.btn-cancel { background-color: #e6e6e6 !important; border: none !important; color: #444 !important; padding: 18px 20px !important; font-weight: 500; }
.btn-confirm { background-color: #165dff !important; border: none !important; padding: 18px 20px !important; font-weight: 500; }
.animate-fade { animation: fadeIn 0.4s ease-out; }
.pagination-custom-text{color: #86909c}
:deep(.el-select__placeholder){color: #86909c}
:deep(.el-pagination__sizes .el-select__wrapper){box-shadow: 0 0 0 2px #EBF0FF;}
:deep(.el-pagination.is-background .el-pager li.is-active){background-color: #165DFF;}
/* 当前激活页码(无论是否 hover)始终是蓝色 */
:deep(.el-pager li.is-active) {
color: #fff !important;
}
:deep(.el-dialog){border-radius: 7px}
:deep(.el-pager li:not(.is-active):hover) {
color: #165DFF !important;
}

Loading…
Cancel
Save