Browse Source

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

# Conflicts:
#	vue/src/components/Menu.vue
mh
hanyuqing 3 months ago
parent
commit
8791a950f3
  1. 263
      controller/BuilderController.py
  2. BIN
      resource/avatar/slipe.png
  3. BIN
      resource/avatar/用户.png
  4. 16
      vue/src/api/builder.js
  5. BIN
      vue/src/assets/blue.png
  6. 1
      vue/src/assets/close.svg
  7. BIN
      vue/src/assets/gpt.png
  8. BIN
      vue/src/assets/load.gif
  9. BIN
      vue/src/assets/word.png
  10. 1
      vue/src/assets/word.svg
  11. BIN
      vue/src/assets/下拉.png
  12. BIN
      vue/src/assets/用户.png
  13. BIN
      vue/src/assets/缩小.png
  14. 71
      vue/src/components/Menu.vue
  15. 464
      vue/src/system/GraphBuilder.vue
  16. 49
      vue/src/system/GraphDemo.vue
  17. 217
      vue/src/system/GraphQA.vue
  18. 2
      vue/src/system/Register.vue
  19. 2
      web_main.py

263
controller/BuilderController.py

@ -1,42 +1,263 @@
# 全局 client(可复用)
import base64
import io
import json
import os
import re
import tempfile
import traceback
from docx import Document
import httpx
from robyn import jsonify, Response
from multipart import FormParser
from robyn import jsonify, Response, Request
from app import app
from controller.client import client
from util.neo4j_utils import neo4j_client
# 中文类型到英文标签的映射字典
CHINESE_TO_ENGLISH_LABEL = {
"疾病": "Disease",
"症状": "Symptom",
"检查项目": "AuxiliaryExamination",
"药物": "Drug",
"手术": "Operation",
"解剖部位": "CheckSubject", # 或 AnatomicalSite,根据你的图谱设计
"并发症": "Complication",
"诊断": "Diagnosis",
"治疗": "Treatment",
"辅助治疗": "AdjuvantTherapy",
"不良反应": "AdverseReactions",
"检查": "Check",
"部门": "Department",
"疾病部位": "DiseaseSite",
"相关疾病": "RelatedDisease",
"相关症状": "RelatedSymptom",
"传播途径": "SpreadWay",
"阶段": "Stage",
"主题/主体": "Subject",
"症状与体征": "SymptomAndSign",
"治疗方案": "TreatmentPrograms",
"类型": "Type",
"原因": "Cause",
"属性": "Attribute",
"指示/适应症": "Indications",
"成分": "Ingredients",
"病原学": "Pathogenesis",
"病理类型": "PathologicalType",
"发病机制": "Pathophysiology",
"注意事项": "Precautions",
"预后": "Prognosis",
"预后生存时间": "PrognosticSurvivalTime",
"疾病比率": "DiseaseRatio",
"药物治疗": "DrugTherapy",
"感染性": "Infectious",
"实体": "Entity",
# 可根据实际需要补充更多
}
@app.post("/api/analyze")
async def analyze(request):
def json_response(data: dict, status_code: int = 200):
body = json.dumps(data, ensure_ascii=False, separators=(',', ':'))
headers = {"Content-Type": "application/json; charset=utf-8"}
return Response(
status_code=status_code,
description=body, # ✅ 关键:字段名是 response
headers=headers # ✅ 必须是 dict
)
@app.post("/api/builder/analyze")
async def analyze(request: Request):
try:
# 1. 解析 JSON 请求体
body = request.json()
input_text = body.get("text", "").strip()
if not input_text:
return jsonify({"error": "缺少 text 字段"}), 400
input_text = body.get("text", "").strip() or ""
file_b64 = body.get("file_base64")
filename = body.get("filename", "unknown.docx")
# 2. 如果既无 text 也无文件,报错
if not input_text and not file_b64:
return json_response({"error": "必须提供 text 或 file"}, status_code=400)
# 3. 从 .docx 提取文本(如果有文件)
file_text = ""
if file_b64:
try:
# 直接转发到大模型服务(假设它返回 { "task_id": "xxx" })
file_data = base64.b64decode(file_b64)
doc = Document(io.BytesIO(file_data))
file_text = "\n".join([para.text for para in doc.paragraphs]).strip()
except Exception as e:
return json_response({"error": f"解析 .docx 文件失败: {str(e)}"}, status_code=400)
# 4. 合并文本:优先用文件内容,或拼接两者(按你需求调整)
# 方案 A:只用文件内容(如果提供了文件)
# final_text = file_text if file_text else input_text
# 方案 B:拼接(推荐,更灵活)
final_text = (input_text + "\n\n" + file_text).strip()
if not final_text:
return json_response({"error": "合并后文本为空"}, status_code=400)
print(f"📄 最终提交文本(前200字符):\n{final_text[:200]}...")
# 5. 转发给大模型服务
resp = await client.post(
"/extract_entities_and_relations",
json={"text": input_text},
json={"text": final_text},
timeout=1800.0 # 30分钟
)
print(resp)
if resp.status_code == 202 or resp.status_code == 200:
return Response(
status_code=200,
description=jsonify(resp.json()),
headers={"Content-Type": "text/plain; charset=utf-8"}
)
# 6. 返回结果
if resp.status_code in (200, 202):
try:
result = resp.json()
except:
result = {"raw_response": resp.text}
return json_response(result, status_code=resp.status_code)
else:
return jsonify({
"error": "提交失败",
"detail": resp.text
}), resp.status_code
return json_response({
"error": "大模型服务调用失败",
"detail": resp.text,
"status_code": resp.status_code
}, status_code=resp.status_code)
except Exception as e:
error_trace = traceback.format_exc()
print("❌ 发生异常:")
print("后端异常:")
print(error_trace)
return json_response({
"error": str(e),
"traceback": error_trace
}, status_code=500)
# @app.post("/api/builder/analyze")
# async def analyze(request: Request):
# ct = (request.headers.get("content-type") or "").lower()
# # === 关键:打印 body 前 100 字节的原始内容(作为字符串,忽略编码错误)===
# preview = request.body[:100].decode('utf-8', errors='replace')
# print("📦 Body preview (first 100 chars):", repr(preview))
# print("🔍 Content-Type:", repr(ct))
# print("📦 Body length:", len(request.body))
# if "multipart/form-data" not in ct:
# return json_response({"error": "仅支持 multipart/form-data"}, 400)
#
# try:
# form_data = parse_multipart(request.body, request.headers.get("content-type"))
# except Exception as e:
# return json_response({"error": f"表单解析失败: {str(e)}"}, 400)
#
# # 获取字段
# text_input = form_data.get("text", "")
# uploaded_file = form_data.get("file") # 是 dict,含 filename/file/content_type
#
# if not uploaded_file or not isinstance(uploaded_file, dict):
# return json_response({"error": "未提供有效文件"}, 400)
#
# file_content = uploaded_file["file"] # bytes
# filename = uploaded_file["filename"]
#
# # 后续处理 .docx 等逻辑保持不变...
# @app.post("/api/builder/analyze")
# async def analyze(request):
# body = request.json()
# input_text = body.get("text", "").strip()
# if not input_text:
# return jsonify({"error": "缺少 text 字段"}), 400
# try:
# # 直接转发到大模型服务(假设它返回 { "task_id": "xxx" })
# resp = await client.post(
# "/extract_entities_and_relations",
# json={"text": input_text},
# timeout=1800.0 # 30分钟
# )
# print(resp)
#
# if resp.status_code == 202 or resp.status_code == 200:
# return Response(
# status_code=200,
# description=jsonify(resp.json()),
# headers={"Content-Type": "text/plain; charset=utf-8"}
# )
# else:
# return jsonify({
# "error": "提交失败",
# "detail": resp.text
# }), resp.status_code
# except Exception as e:
# error_trace = traceback.format_exc()
# print("❌ 发生异常:")
# print(error_trace)
#
# return jsonify({"error": str(e), "traceback": error_trace}), 500
@app.post("/api/builder/build")
async def build(request):
body = request.json()
entities = body.get("entities", "[]")
relations=body.get("relations", "[]")
try:
# 确保是字符串后再 loads
if isinstance(entities, str):
entities = json.loads(entities)
else:
entities = entities # 已经是 list(理想情况)
return jsonify({"error": str(e), "traceback": error_trace}), 500
if isinstance(relations, str):
relations = json.loads(relations)
else:
relations = relations
except Exception as e:
print("JSON decode error:", e)
return Response(status_code=400, description=f"Invalid JSON in entities or relations: {e}")
name_to_label = {}
for ent in entities:
name = ent.get("n")
typ = ent.get("t")
print(f"Entity: {name}, Type: {typ}")
# 将中文类型转为英文标签
label = CHINESE_TO_ENGLISH_LABEL.get(typ)
if label is None:
print(f"⚠️ Warning: Unknown entity type '{typ}' for entity '{name}'. Skipping or using generic label.")
label = typ # 默认回退标签
name_to_label[name] = label
# 查询 Neo4j(假设函数按属性查)
print(label)
node = neo4j_client.find_nodes_with_element_id(label=label,properties={"name": name})
if not node:
print("1111111")
if label is None:
print("sssss")
node_id = neo4j_client.insert_node(label=None, properties={"name":name})
else:
print("2222222")
node_id = neo4j_client.insert_node(label=label, properties={"name": name})
print("Found node:", node)
for rel in relations:
e1 = rel.get("e1")
r = rel.get("r")
e2 = rel.get("e2")
src_label = name_to_label.get(e1)
tgt_label = name_to_label.get(e2)
relationships = neo4j_client.find_relationships_by_condition(
source_label=src_label,
source_props={"name": e1},
target_label=tgt_label,
target_props={"name": e2},
rel_type=r,
rel_properties={"label": r}
)
if not relationships:
neo4j_client.create_relationship(
source_label=src_label,
source_props={"name": e1},
target_label=tgt_label,
target_props={"name": e2},
rel_type=r,
rel_properties={"label": r}
)
# nodes=neo4j_client.find_nodes_with_element_id(properties={"name": "糖尿病"})
print(body)

