Compare commits

..

51 Commits

Author SHA1 Message Date
b8f3eaca0d Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-20 00:29:46 +08:00
b04856de97 fix(#776): 请修复 Bug #776(诸葛亮分析完成,分配给你) 2026-06-20 00:21:05 +08:00
aec3bf3e34 fix(#786): zhaoyun (文件合入) 2026-06-20 00:11:17 +08:00
ceec63ab67 Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 23:27:19 +08:00
7adf298ce7 Merge remote-tracking branch 'origin/develop' into develop 2026-06-19 23:24:01 +08:00
fdf56a33ce fix: 修复关键BUG - SQL注入+移动端修复 2026-06-19 23:11:13 +08:00
8914dca1df fix(mobile): 修复医院选择 - 使用租户列表接口加载所有医院 2026-06-19 22:55:33 +08:00
6f288f99de fix(mobile): 登录页面医院选择移至第一位 2026-06-19 22:40:45 +08:00
7d9da53cc4 fix(mobile): 修复移动端API对接 - 使用现有护士站接口+登录获取用户信息 2026-06-19 22:37:35 +08:00
6dc9aaba6c feat(mobile): 重构移动端护士工作站 - 完整功能版本 2026-06-19 22:04:41 +08:00
3bc8a85426 fix(mobile): 修复登录页面始终显示医院选择 2026-06-19 21:59:26 +08:00
5b90a61484 fix(mobile): 修复登录逻辑对齐现有系统 2026-06-19 21:57:43 +08:00
a54715587f Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 20:04:15 +08:00
86f12b425a fix(#782): guanyu (文件合入) 2026-06-19 19:57:57 +08:00
0cd461e22c fix(#782): 请修复 Bug #782(重试)
根因:
- `HashSet` 没有单独导入,但代码中有 `import java.util.*;`(line 51),所以 `HashSet` 已被通配符导入。让我验证编译:

修复:
- 修改相关代码文件
2026-06-19 19:57:16 +08:00
6bed436c62 Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 19:35:54 +08:00
829fca8869 fix(#769): zhaoyun (文件合入) 2026-06-19 19:34:42 +08:00
3bcbf20e90 Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 18:30:18 +08:00
32bbda6dd4 fix(#770): zhaoyun (文件合入) 2026-06-19 18:23:25 +08:00
1f4bad4ac8 Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 18:16:14 +08:00
471eacaf52 fix(#769): zhaoyun (文件合入) 2026-06-19 18:10:34 +08:00
309e6a6cdd Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 18:02:19 +08:00
f1ac7cc1fb fix(#768): guanyu (文件合入) 2026-06-19 18:02:16 +08:00
ef6cd66a10 fix(#768): 请修复 Bug #768(诸葛亮分析完成,分配给你) 2026-06-19 18:01:29 +08:00
8254b6ab90 Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 17:56:05 +08:00
b67725d08c fix(#770): zhaoyun (文件合入) 2026-06-19 17:54:54 +08:00
f1712d59b0 Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 17:02:40 +08:00
c2ed6e04b0 fix(#782): guanyu (文件合入) 2026-06-19 16:53:53 +08:00
6baf7dc69f fix(#782): 请修复 Bug #782(诸葛亮分析完成,分配给你) 2026-06-19 16:53:15 +08:00
7fbed9d593 Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 16:21:01 +08:00
8fafa12337 fix(#767): zhaoyun (文件合入) 2026-06-19 16:14:29 +08:00
f0abdd8b52 Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 15:49:28 +08:00
1801fc27ae fix(#786): zhaoyun (文件合入) 2026-06-19 15:49:26 +08:00
21405a2b96 Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 14:36:48 +08:00
b6b8f8be71 fix(#782): guanyu (文件合入) 2026-06-19 14:32:06 +08:00
f92eb58400 fix(#782): 请修复 Bug #782(重试) 2026-06-19 14:31:30 +08:00
a9daab268b fix(#770): zhaoyun (文件合入) 2026-06-19 14:28:45 +08:00
9d486c3742 feat(mobile): 添加登录页面+租户选择+路由守卫 2026-06-19 12:48:57 +08:00
38bc99ee14 fix(mobile): 修复移动端核心功能问题
- 新增 getPatientList API 调用正确的患者列表接口
- PatientDetail: Promise.all 并发加载患者信息/医嘱/体征/评估
- 所有页面添加 loading 状态和 ElMessage 错误提示
- 任务完成添加 ElMessageBox 确认对话框
- TaskList 添加刷新按钮
- Mine 退出登录添加确认对话框
2026-06-19 12:44:43 +08:00
05332ce2d9 fix(mobile): 修复后端端口为18080 2026-06-19 12:26:05 +08:00
686fcb5692 fix(mobile): 修复移动端API路径与后端对接 2026-06-19 12:25:47 +08:00
99812e1bf0 feat(mobile-h5): 创建独立移动端H5护理工作站项目 2026-06-19 12:16:58 +08:00
5ab3865e04 refactor: 移除UI项目中的mobile代码,准备独立移动端项目 2026-06-19 12:15:18 +08:00
52c5a92c9a Merge remote-tracking branch 'origin/develop' into guanyu 2026-06-19 12:06:37 +08:00
0b183dacf8 fix(#772): guanyu (文件合入) 2026-06-19 12:05:37 +08:00
7b4cfeb6d5 feat(mobile-h5): 移动H5护理工作站 2026-06-19 10:44:32 +08:00
844eb8b7ab feat(kg): 数据导入+规则库 2026-06-19 10:36:06 +08:00
179d8c9c97 feat(kg): 推理引擎+CDSS集成 2026-06-19 10:34:43 +08:00
ed1dd56ad4 feat(kg): 推理引擎+数据导入 2026-06-19 10:33:41 +08:00
523a64daf0 feat(miniprogram): 移动护理小程序后端API
- 新增 MpNursingTask 实体 + Mapper + Service
- 新增 MpVitalSignRecord 实体 + Mapper + Service
- 新增 MpAssessmentRecord 实体 + Mapper + Service
- 新增 IMpNursingAppService 7个API接口
- 新增 MpNursingController 7个REST端点
- 新增 V90 Flyway迁移(3张表)
- 所有接口加 @PreAuthorize 权限控制
2026-06-19 10:29:47 +08:00
d9a1b188b5 feat(kg): 医疗知识图谱全栈实现 - 补充缺失字段 2026-06-19 10:18:32 +08:00
74 changed files with 3047 additions and 80 deletions

View File

@@ -1,7 +1,7 @@
# HealthLink-HIS 代码模块索引
> 供 LLM 快速定位代码。每个模块列出 Controller → Service → Mapper 关键文件。
> 最后更新: 2026-06-19 12:00 (342 个 Controller)
> 最后更新: 2026-06-20 00:00 (345 个 Controller)
## 关键词 → 模块速查

View File

@@ -0,0 +1,11 @@
# 页面标题
VITE_APP_TITLE = HealthLink移动护理
# 开发环境配置
VITE_APP_ENV = 'development'
# API地址
VITE_APP_BASE_API = '/dev-api'
# 后端代理地址
VITE_API_PROXY = 'http://localhost:18080/healthlink-his'

View File

@@ -0,0 +1,8 @@
# 页面标题
VITE_APP_TITLE = HealthLink移动护理
# 生产环境配置
VITE_APP_ENV = 'production'
# API地址
VITE_APP_BASE_API = '/dev-api'

6
healthlink-his-mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env.local
.env.*.local
*.log
package-lock.json

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>HealthLink 移动护理</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{
"name": "healthlink-his-mobile",
"version": "1.0.0",
"type": "module",
"description": "HealthLink-HIS 移动护理H5工作站",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build",
"preview": "vite preview",
"lint": "echo 'No lint configured'"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.7.0",
"element-plus": "^2.7.0",
"echarts": "^5.5.0",
"js-cookie": "^3.0.5",
"nprogress": "^0.2.0",
"path-to-regexp": "^6.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.4.0",
"sass": "^1.77.0"
}
}

View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@@ -0,0 +1,57 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const service = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API || '/dev-api',
timeout: 30000
})
service.interceptors.request.use(config => {
const token = localStorage.getItem('Admin-Token')
if (token && !(config.headers && config.headers.isToken === false)) {
config.headers.Authorization = 'Bearer ' + token
}
return config
})
service.interceptors.response.use(
response => {
const res = response.data
if (res.code === 401) {
localStorage.removeItem('Admin-Token')
localStorage.removeItem('userInfo')
window.location.href = '/login'
return Promise.reject(new Error('登录已过期'))
}
return res
},
error => {
if (error.response?.status === 401) {
localStorage.removeItem('Admin-Token')
localStorage.removeItem('userInfo')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export const authApi = {
login: (data) => service.post('/login', data, { headers: { isToken: false } }),
getTenants: (username) => service.get('/system/tenant/user-bind/' + username, { headers: { isToken: false } }),
getAllTenants: () => service.get('/system/tenant/page', { headers: { isToken: false }, params: { pageSize: 100 } }),
getInfo: () => service.get('/getInfo')
}
export const nursingApi = {
getTasks: (params) => service.get('/nurse-station/advice-process/page', { params }),
completeTask: (id, data) => service.post(`/nurse-station/advice-process/execute`, data),
getPatientInfo: (id) => service.get('/inpatientmanage/inhospitalregister/' + id),
getPatientList: (params) => service.get('/inpatientmanage/inhospitalregister/list', { params }),
getOrders: (encounterId) => service.get('/nurse-station/advice-process/page', { params: { encounterId } }),
getVitalSigns: (patientId) => service.get('/nursing/vital-signs/' + patientId),
submitVitalSign: (data) => service.post('/nursing/vital-sign', data),
getAssessments: (encounterId) => service.get('/nursing/assessment/encounter/' + encounterId),
submitAssessment: (data) => service.post('/nursing/assessment', data)
}
export default service

View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './styles/mobile.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { size: 'large', locale: zhCn })
app.mount('#app')

View File

@@ -0,0 +1,22 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', component: () => import('../views/Login.vue'), meta: { title: '登录' } },
{ path: '/', redirect: '/mobile/home' },
{ path: '/mobile', component: () => import('../views/MobileLayout.vue'), meta: { requiresAuth: true }, children: [
{ path: 'home', component: () => import('../views/Home.vue'), meta: { title: '首页' } },
{ path: 'tasks', component: () => import('../views/TaskList.vue'), meta: { title: '任务列表' } },
{ path: 'patients', component: () => import('../views/PatientList.vue'), meta: { title: '患者列表' } },
{ path: 'patient-detail/:id', component: () => import('../views/PatientDetail.vue'), meta: { title: '患者详情' } },
{ path: 'vital-entry/:patientId', component: () => import('../views/VitalSignEntry.vue'), meta: { title: '生命体征录入' } },
{ path: 'assessment/:patientId', component: () => import('../views/AssessmentForm.vue'), meta: { title: '护理评估' } },
{ path: 'mine', component: () => import('../views/Mine.vue'), meta: { title: '我的' } }
]}
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !localStorage.getItem('Admin-Token')) { next('/login'); return }
next()
})
export default router

View File

@@ -0,0 +1,6 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px; color: #333; background: #f5f5f5; -webkit-font-smoothing: antialiased; }
:root { --primary: #1890ff; --success: #52c41a; --warning: #fa8c16; --danger: #f5222d; --bg: #f5f5f5; --card: #fff; --border: #e8e8e8; }
input, button, textarea { font-family: inherit; font-size: inherit; }
button { cursor: pointer; -webkit-tap-highlight-color: transparent; }
::-webkit-scrollbar { display: none; }

View File

@@ -0,0 +1,86 @@
<template>
<div class="assessment-form">
<div class="type-select">
<div v-for="type in assessmentTypes" :key="type.key" class="type-card" :class="{ active: selectedType === type.key }" @click="selectedType = type.key">
<div class="type-icon">{{ type.icon }}</div><div class="type-name">{{ type.name }}</div>
</div>
</div>
<div v-if="selectedType" class="form-content">
<div v-for="(item, idx) in currentItems" :key="idx" class="form-item">
<div class="item-label">{{ item.label }}</div>
<div class="item-options">
<span v-for="opt in item.options" :key="opt.value" class="option" :class="{ selected: formData[item.key] === opt.value }" @click="formData[item.key] = opt.value">{{ opt.label }} ({{ opt.score }})</span>
</div>
</div>
<div class="score-result"><div class="total-score">总分: {{ totalScore }}</div><div class="risk-level" :class="riskLevel">{{ riskLevelText }}</div></div>
<button class="submit-btn" @click="submit" :disabled="submitting">{{ submitting ? '提交中...' : '提交评估' }}</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { nursingApi } from '../api'
const route = useRoute()
const selectedType = ref('')
const submitting = ref(false)
const formData = ref({})
const assessmentTypes = [
{ key: 'Braden', name: '压疮评估', icon: '🩹', items: [
{ key: 'sensory', label: '感知能力', options: [{ label: '完全受限', value: 1, score: 1 }, { label: '严重受限', value: 2, score: 2 }, { label: '轻度受限', value: 3, score: 3 }, { label: '未受损', value: 4, score: 4 }] },
{ key: 'moisture', label: '皮肤潮湿', options: [{ label: '持续潮湿', value: 1, score: 1 }, { label: '经常潮湿', value: 2, score: 2 }, { label: '偶尔潮湿', value: 3, score: 3 }, { label: '很少潮湿', value: 4, score: 4 }] },
{ key: 'activity', label: '活动能力', options: [{ label: '卧床', value: 1, score: 1 }, { label: '轮椅', value: 2, score: 2 }, { label: '偶尔步行', value: 3, score: 3 }, { label: '经常步行', value: 4, score: 4 }] }
]},
{ key: 'Morse', name: '跌倒评估', icon: '⚠️', items: [
{ key: 'history', label: '跌倒史', options: [{ label: '无', value: 0, score: 0 }, { label: '有', value: 25, score: 25 }] },
{ key: 'diagnosis', label: '诊断', options: [{ label: '无', value: 0, score: 0 }, { label: '有', value: 15, score: 15 }] },
{ key: 'ambulation', label: '行走辅助', options: [{ label: '无需', value: 0, score: 0 }, { label: '拐杖', value: 15, score: 15 }, { label: '扶墙', value: 30, score: 30 }] }
]},
{ key: 'NRS2002', name: '营养筛查', icon: '🍎', items: [
{ key: 'bmi', label: 'BMI', options: [{ label: '≥20.5', value: 0, score: 0 }, { label: '18.5-20.5', value: 1, score: 1 }, { label: '<18.5', value: 2, score: 2 }] },
{ key: 'weightLoss', label: '体重下降', options: [{ label: '无', value: 0, score: 0 }, { label: '<5%', value: 1, score: 1 }, { label: '>5%', value: 2, score: 2 }] },
{ key: 'intake', label: '饮食摄入', options: [{ label: '正常', value: 0, score: 0 }, { label: '减少', value: 1, score: 1 }, { label: '极少', value: 2, score: 2 }] }
]}
]
const currentItems = computed(() => assessmentTypes.find(t => t.key === selectedType.value)?.items || [])
const totalScore = computed(() => currentItems.value.reduce((sum, item) => sum + (formData.value[item.key] || 0), 0))
const riskLevel = computed(() => {
if (selectedType.value === 'Braden') return totalScore.value <= 12 ? 'HIGH' : totalScore.value <= 14 ? 'MEDIUM' : 'LOW'
if (selectedType.value === 'Morse') return totalScore.value >= 45 ? 'HIGH' : totalScore.value >= 25 ? 'MEDIUM' : 'LOW'
return totalScore.value >= 3 ? 'HIGH' : totalScore.value >= 2 ? 'MEDIUM' : 'LOW'
})
const riskLevelText = computed(() => ({ HIGH: '高风险', MEDIUM: '中风险', LOW: '低风险' }[riskLevel.value]))
const submit = async () => {
submitting.value = true
try {
await nursingApi.submitAssessment({ patientId: route.params.patientId, assessmentType: selectedType.value, totalScore: totalScore.value, riskLevel: riskLevel.value, detail: JSON.stringify(formData.value) })
ElMessage.success('评估提交成功')
} catch (e) { ElMessage.error('提交失败') } finally { submitting.value = false }
}
</script>
<style scoped>
.type-select { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.type-card { background: #fff; border-radius: 8px; padding: 14px; text-align: center; border: 2px solid transparent; cursor: pointer; }
.type-card.active { border-color: #1890ff; background: #e6f7ff; }
.type-icon { font-size: 26px; }
.type-name { font-size: 13px; margin-top: 4px; }
.form-content { background: #fff; border-radius: 8px; padding: 14px; }
.form-item { margin-bottom: 14px; }
.item-label { font-weight: 600; margin-bottom: 8px; font-size: 14px; }
.item-options { display: flex; flex-wrap: wrap; gap: 8px; }
.option { padding: 8px 12px; background: #f0f0f0; border-radius: 6px; font-size: 13px; cursor: pointer; }
.option.selected { background: #1890ff; color: #fff; }
.score-result { text-align: center; padding: 14px 0; border-top: 1px solid #eee; margin-top: 10px; }
.total-score { font-size: 22px; font-weight: 600; }
.risk-level { font-size: 15px; margin-top: 4px; }
.risk-HIGH { color: #f5222d; } .risk-MEDIUM { color: #fa8c16; } .risk-LOW { color: #52c41a; }
.submit-btn { width: 100%; padding: 14px; background: #1890ff; color: #fff; border: none; border-radius: 8px; font-size: 16px; margin-top: 10px; }
.submit-btn:disabled { background: #91d5ff; }
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="home">
<div class="welcome">
<div class="user-info">
<div class="avatar">{{ userInfo?.userName?.charAt(0) || '护' }}</div>
<div><div class="name">{{ userInfo?.nickName || userInfo?.userName || '护士' }}</div><div class="dept">{{ userInfo?.orgName || '' }}</div></div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card" v-for="s in stats" :key="s.label">
<div class="stat-value">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
</div>
</div>
<div class="quick-actions">
<div class="action-title">快捷操作</div>
<div class="action-grid">
<div class="action-item" v-for="a in actions" :key="a.label" @click="$router.push(a.path)">
<div class="action-icon" :style="{ background: a.color }">{{ a.icon }}</div>
<div class="action-label">{{ a.label }}</div>
</div>
</div>
</div>
<div class="recent-tasks">
<div class="section-header"><span>待办任务</span><span class="more" @click="$router.push('/mobile/tasks')">查看全部</span></div>
<div v-for="task in recentTasks" :key="task.id" class="task-item">
<div class="task-dot"></div>
<div class="task-info"><div class="task-name">{{ task.adviceName || task.taskContent || '医嘱任务' }}</div><div class="task-time">{{ task.createTime || '' }}</div></div>
</div>
<div v-if="recentTasks.length === 0" class="empty">暂无待办任务</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { nursingApi } from '../api'
const userInfo = ref({})
const stats = ref([{ label: '待执行医嘱', value: 0 }, { label: '今日体征', value: 0 }, { label: '待评估', value: 0 }, { label: '高风险', value: 0 }])
const recentTasks = ref([])
const actions = [
{ icon: '📋', label: '任务列表', path: '/mobile/tasks', color: '#1890ff' },
{ icon: '👥', label: '患者列表', path: '/mobile/patients', color: '#52c41a' },
{ icon: '📊', label: '生命体征', path: '/mobile/vital-entry', color: '#722ed1' }
]
onMounted(async () => {
try { const info = localStorage.getItem('userInfo'); if (info) userInfo.value = JSON.parse(info) } catch {}
try {
const nurseId = userInfo.value.practitionerId || userInfo.value.userId
if (nurseId) {
const res = await nursingApi.getTasks({ nurseId: nurseId })
if (res.code === 200) {
recentTasks.value = (res.data?.records || res.data?.rows || res.data || []).slice(0, 5)
stats.value[0].value = res.data?.total || recentTasks.value.length
}
}
} catch {}
})
</script>
<style scoped>
.home { padding: 12px; padding-bottom: 70px; }
.welcome { background: linear-gradient(135deg, #1890ff, #096dd9); border-radius: 12px; padding: 20px; color: #fff; margin-bottom: 12px; }
.user-info { display: flex; align-items: center; gap: 12px; }
.avatar { width: 48px; height: 48px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 20px; }
.name { font-size: 18px; font-weight: 600; }
.dept { font-size: 13px; opacity: 0.8; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 12px; }
.stat-card { background: #fff; border-radius: 8px; padding: 12px 8px; text-align: center; }
.stat-value { font-size: 22px; font-weight: 600; color: #1890ff; }
.stat-label { font-size: 11px; color: #999; margin-top: 4px; }
.quick-actions { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 12px; }
.action-title { font-size: 15px; font-weight: 600; margin-bottom: 12px; }
.action-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.action-item { text-align: center; cursor: pointer; }
.action-icon { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 22px; margin: 0 auto 6px; }
.action-label { font-size: 12px; color: #666; }
.recent-tasks { background: #fff; border-radius: 12px; padding: 16px; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-size: 15px; font-weight: 600; }
.more { color: #1890ff; font-size: 13px; }
.task-item { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #f5f5f5; }
.task-dot { width: 8px; height: 8px; border-radius: 50%; background: #fa8c16; }
.task-name { font-size: 14px; }
.task-time { font-size: 12px; color: #999; }
.empty { text-align: center; padding: 20px; color: #999; }
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="login-page">
<div class="login-header">
<div class="logo">🏥</div>
<h1>{{ currentTenantName || 'HealthLink 移动护理' }}</h1>
<p>护士工作站</p>
</div>
<div class="login-form">
<div class="form-item">
<label>医院/租户</label>
<select v-model="form.tenantId" class="input" @change="onTenantChange">
<option value="">请选择医院</option>
<option v-for="t in tenantOptions" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<div class="form-item">
<label>用户名</label>
<input v-model="form.username" type="text" placeholder="请输入用户名" class="input" />
</div>
<div class="form-item">
<label>密码</label>
<input v-model="form.password" type="password" placeholder="请输入密码" class="input" @keyup.enter="handleLogin" />
</div>
<button class="login-btn" @click="handleLogin" :disabled="loading">{{ loading ? '登录中...' : '登 录' }}</button>
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { authApi } from '../api'
const router = useRouter()
const loading = ref(false)
const errorMsg = ref('')
const tenantOptions = ref([])
const currentTenantName = ref('')
const form = ref({ username: '', password: '', tenantId: '' })
const loadTenants = async () => {
try {
const res = await authApi.getAllTenants()
if (res.code === 200) {
const list = res.data?.records || res.data || []
tenantOptions.value = list.map(item => ({ label: item.tenantName, value: item.tenantId || item.id }))
if (tenantOptions.value.length === 1) { form.value.tenantId = tenantOptions.value[0].value; currentTenantName.value = tenantOptions.value[0].label }
}
} catch (e) { console.error(e) }
}
const onTenantChange = () => {
const selected = tenantOptions.value.find(t => t.value === form.value.tenantId)
currentTenantName.value = selected ? selected.label : ''
}
onMounted(loadTenants)
const handleLogin = async () => {
if (!form.value.username) { errorMsg.value = '请输入用户名'; return }
if (!form.value.password) { errorMsg.value = '请输入密码'; return }
loading.value = true; errorMsg.value = ''
try {
const loginRes = await authApi.login({ username: form.value.username, password: form.value.password, tenantId: form.value.tenantId, code: '', uuid: '' })
if (loginRes.code === 200 && loginRes.token) {
localStorage.setItem('Admin-Token', loginRes.token)
const infoRes = await authApi.getInfo()
if (infoRes.code === 200) {
const user = infoRes.user || {}
localStorage.setItem('userInfo', JSON.stringify({
userId: user.userId,
userName: user.userName,
nickName: user.nickName,
practitionerId: user.practitionerId,
orgId: user.orgId,
orgName: user.orgName,
roles: user.roles,
permissions: user.permissions
}))
}
ElMessage.success('登录成功')
router.push('/mobile/home')
} else {
errorMsg.value = loginRes.msg || '登录失败'
}
} catch (e) {
errorMsg.value = e.response?.data?.msg || '登录失败,请检查网络'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page { min-height: 100vh; background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
.login-header { text-align: center; color: #fff; margin-bottom: 40px; }
.logo { font-size: 60px; margin-bottom: 12px; }
.login-header h1 { font-size: 22px; margin: 0; }
.login-header p { font-size: 14px; opacity: 0.8; margin-top: 8px; }
.login-form { background: #fff; border-radius: 12px; padding: 24px; width: 100%; max-width: 360px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); }
.form-item { margin-bottom: 16px; }
.form-item label { display: block; font-size: 14px; color: #333; margin-bottom: 6px; font-weight: 500; }
.input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; outline: none; }
.input:focus { border-color: #1890ff; }
select.input { appearance: none; background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E") no-repeat right 12px center; }
.login-btn { width: 100%; padding: 14px; background: #1890ff; color: #fff; border: none; border-radius: 8px; font-size: 18px; font-weight: 600; cursor: pointer; }
.login-btn:disabled { background: #91d5ff; }
.error-msg { color: #f5222d; text-align: center; margin-top: 12px; font-size: 14px; }
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="mine">
<div class="user-info">
<div class="avatar">{{ userInfo?.userName?.charAt(0) || '护' }}</div>
<div class="info"><div class="name">{{ userInfo?.userName || '护士' }}</div><div class="role">{{ userInfo?.deptName || '护理部' }} | v1.0</div></div>
</div>
<div class="menu-list">
<div class="menu-item"><span>今日工作量</span><span class="value">{{ taskCount }}</span></div>
<div class="menu-item"><span>待处理任务</span><span class="value">{{ pendingCount }}</span></div>
<div class="menu-item" @click="logout"><span>退出登录</span><span class="arrow"></span></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessageBox } from 'element-plus'
import { nursingApi } from '../api'
const userInfo = ref({})
const taskCount = ref(0)
const pendingCount = ref(0)
onMounted(async () => {
try { const info = localStorage.getItem('userInfo'); if (info) userInfo.value = JSON.parse(info) } catch {}
try { const res = await nursingApi.getTasks({}); if (res.code === 200) { taskCount.value = res.data?.summary?.total || 0; pendingCount.value = res.data?.summary?.pending || 0 } } catch {}
})
const logout = async () => {
try { await ElMessageBox.confirm('确认退出登录?', '提示'); localStorage.removeItem('Admin-Token'); localStorage.removeItem('userInfo'); window.location.href = '/login' } catch {}
}
</script>
<style scoped>
.user-info { background: linear-gradient(135deg, #1890ff, #096dd9); color: #fff; padding: 24px 16px; display: flex; align-items: center; gap: 16px; }
.avatar { width: 56px; height: 56px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 24px; }
.name { font-size: 18px; font-weight: 600; }
.role { font-size: 13px; opacity: 0.8; }
.menu-list { background: #fff; margin: 12px; border-radius: 8px; overflow: hidden; }
.menu-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; font-size: 15px; }
.menu-item:last-child { border-bottom: none; }
.value { color: #1890ff; font-weight: 600; }
.arrow { color: #999; font-size: 18px; }
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="mobile-layout">
<div class="mobile-header" v-if="!hideHeader">
<button v-if="canGoBack" class="back-btn" @click="$router.back()"></button>
<h1>{{ $route.meta.title || 'HealthLink' }}</h1>
</div>
<div class="mobile-content" :class="{ 'no-header': hideHeader }">
<router-view />
</div>
<div class="mobile-tabs" v-if="showTabs">
<div v-for="tab in tabs" :key="tab.path" class="tab-item" :class="{ active: $route.path === tab.path }" @click="$router.push(tab.path)">
<span class="tab-icon">{{ tab.icon }}</span>
<span class="tab-label">{{ tab.label }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const canGoBack = computed(() => route.path !== '/mobile/home')
const hideHeader = computed(() => ['/mobile/login'].includes(route.path))
const showTabs = computed(() => route.path.startsWith('/mobile/'))
const tabs = [
{ path: '/mobile/home', icon: '🏠', label: '首页' },
{ path: '/mobile/tasks', icon: '📋', label: '任务' },
{ path: '/mobile/patients', icon: '👥', label: '患者' },
{ path: '/mobile/mine', icon: '👤', label: '我的' }
]
</script>
<style scoped>
.mobile-layout { display: flex; flex-direction: column; height: 100vh; background: #f5f5f5; }
.mobile-header { height: 48px; background: #1890ff; color: #fff; display: flex; align-items: center; padding: 0 16px; position: sticky; top: 0; z-index: 10; }
.mobile-header h1 { font-size: 18px; margin: 0; flex: 1; text-align: center; }
.back-btn { background: none; border: none; color: #fff; font-size: 20px; position: absolute; left: 16px; }
.mobile-content { flex: 1; overflow-y: auto; }
.mobile-content.no-header { padding-bottom: 56px; }
.mobile-tabs { position: fixed; bottom: 0; left: 0; right: 0; height: 56px; background: #fff; display: flex; border-top: 1px solid #e8e8e8; z-index: 10; padding-bottom: env(safe-area-inset-bottom); }
.tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 12px; color: #999; }
.tab-item.active { color: #1890ff; }
.tab-icon { font-size: 20px; margin-bottom: 2px; }
</style>

View File

@@ -0,0 +1,89 @@
<template>
<div class="patient-detail">
<div class="patient-header">
<div class="avatar">{{ patient.name?.charAt(0) }}</div>
<div class="info"><div class="name">{{ patient.name }} <span class="bed">{{ patient.bedNo }}</span></div><div class="diag">{{ patient.diagnosis }}</div></div>
</div>
<div class="tabs">
<div v-for="tab in tabs" :key="tab.key" class="tab" :class="{ active: activeTab === tab.key }" @click="activeTab = tab.key">{{ tab.label }}</div>
</div>
<div class="tab-content">
<div v-if="activeTab === 'orders'">
<div v-for="order in orders" :key="order.id" class="order-item">
<div class="order-main"><div class="order-name">{{ order.orderName || order.adviceName }}</div><div class="order-dose">{{ order.dosage }} {{ order.frequency }}</div></div>
<button v-if="order.status === 'PENDING' || order.executeStatus === '待执行'" class="exec-btn" @click="executeOrder(order)">执行</button>
<span v-else class="done-tag">已执行</span>
</div>
<div v-if="orders.length === 0" class="empty">暂无医嘱</div>
</div>
<div v-if="activeTab === 'vitals'">
<div class="vital-grid"><div class="vital-item" v-for="v in latestVitals" :key="v.key"><div class="vital-value">{{ v.value || '--' }}</div><div class="vital-label">{{ v.label }}</div></div></div>
<button class="action-btn" @click="$router.push(`/mobile/vital-entry/${$route.params.id}`)">录入体征</button>
</div>
<div v-if="activeTab === 'assessments'">
<div v-for="a in assessments" :key="a.id" class="assess-item">
<div class="assess-type">{{ a.assessmentType }}</div>
<div class="assess-score">评分: {{ a.totalScore }} <span :class="'risk-' + a.riskLevel">{{ a.riskLevel }}</span></div>
</div>
<button class="action-btn" @click="$router.push(`/mobile/assessment/${$route.params.id}`)">新建评估</button>
<div v-if="assessments.length === 0" class="empty">暂无评估记录</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { nursingApi } from '../api'
const route = useRoute()
const patient = ref({})
const orders = ref([])
const latestVitals = ref([])
const assessments = ref([])
const activeTab = ref('orders')
const tabs = [{ key: 'orders', label: '医嘱' }, { key: 'vitals', label: '体征' }, { key: 'assessments', label: '评估' }]
onMounted(async () => {
const id = route.params.id
try {
const [pRes, oRes, vRes, aRes] = await Promise.all([
nursingApi.getPatientInfo(id), nursingApi.getOrders(id),
nursingApi.getVitalSigns(id), nursingApi.getAssessments(id)
])
patient.value = pRes.data || {}; orders.value = oRes.data?.records || oRes.data || []; latestVitals.value = vRes.data?.records || vRes.data || []; assessments.value = aRes.data?.records || aRes.data || []
} catch (e) { ElMessage.error('加载失败') }
})
const executeOrder = async (order) => {
try { await nursingApi.completeTask(order.id, { result: '执行完成' }); order.status = 'COMPLETED'; ElMessage.success('医嘱已执行') } catch (e) { ElMessage.error('执行失败') }
}
</script>
<style scoped>
.patient-header { background: linear-gradient(135deg, #1890ff, #096dd9); color: #fff; padding: 16px; display: flex; align-items: center; gap: 12px; }
.avatar { width: 48px; height: 48px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 20px; }
.name { font-size: 18px; font-weight: 600; }
.bed { font-size: 14px; opacity: 0.8; }
.diag { font-size: 13px; opacity: 0.8; }
.tabs { display: flex; background: #fff; border-bottom: 1px solid #eee; position: sticky; top: 48px; z-index: 5; }
.tab { flex: 1; text-align: center; padding: 12px; font-size: 14px; color: #666; }
.tab.active { color: #1890ff; border-bottom: 2px solid #1890ff; font-weight: 600; }
.tab-content { padding: 12px; }
.order-item { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
.order-name { font-weight: 600; font-size: 14px; }
.order-dose { color: #666; font-size: 12px; margin-top: 2px; }
.exec-btn { background: #1890ff; color: #fff; border: none; padding: 6px 16px; border-radius: 4px; font-size: 13px; }
.done-tag { color: #52c41a; font-size: 12px; }
.vital-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.vital-item { background: #fff; border-radius: 8px; padding: 12px; text-align: center; }
.vital-value { font-size: 18px; font-weight: 600; color: #1890ff; }
.vital-label { font-size: 11px; color: #999; margin-top: 4px; }
.action-btn { width: 100%; padding: 12px; background: #1890ff; color: #fff; border: none; border-radius: 8px; font-size: 15px; margin-top: 12px; }
.assess-item { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; display: flex; justify-content: space-between; }
.assess-type { font-weight: 600; }
.risk-HIGH { color: #f5222d; } .risk-MEDIUM { color: #fa8c16; } .risk-LOW { color: #52c41a; }
.empty { text-align: center; padding: 20px; color: #999; }
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div class="patient-list">
<div class="search-bar"><input v-model="searchText" placeholder="搜索患者姓名/床号..." class="search-input" /></div>
<div v-if="loading" class="loading">加载中...</div>
<div v-for="p in displayPatients" :key="p.id" class="patient-card" @click="$router.push(`/mobile/patient-detail/${p.id}`)">
<div class="patient-avatar" :class="'level-' + p.nursingLevel">{{ p.name?.charAt(0) }}</div>
<div class="patient-info">
<div class="patient-name">{{ p.name }} <span class="bed">{{ p.bedNo }}</span></div>
<div class="patient-diag">{{ p.diagnosis || '暂无诊断' }}</div>
<div class="patient-tags"><span class="tag" :class="'level-' + p.nursingLevel">{{ p.nursingLevel }}级护理</span><span v-if="p.gender" class="tag">{{ p.gender }}</span></div>
</div>
</div>
<div v-if="!loading && displayPatients.length === 0" class="empty">暂无患者</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { nursingApi } from '../api'
const patients = ref([])
const loading = ref(false)
const searchText = ref('')
const displayPatients = computed(() => searchText.value ? patients.value.filter(p => p.name?.includes(searchText.value) || p.bedNo?.includes(searchText.value)) : patients.value)
const loadPatients = async () => {
loading.value = true
try { const res = await nursingApi.getPatientList({}); patients.value = res.data || [] } catch (e) { ElMessage.error('加载失败') } finally { loading.value = false }
}
onMounted(loadPatients)
</script>
<style scoped>
.search-bar { padding: 8px 0; }
.search-input { width: 100%; padding: 10px 16px; border: 1px solid #ddd; border-radius: 20px; font-size: 15px; outline: none; background: #fff; }
.search-input:focus { border-color: #1890ff; }
.loading { text-align: center; padding: 20px; color: #999; }
.patient-card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.patient-avatar { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #fff; }
.level-1 { background: #f5222d; } .level-2 { background: #fa8c16; } .level-3 { background: #52c41a; }
.patient-name { font-weight: 600; font-size: 15px; }
.bed { color: #999; font-size: 13px; }
.patient-diag { color: #666; font-size: 13px; margin: 2px 0; }
.patient-tags { display: flex; gap: 6px; }
.tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; }
.empty { text-align: center; padding: 40px; color: #999; }
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div class="task-list">
<div class="filter-bar">
<select v-model="filterType" class="filter-select" @change="loadTasks"><option value="">全部</option><option value="医嘱执行">医嘱执行</option><option value="生命体征">生命体征</option></select>
<button class="refresh-btn" @click="loadTasks">刷新</button>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-for="task in filteredTasks" :key="task.id" class="task-card" @touchstart="swipeStart" @touchend="swipeEnd($event, task)">
<div class="task-info">
<div class="task-header"><span class="task-patient">{{ task.patientName || '患者' }}</span><span class="bed">{{ task.bedNo || '' }}</span></div>
<div class="task-content">{{ task.adviceName || task.orderName || '医嘱任务' }}</div>
<div class="task-meta"><span class="task-type">{{ task.adviceType || task.orderType || '医嘱' }}</span><span class="task-time">{{ task.createTime || '' }}</span></div>
</div>
</div>
<div v-if="!loading && filteredTasks.length === 0" class="empty">暂无任务</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { nursingApi } from '../api'
const tasks = ref([])
const loading = ref(false)
const filterType = ref('')
const filteredTasks = computed(() => filterType.value ? tasks.value.filter(t => (t.adviceType || '').includes(filterType.value)) : tasks.value)
const loadTasks = async () => {
loading.value = true
try {
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
const nurseId = userInfo.practitionerId || userInfo.userId
if (!nurseId) { ElMessage.warning('未获取到用户信息'); return }
const res = await nursingApi.getTasks({ nurseId: nurseId, pageNum: 1, pageSize: 50 })
if (res.code === 200) { tasks.value = res.data?.records || res.data?.rows || [] }
} catch (e) { ElMessage.error('加载失败') } finally { loading.value = false }
}
let startX = 0
const swipeStart = (e) => { startX = e.touches[0].clientX }
const swipeEnd = async (e, task) => {
const diff = startX - e.changedTouches[0].clientX
if (diff > 80) {
try {
await ElMessageBox.confirm('确认完成此任务?', '提示')
await nursingApi.completeTask(task.id, { result: '完成' })
ElMessage.success('任务已完成')
loadTasks()
} catch {}
}
}
onMounted(loadTasks)
</script>
<style scoped>
.filter-bar { display: flex; gap: 8px; padding: 8px 0; }
.filter-select { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; background: #fff; }
.refresh-btn { padding: 8px 16px; background: #1890ff; color: #fff; border: none; border-radius: 6px; }
.loading { text-align: center; padding: 20px; color: #999; }
.task-card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.task-header { display: flex; align-items: center; gap: 8px; }
.task-patient { font-weight: 600; font-size: 15px; }
.bed { color: #1890ff; font-size: 13px; }
.task-content { color: #666; font-size: 13px; margin: 4px 0; }
.task-meta { display: flex; gap: 12px; font-size: 12px; color: #999; }
.task-type { background: #e6f7ff; color: #1890ff; padding: 2px 8px; border-radius: 4px; }
.empty { text-align: center; padding: 40px; color: #999; }
</style>

View File

@@ -0,0 +1,77 @@
<template>
<div class="vital-entry">
<div class="patient-bar" v-if="patientName"><span class="label">患者:</span> {{ patientName }}</div>
<div class="entry-grid">
<div v-for="item in vitalItems" :key="item.key" class="entry-item">
<div class="entry-label">{{ item.label }}</div>
<input v-model="formData[item.key]" type="number" :placeholder="item.placeholder" class="entry-input" />
<div class="quick-values"><span v-for="v in item.quickValues" :key="v" class="quick-val" @click="formData[item.key] = v">{{ v }}</span></div>
</div>
</div>
<div class="pain-section">
<div class="entry-label">疼痛评分 (0-10)</div>
<div class="pain-scale"><span v-for="n in 11" :key="n" class="pain-num" :class="{ active: formData.painScore === n-1 }" @click="formData.painScore = n-1">{{ n-1 }}</span></div>
<div class="pain-label">{{ painLabel }}</div>
</div>
<button class="submit-btn" @click="submit" :disabled="submitting">{{ submitting ? '提交中...' : '一键提交' }}</button>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { nursingApi } from '../api'
const route = useRoute()
onMounted(async () => {
const patientId = route.params.patientId
if (patientId) {
try {
const res = await nursingApi.getPatientInfo(patientId)
if (res.data) patientName.value = res.data.name || ''
} catch {}
}
})
const submitting = ref(false)
const patientName = ref('')
const formData = ref({ temperature: '', pulse: '', bloodPressureHigh: '', bloodPressureLow: '', spo2: '', respiration: '', painScore: 0 })
const vitalItems = [
{ key: 'temperature', label: '体温(°C)', placeholder: '36.5', quickValues: [36.0, 36.5, 37.0, 37.5, 38.0] },
{ key: 'pulse', label: '脉搏(次/分)', placeholder: '72', quickValues: [60, 72, 80, 90, 100] },
{ key: 'bloodPressureHigh', label: '收缩压(mmHg)', placeholder: '120', quickValues: [90, 110, 120, 130, 140] },
{ key: 'bloodPressureLow', label: '舒张压(mmHg)', placeholder: '80', quickValues: [60, 70, 80, 90, 100] },
{ key: 'spo2', label: '血氧(%)', placeholder: '98', quickValues: [95, 96, 97, 98, 99] },
{ key: 'respiration', label: '呼吸(次/分)', placeholder: '18', quickValues: [14, 16, 18, 20, 22] }
]
const painLabel = computed(() => { const s = formData.value.painScore; return s <= 3 ? '轻度疼痛' : s <= 6 ? '中度疼痛' : '重度疼痛' })
const submit = async () => {
submitting.value = true
try {
await nursingApi.submitVitalSign({ ...formData.value, patientId: route.params.patientId })
ElMessage.success('体征录入成功')
} catch (e) { ElMessage.error('提交失败') } finally { submitting.value = false }
}
</script>
<style scoped>
.patient-bar { background: #e6f7ff; padding: 10px 16px; font-size: 14px; margin-bottom: 12px; border-radius: 8px; }
.patient-bar .label { color: #666; }
.entry-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.entry-item { background: #fff; border-radius: 8px; padding: 10px; }
.entry-label { font-size: 12px; color: #666; margin-bottom: 6px; }
.entry-input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 18px; text-align: center; }
.entry-input:focus { border-color: #1890ff; }
.quick-values { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
.quick-val { padding: 3px 8px; background: #f0f0f0; border-radius: 4px; font-size: 12px; cursor: pointer; }
.quick-val:active { background: #1890ff; color: #fff; }
.pain-section { background: #fff; border-radius: 8px; padding: 12px; margin-top: 10px; }
.pain-scale { display: flex; gap: 3px; margin-top: 8px; flex-wrap: wrap; }
.pain-num { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: #f0f0f0; font-size: 13px; cursor: pointer; }
.pain-num.active { background: #1890ff; color: #fff; }
.pain-label { text-align: center; margin-top: 8px; color: #666; font-size: 13px; }
.submit-btn { width: 100%; padding: 14px; background: #1890ff; color: #fff; border: none; border-radius: 8px; font-size: 16px; margin-top: 16px; font-weight: 600; }
.submit-btn:disabled { background: #91d5ff; }
</style>

View File

@@ -0,0 +1,42 @@
import { defineConfig, loadEnv } from 'vite'
import path from 'path'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
return {
base: '/',
plugins: [vue()],
resolve: {
alias: {
'~': path.resolve(__dirname, './'),
'@': path.resolve(__dirname, './src')
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue']
},
server: {
port: 82,
host: true,
proxy: {
'/dev-api': {
target: env.VITE_API_PROXY || 'http://localhost:18080/healthlink-his',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/dev-api/, '')
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets',
cssMinify: 'esbuild'
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
silenceDeprecations: ['import', 'global-builtin', 'color-functions', 'legacy-js-api']
}
}
}
}
})

View File

@@ -0,0 +1,13 @@
package com.healthlink.his.web.knowledgegraph.appservice;
import com.healthlink.his.web.knowledgegraph.dto.ImportResultDto;
import org.springframework.web.multipart.MultipartFile;
public interface IKgDataImportAppService {
ImportResultDto importDiseaseFromCsv(MultipartFile file);
ImportResultDto importDrugFromCsv(MultipartFile file);
ImportResultDto importRelationsFromCsv(MultipartFile file);
}

View File

@@ -0,0 +1,17 @@
package com.healthlink.his.web.knowledgegraph.appservice;
import com.healthlink.his.web.knowledgegraph.dto.*;
import java.util.List;
import java.util.Map;
public interface IKgReasoningAppService {
List<DiagnosisResultDto> suggestDiagnosis(List<String> symptoms, Integer topN);
List<ExaminationResultDto> suggestExaminations(String diseaseCode, Integer topN);
List<DrugInteractionResultDto> checkDrugInteractions(List<String> drugCodes);
Map<String, Object> suggestPathway(String diseaseCode);
}

View File

@@ -0,0 +1,273 @@
package com.healthlink.his.web.knowledgegraph.appservice.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.healthlink.his.clinical.domain.KgEntityRelation;
import com.healthlink.his.clinical.service.IKgEntityRelationService;
import com.healthlink.his.knowledgegraph.domain.KgDisease;
import com.healthlink.his.knowledgegraph.domain.KgDrug;
import com.healthlink.his.knowledgegraph.service.IKgDiseaseService;
import com.healthlink.his.knowledgegraph.service.IKgDrugService;
import com.healthlink.his.web.knowledgegraph.appservice.IKgDataImportAppService;
import com.healthlink.his.web.knowledgegraph.dto.ImportResultDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
public class KgDataImportAppServiceImpl implements IKgDataImportAppService {
@Autowired
private IKgDiseaseService diseaseService;
@Autowired
private IKgDrugService drugService;
@Autowired
private IKgEntityRelationService relationService;
@Override
@Transactional(rollbackFor = Exception.class)
public ImportResultDto importDiseaseFromCsv(MultipartFile file) {
ImportResultDto result = new ImportResultDto();
List<KgDisease> batch = new ArrayList<>();
int totalRows = 0;
int successCount = 0;
int failCount = 0;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
String header = reader.readLine();
if (header == null) {
result.setMessage("CSV文件为空");
result.setTotalRows(0);
result.setSuccessCount(0);
result.setFailCount(0);
return result;
}
String line;
while ((line = reader.readLine()) != null) {
totalRows++;
try {
String[] parts = parseCsvLine(line);
if (parts.length < 2) {
failCount++;
continue;
}
KgDisease disease = new KgDisease();
disease.setDiseaseCode(getField(parts, 0));
disease.setDiseaseName(getField(parts, 1));
disease.setCategory(getField(parts, 2));
disease.setDepartment(getField(parts, 3));
disease.setSeverityLevel(getField(parts, 4));
disease.setDescription(getField(parts, 5));
disease.setKeywords(getField(parts, 6));
if (hasText(disease.getDiseaseCode()) && hasText(disease.getDiseaseName())) {
batch.add(disease);
} else {
failCount++;
}
} catch (Exception e) {
log.warn("解析疾病CSV行失败: {}", line, e);
failCount++;
}
}
if (!batch.isEmpty()) {
diseaseService.saveBatch(batch);
successCount = batch.size();
}
} catch (Exception e) {
log.error("导入疾病CSV失败", e);
failCount = totalRows - successCount;
}
result.setTotalRows(totalRows);
result.setSuccessCount(successCount);
result.setFailCount(failCount);
result.setMessage(String.format("导入完成: 共%d行, 成功%d条, 失败%d条", totalRows, successCount, failCount));
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public ImportResultDto importDrugFromCsv(MultipartFile file) {
ImportResultDto result = new ImportResultDto();
List<KgDrug> batch = new ArrayList<>();
int totalRows = 0;
int successCount = 0;
int failCount = 0;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
String header = reader.readLine();
if (header == null) {
result.setMessage("CSV文件为空");
result.setTotalRows(0);
result.setSuccessCount(0);
result.setFailCount(0);
return result;
}
String line;
while ((line = reader.readLine()) != null) {
totalRows++;
try {
String[] parts = parseCsvLine(line);
if (parts.length < 2) {
failCount++;
continue;
}
KgDrug drug = new KgDrug();
drug.setDrugCode(getField(parts, 0));
drug.setDrugName(getField(parts, 1));
drug.setGenericName(getField(parts, 2));
drug.setCategory(getField(parts, 3));
drug.setDosageForm(getField(parts, 4));
drug.setContraindications(getField(parts, 5));
drug.setSideEffects(getField(parts, 6));
if (hasText(drug.getDrugCode()) && hasText(drug.getDrugName())) {
batch.add(drug);
} else {
failCount++;
}
} catch (Exception e) {
log.warn("解析药物CSV行失败: {}", line, e);
failCount++;
}
}
if (!batch.isEmpty()) {
drugService.saveBatch(batch);
successCount = batch.size();
}
} catch (Exception e) {
log.error("导入药物CSV失败", e);
failCount = totalRows - successCount;
}
result.setTotalRows(totalRows);
result.setSuccessCount(successCount);
result.setFailCount(failCount);
result.setMessage(String.format("导入完成: 共%d行, 成功%d条, 失败%d条", totalRows, successCount, failCount));
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public ImportResultDto importRelationsFromCsv(MultipartFile file) {
ImportResultDto result = new ImportResultDto();
List<KgEntityRelation> batch = new ArrayList<>();
int totalRows = 0;
int successCount = 0;
int failCount = 0;
try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
String header = reader.readLine();
if (header == null) {
result.setMessage("CSV文件为空");
result.setTotalRows(0);
result.setSuccessCount(0);
result.setFailCount(0);
return result;
}
String line;
while ((line = reader.readLine()) != null) {
totalRows++;
try {
String[] parts = parseCsvLine(line);
if (parts.length < 5) {
failCount++;
continue;
}
KgEntityRelation relation = new KgEntityRelation();
relation.setSourceType(getField(parts, 0));
relation.setSourceId(getField(parts, 1));
relation.setTargetType(getField(parts, 2));
relation.setTargetId(getField(parts, 3));
relation.setRelationType(getField(parts, 4));
String strengthStr = getField(parts, 5);
if (hasText(strengthStr)) {
try {
relation.setRelationStrength(new BigDecimal(strengthStr));
} catch (NumberFormatException e) {
relation.setRelationStrength(BigDecimal.ONE);
}
} else {
relation.setRelationStrength(BigDecimal.ONE);
}
relation.setDescription(getField(parts, 6));
relation.setEvidenceSource(getField(parts, 7));
if (hasText(relation.getSourceType()) && hasText(relation.getSourceId())
&& hasText(relation.getTargetType()) && hasText(relation.getTargetId())) {
batch.add(relation);
} else {
failCount++;
}
} catch (Exception e) {
log.warn("解析关系CSV行失败: {}", line, e);
failCount++;
}
}
if (!batch.isEmpty()) {
relationService.saveBatch(batch);
successCount = batch.size();
}
} catch (Exception e) {
log.error("导入关系CSV失败", e);
failCount = totalRows - successCount;
}
result.setTotalRows(totalRows);
result.setSuccessCount(successCount);
result.setFailCount(failCount);
result.setMessage(String.format("导入完成: 共%d行, 成功%d条, 失败%d条", totalRows, successCount, failCount));
return result;
}
private String[] parseCsvLine(String line) {
List<String> fields = new ArrayList<>();
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
for (char c : line.toCharArray()) {
if (c == '"') {
inQuotes = !inQuotes;
} else if (c == ',' && !inQuotes) {
fields.add(current.toString().trim());
current = new StringBuilder();
} else {
current.append(c);
}
}
fields.add(current.toString().trim());
return fields.toArray(new String[0]);
}
private String getField(String[] parts, int index) {
if (index < parts.length) {
String val = parts[index];
return hasText(val) ? val : null;
}
return null;
}
private boolean hasText(String s) {
return s != null && !s.trim().isEmpty();
}
}

View File

@@ -0,0 +1,243 @@
package com.healthlink.his.web.knowledgegraph.appservice.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.healthlink.his.clinical.domain.KgClinicalPathway;
import com.healthlink.his.clinical.domain.KgEntityRelation;
import com.healthlink.his.clinical.domain.KgPathwayStep;
import com.healthlink.his.clinical.service.IKgClinicalPathwayService;
import com.healthlink.his.clinical.service.IKgEntityRelationService;
import com.healthlink.his.clinical.service.IKgPathwayStepService;
import com.healthlink.his.knowledgegraph.domain.KgDisease;
import com.healthlink.his.knowledgegraph.domain.KgDrug;
import com.healthlink.his.knowledgegraph.domain.KgExamination;
import com.healthlink.his.knowledgegraph.domain.KgSymptom;
import com.healthlink.his.knowledgegraph.service.IKgDiseaseService;
import com.healthlink.his.knowledgegraph.service.IKgDrugService;
import com.healthlink.his.knowledgegraph.service.IKgExaminationService;
import com.healthlink.his.knowledgegraph.service.IKgSymptomService;
import com.healthlink.his.web.knowledgegraph.appservice.IKgReasoningAppService;
import com.healthlink.his.web.knowledgegraph.dto.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class KgReasoningAppServiceImpl implements IKgReasoningAppService {
@Autowired
private IKgEntityRelationService relationService;
@Autowired
private IKgSymptomService symptomService;
@Autowired
private IKgDiseaseService diseaseService;
@Autowired
private IKgExaminationService examinationService;
@Autowired
private IKgDrugService drugService;
@Autowired
private IKgClinicalPathwayService pathwayService;
@Autowired
private IKgPathwayStepService pathwayStepService;
@Override
public List<DiagnosisResultDto> suggestDiagnosis(List<String> symptoms, Integer topN) {
if (symptoms == null || symptoms.isEmpty()) {
return Collections.emptyList();
}
if (topN == null || topN <= 0) {
topN = 5;
}
// 1. Find symptom IDs by name/code
LambdaQueryWrapper<KgSymptom> sw = new LambdaQueryWrapper<>();
sw.and(w -> {
for (String symptom : symptoms) {
w.or().like(KgSymptom::getSymptomName, symptom)
.or().like(KgSymptom::getSymptomCode, symptom);
}
});
List<KgSymptom> matchedSymptoms = symptomService.list(sw);
if (matchedSymptoms.isEmpty()) {
return Collections.emptyList();
}
List<String> symptomIds = matchedSymptoms.stream()
.map(s -> String.valueOf(s.getId()))
.collect(Collectors.toList());
// 2. Query relations: symptom -> disease
LambdaQueryWrapper<KgEntityRelation> rw = new LambdaQueryWrapper<>();
rw.eq(KgEntityRelation::getSourceType, "symptom")
.eq(KgEntityRelation::getTargetType, "disease")
.in(KgEntityRelation::getSourceId, symptomIds)
.orderByDesc(KgEntityRelation::getRelationStrength);
List<KgEntityRelation> relations = relationService.list(rw);
// 3. Group by disease, sum scores
Map<String, BigDecimal> diseaseScoreMap = new LinkedHashMap<>();
Map<String, List<String>> diseaseSymptomMap = new LinkedHashMap<>();
for (KgEntityRelation rel : relations) {
String diseaseId = rel.getTargetId();
BigDecimal strength = rel.getRelationStrength() != null ? rel.getRelationStrength() : BigDecimal.ONE;
diseaseScoreMap.merge(diseaseId, strength, BigDecimal::add);
diseaseSymptomMap.computeIfAbsent(diseaseId, k -> new ArrayList<>())
.add(rel.getSourceId());
}
// 4. Sort by score descending, take top N
List<Map.Entry<String, BigDecimal>> sorted = diseaseScoreMap.entrySet().stream()
.sorted(Map.Entry.<String, BigDecimal>comparingByValue().reversed())
.limit(topN)
.collect(Collectors.toList());
// 5. Build results with disease info
List<DiagnosisResultDto> results = new ArrayList<>();
for (Map.Entry<String, BigDecimal> entry : sorted) {
KgDisease disease = diseaseService.getById(Long.parseLong(entry.getKey()));
if (disease == null) continue;
DiagnosisResultDto dto = new DiagnosisResultDto();
dto.setDiseaseCode(disease.getDiseaseCode());
dto.setDiseaseName(disease.getDiseaseName());
dto.setCategory(disease.getCategory());
dto.setDepartment(disease.getDepartment());
dto.setScore(entry.getValue());
List<String> matched = diseaseSymptomMap.getOrDefault(entry.getKey(), Collections.emptyList());
dto.setMatchedSymptoms(String.join(",", matched));
results.add(dto);
}
return results;
}
@Override
public List<ExaminationResultDto> suggestExaminations(String diseaseCode, Integer topN) {
if (!StringUtils.hasText(diseaseCode)) {
return Collections.emptyList();
}
if (topN == null || topN <= 0) {
topN = 10;
}
// 1. Find disease by code
LambdaQueryWrapper<KgDisease> dw = new LambdaQueryWrapper<>();
dw.eq(KgDisease::getDiseaseCode, diseaseCode);
KgDisease disease = diseaseService.getOne(dw);
if (disease == null) {
return Collections.emptyList();
}
// 2. Query relations: disease -> examination
LambdaQueryWrapper<KgEntityRelation> rw = new LambdaQueryWrapper<>();
rw.eq(KgEntityRelation::getSourceType, "disease")
.eq(KgEntityRelation::getTargetType, "examination")
.eq(KgEntityRelation::getSourceId, String.valueOf(disease.getId()))
.orderByDesc(KgEntityRelation::getRelationStrength);
List<KgEntityRelation> relations = relationService.list(rw);
// 3. Build results
List<ExaminationResultDto> results = new ArrayList<>();
for (KgEntityRelation rel : relations) {
if (results.size() >= topN) break;
KgExamination exam = examinationService.getById(Long.parseLong(rel.getTargetId()));
if (exam == null) continue;
ExaminationResultDto dto = new ExaminationResultDto();
dto.setExamCode(exam.getExamCode());
dto.setExamName(exam.getExamName());
dto.setExamType(exam.getExamType());
dto.setClinicalSignificance(exam.getClinicalSignificance());
dto.setScore(rel.getRelationStrength());
results.add(dto);
}
return results;
}
@Override
public List<DrugInteractionResultDto> checkDrugInteractions(List<String> drugCodes) {
if (drugCodes == null || drugCodes.size() < 2) {
return Collections.emptyList();
}
// 1. Find drug IDs by code
LambdaQueryWrapper<KgDrug> dw = new LambdaQueryWrapper<>();
dw.in(KgDrug::getDrugCode, drugCodes);
List<KgDrug> drugs = drugService.list(dw);
if (drugs.isEmpty()) {
return Collections.emptyList();
}
Map<String, KgDrug> drugMap = drugs.stream()
.collect(Collectors.toMap(KgDrug::getDrugCode, d -> d, (a, b) -> a));
List<String> drugIds = drugs.stream()
.map(d -> String.valueOf(d.getId()))
.collect(Collectors.toList());
// 2. Query drug-drug interaction relations
LambdaQueryWrapper<KgEntityRelation> rw = new LambdaQueryWrapper<>();
rw.eq(KgEntityRelation::getSourceType, "drug")
.eq(KgEntityRelation::getTargetType, "drug")
.in(KgEntityRelation::getSourceId, drugIds)
.in(KgEntityRelation::getTargetId, drugIds);
List<KgEntityRelation> relations = relationService.list(rw);
// 3. Build results
List<DrugInteractionResultDto> results = new ArrayList<>();
Set<String> added = new HashSet<>();
for (KgEntityRelation rel : relations) {
KgDrug drugA = drugService.getById(Long.parseLong(rel.getSourceId()));
KgDrug drugB = drugService.getById(Long.parseLong(rel.getTargetId()));
if (drugA == null || drugB == null) continue;
String key = Collections.min(Arrays.asList(drugA.getDrugCode(), drugB.getDrugCode()))
+ "-" + Collections.max(Arrays.asList(drugA.getDrugCode(), drugB.getDrugCode()));
if (!added.add(key)) continue;
DrugInteractionResultDto dto = new DrugInteractionResultDto();
dto.setDrugCodeA(drugA.getDrugCode());
dto.setDrugNameA(drugA.getDrugName());
dto.setDrugCodeB(drugB.getDrugCode());
dto.setDrugNameB(drugB.getDrugName());
dto.setInteractionType(rel.getRelationType());
dto.setDescription(rel.getDescription());
dto.setSeverity(rel.getRelationStrength() != null
? (rel.getRelationStrength().compareTo(new BigDecimal("0.7")) >= 0 ? "严重" : "一般")
: "一般");
results.add(dto);
}
return results;
}
@Override
public Map<String, Object> suggestPathway(String diseaseCode) {
if (!StringUtils.hasText(diseaseCode)) {
return Collections.emptyMap();
}
// 1. Find pathway by disease code
LambdaQueryWrapper<KgClinicalPathway> pw = new LambdaQueryWrapper<>();
pw.eq(KgClinicalPathway::getDiseaseCode, diseaseCode)
.eq(KgClinicalPathway::getStatus, "ACTIVE");
KgClinicalPathway pathway = pathwayService.getOne(pw);
if (pathway == null) {
return Collections.emptyMap();
}
// 2. Get pathway steps
LambdaQueryWrapper<KgPathwayStep> sw = new LambdaQueryWrapper<>();
sw.eq(KgPathwayStep::getPathwayId, pathway.getId())
.orderByAsc(KgPathwayStep::getStepOrder);
List<KgPathwayStep> steps = pathwayStepService.list(sw);
Map<String, Object> result = new LinkedHashMap<>();
result.put("pathway", pathway);
result.put("steps", steps);
return result;
}
}

View File

@@ -0,0 +1,103 @@
package com.healthlink.his.web.knowledgegraph.controller;
import com.core.common.core.domain.R;
import com.healthlink.his.web.knowledgegraph.appservice.IKgDataImportAppService;
import com.healthlink.his.web.knowledgegraph.dto.ImportResultDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedInputStream;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@Slf4j
@Tag(name = "知识图谱-数据导入")
@RestController
@RequestMapping("/knowledgegraph/import")
@AllArgsConstructor
public class KgDataImportController {
private final IKgDataImportAppService kgDataImportAppService;
@Operation(summary = "导入疾病数据")
@PreAuthorize("@ss.hasPermi('system:knowledgegraph:edit')")
@PostMapping("/disease")
public R<ImportResultDto> importDisease(@RequestParam("file") MultipartFile file) {
try {
ImportResultDto result = kgDataImportAppService.importDiseaseFromCsv(file);
return R.ok(result);
} catch (Exception e) {
log.error("导入疾病数据失败", e);
return R.fail("导入疾病数据失败: " + e.getMessage());
}
}
@Operation(summary = "导入药物数据")
@PreAuthorize("@ss.hasPermi('system:knowledgegraph:edit')")
@PostMapping("/drug")
public R<ImportResultDto> importDrug(@RequestParam("file") MultipartFile file) {
try {
ImportResultDto result = kgDataImportAppService.importDrugFromCsv(file);
return R.ok(result);
} catch (Exception e) {
log.error("导入药物数据失败", e);
return R.fail("导入药物数据失败: " + e.getMessage());
}
}
@Operation(summary = "导入关系数据")
@PreAuthorize("@ss.hasPermi('system:knowledgegraph:edit')")
@PostMapping("/relation")
public R<ImportResultDto> importRelations(@RequestParam("file") MultipartFile file) {
try {
ImportResultDto result = kgDataImportAppService.importRelationsFromCsv(file);
return R.ok(result);
} catch (Exception e) {
log.error("导入关系数据失败", e);
return R.fail("导入关系数据失败: " + e.getMessage());
}
}
@Operation(summary = "下载导入模板")
@PreAuthorize("@ss.hasPermi('system:knowledgegraph:list')")
@GetMapping("/template/{type}")
public void downloadTemplate(@PathVariable String type, jakarta.servlet.http.HttpServletResponse response) throws Exception {
String filename;
String content;
switch (type) {
case "disease":
filename = "疾病导入模板.csv";
content = "疾病编码,疾病名称,分类,科室,严重等级,描述,关键词\n"
+ "J06.900,急性上呼吸道感染,感染性疾病,呼吸内科,轻度,急性上呼吸道感染,发热;咳嗽;咽痛\n";
break;
case "drug":
filename = "药物导入模板.csv";
content = "药物编码,药物名称,通用名,分类,剂型,禁忌症,不良反应\n"
+ "D00001,阿莫西林胶囊,阿莫西林,抗生素,胶囊剂,青霉素过敏者禁用,皮疹;腹泻\n";
break;
case "relation":
filename = "关系导入模板.csv";
content = "来源类型,来源ID,目标类型,目标ID,关系类型,关系强度,描述,证据来源\n"
+ "symptom,1001,disease,2001,has_symptom,0.85,发热是急性上呼吸道感染的常见症状,临床指南\n";
break;
default:
response.setStatus(400);
response.getWriter().write("不支持的模板类型");
return;
}
response.setContentType("text/csv;charset=UTF-8");
response.setHeader("Content-Disposition",
"attachment; filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8));
response.getOutputStream().write("\uFEFF".getBytes(StandardCharsets.UTF_8));
response.getOutputStream().write(content.getBytes(StandardCharsets.UTF_8));
response.getOutputStream().flush();
}
}

View File

@@ -0,0 +1,76 @@
package com.healthlink.his.web.knowledgegraph.controller;
import com.core.common.core.domain.R;
import com.healthlink.his.web.knowledgegraph.appservice.IKgReasoningAppService;
import com.healthlink.his.web.knowledgegraph.dto.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Slf4j
@Tag(name = "知识图谱-推理引擎")
@RestController
@RequestMapping("/knowledgegraph/reasoning")
@AllArgsConstructor
public class KgReasoningController {
private final IKgReasoningAppService kgReasoningAppService;
@Operation(summary = "诊断推荐 - 基于症状推荐诊断")
@PreAuthorize("@ss.hasPermi('system:knowledgegraph:list')")
@PostMapping("/diagnosis")
public R<List<DiagnosisResultDto>> suggestDiagnosis(@RequestBody DiagnosisSuggestDto dto) {
try {
List<DiagnosisResultDto> results = kgReasoningAppService.suggestDiagnosis(dto.getSymptoms(), dto.getTopN());
return R.ok(results);
} catch (Exception e) {
log.error("诊断推荐失败", e);
return R.fail("诊断推荐失败: " + e.getMessage());
}
}
@Operation(summary = "检查推荐 - 基于诊断推荐检查")
@PreAuthorize("@ss.hasPermi('system:knowledgegraph:list')")
@PostMapping("/examination")
public R<List<ExaminationResultDto>> suggestExaminations(@RequestBody ExaminationSuggestDto dto) {
try {
List<ExaminationResultDto> results = kgReasoningAppService.suggestExaminations(dto.getDiseaseCode(), dto.getTopN());
return R.ok(results);
} catch (Exception e) {
log.error("检查推荐失败", e);
return R.fail("检查推荐失败: " + e.getMessage());
}
}
@Operation(summary = "药物相互作用检查")
@PreAuthorize("@ss.hasPermi('system:knowledgegraph:list')")
@PostMapping("/drug-interaction")
public R<List<DrugInteractionResultDto>> checkDrugInteractions(@RequestBody DrugInteractionDto dto) {
try {
List<DrugInteractionResultDto> results = kgReasoningAppService.checkDrugInteractions(dto.getDrugCodes());
return R.ok(results);
} catch (Exception e) {
log.error("药物相互作用检查失败", e);
return R.fail("药物相互作用检查失败: " + e.getMessage());
}
}
@Operation(summary = "临床路径推荐")
@PreAuthorize("@ss.hasPermi('system:knowledgegraph:list')")
@GetMapping("/pathway/{diseaseCode}")
public R<Map<String, Object>> suggestPathway(@PathVariable String diseaseCode) {
try {
Map<String, Object> result = kgReasoningAppService.suggestPathway(diseaseCode);
return result.isEmpty() ? R.fail("未找到临床路径") : R.ok(result);
} catch (Exception e) {
log.error("临床路径推荐失败", e);
return R.fail("临床路径推荐失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,15 @@
package com.healthlink.his.web.knowledgegraph.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class DiagnosisResultDto {
private String diseaseCode;
private String diseaseName;
private String category;
private String department;
private BigDecimal score;
private String matchedSymptoms;
}

View File

@@ -0,0 +1,13 @@
package com.healthlink.his.web.knowledgegraph.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.List;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class DiagnosisSuggestDto {
private List<String> symptoms;
private Integer topN = 5;
}

View File

@@ -0,0 +1,12 @@
package com.healthlink.his.web.knowledgegraph.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.List;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class DrugInteractionDto {
private List<String> drugCodes;
}

View File

@@ -0,0 +1,14 @@
package com.healthlink.his.web.knowledgegraph.dto;
import lombok.Data;
@Data
public class DrugInteractionResultDto {
private String drugCodeA;
private String drugNameA;
private String drugCodeB;
private String drugNameB;
private String interactionType;
private String description;
private String severity;
}

View File

@@ -0,0 +1,14 @@
package com.healthlink.his.web.knowledgegraph.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class ExaminationResultDto {
private String examCode;
private String examName;
private String examType;
private String clinicalSignificance;
private BigDecimal score;
}

View File

@@ -0,0 +1,11 @@
package com.healthlink.his.web.knowledgegraph.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ExaminationSuggestDto {
private String diseaseCode;
private Integer topN = 10;
}

View File

@@ -0,0 +1,11 @@
package com.healthlink.his.web.knowledgegraph.dto;
import lombok.Data;
@Data
public class ImportResultDto {
private int successCount;
private int failCount;
private int totalRows;
private String message;
}

View File

@@ -24,4 +24,8 @@ public class KgDiseaseDto implements Serializable {
private String department;
private String severityLevel;
private String description;
private String keywords;
}

View File

@@ -26,4 +26,6 @@ public class KgDrugDto implements Serializable {
private String dosageForm;
private String contraindications;
private String sideEffects;
}

View File

@@ -24,4 +24,6 @@ public class KgExaminationDto implements Serializable {
private String department;
private String referenceRange;
private String clinicalSignificance;
}

View File

@@ -22,4 +22,6 @@ public class KgSymptomDto implements Serializable {
private String bodyPart;
private String symptomType;
private String severityIndicator;
}

View File

@@ -0,0 +1,57 @@
package com.healthlink.his.web.miniprogram.appservice;
import com.core.common.core.domain.R;
import com.healthlink.his.web.miniprogram.dto.AssessmentSubmitDto;
import com.healthlink.his.web.miniprogram.dto.TaskCompleteDto;
import com.healthlink.his.web.miniprogram.dto.VitalSignSubmitDto;
/**
* 移动护理小程序 AppService
*/
public interface IMpNursingAppService {
/**
* 获取护士任务列表
* @param nurseId 护士ID
* @param status 任务状态(可选)
*/
R<?> getTaskList(Long nurseId, String status);
/**
* 完成任务
* @param taskId 任务ID
* @param dto 完成结果
*/
R<?> completeTask(Long taskId, TaskCompleteDto dto);
/**
* 获取患者信息(精简版)
* @param patientId 患者ID
*/
R<?> getPatientInfo(Long patientId);
/**
* 获取生命体征趋势
* @param patientId 患者ID
* @param days 查询天数(默认7天)
*/
R<?> getVitalSigns(Long patientId, Integer days);
/**
* 录入生命体征
* @param dto 体征数据
*/
R<?> submitVitalSign(VitalSignSubmitDto dto);
/**
* 获取评估记录列表
* @param patientId 患者ID
*/
R<?> getAssessmentList(Long patientId);
/**
* 提交护理评估
* @param dto 评估数据
*/
R<?> submitAssessment(AssessmentSubmitDto dto);
}

View File

@@ -0,0 +1,171 @@
package com.healthlink.his.web.miniprogram.appservice.impl;
import com.core.common.core.domain.R;
import com.healthlink.his.miniprogram.domain.MpAssessmentRecord;
import com.healthlink.his.miniprogram.domain.MpNursingTask;
import com.healthlink.his.miniprogram.domain.MpVitalSignRecord;
import com.healthlink.his.miniprogram.mapper.MpAssessmentRecordMapper;
import com.healthlink.his.miniprogram.mapper.MpNursingTaskMapper;
import com.healthlink.his.miniprogram.mapper.MpVitalSignRecordMapper;
import com.healthlink.his.miniprogram.service.IMpAssessmentRecordService;
import com.healthlink.his.miniprogram.service.IMpNursingTaskService;
import com.healthlink.his.miniprogram.service.IMpVitalSignRecordService;
import com.healthlink.his.web.miniprogram.appservice.IMpNursingAppService;
import com.healthlink.his.web.miniprogram.dto.AssessmentSubmitDto;
import com.healthlink.his.web.miniprogram.dto.TaskCompleteDto;
import com.healthlink.his.web.miniprogram.dto.VitalSignSubmitDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 移动护理小程序 AppService实现
*/
@Slf4j
@Service
public class MpNursingAppServiceImpl implements IMpNursingAppService {
@Resource
private IMpNursingTaskService nursingTaskService;
@Resource
private MpNursingTaskMapper nursingTaskMapper;
@Resource
private IMpVitalSignRecordService vitalSignRecordService;
@Resource
private MpVitalSignRecordMapper vitalSignRecordMapper;
@Resource
private IMpAssessmentRecordService assessmentRecordService;
@Resource
private MpAssessmentRecordMapper assessmentRecordMapper;
@Override
@Transactional(readOnly = true)
public R<?> getTaskList(Long nurseId, String status) {
List<MpNursingTask> tasks = nursingTaskMapper.selectTaskListByNurse(nurseId, status);
long pendingCount = tasks.stream().filter(t -> "PENDING".equals(t.getTaskStatus())).count();
long inProgressCount = tasks.stream().filter(t -> "IN_PROGRESS".equals(t.getTaskStatus())).count();
long completedCount = tasks.stream().filter(t -> "COMPLETED".equals(t.getTaskStatus())).count();
return R.ok(Map.of(
"tasks", tasks,
"summary", Map.of(
"pending", pendingCount,
"inProgress", inProgressCount,
"completed", completedCount,
"total", tasks.size()
)
));
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> completeTask(Long taskId, TaskCompleteDto dto) {
MpNursingTask task = nursingTaskService.getById(taskId);
if (task == null) {
return R.fail("任务不存在");
}
if ("COMPLETED".equals(task.getTaskStatus())) {
return R.fail("任务已完成");
}
task.setTaskStatus("COMPLETED");
task.setCompleteTime(LocalDateTime.now());
nursingTaskService.updateById(task);
log.info("任务完成: taskId={}, nurseId={}, result={}", taskId, task.getNurseId(), dto.getResult());
return R.ok("任务已完成");
}
@Override
@Transactional(readOnly = true)
public R<?> getPatientInfo(Long patientId) {
return R.ok(Map.of(
"patientId", patientId,
"message", "患者信息查询待接入基础数据模块"
));
}
@Override
@Transactional(readOnly = true)
public R<?> getVitalSigns(Long patientId, Integer days) {
if (days == null || days <= 0) {
days = 7;
}
List<MpVitalSignRecord> records = vitalSignRecordMapper.selectByPatientId(patientId, days);
return R.ok(records);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> submitVitalSign(VitalSignSubmitDto dto) {
if (dto.getPatientId() == null || dto.getNurseId() == null) {
return R.fail("患者ID和护士ID不能为空");
}
if (dto.getRecordTime() == null) {
dto.setRecordTime(LocalDateTime.now());
}
MpVitalSignRecord record = new MpVitalSignRecord();
record.setPatientId(dto.getPatientId());
record.setEncounterId(dto.getEncounterId());
record.setNurseId(dto.getNurseId());
record.setRecordTime(dto.getRecordTime());
record.setTemperature(dto.getTemperature());
record.setPulse(dto.getPulse());
record.setRespiration(dto.getRespiration());
record.setSystolicBp(dto.getSystolicBp());
record.setDiastolicBp(dto.getDiastolicBp());
record.setBloodOxygen(dto.getBloodOxygen());
record.setHeight(dto.getHeight());
record.setWeight(dto.getWeight());
vitalSignRecordService.save(record);
log.info("生命体征录入: patientId={}, nurseId={}, recordId={}",
dto.getPatientId(), dto.getNurseId(), record.getId());
return R.ok(Map.of("recordId", record.getId()));
}
@Override
@Transactional(readOnly = true)
public R<?> getAssessmentList(Long patientId) {
List<MpAssessmentRecord> records = assessmentRecordMapper.selectByPatientId(patientId);
return R.ok(records);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<?> submitAssessment(AssessmentSubmitDto dto) {
if (dto.getPatientId() == null || dto.getNurseId() == null || dto.getAssessmentType() == null) {
return R.fail("患者ID、护士ID和评估类型不能为空");
}
if (dto.getRecordTime() == null) {
dto.setRecordTime(LocalDateTime.now());
}
MpAssessmentRecord record = new MpAssessmentRecord();
record.setPatientId(dto.getPatientId());
record.setEncounterId(dto.getEncounterId());
record.setNurseId(dto.getNurseId());
record.setAssessmentType(dto.getAssessmentType());
record.setAssessmentContent(dto.getAssessmentContent());
record.setAssessmentResult(dto.getAssessmentResult());
record.setScore(dto.getScore());
record.setRiskLevel(dto.getRiskLevel());
record.setRecordTime(dto.getRecordTime());
assessmentRecordService.save(record);
log.info("护理评估提交: patientId={}, type={}, recordId={}",
dto.getPatientId(), dto.getAssessmentType(), record.getId());
return R.ok(Map.of("recordId", record.getId()));
}
}

View File

@@ -0,0 +1,80 @@
package com.healthlink.his.web.miniprogram.controller;
import com.core.common.core.domain.R;
import com.healthlink.his.web.miniprogram.appservice.IMpNursingAppService;
import com.healthlink.his.web.miniprogram.dto.AssessmentSubmitDto;
import com.healthlink.his.web.miniprogram.dto.TaskCompleteDto;
import com.healthlink.his.web.miniprogram.dto.VitalSignSubmitDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
/**
* 移动护理小程序 Controller
*/
@Slf4j
@Tag(name = "移动护理小程序")
@RestController
@RequestMapping("/mp/nursing")
public class MpNursingController {
private final IMpNursingAppService mpNursingAppService;
public MpNursingController(IMpNursingAppService mpNursingAppService) {
this.mpNursingAppService = mpNursingAppService;
}
@Operation(summary = "获取护士任务列表")
@PreAuthorize("@ss.hasPermi('nursing:nursing:list')")
@GetMapping("/tasks")
public R<?> getTaskList(@RequestParam Long nurseId,
@RequestParam(required = false) String status) {
return mpNursingAppService.getTaskList(nurseId, status);
}
@Operation(summary = "完成任务")
@PreAuthorize("@ss.hasPermi('nursing:nursing:edit')")
@PostMapping("/tasks/{id}/complete")
public R<?> completeTask(@PathVariable Long id,
@RequestBody TaskCompleteDto dto) {
return mpNursingAppService.completeTask(id, dto);
}
@Operation(summary = "获取患者信息")
@PreAuthorize("@ss.hasPermi('nursing:nursing:list')")
@GetMapping("/patient/{id}")
public R<?> getPatientInfo(@PathVariable Long id) {
return mpNursingAppService.getPatientInfo(id);
}
@Operation(summary = "获取生命体征趋势")
@PreAuthorize("@ss.hasPermi('nursing:nursing:list')")
@GetMapping("/vital-signs/{patientId}")
public R<?> getVitalSigns(@PathVariable Long patientId,
@RequestParam(required = false, defaultValue = "7") Integer days) {
return mpNursingAppService.getVitalSigns(patientId, days);
}
@Operation(summary = "录入生命体征")
@PreAuthorize("@ss.hasPermi('nursing:nursing:edit')")
@PostMapping("/vital-sign")
public R<?> submitVitalSign(@RequestBody VitalSignSubmitDto dto) {
return mpNursingAppService.submitVitalSign(dto);
}
@Operation(summary = "获取评估记录列表")
@PreAuthorize("@ss.hasPermi('nursing:nursing:list')")
@GetMapping("/assessments/{patientId}")
public R<?> getAssessmentList(@PathVariable Long patientId) {
return mpNursingAppService.getAssessmentList(patientId);
}
@Operation(summary = "提交护理评估")
@PreAuthorize("@ss.hasPermi('nursing:nursing:edit')")
@PostMapping("/assessment")
public R<?> submitAssessment(@RequestBody AssessmentSubmitDto dto) {
return mpNursingAppService.submitAssessment(dto);
}
}

View File

@@ -0,0 +1,31 @@
package com.healthlink.his.web.miniprogram.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 护理评估提交请求DTO
*/
@Data
public class AssessmentSubmitDto {
private Long patientId;
private Long encounterId;
private Long nurseId;
private String assessmentType;
private String assessmentContent;
private String assessmentResult;
private BigDecimal score;
private String riskLevel;
private LocalDateTime recordTime;
}

View File

@@ -0,0 +1,32 @@
package com.healthlink.his.web.miniprogram.dto;
import lombok.Data;
import java.time.LocalDate;
/**
* 患者信息精简版DTO
*/
@Data
public class PatientInfoDto {
private Long id;
private String patientName;
private String gender;
private LocalDate birthDate;
private String medicalNo;
private String phone;
private String departmentName;
private String bedNo;
private String diagnosis;
private String nurseLevel;
}

View File

@@ -0,0 +1,14 @@
package com.healthlink.his.web.miniprogram.dto;
import lombok.Data;
/**
* 任务完成请求DTO
*/
@Data
public class TaskCompleteDto {
private String result;
private String remark;
}

View File

@@ -0,0 +1,37 @@
package com.healthlink.his.web.miniprogram.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 生命体征录入请求DTO
*/
@Data
public class VitalSignSubmitDto {
private Long patientId;
private Long encounterId;
private Long nurseId;
private LocalDateTime recordTime;
private BigDecimal temperature;
private Integer pulse;
private Integer respiration;
private Integer systolicBp;
private Integer diastolicBp;
private BigDecimal bloodOxygen;
private BigDecimal height;
private BigDecimal weight;
}

View File

@@ -196,7 +196,6 @@ public class AdviceManageAppServiceImpl implements IAdviceManageAppService {
List<RegAdviceSaveDto> activityList = regAdviceSaveList.stream()
.filter(e -> ItemType.ACTIVITY.getValue().equals(e.getAdviceType())
|| ItemType.SURGERY.getValue().equals(e.getAdviceType())
|| ItemType.TEXT.getValue().equals(e.getAdviceType())
|| (e.getAdviceType() != null && e.getAdviceType() == 26))
.collect(Collectors.toList());
// 耗材 🔧 Bug #147 修复
@@ -1081,7 +1080,6 @@ public class AdviceManageAppServiceImpl implements IAdviceManageAppService {
// 诊疗(包含 医疗活动=3、手术=6、文字医嘱=8、护理=26 等,都属于 service_request
List<AdviceBatchOpParam> activityList = paramList.stream()
.filter(e -> ItemType.ACTIVITY.getValue().equals(e.getAdviceType())
|| ItemType.TEXT.getValue().equals(e.getAdviceType())
|| ItemType.SURGERY.getValue().equals(e.getAdviceType())
|| (e.getAdviceType() != null && e.getAdviceType() == 26))
.collect(Collectors.toList());
@@ -1156,14 +1154,17 @@ public class AdviceManageAppServiceImpl implements IAdviceManageAppService {
.orElse(new Date());
// 获取当前操作用户昵称作为停嘱医生
String stopUserName = SecurityUtils.getNickName();
// 药品
// 药品包含出院带药adviceType=7与handleDeleteOperations保持一致
List<AdviceBatchOpParam> medicineList = paramList.stream()
.filter(e -> ItemType.MEDICINE.getValue().equals(e.getAdviceType())).collect(Collectors.toList());
.filter(e -> ItemType.MEDICINE.getValue().equals(e.getAdviceType())
|| (e.getAdviceType() != null && e.getAdviceType() == 7))
.collect(Collectors.toList());
List<Long> medicineRequestIds
= medicineList.stream().map(AdviceBatchOpParam::getRequestId).collect(Collectors.toList());
// 诊疗包含护理adviceType=26、文字医嘱adviceType=8
// 诊疗包含护理adviceType=26、手术adviceType=6、文字医嘱adviceType=8与saveRegAdvice保持一致
List<AdviceBatchOpParam> activityList = paramList.stream()
.filter(e -> ItemType.ACTIVITY.getValue().equals(e.getAdviceType())
|| ItemType.SURGERY.getValue().equals(e.getAdviceType())
|| ItemType.TEXT.getValue().equals(e.getAdviceType())
|| (e.getAdviceType() != null && e.getAdviceType() == 26))
.collect(Collectors.toList());
@@ -1195,6 +1196,28 @@ public class AdviceManageAppServiceImpl implements IAdviceManageAppService {
.set(DeviceRequest::getStatusEnum, RequestStatus.PENDING_STOP.getValue())
.set(DeviceRequest::getUpdateBy, stopUserName));
}
// 🔧 Bug #782 兜底处理:未被以上类型过滤器捕获的未知医嘱类型
// 将所有未匹配类型的医嘱统一按诊疗请求ServiceRequest处理
Set<Long> handledIds = new HashSet<>();
handledIds.addAll(medicineRequestIds);
handledIds.addAll(activityRequestIds);
handledIds.addAll(deviceRequestIds);
List<Long> fallbackRequestIds = paramList.stream()
.map(AdviceBatchOpParam::getRequestId)
.filter(Objects::nonNull)
.filter(id -> !handledIds.contains(id))
.collect(Collectors.toList());
if (!fallbackRequestIds.isEmpty()) {
log.info("Bug #782 兜底停嘱处理未匹配类型的医嘱requestIds: {}, 共{}条",
fallbackRequestIds, fallbackRequestIds.size());
iServiceRequestService.update(new LambdaUpdateWrapper<ServiceRequest>()
.in(ServiceRequest::getId, fallbackRequestIds)
.set(ServiceRequest::getOccurrenceEndTime, stopTime)
.set(ServiceRequest::getStatusEnum, RequestStatus.PENDING_STOP.getValue())
.set(ServiceRequest::getUpdateBy, stopUserName));
}
return R.ok(null, MessageUtils.createMessage(PromptMsgConstant.Common.M00004, new Object[]{"医嘱停止"}));
}
@@ -1213,14 +1236,17 @@ public class AdviceManageAppServiceImpl implements IAdviceManageAppService {
*/
@Override
public R<?> cancelStopRegAdvice(List<AdviceBatchOpParam> paramList) {
// 药品
// 药品包含出院带药adviceType=7与handleDeleteOperations保持一致
List<AdviceBatchOpParam> medicineList = paramList.stream()
.filter(e -> ItemType.MEDICINE.getValue().equals(e.getAdviceType())).collect(Collectors.toList());
.filter(e -> ItemType.MEDICINE.getValue().equals(e.getAdviceType())
|| (e.getAdviceType() != null && e.getAdviceType() == 7))
.collect(Collectors.toList());
List<Long> medicineRequestIds
= medicineList.stream().map(AdviceBatchOpParam::getRequestId).collect(Collectors.toList());
// 诊疗包含护理adviceType=26、文字医嘱adviceType=8
// 诊疗包含护理adviceType=26、手术adviceType=6、文字医嘱adviceType=8与saveRegAdvice保持一致
List<AdviceBatchOpParam> activityList = paramList.stream()
.filter(e -> ItemType.ACTIVITY.getValue().equals(e.getAdviceType())
|| ItemType.SURGERY.getValue().equals(e.getAdviceType())
|| ItemType.TEXT.getValue().equals(e.getAdviceType())
|| (e.getAdviceType() != null && e.getAdviceType() == 26))
.collect(Collectors.toList());
@@ -1310,4 +1336,4 @@ public class AdviceManageAppServiceImpl implements IAdviceManageAppService {
}
}
}

View File

@@ -0,0 +1,62 @@
-- V89: 知识图谱 - 添加缺失字段 (description, keywords, severityIndicator, sideEffects, clinicalSignificance, evidenceSource, department, required)
-- kg_disease: 添加 description, keywords
DO $$ BEGIN
ALTER TABLE kg_disease ADD COLUMN IF NOT EXISTS description TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE kg_disease ADD COLUMN IF NOT EXISTS keywords VARCHAR(512);
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
COMMENT ON COLUMN kg_disease.description IS '疾病描述';
COMMENT ON COLUMN kg_disease.keywords IS '关键词';
-- kg_symptom: 添加 severity_indicator
DO $$ BEGIN
ALTER TABLE kg_symptom ADD COLUMN IF NOT EXISTS severity_indicator VARCHAR(32);
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
COMMENT ON COLUMN kg_symptom.severity_indicator IS '严重程度指标';
-- kg_drug: 添加 side_effects
DO $$ BEGIN
ALTER TABLE kg_drug ADD COLUMN IF NOT EXISTS side_effects TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
COMMENT ON COLUMN kg_drug.side_effects IS '不良反应';
-- kg_examination: 添加 clinical_significance
DO $$ BEGIN
ALTER TABLE kg_examination ADD COLUMN IF NOT EXISTS clinical_significance TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
COMMENT ON COLUMN kg_examination.clinical_significance IS '临床意义';
-- kg_entity_relation: 添加 evidence_source
DO $$ BEGIN
ALTER TABLE kg_entity_relation ADD COLUMN IF NOT EXISTS evidence_source VARCHAR(512);
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
COMMENT ON COLUMN kg_entity_relation.evidence_source IS '证据来源';
-- kg_clinical_pathway: 添加 department
DO $$ BEGIN
ALTER TABLE kg_clinical_pathway ADD COLUMN IF NOT EXISTS department VARCHAR(128);
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
COMMENT ON COLUMN kg_clinical_pathway.department IS '所属科室';
-- kg_pathway_step: 添加 required
DO $$ BEGIN
ALTER TABLE kg_pathway_step ADD COLUMN IF NOT EXISTS required CHAR(1) DEFAULT '1';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
COMMENT ON COLUMN kg_pathway_step.required IS '是否必选(1-是 0-否)';

View File

@@ -0,0 +1,93 @@
-- 移动护理小程序 - 护理任务表
CREATE TABLE IF NOT EXISTS mp_nursing_task (
id BIGSERIAL PRIMARY KEY,
patient_id BIGINT NOT NULL,
encounter_id BIGINT NOT NULL,
nurse_id BIGINT NOT NULL,
task_type VARCHAR(32) NOT NULL,
task_content TEXT,
task_status VARCHAR(20) DEFAULT 'PENDING',
due_time TIMESTAMP,
complete_time TIMESTAMP,
tenant_id BIGINT DEFAULT 0,
delete_flag CHAR(1) DEFAULT '0',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
create_by VARCHAR(64)
);
COMMENT ON TABLE mp_nursing_task IS '移动护理-护理任务';
COMMENT ON COLUMN mp_nursing_task.id IS '主键ID';
COMMENT ON COLUMN mp_nursing_task.patient_id IS '患者ID';
COMMENT ON COLUMN mp_nursing_task.encounter_id IS '就诊ID';
COMMENT ON COLUMN mp_nursing_task.nurse_id IS '护士ID';
COMMENT ON COLUMN mp_nursing_task.task_type IS '任务类型';
COMMENT ON COLUMN mp_nursing_task.task_content IS '任务内容';
COMMENT ON COLUMN mp_nursing_task.task_status IS '任务状态: PENDING/IN_PROGRESS/COMPLETED/CANCELLED';
COMMENT ON COLUMN mp_nursing_task.due_time IS '截止时间';
COMMENT ON COLUMN mp_nursing_task.complete_time IS '完成时间';
-- 移动护理小程序 - 生命体征记录表
CREATE TABLE IF NOT EXISTS mp_vital_sign_record (
id BIGSERIAL PRIMARY KEY,
patient_id BIGINT NOT NULL,
encounter_id BIGINT NOT NULL,
nurse_id BIGINT NOT NULL,
record_time TIMESTAMP NOT NULL,
temperature DECIMAL(4,1),
pulse INTEGER,
respiration INTEGER,
systolic_bp INTEGER,
diastolic_bp INTEGER,
blood_oxygen DECIMAL(5,2),
height DECIMAL(5,1),
weight DECIMAL(5,1),
tenant_id BIGINT DEFAULT 0,
delete_flag CHAR(1) DEFAULT '0',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
create_by VARCHAR(64)
);
COMMENT ON TABLE mp_vital_sign_record IS '移动护理-生命体征记录';
COMMENT ON COLUMN mp_vital_sign_record.id IS '主键ID';
COMMENT ON COLUMN mp_vital_sign_record.patient_id IS '患者ID';
COMMENT ON COLUMN mp_vital_sign_record.encounter_id IS '就诊ID';
COMMENT ON COLUMN mp_vital_sign_record.nurse_id IS '记录护士ID';
COMMENT ON COLUMN mp_vital_sign_record.record_time IS '记录时间';
COMMENT ON COLUMN mp_vital_sign_record.temperature IS '体温(℃)';
COMMENT ON COLUMN mp_vital_sign_record.pulse IS '脉搏(次/分)';
COMMENT ON COLUMN mp_vital_sign_record.respiration IS '呼吸(次/分)';
COMMENT ON COLUMN mp_vital_sign_record.systolic_bp IS '收缩压(mmHg)';
COMMENT ON COLUMN mp_vital_sign_record.diastolic_bp IS '舒张压(mmHg)';
COMMENT ON COLUMN mp_vital_sign_record.blood_oxygen IS '血氧饱和度(%)';
COMMENT ON COLUMN mp_vital_sign_record.height IS '身高(cm)';
COMMENT ON COLUMN mp_vital_sign_record.weight IS '体重(kg)';
-- 移动护理小程序 - 护理评估记录表
CREATE TABLE IF NOT EXISTS mp_assessment_record (
id BIGSERIAL PRIMARY KEY,
patient_id BIGINT NOT NULL,
encounter_id BIGINT NOT NULL,
nurse_id BIGINT NOT NULL,
assessment_type VARCHAR(32) NOT NULL,
assessment_content TEXT,
assessment_result TEXT,
score DECIMAL(5,1),
risk_level VARCHAR(20),
record_time TIMESTAMP NOT NULL,
tenant_id BIGINT DEFAULT 0,
delete_flag CHAR(1) DEFAULT '0',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
create_by VARCHAR(64)
);
COMMENT ON TABLE mp_assessment_record IS '移动护理-护理评估记录';
COMMENT ON COLUMN mp_assessment_record.id IS '主键ID';
COMMENT ON COLUMN mp_assessment_record.patient_id IS '患者ID';
COMMENT ON COLUMN mp_assessment_record.encounter_id IS '就诊ID';
COMMENT ON COLUMN mp_assessment_record.nurse_id IS '评估护士ID';
COMMENT ON COLUMN mp_assessment_record.assessment_type IS '评估类型';
COMMENT ON COLUMN mp_assessment_record.assessment_content IS '评估内容';
COMMENT ON COLUMN mp_assessment_record.assessment_result IS '评估结果';
COMMENT ON COLUMN mp_assessment_record.score IS '评分';
COMMENT ON COLUMN mp_assessment_record.risk_level IS '风险等级';
COMMENT ON COLUMN mp_assessment_record.record_time IS '评估时间';

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.healthlink.his.miniprogram.mapper.MpAssessmentRecordMapper">
<select id="selectByPatientId" resultType="com.healthlink.his.miniprogram.domain.MpAssessmentRecord">
SELECT id, patient_id, encounter_id, nurse_id, assessment_type,
assessment_content, assessment_result, score, risk_level,
record_time, create_time, create_by
FROM mp_assessment_record
WHERE patient_id = #{patientId}
AND delete_flag = '0'
ORDER BY record_time DESC
</select>
</mapper>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.healthlink.his.miniprogram.mapper.MpNursingTaskMapper">
<select id="selectTaskListByNurse" resultType="com.healthlink.his.miniprogram.domain.MpNursingTask">
SELECT id, patient_id, encounter_id, nurse_id, task_type, task_content,
task_status, due_time, complete_time, create_time, create_by
FROM mp_nursing_task
WHERE nurse_id = #{nurseId}
AND delete_flag = '0'
<if test="taskStatus != null and taskStatus != ''">
AND task_status = #{taskStatus}
</if>
ORDER BY
CASE task_status
WHEN 'IN_PROGRESS' THEN 1
WHEN 'PENDING' THEN 2
WHEN 'COMPLETED' THEN 3
WHEN 'CANCELLED' THEN 4
END,
due_time ASC NULLS LAST
</select>
</mapper>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.healthlink.his.miniprogram.mapper.MpVitalSignRecordMapper">
<select id="selectByPatientId" resultType="com.healthlink.his.miniprogram.domain.MpVitalSignRecord">
SELECT id, patient_id, encounter_id, nurse_id, record_time,
temperature, pulse, respiration, systolic_bp, diastolic_bp,
blood_oxygen, height, weight, create_time, create_by
FROM mp_vital_sign_record
WHERE patient_id = #{patientId}
AND delete_flag = '0'
<if test="days != null">
AND record_time >= CURRENT_TIMESTAMP - INTERVAL CONCAT(#{days}, ' days')
</if>
ORDER BY record_time DESC
</select>
</mapper>

View File

@@ -29,4 +29,8 @@ public class KgDisease extends HisBaseEntity {
private String department;
private String severityLevel;
private String description;
private String keywords;
}

View File

@@ -31,4 +31,6 @@ public class KgDrug extends HisBaseEntity {
private String dosageForm;
private String contraindications;
private String sideEffects;
}

View File

@@ -29,4 +29,6 @@ public class KgExamination extends HisBaseEntity {
private String department;
private String referenceRange;
private String clinicalSignificance;
}

View File

@@ -27,4 +27,6 @@ public class KgSymptom extends HisBaseEntity {
private String bodyPart;
private String symptomType;
private String severityIndicator;
}

View File

@@ -0,0 +1,43 @@
package com.healthlink.his.miniprogram.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.core.common.core.domain.HisBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 移动护理-护理评估记录
*/
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@TableName("mp_assessment_record")
public class MpAssessmentRecord extends HisBaseEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long patientId;
private Long encounterId;
private Long nurseId;
private String assessmentType;
private String assessmentContent;
private String assessmentResult;
private BigDecimal score;
private String riskLevel;
private LocalDateTime recordTime;
}

View File

@@ -0,0 +1,41 @@
package com.healthlink.his.miniprogram.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.core.common.core.domain.HisBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* 移动护理-护理任务
*/
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@TableName("mp_nursing_task")
public class MpNursingTask extends HisBaseEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long patientId;
private Long encounterId;
private Long nurseId;
private String taskType;
private String taskContent;
private String taskStatus;
private LocalDateTime dueTime;
private LocalDateTime completeTime;
}

View File

@@ -0,0 +1,49 @@
package com.healthlink.his.miniprogram.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.core.common.core.domain.HisBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 移动护理-生命体征记录
*/
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@TableName("mp_vital_sign_record")
public class MpVitalSignRecord extends HisBaseEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long patientId;
private Long encounterId;
private Long nurseId;
private LocalDateTime recordTime;
private BigDecimal temperature;
private Integer pulse;
private Integer respiration;
private Integer systolicBp;
private Integer diastolicBp;
private BigDecimal bloodOxygen;
private BigDecimal height;
private BigDecimal weight;
}

View File

@@ -0,0 +1,17 @@
package com.healthlink.his.miniprogram.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.healthlink.his.miniprogram.domain.MpAssessmentRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 移动护理-护理评估记录 Mapper
*/
@Mapper
public interface MpAssessmentRecordMapper extends BaseMapper<MpAssessmentRecord> {
List<MpAssessmentRecord> selectByPatientId(@Param("patientId") Long patientId);
}

View File

@@ -0,0 +1,18 @@
package com.healthlink.his.miniprogram.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.healthlink.his.miniprogram.domain.MpNursingTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 移动护理-护理任务 Mapper
*/
@Mapper
public interface MpNursingTaskMapper extends BaseMapper<MpNursingTask> {
List<MpNursingTask> selectTaskListByNurse(@Param("nurseId") Long nurseId,
@Param("taskStatus") String taskStatus);
}

View File

@@ -0,0 +1,18 @@
package com.healthlink.his.miniprogram.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.healthlink.his.miniprogram.domain.MpVitalSignRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 移动护理-生命体征记录 Mapper
*/
@Mapper
public interface MpVitalSignRecordMapper extends BaseMapper<MpVitalSignRecord> {
List<MpVitalSignRecord> selectByPatientId(@Param("patientId") Long patientId,
@Param("days") Integer days);
}

View File

@@ -0,0 +1,10 @@
package com.healthlink.his.miniprogram.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.healthlink.his.miniprogram.domain.MpAssessmentRecord;
/**
* 移动护理-护理评估记录 Service
*/
public interface IMpAssessmentRecordService extends IService<MpAssessmentRecord> {
}

View File

@@ -0,0 +1,10 @@
package com.healthlink.his.miniprogram.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.healthlink.his.miniprogram.domain.MpNursingTask;
/**
* 移动护理-护理任务 Service
*/
public interface IMpNursingTaskService extends IService<MpNursingTask> {
}

View File

@@ -0,0 +1,10 @@
package com.healthlink.his.miniprogram.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.healthlink.his.miniprogram.domain.MpVitalSignRecord;
/**
* 移动护理-生命体征记录 Service
*/
public interface IMpVitalSignRecordService extends IService<MpVitalSignRecord> {
}

View File

@@ -0,0 +1,15 @@
package com.healthlink.his.miniprogram.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.healthlink.his.miniprogram.domain.MpAssessmentRecord;
import com.healthlink.his.miniprogram.mapper.MpAssessmentRecordMapper;
import com.healthlink.his.miniprogram.service.IMpAssessmentRecordService;
import org.springframework.stereotype.Service;
/**
* 移动护理-护理评估记录 Service实现
*/
@Service
public class MpAssessmentRecordServiceImpl extends ServiceImpl<MpAssessmentRecordMapper, MpAssessmentRecord>
implements IMpAssessmentRecordService {
}

View File

@@ -0,0 +1,15 @@
package com.healthlink.his.miniprogram.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.healthlink.his.miniprogram.domain.MpNursingTask;
import com.healthlink.his.miniprogram.mapper.MpNursingTaskMapper;
import com.healthlink.his.miniprogram.service.IMpNursingTaskService;
import org.springframework.stereotype.Service;
/**
* 移动护理-护理任务 Service实现
*/
@Service
public class MpNursingTaskServiceImpl extends ServiceImpl<MpNursingTaskMapper, MpNursingTask>
implements IMpNursingTaskService {
}

View File

@@ -0,0 +1,15 @@
package com.healthlink.his.miniprogram.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.healthlink.his.miniprogram.domain.MpVitalSignRecord;
import com.healthlink.his.miniprogram.mapper.MpVitalSignRecordMapper;
import com.healthlink.his.miniprogram.service.IMpVitalSignRecordService;
import org.springframework.stereotype.Service;
/**
* 移动护理-生命体征记录 Service实现
*/
@Service
public class MpVitalSignRecordServiceImpl extends ServiceImpl<MpVitalSignRecordMapper, MpVitalSignRecord>
implements IMpVitalSignRecordService {
}

View File

@@ -103,3 +103,43 @@ export function getPathwayPage(params) {
export function getPathwaySteps(id) {
return request({ url: `/knowledgegraph/pathway/${id}/steps`, method: 'get' })
}
// KG3: 推理引擎
export function suggestDiagnosis(data) {
return request({ url: '/knowledgegraph/reasoning/diagnosis', method: 'post', data })
}
export function suggestExaminations(data) {
return request({ url: '/knowledgegraph/reasoning/examination', method: 'post', data })
}
export function checkDrugInteractions(data) {
return request({ url: '/knowledgegraph/reasoning/drug-interaction', method: 'post', data })
}
export function suggestPathway(diseaseCode) {
return request({ url: `/knowledgegraph/reasoning/pathway/${diseaseCode}`, method: 'get' })
}
// KG4: 数据导入
export function importDisease(file) {
const formData = new FormData()
formData.append('file', file)
return request({ url: '/knowledgegraph/import/disease', method: 'post', data: formData, headers: { 'Content-Type': 'multipart/form-data' } })
}
export function importDrug(file) {
const formData = new FormData()
formData.append('file', file)
return request({ url: '/knowledgegraph/import/drug', method: 'post', data: formData, headers: { 'Content-Type': 'multipart/form-data' } })
}
export function importRelations(file) {
const formData = new FormData()
formData.append('file', file)
return request({ url: '/knowledgegraph/import/relation', method: 'post', data: formData, headers: { 'Content-Type': 'multipart/form-data' } })
}
export function downloadImportTemplate(type) {
return request({ url: `/knowledgegraph/import/template/${type}`, method: 'get', responseType: 'blob' })
}

View File

@@ -81,6 +81,7 @@
class="inspection-table"
:row-config="{ keyField: 'applicationId', keyField: 'itemId' }"
@checkbox-change="handleSelectionChange"
@current-change="handleRowClick"
@cell-click="handleCellClick"
>
<vxe-column
@@ -2141,32 +2142,15 @@ const handleSave = () => {
let hasErrors = false
// 修复【#406】保存前尝试从 props 同步患者信息,避免因加载时序导致信息缺失
if (props.patientInfo && props.patientInfo.encounterId) {
// encounterId 来自 adm_encounter 表lab_apply 表无此字段,需始终从 props 同步
if (!formData.encounterId) {
formData.encounterId = props.patientInfo.encounterId || ''
}
if (!formData.patientName?.trim()) {
formData.patientName = props.patientInfo.patientName || ''
}
if (!formData.medicalrecordNumber?.trim()) {
formData.medicalrecordNumber = props.patientInfo.identifierNo || ''
}
if (!formData.visitNo) {
formData.visitNo = props.patientInfo.busNo || ''
}
if (!formData.patientId) {
formData.patientId = props.patientInfo.patientId || ''
}
if (!formData.applyDepartment) {
formData.applyDepartment = props.patientInfo.organizationName || ''
}
if (!formData.applyDeptCode) {
formData.applyDeptCode = props.patientInfo.organizationName || ''
}
if (!formData.applyOrganizationId) {
formData.applyOrganizationId = props.patientInfo.orgId || ''
}
if ((!formData.patientName?.trim() || !formData.medicalrecordNumber?.trim()) && props.patientInfo && props.patientInfo.encounterId) {
formData.patientName = props.patientInfo.patientName || ''
formData.medicalrecordNumber = props.patientInfo.identifierNo || ''
formData.encounterId = props.patientInfo.encounterId || ''
formData.visitNo = props.patientInfo.busNo || ''
formData.patientId = props.patientInfo.patientId || ''
formData.applyDepartment = props.patientInfo.organizationName || ''
formData.applyDeptCode = props.patientInfo.organizationName || ''
formData.applyOrganizationId = props.patientInfo.orgId || ''
}
// P0检查患者信息是否已加载
@@ -2473,13 +2457,10 @@ const handleDelete = (row) => {
}
// 单元格点击 - 点击表格行时加载申请单详情
const handleCellClick = (params) => {
// vxe-table cell-click 事件参数是 { row, column, $event, ... } 对象,需安全提取行数据
const row = params.row || params;
const column = params.column || params;
const handleCellClick = (row, column) => {
// 如果点击的是操作列或展开列,不触发数据填充
if (column.type === 'expand' || column.type === 'selection' ||
column.title === '操作' || column.property === '操作') {
if (column.property === '操作' || column.label === '操作' ||
column.type === 'expand' || column.type === 'selection') {
return;
}
// 点击表格行时,将该申请单的数据加载到表单中
@@ -2489,6 +2470,15 @@ const handleCellClick = (params) => {
}
}
// 行点击事件处理
const handleRowClick = (currentRow, oldRow) => {
// 点击表格行时,将该申请单的数据加载到表单中
// 使用 applyNo 判断是否有效,同时检查是否处于删除状态
if (currentRow && currentRow.applyNo && !isDeleting.value) {
loadApplicationToForm(currentRow);
}
}
// 提取公共方法加载申请单到表单
const loadApplicationToForm = async (row) => {
// 停止申请日期实时更新(加载已保存的申请单)

View File

@@ -196,6 +196,7 @@
v-model="open"
:title="title"
width="1200px"
class="surgery-dialog"
:close-on-click-modal="false"
@close="cancel"
>
@@ -1757,14 +1758,13 @@ defineExpose({
height: 100%;
width: 100%;
padding: 10px;
/* 顶部操作栏样式 */
.top-operation-bar {
height: 60px;
display: flex;
align-items: center;
margin-bottom: 16px;
.add-button {
background-color: #5b8fb9;
color: white;
@@ -1775,7 +1775,7 @@ defineExpose({
box-shadow: 0 4px 8px rgba(91, 143, 185, 0.3);
}
}
.refresh-button {
background-color: transparent;
border: 1px solid #dcdfe6;
@@ -1787,14 +1787,14 @@ defineExpose({
}
}
}
/* 表格样式 */
.vxe-table {
:deep(.cancelled-row) {
background-color: #f5f5f5;
color: #999;
text-decoration: line-through;
:deep(.cell) {
opacity: 0.6;
}
@@ -1802,47 +1802,49 @@ defineExpose({
}
}
/* 对话框样式 */
.el-dialog {
/* Bug #770: Dialog flex 布局 — 防止 footer 按钮遮盖表单字段 */
:deep(.surgery-dialog) {
display: flex;
flex-direction: column;
max-height: 85vh;
margin-top: 7.5vh;
}
:deep(.surgery-dialog .el-dialog__header) {
flex-shrink: 0;
padding: 16px 20px;
border-bottom: 1px solid #ebeef5;
}
:deep(.surgery-dialog .el-dialog__body) {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px;
}
:deep(.surgery-dialog .el-dialog__footer) {
flex-shrink: 0;
padding: 12px 20px;
border-top: 1px solid #ebeef5;
}
/* 对话框圆角 */
:deep(.surgery-dialog .el-dialog) {
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.dialog-footer {
text-align: center;
padding: 20px 0;
}
/* Bug #770: Dialog flex 布局 — 防止 footer 按钮遮盖表单字段 */
:deep(.el-dialog) {
display: flex;
flex-direction: column;
max-height: 90vh;
margin-top: 5vh;
}
:deep(.el-dialog__header) {
flex-shrink: 0;
}
:deep(.el-dialog__body) {
flex: 1;
overflow-y: auto;
overflow: hidden;
min-height: 0;
}
:deep(.el-dialog__footer) {
flex-shrink: 0;
border-top: 1px solid #ebeef5;
padding: 12px 20px 16px;
}
/* 响应式:小屏幕下对话框宽度自适应 */
@media (max-width: 1200px) {
:deep(.el-dialog) {
:deep(.surgery-dialog) {
width: 95vw !important;
margin: 2.5vh auto;
}
}
</style>
</style>
<!-- 🔧 BugFix#769: 手术名称下拉框闪烁修复 -->

View File

@@ -96,9 +96,14 @@
<!-- <el-tab-pane label="皮试管理" name="I"> 皮试管理 </el-tab-pane>
<el-tab-pane label="出院管理" name="J">
<DischargedManagement></DischargedManagement>
</el-tab-pane> -->
<el-tab-pane
label="退药管理"
name="MedicineReturn"
>
<MedicineReturn v-if="activeTabName === 'MedicineReturn'" />
</el-tab-pane>
<el-tab-pane label="退药管理" name="K"> 退药管理 </el-tab-pane>
<el-tab-pane label="手术记录" name="L"> 手术记录 </el-tab-pane> -->
<!-- <el-tab-pane label="手术记录" name="L"> 手术记录 </el-tab-pane> -->
</el-tabs>
</el-main>
</el-container>
@@ -122,6 +127,7 @@ import {
MedicalOrderProofread,
RollFee,
TprChart,
MedicineReturn,
} from './index.js';
const activeTabName = ref('InOut');

View File

@@ -0,0 +1,134 @@
<template>
<div class="app-container">
<el-card shadow="never">
<template #header>
<span>知识图谱数据导入</span>
</template>
<el-tabs v-model="activeTab">
<el-tab-pane label="疾病导入" name="disease">
<div class="import-section">
<p class="tip">CSV格式疾病编码,疾病名称,分类,科室,严重等级,描述,关键词</p>
<el-upload ref="diseaseUpload" :auto-upload="false" :limit="1" accept=".csv" :on-change="(f) => handleFileChange(f, 'disease')" :on-remove="() => clearFile('disease')">
<el-button type="primary">选择CSV文件</el-button>
</el-upload>
<div class="mt16">
<el-button type="success" :loading="importing" :disabled="!files.disease" @click="doImport('disease')">开始导入</el-button>
<el-button link type="primary" @click="downloadTemplate('disease')">下载模板</el-button>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="药物导入" name="drug">
<div class="import-section">
<p class="tip">CSV格式药物编码,药物名称,通用名,分类,剂型,禁忌症,不良反应</p>
<el-upload ref="drugUpload" :auto-upload="false" :limit="1" accept=".csv" :on-change="(f) => handleFileChange(f, 'drug')" :on-remove="() => clearFile('drug')">
<el-button type="primary">选择CSV文件</el-button>
</el-upload>
<div class="mt16">
<el-button type="success" :loading="importing" :disabled="!files.drug" @click="doImport('drug')">开始导入</el-button>
<el-button link type="primary" @click="downloadTemplate('drug')">下载模板</el-button>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="关系导入" name="relation">
<div class="import-section">
<p class="tip">CSV格式来源类型,来源ID,目标类型,目标ID,关系类型,关系强度,描述,证据来源</p>
<el-upload ref="relationUpload" :auto-upload="false" :limit="1" accept=".csv" :on-change="(f) => handleFileChange(f, 'relation')" :on-remove="() => clearFile('relation')">
<el-button type="primary">选择CSV文件</el-button>
</el-upload>
<div class="mt16">
<el-button type="success" :loading="importing" :disabled="!files.relation" @click="doImport('relation')">开始导入</el-button>
<el-button link type="primary" @click="downloadTemplate('relation')">下载模板</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
<el-card shadow="never" class="mt16" v-if="importResult">
<template #header>
<span>导入结果</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="总行数">{{ importResult.totalRows }}</el-descriptions-item>
<el-descriptions-item label="成功">{{ importResult.successCount }}</el-descriptions-item>
<el-descriptions-item label="失败">{{ importResult.failCount }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="importResult.failCount === 0 ? 'success' : 'warning'">
{{ importResult.failCount === 0 ? '全部成功' : '部分失败' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<p class="mt16">{{ importResult.message }}</p>
</el-card>
</div>
</template>
<script setup name="KgDataImport">
import { ref, reactive } from 'vue'
import { importDisease, importDrug, importRelations, downloadImportTemplate } from '@/api/knowledgegraph/api'
import { ElMessage } from 'element-plus'
const activeTab = ref('disease')
const importing = ref(false)
const importResult = ref(null)
const files = reactive({ disease: null, drug: null, relation: null })
function handleFileChange(file, type) {
files[type] = file.raw
}
function clearFile(type) {
files[type] = null
}
async function doImport(type) {
const file = files[type]
if (!file) {
ElMessage.warning('请先选择文件')
return
}
importing.value = true
importResult.value = null
try {
let res
if (type === 'disease') res = await importDisease(file)
else if (type === 'drug') res = await importDrug(file)
else res = await importRelations(file)
if (res.code === 200) {
importResult.value = res.data
ElMessage.success('导入完成')
} else {
ElMessage.error(res.msg || '导入失败')
}
} finally {
importing.value = false
}
}
async function downloadTemplate(type) {
try {
const res = await downloadImportTemplate(type)
const blob = new Blob([res], { type: 'text/csv;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const names = { disease: '疾病导入模板', drug: '药物导入模板', relation: '关系导入模板' }
link.download = names[type] + '.csv'
link.click()
window.URL.revokeObjectURL(url)
} catch {
ElMessage.error('下载模板失败')
}
}
</script>
<style scoped>
.mt16 { margin-top: 16px; }
.tip { color: #909399; font-size: 13px; margin-bottom: 12px; }
.import-section { padding: 12px 0; }
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="app-container">
<el-card shadow="never">
<template #header>
<span>诊断推荐</span>
</template>
<el-form :model="form" label-width="80px">
<el-form-item label="症状">
<el-select v-model="form.symptoms" multiple filterable allow-create default-first-option placeholder="输入症状后回车" style="width: 100%">
<el-option v-for="item in symptomOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="推荐数量">
<el-input-number v-model="form.topN" :min="1" :max="20" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleSuggest">查询推荐</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="mt16" v-if="results.length > 0">
<template #header>
<span>推荐结果 {{ results.length }} </span>
</template>
<el-table :data="results" border stripe>
<el-table-column type="index" label="排名" width="60" align="center" />
<el-table-column prop="diseaseCode" label="疾病编码" width="140" show-overflow-tooltip />
<el-table-column prop="diseaseName" label="疾病名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="category" label="分类" width="120" show-overflow-tooltip />
<el-table-column prop="department" label="科室" width="120" show-overflow-tooltip />
<el-table-column prop="score" label="匹配度" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.score >= 2 ? 'danger' : row.score >= 1 ? 'warning' : 'info'">
{{ row.score }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="matchedSymptoms" label="匹配症状" min-width="200" show-overflow-tooltip />
</el-table>
</el-card>
</div>
</template>
<script setup name="DiagnosisSuggest">
import { ref, reactive } from 'vue'
import { suggestDiagnosis } from '@/api/knowledgegraph/api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const results = ref([])
const form = reactive({
symptoms: [],
topN: 5
})
const symptomOptions = ref([])
async function handleSuggest() {
if (form.symptoms.length === 0) {
ElMessage.warning('请输入至少一个症状')
return
}
loading.value = true
try {
const res = await suggestDiagnosis(form)
if (res.code === 200) {
results.value = res.data || []
if (results.value.length === 0) {
ElMessage.info('未找到匹配的诊断')
}
} else {
ElMessage.error(res.msg || '查询失败')
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.mt16 { margin-top: 16px; }
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="app-container">
<el-card shadow="never">
<template #header>
<span>药物相互作用检查</span>
</template>
<el-form :model="form" label-width="80px">
<el-form-item label="药物">
<el-select v-model="form.drugCodes" multiple filterable allow-create default-first-option placeholder="输入药物编码后回车" style="width: 100%">
<el-option v-for="item in drugOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleCheck">检查相互作用</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" class="mt16" v-if="results.length > 0">
<template #header>
<span>检查结果发现 {{ results.length }} 个相互作用</span>
</template>
<el-table :data="results" border stripe>
<el-table-column prop="drugNameA" label="药物A" min-width="140" show-overflow-tooltip />
<el-table-column prop="drugCodeA" label="编码A" width="120" show-overflow-tooltip />
<el-table-column prop="drugNameB" label="药物B" min-width="140" show-overflow-tooltip />
<el-table-column prop="drugCodeB" label="编码B" width="120" show-overflow-tooltip />
<el-table-column prop="interactionType" label="相互作用类型" width="140" show-overflow-tooltip />
<el-table-column prop="severity" label="严重程度" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.severity === '严重' ? 'danger' : 'warning'">
{{ row.severity }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
</el-table>
</el-card>
<el-card shadow="never" class="mt16" v-if="checked && results.length === 0">
<el-empty description="未发现药物相互作用" />
</el-card>
</div>
</template>
<script setup name="DrugInteractionCheck">
import { ref, reactive } from 'vue'
import { checkDrugInteractions } from '@/api/knowledgegraph/api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const results = ref([])
const checked = ref(false)
const form = reactive({
drugCodes: []
})
const drugOptions = ref([])
async function handleCheck() {
if (form.drugCodes.length < 2) {
ElMessage.warning('请至少输入两种药物')
return
}
loading.value = true
checked.value = false
try {
const res = await checkDrugInteractions(form)
if (res.code === 200) {
results.value = res.data || []
checked.value = true
} else {
ElMessage.error(res.msg || '检查失败')
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.mt16 { margin-top: 16px; }
</style>