BIN
resource/avatar/slipe.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
resource/avatar/用户.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

16
vue/src/api/builder.js

@ -8,14 +8,24 @@ import request from '@/utils/request';
*/
export function analyze(data) {
return request({
url: '/api/analyze',
url: '/api/builder/analyze',
method: 'post',
data
data: data,
headers: {
'Content-Type': undefined // ⚠️ 关键:让浏览器自动设置 multipart + boundary
}
});
}
export function getAIStatus() {
return request({
url: '/api/analyze/status',
url: '/api/builder/status',
method: 'get',
});
}
export function build(data) {
return request({
url: '/api/builder/build',
method: 'post',
data
});
}

BIN
vue/src/assets/blue.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

1
vue/src/assets/close.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1766738932137" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4330" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M816.872727 768c13.963636 13.963636 13.963636 34.909091 0 48.872727-6.981818 6.981818-16.290909 9.309091-25.6 9.309091s-18.618182-2.327273-25.6-9.309091l-256-256-256 256c-6.981818 6.981818-16.290909 9.309091-25.6 9.309091s-18.618182-2.327273-25.6-9.309091c-13.963636-13.963636-13.963636-34.909091 0-48.872727l256-256-256-256c-13.963636-13.963636-13.963636-34.909091 0-48.872727 13.963636-13.963636 34.909091-13.963636 48.872728 0l256 256 256-256c13.963636-13.963636 34.909091-13.963636 48.872727 0 13.963636 13.963636 13.963636 34.909091 0 48.872727l-256 256 260.654545 256z" fill="#707070" p-id="4331"></path></svg>

After

Width:  |  Height:  |  Size: 949 B

BIN
vue/src/assets/gpt.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
vue/src/assets/load.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
vue/src/assets/word.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

1
vue/src/assets/word.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

BIN
vue/src/assets/下拉.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
vue/src/assets/用户.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
vue/src/assets/缩小.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

71
vue/src/components/Menu.vue

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

464
vue/src/system/GraphBuilder.vue

@ -12,55 +12,130 @@
:key="index"
:class="['message', msg.role]"
>
<div>2025</div>
<div v-if="msg.role === 'user'" class="bubble">
<div class="time">{{ msg.time }}</div>
<div class="word" v-if="msg.role === 'user'&& msg.hasFile" style="margin-bottom: 6px">
<img src="../assets/word.svg" style="width: 28px;margin-right: 5px; flex-shrink: 0; flex-grow: 0;">
<div style="flex: 1; overflow: auto;">
<div class="wordName" style="text-align: left;margin-bottom: 1px;font-size: 11px;line-height: 14px;">{{msg.file.name}}</div>
<div style=" align-items: center;height: 18px;font-size: 10px; line-height: 12px;display: flex;">{{msg.file.size}}</div>
</div>
<img
src="../assets/close.svg"
alt="close"
class="close-btn"
@click="removeFile"
/>
</div>
<div v-if="msg.role === 'user'" style="display: flex;align-items: flex-start; justify-content: flex-end;">
<div class="bubble">
{{ msg.content }}
</div>
<div v-else-if="msg.role === 'assistant'">
</div>
<div v-else-if="msg.role === 'assistant'" style=" display: flex;align-items: flex-start; justify-content: flex-start;">
<img src="../assets/gpt.png" style="width: 45px;height: 45px;margin-right: 15px">
<div v-if="msg.isKG" class="kg-card">
<div v-if="msg.content.entities?.length" class="kg-section">
<div v-if="msg.entities?.length" class="kg-section">
<h5 style="text-align: left">识别出的实体</h5>
<div class="entity-list">
<span
v-for="(ent, i) in msg.content.entities"
<div
v-for="(ent, i) in msg.entities"
:key="i"
class="entity-item"
style="display: flex;margin-bottom: 10px"
>
<!-- 复选框放在外面 -->
<input
type="checkbox"
:id="'entity-' + i"
:value="ent"
v-model="selectedEntities"
@change=""
/>
<!-- 使用 for 关联到 input id -->
<label
:for="'entity-' + i"
class="entity-tag"
:class="'tag-' + ent.t"
>
{{ ent.n }}<small>({{ ent.t }})</small>
</span>
</label>
</div>
</div>
</div>
<div v-if="msg.content.relations?.length" class="kg-section">
<div v-if="msg.relations?.length" class="kg-section">
<h5 style="text-align: left">识别出的关系</h5>
<ul class="relation-list">
<li v-for="(rel, i) in msg.content.relations" :key="i">
<li v-for="(rel, i) in msg.relations" :key="i" class="relation-item">
<!-- 复选框 -->
<input
type="checkbox"
:id="'rel-' + i"
:value="rel"
v-model="selectedRelations"
:disabled="!isRelationEnabled(rel)"
/>
<!-- 标签区域可点击 -->
<label :for="'rel-' + i" class="relation-label">
<span class="rel-subject">{{ rel.e1 }}</span>
<span class="rel-predicate"> {{ rel.r }} </span>
<span class="rel-object">{{ rel.e2 }}</span>
</label>
</li>
</ul>
</div>
<div v-if="!msg.content.entities?.length && !msg.content.relations?.length" class="empty-state">
未提取到有效医学实体或关系
<div v-if="msg.entities?.length || msg.relations?.length" style="text-align: right; display: flex;
justify-content: flex-end;
margin-top: 12px;">
<button class="send-btn" @click="build()" style="border-radius: 10px">
构建
</button>
</div>
<div v-if="!msg.entities?.length && !msg.relations?.length" style="font-size: 12px;
text-align: left;" >
未提取到有效医学实体或关系</div>
</div>
<div v-else class="bubble assistant-text">
{{ msg.content }}
</div>
</div>
</div>
<div v-if="isSending">
<img src="../assets/load.gif" style="width: 100px;">
</div>
</div>
<!-- 输入区域Qwen 风格 -->
<div class="input-area">
<!-- 下拉图标绝对定位在 input-area 上方居中 -->
<div class="dropdown-icon" v-if="showScrollToBottom" @click="scrollToLatest">
<img src="../assets/下拉.png" style="width: 45px" />
</div>
<div class="word" v-if="showFile">
<img src="../assets/word.png" style="width: 28px;margin-right: 5px; flex-shrink: 0; flex-grow: 0;">
<div style="flex: 1; overflow: auto;">
<div class="wordName" style="text-align: left;margin-bottom: 1px;font-size: 11px;line-height: 14px;">{{file.fileName}}</div>
<div style=" color:#a2a5a7; align-items: center;height: 18px;font-size: 10px; line-height: 12px;display: flex;">{{file.size}}</div>
</div>
<img
src="../assets/close.svg"
alt="close"
class="close-btn"
@click="removeFile"
/>
</div>
<textarea
v-model="inputText"
placeholder="有什么可以帮助您?"
class="input-box"
@keyup.enter.exact.prevent="sendMessage"
rows="1"
:rows="rows"
></textarea>
@ -71,19 +146,36 @@
src="../assets/upload.png"
alt="用户头像"
class="avatar"
@click="upload"
style="margin-left: 10px;cursor:pointer;border-radius: 50%;width: 30px;box-shadow: rgb(0 0 0 / 18%) 0px 2px 8px; "
>
<div style="display: flex;align-items: center;">
<img
<img v-if="inputStatus"
src="../assets/放大.png"
alt="用户头像"
class="avatar"
style="margin-left: 10px;cursor:pointer;width: 15px;margin-right: 26px "
@click="adjustHeight(false)"
>
<img v-if="!inputStatus"
src="../assets/缩小.png"
alt="用户头像"
class="avatar"
style="margin-left: 10px;cursor:pointer;width: 15px;margin-right: 26px "
@click="adjustHeight(true)"
>
<button class="send-btn" @click="sendMessage" title="发送">
<button class="send-btn" @click="sendMessage" title="发送" :disabled="isSending">
发送
</button>
<input
ref="fileInput"
type="file"
style="display: none;"
accept=".docx"
@change="handleFileChange"
/>
</div>
</div>
@ -96,7 +188,7 @@
<script>
import Menu from "@/components/Menu.vue";
import axios from "axios";
import {analyze, getAIStatus} from "@/api/builder";
import {analyze, build, getAIStatus} from "@/api/builder";
import {qaAnalyze} from "@/api/qa";
export default {
@ -106,12 +198,114 @@ export default {
return {
inputText: '',
messages: [
{ role: 'assistant', content: '你好!我是图谱构建助手,有什么可以帮你的吗?',isKG:false }
{ role: 'assistant', content: '你好!我是图谱构建助手,有什么可以帮你的吗?',isKG:false,hasFile:false }
],
status:"wait"
status:"wait",
rows:3,
inputStatus:true,
fileName:"",
file:{},
showFile:false,
selectedEntities:[],
selectedRelations:[],
showScrollToBottom: false,
isSending: false,
};
},
methods: {
handleScroll() {
const container = this.$refs.messagesContainer;
const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 20;
this.showScrollToBottom = !isAtBottom;
},
scrollToBottom(behavior = 'smooth') {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
container.scrollTo({
top: container.scrollHeight,
behavior
});
this.showScrollToBottom = false; //
});
},
//
scrollToLatest() {
this.scrollToBottom('smooth');
},
build(){
console.log(this.selectedRelations)
console.log(this.selectedEntities)
let data={
entities: this.selectedEntities,
relations: this.selectedRelations
}
build(data)
},
isRelationEnabled(rel) {
const selected = this.selectedEntities || [];
const hasE1 = selected.some(ent => ent.n === rel.e1);
const hasE2 = selected.some(ent => ent.n === rel.e2);
return hasE1 && hasE2;
},
removeFile(){
this.file={}
this.showFile=false
},
upload(){
this.$refs.fileInput.value = '';
this.$refs.fileInput.click(); //
},
handleFileChange(event) {
console.log(event)
const file = event.target.files[0]; //
console.log(file)
if (file) {
this.fileName = file.name; //
const sizeInBytes = file.size; //
let fileSize;
if (sizeInBytes < 1024 * 1024) {
// 1MB KB
const sizeInKB = sizeInBytes / 1024; // KB
fileSize = `${sizeInKB.toFixed(2)} KB`;
} else {
// 1MB MB
const sizeInMB = sizeInBytes / (1024 * 1024); // MB
fileSize = `${sizeInMB.toFixed(2)} MB`;
}
this.file = {
fileName: file.name,
size: fileSize, // KB MB
file:file
};
this.showFile = true;
console.log("文件信息:", this.file); //
}
},
allSelect(msg){
if (msg.entities && msg.entities.length > 0) {
msg.entities.forEach(ent => {
this.selectedEntities.push(ent);
});
}
if (msg.relations && msg.relations.length > 0) {
msg.relations.forEach(rel => {
this.selectedRelations.push(rel);
});
}
},
adjustHeight(status){
if(status){
this.rows=3
this.inputStatus=true
}else{
this.rows=10
this.inputStatus=false
}
},
pollAIStatus() {
if (this.status !== "wait") return; //
try {
@ -129,26 +323,109 @@ export default {
alert("查询状态失败,请重试");
}
},
sendMessage() {
getNowDate(){
const currentDate = new Date();
//
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, '0'); // 01
const day = String(currentDate.getDate()).padStart(2, '0');
const hours = String(currentDate.getHours()).padStart(2, '0');
const minutes = String(currentDate.getMinutes()).padStart(2, '0');
const seconds = String(currentDate.getSeconds()).padStart(2, '0');
// YYYY-MM-DD HH:mm:ss
const formattedTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
return formattedTime;
},
fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// "data:...;base64," base64
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
},
async sendMessage() {
this.isSending=true
const text = this.inputText.trim();
if (!text) return;
const hasFile = this.showFile && this.file.file;
//
if (!text && !hasFile) {
this.isSending = false;
return;
}
let data={
"text":text
}
// this.pollAIStatus()
//
let message={ role: 'user', content: text }
this.messages.push(message);
const userMessage = {
role: 'user',
content: text,
time: this.getNowDate(),
file: hasFile ? { name: this.file.fileName, size: this.file.size,file:this.file.file } : null,
hasFile:hasFile
};
this.messages.push(userMessage);
this.inputText = '';
analyze(data).then(res=>{
console.log(res)
let message={ role: 'assistant',
content: res,entities:res.entities,relations:res.relations,isKG:true }
this.showFile=false
if (hasFile) {
// 📎 使 FormData
// File Base64
const fileBase64 = await this.fileToBase64(this.file.file);
// JSON payload FormData
const payload = {
file_base64: fileBase64,
filename: this.file.file.name,
text: text || ''
};
// analyzeJson formData
analyze(payload).then(res => {
let message = {
role: 'assistant',
content: res,
entities: res.entities,
relations: res.relations,
isKG: true,
time: this.getNowDate()
};
this.messages.push(message);
this.scrollToBottom('smooth');
this.isSending = false;
this.file={}
this.allSelect(message)
})
} else {
const payload = {
text: text || ''
};
analyze(payload).then(res => {
let message = {
role: 'assistant',
content: res,
entities: res.entities,
relations: res.relations,
isKG: true,
time: this.getNowDate()
};
this.messages.push(message);
this.scrollToBottom('smooth');
this.isSending = false;
this.file={}
this.allSelect(message)
})
// qaAnalyze(data).then(res=>{
//
// })
}
this.scrollToBottom('smooth');
},
insertPrefix(prefix) {
this.inputText += prefix;
@ -158,14 +435,21 @@ export default {
el.focus();
});
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
},
// scrollToBottom() {
// this.$nextTick(() => {
// const container = this.$refs.messagesContainer;
// console.log(container)
// if (container) {
// // 使 setTimeout DOM
// setTimeout(() => {
// container.scrollTop = container.scrollHeight;
// console.log(container.scrollTop)
// console.log(container.scrollHeight)
// }, 100); // 100ms
// }
// });
// },
saveMessage(message) {
const messages = JSON.parse(localStorage.getItem('messages')) || [];
messages.push(message); //
@ -173,6 +457,7 @@ export default {
},
loadMessages() {
const savedMessages = JSON.parse(localStorage.getItem('messages')) || [];
console.log(savedMessages)
this.messages = savedMessages; //
},
handleBeforeUnload() {
@ -186,13 +471,19 @@ export default {
},
watch: {
messages() {
this.scrollToBottom();
this.scrollToBottom('smooth');
}
},
beforeUnmount() {
const container = this.$refs.messagesContainer;
container.removeEventListener('scroll', this.handleScroll);
},
mounted() {
const container = this.$refs.messagesContainer;
container.addEventListener('scroll', this.handleScroll);
this.loadMessages();
this.scrollToBottom();
this.scrollToBottom('smooth');
window.addEventListener('beforeunload', this.handleBeforeUnload);
},
@ -224,29 +515,30 @@ export default {
.message {
display: flex;
margin-bottom: 12px;
align-items: flex-start;
flex-direction: column; /* 垂直排列时间和内容 */
}
.message.user {
justify-content: flex-end;
align-items: flex-end;
}
.message.assistant {
justify-content: flex-start;
align-items: flex-start;
}
.bubble {
padding: 10px 14px;
border-radius: 18px;
max-width: 75%;
padding: 16px;
border-radius: 13px;
max-width: 55%;
word-break: break-word;
line-height: 1.5;
font-size: 12px;
text-align: left;
}
.message.user .bubble {
background-color: #155DFF;
color: white;
background-color: #e9e9e9;
color: #333;
}
.message.assistant .bubble {
@ -256,6 +548,7 @@ export default {
/* === 输入区域 === */
.input-area {
position: relative;
padding: 16px;
background: white;
border-top-left-radius: 24px;
@ -267,7 +560,6 @@ export default {
font-weight: 500;
}
.input-box {
height: 75px;
width: 100%;
padding: 12px 16px;
border: none;
@ -391,7 +683,6 @@ export default {
/* 实体标签 */
.entity-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
@ -414,10 +705,12 @@ export default {
}
/* 按类型着色(可扩展) */
/*
.tag-疾病 { background: #ffebee; color: #c62828; border-color: #ffcdd2; }
.tag-症状 { background: #e8f5e9; color: #2e7d32; border-color: #c8e6c9; }
.tag-检查 { background: #fff8e1; color: #ff8f00; border-color: #ffecb3; }
.tag-药物 { background: #f3e5f5; color: #7b1fa2; border-color: #ce93d8; }
*/
/* 关系列表 */
.relation-list {
@ -428,7 +721,6 @@ export default {
.relation-list li {
padding: 6px 0;
border-bottom: 1px dashed #eee;
display: flex;
flex-wrap: wrap;
gap: 6px;
@ -438,9 +730,7 @@ export default {
border-bottom: none;
}
.rel-subject { font-weight: 600; color: #1a237e; }
.rel-predicate { color: #d32f2f; font-weight: 500; }
.rel-object { font-weight: 600; color: #1b5e20; }
/* 空状态 */
.empty-state {
@ -454,4 +744,70 @@ export default {
background-color: #e9ecef;
color: #333;
}
.message.assistant .time{
text-align: left;
color: #A5A5A5;
margin-bottom: 5px;
font-size: 12px;
margin-left: 60px;
}
.message.user .time{
text-align: right;
color: #A5A5A5;
margin-bottom: 5px;
font-size: 12px;
}
.word{
/* border: 1px solid rgba(17,17,51,.1);*/
border-radius: 8px;
justify-content: flex-start;
align-items: center;
width: 16%;
min-width: 149px;
max-width: 206px;
padding: 9px 6px;
display: flex;
margin-left: 10px;
position: relative;
background-color: #e9e9e9;
}
.wordName{
overflow: hidden; /* 隐藏超出部分 */
display: -webkit-box; /* 使用 webkit 的盒子模型 */
-webkit-box-orient: vertical; /* 设置为垂直方向 */
-webkit-line-clamp: 1; /* 限制为两行 */
text-overflow: ellipsis; /* 显示省略号 */
}
.word:hover .close-btn {
display: block; /* 悬浮时显示X按钮 */
}
.close-btn{
position: absolute;
width: 20px; /* 设置适合的图片大小 */
height: 20px;
cursor: pointer;
padding: 5px;
box-sizing: border-box;
top: -8px;
right: -7px;
border: 1px solid rgba(17,17,51,.1);
border-radius: 50%;
background-color: #fff;
display: none;
}
.dropdown-icon {
position: absolute;
top: -45px; /* 图标高度约45px,一半是22.5,所以 -22~ -25 可居中于顶部线上 */
left: 50%;
transform: translateX(-50%);
z-index: 10;
cursor: pointer;
}
.send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: #ccc;
}
</style>

49
vue/src/system/GraphDemo.vue

@ -28,7 +28,11 @@
<section class="main-content">
<div class="disease-container">
<div class="disease-header" :style="headerStyle">
<div class="d-title"><img :src="iconSrc" class="d-icon"/><span>疾病信息</span></div>
<div class="d-title"><img :src="iconSrc" class="d-icon"/>
<span v-if="typeRadio=== 'Disease'">疾病信息</span>
<span v-if="typeRadio=== 'Drug'">药品信息</span>
<span v-if="typeRadio=== 'Check'">检查信息</span>
</div>
<div class="d-count" :style="headerLabel">12</div>
</div>
<div>
@ -132,10 +136,10 @@ export default {
diseaseTree:[],
checkTree:[],
legendItems: [
{ key: 'Drug', label: '药品', color: '#91cc75' },
{ key: 'Symptom', label: '症状', color: '#fac858' },
{ key: 'Disease', label: '疾病', color: '#EF4444' },
{ key: 'Drug', label: '药品', color: '#91cc75' },
{ key: 'Check', label: '检查', color: '#336eee' },
{ key: 'Symptom', label: '症状', color: '#fac858' },
{ key: 'Other', label: '其他', color: '#59d1d4' }
],
visibleCategories: new Set(), //
@ -169,9 +173,9 @@ export default {
case 'Drug':
return require('@/assets/green.png'); //
case 'Check':
return require('@/assets/red.png'); //
return require('@/assets/blue.png'); //
default:
return require('@/assets/red.png'); //
return require('@/assets/blue.png'); //
}
},
headerLabel() {
@ -1099,31 +1103,34 @@ button:hover {
/* 动态给每个 el-radio 设置不同的背景颜色 */
.radio-disease .el-radio__label {
color: #ff4d4f; /* 疾病的颜色 */
/deep/ .radio-disease .el-radio__input.is-checked .el-radio__inner {
background: rgb(153, 10, 0); /* 疾病的颜色 */
border-color: rgb(153, 10, 0);
}
.radio-drug .el-radio__label {
color: #1890ff; /* 药品的颜色 */
.el-radio__inner:hover {
border-color: rgb(153, 10, 0);
}
.radio-check .el-radio__label {
color: #52c41a; /* 检查的颜色 */
/deep/ .radio-drug .el-radio__input.is-checked .el-radio__inner {
background: #52c41a; /* 检查的颜色 */
border-color:#52c41a;
}
/* 自定义选中后的样式 */
.el-radio.is-checked.radio-disease .el-radio__label {
color: white;
background-color: #ff4d4f;
/deep/ .radio-check .el-radio__input.is-checked .el-radio__inner {
background: #1890ff; /* 药品的颜色 */
border-color:#1890ff;
}
.el-radio.is-checked.radio-drug .el-radio__label {
color: white;
background-color: #1890ff;
/* 自定义选中后的样式 */
/deep/ .radio-disease .el-radio__input.is-checked+.el-radio__label {
color: rgb(153, 10, 0);
}
.el-radio.is-checked.radio-check .el-radio__label {
color: white;
background-color: #52c41a;
/deep/ .radio-drug .el-radio__input.is-checked+.el-radio__label {
color: #52c41a;
}
/deep/ .radio-check .el-radio__input.is-checked+.el-radio__label {
color: #1890ff;
}
</style>

217
vue/src/system/GraphQA.vue

@ -5,13 +5,228 @@
<Menu
:initial-active="2"
/>
<div class="medical-qa-container">
<!-- 标题 -->
<h2 class="title">医疗问答</h2>
<!-- 搜索框 -->
<div class="search-box">
<el-input
v-model="query"
placeholder="请输入问题..."
clearable
/>
<el-button type="primary" @click="handleSearch" style=" background: #114FE6;">查询</el-button>
</div>
<!-- 主内容区 -->
<div class="content-wrapper">
<!-- 左侧问答结果 -->
<div class="answer-list">
<div class="section-title">问答结果</div>
<div v-if="answers.length === 0" class="empty-state">
请输入问题进行查询...
</div>
<div v-else class="answer-items">
<div
v-for="(item, index) in answers"
:key="index"
class="answer-item"
:class="{ 'highlight': index === 0 }"
>
{{ item }}
</div>
</div>
</div>
<!-- 右侧知识图谱 -->
<div class="knowledge-graph">
<KnowledgeGraph />
</div>
</div>
</div>
</div>
</template>
<script setup>
<script>
import Menu from "@/components/Menu.vue";
import {qaAnalyze} from "@/api/qa";
export default {
name: 'GraghQA',
components: {Menu},
data() {
return {
query:"",
answers:[]
};
},
methods: {
handleSearch(){
let data={
text:this.query
}
qaAnalyze(data).then(res=>{
})
}
},
};
</script>
<style scoped>
.medical-qa-container {
padding: 25px;
background-color: #F6F9FF;
flex: 1;
overflow: auto;
height: 100vh;
margin: 0 auto;
}
.title {
font-size: 20px;
margin-bottom: 20px;
padding-left: 12px;
position: relative;
display: flex;
align-items: center;
color: #165DFF;
margin-left: 15px;
}
.title::before {
content: '';
width: 6px;
height: 16px;
background-color: #165DFF;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
border-radius: 2px;
}
.search-box {
display: flex;
gap: 20px;
margin-bottom: 30px;
align-items: center;
}
.search-box .el-input {
flex: 1;
max-width: 94%;
margin-right: 5px;
}
.content-wrapper {
display: flex;
gap: 20px;
height: 37vw;
}
.answer-list {
flex: 1;
background-color: white;
border-radius: 33px;
padding: 25px;
box-shadow: 3px 1px 12px 7px #ECF2FF;
max-width: 32%;
}
.section-title {
font-size: 16px;
color: #165DFF;
margin-bottom: 15px;
padding-left: 12px;
position: relative;
display: flex;
align-items: center;
font-weight: 900;
}
.section-title::before {
content: '';
width: 6px;
height: 12px;
background-color: #165DFF;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
border-radius: 2px;
}
.answer-items {
height: 30vw;
overflow-y: auto;
padding-right: 10px;
}
.answer-items::-webkit-scrollbar {
width: 6px; /* 滚动条宽度 */
}
.answer-items::-webkit-scrollbar-track {
background: #fff; /* 轨道背景 */
border-radius: 4px;
}
.answer-items::-webkit-scrollbar-thumb {
background: #B8C2D9; /* 滑块颜色 */
border-radius: 4px;
transition: background 0.2s ease;
}
.answer-items::-webkit-scrollbar-thumb:hover {
background: #B8C2D9; /* 悬停时滑块颜色 */
}
.answer-item {
padding: 12px;
border: 1px solid #165DFF;
border-radius: 15px;
background-color: #f9f9f9;
line-height: 1.5;
word-wrap: break-word;
color: #a2a5a7;
font-weight: 400;
font-size: 13px;
text-align: left;
height: 4.5vw;
margin-bottom: 12px;
}
.answer-item.highlight {
background-color: #165DFF;
border-color: #0066cc;
color: #fff;
}
.empty-state {
text-align: center;
color: #fff;
padding: 40px 0;
font-size: 14px;
}
.knowledge-graph {
flex: 1;
background-color: white;
border-radius: 16px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
/deep/.el-input__wrapper{
background-color: #EFF4FF;
border: 1px solid #165DFF;
border-radius: 8px;
}
</style>

2
vue/src/system/Register.vue

@ -157,7 +157,7 @@ const errors = ref({
});
//
const defaultAvatar = '/resource/avatar/4.png';
const defaultAvatar = '/resource/avatar/用户.png';
const avatarPreview = ref(defaultAvatar);
// -

2
web_main.py

@ -1,3 +1,5 @@
from robyn import ALLOW_CORS
from app import app
import controller
from service.UserService import init_mysql_connection

Loading…
Cancel
Save