26-7-31-1

This commit is contained in:
shuai
2026-07-31 12:27:41 +08:00
commit 9b8b8b57b3
142 changed files with 22408 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
<template>
<!--
仅在「登录页」与「主布局壳」之间过渡。
key 不能用完整 path,否则子路由切换会整页卸载侧边栏。
-->
<router-view v-slot="{ Component, route }">
<transition name="shell" mode="out-in">
<component
:is="Component"
:key="route.name === 'Login' ? 'login' : 'main-shell'"
/>
</transition>
</router-view>
</template>
<script setup></script>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
overflow: hidden; /* 由布局内部各自滚动 */
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
background: #e8f0f7;
color: #0f172a;
-webkit-font-smoothing: antialiased;
}
/* 登录 ↔ 主壳:柔和淡入,不位移,避免布局抖动 */
.shell-enter-active,
.shell-leave-active {
transition: opacity 0.32s ease;
}
.shell-enter-from,
.shell-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.shell-enter-active,
.shell-leave-active {
transition: none !important;
}
.shell-enter-from,
.shell-leave-to {
opacity: 1;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
a {
color: inherit;
text-decoration: none;
}
/* Element Plus 微调 */
.el-card {
--el-card-border-color: transparent;
}
.el-table {
--el-table-header-bg-color: #f8fafc;
--el-table-header-text-color: #475569;
}
.el-dialog__header {
margin-right: 0;
font-weight: 700;
color: #0f172a;
flex-shrink: 0;
}
.el-dialog {
border-radius: 14px;
overflow: hidden;
}
/*
* 仅作用于 Dialog 的 overlay(不要影响 Drawer)。
* Element Plus:Dialog 用 .el-overlay-dialog,Drawer 用 .el-drawer。
*/
/*
* 遮罩内垂直居中:用 margin:auto 而不是 align-items:center,
* 避免弹窗高于视口时顶部被裁切且无法滚到开头。
*/
.el-overlay-dialog {
display: flex !important;
align-items: flex-start !important;
justify-content: center !important;
overflow: auto;
padding: 24px 12px;
box-sizing: border-box;
}
.el-overlay-dialog .el-dialog {
margin: auto !important;
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 仅 Dialog body,避免误伤 Drawer body */
.el-overlay-dialog .el-dialog__body {
overflow-x: hidden !important;
overflow-y: auto;
max-width: 100%;
flex: 1 1 auto;
min-height: 0;
}
.el-overlay-dialog .el-dialog__header,
.el-overlay-dialog .el-dialog__footer {
flex-shrink: 0;
}
/* Drawer:保证全高贴右,内容在 body 内滚动(防止被布局 overflow 裁切) */
.el-drawer.rtl,
.el-drawer.ltr {
height: 100% !important;
max-height: 100vh !important;
}
.el-drawer__body {
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
}
/* 表单类弹窗:整窗限高,仅 body 滚动,页脚始终可见 */
.el-dialog.form-dialog {
max-height: min(88vh, 760px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.el-dialog.form-dialog .el-dialog__header {
padding: 16px 24px 12px;
}
.el-dialog.form-dialog .el-dialog__body {
/* 左右内边距一致,避免表单视觉偏左/偏右 */
padding: 8px 24px 12px;
max-height: none;
overflow-x: hidden !important;
overflow-y: auto;
box-sizing: border-box;
/* 有无滚动条都预留同一侧空间,避免右侧空白忽大忽小 */
scrollbar-gutter: stable;
}
.el-dialog.form-dialog .el-dialog__footer {
padding: 12px 24px 16px;
}
/* 报告类宽弹窗同样限高,避免长报告顶出视口 */
.el-dialog.result-dialog {
max-height: min(90vh, 900px);
width: min(920px, calc(100vw - 32px)) !important;
display: flex;
flex-direction: column;
overflow: hidden;
}
.el-dialog.result-dialog .el-dialog__body {
padding: 8px 20px 12px;
max-height: none;
overflow-x: hidden !important;
overflow-y: auto;
box-sizing: border-box;
flex: 1 1 auto;
min-height: 0;
}
.el-dialog .el-form {
max-width: 100%;
box-sizing: border-box;
}
/*
* 勿再清掉 el-row 的负 margin:
* gutter 依赖 row 负边距 + col 内边距成对出现,只清一边会导致左右空白不均。
* 横向溢出改由 body 的 overflow-x + box-sizing 约束。
*/
.el-dialog .el-slider {
max-width: 100%;
box-sizing: border-box;
}
.el-pagination {
--el-pagination-button-bg-color: transparent;
}
/* 业务卡片半透明叠在背景图上更清晰 */
.page .panel.el-card,
.dashboard .stat-card.el-card,
.dashboard .chart-card.el-card {
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
</style>
+41
View File
@@ -0,0 +1,41 @@
import request from '@/utils/request'
// —— 管理端:AI 配置 ——
export const getAiSettings = () => request.get('/admin/ai-settings')
export const saveAiSettings = (data) => request.put('/admin/ai-settings', data)
export const testAiSettings = () => request.post('/admin/ai-settings/test', null, { timeout: 90000 })
// —— AI 微服务状态(所有登录用户)——
export const getAiServiceStatus = () => request.get('/admin/ai-service/status')
// —— 管理端:知识库 ——
export const listKnowledge = (params) => request.get('/admin/ai-knowledge', { params })
export const getKnowledge = (id) => request.get(`/admin/ai-knowledge/${id}`)
export const createKnowledge = (data) => request.post('/admin/ai-knowledge', data)
export const updateKnowledge = (id, data) => request.put(`/admin/ai-knowledge/${id}`, data)
export const setKnowledgeEnabled = (id, enabled) =>
request.patch(`/admin/ai-knowledge/${id}/enabled`, { enabled })
export const deleteKnowledge = (id) => request.delete(`/admin/ai-knowledge/${id}`)
// —— 业务端:对话 ——
export const aiChat = (data) => request.post('/ai/chat', data, { timeout: 120000 })
export const aiChatHistory = (params) => request.get('/ai/chat/history', { params })
export const clearAiChatHistory = () => request.delete('/ai/chat/history')
// —— 管理端:YOLO 权重 ——
export const getYoloStatus = () => request.get('/admin/yolo/status')
export const listYoloWeights = () => request.get('/admin/yolo/weights')
export const uploadYoloWeight = (file) => {
const fd = new FormData()
fd.append('file', file)
return request.post('/admin/yolo/weights/upload', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000
})
}
export const activateYoloWeight = (data) => request.post('/admin/yolo/weights/activate', data)
export const deactivateYoloWeight = () => request.post('/admin/yolo/weights/deactivate')
export const deleteYoloWeight = (name) => request.delete(`/admin/yolo/weights/${encodeURIComponent(name)}`)
export const setYoloMode = (demoMode) => request.post('/admin/yolo/mode', { demo_mode: demoMode })
export const getYoloVisualization = () => request.get('/admin/yolo/visualization')
export const resetYoloStats = () => request.post('/admin/yolo/stats/reset')
+11
View File
@@ -0,0 +1,11 @@
import request from '@/utils/request'
export const listAppointments = (params) => request.get('/appointments', { params })
export const getAppointment = (id) => request.get(`/appointments/${id}`)
export const getAppointmentStats = () => request.get('/appointments/stats')
export const listAppointmentDoctors = () => request.get('/appointments/doctors')
export const createAppointment = (data) => request.post('/appointments', data)
export const updateAppointment = (id, data) => request.put(`/appointments/${id}`, data)
export const updateAppointmentStatus = (id, status) =>
request.patch(`/appointments/${id}/status`, { status })
export const deleteAppointment = (id) => request.delete(`/appointments/${id}`)
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request'
export const login = (data) => request.post('/auth/login', data)
export const getMe = () => request.get('/auth/me')
export const logout = () => request.post('/auth/logout')
export const changePassword = (data) => request.post('/auth/change-password', data)
export const uploadAvatar = (file) => {
const form = new FormData()
form.append('file', file)
return request.post('/auth/avatar', form, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
+9
View File
@@ -0,0 +1,9 @@
import request from '@/utils/request'
export const listEmrs = (params) => request.get('/emrs', { params })
export const getEmr = (id) => request.get(`/emrs/${id}`)
export const createEmr = (data) => request.post('/emrs', data)
export const updateEmr = (id, data) => request.put(`/emrs/${id}`, data)
export const deleteEmr = (id) => request.delete(`/emrs/${id}`)
export const getAiSuggestions = (id) => request.get(`/emrs/${id}/ai-suggestions`)
export const aiSuggestions = getAiSuggestions
+10
View File
@@ -0,0 +1,10 @@
import request from '@/utils/request'
export const listImaging = (params) => request.get('/imaging', { params })
export const getImaging = (id) => request.get(`/imaging/${id}`)
export const createImaging = (data) => request.post('/imaging', data)
export const updateImaging = (id, data) => request.put(`/imaging/${id}`, data)
export const deleteImaging = (id) => request.delete(`/imaging/${id}`)
export const analyzeImaging = (id) => request.post(`/ai-diagnosis/analyze/${id}`)
export const getImagingResult = (id) => request.get(`/ai-diagnosis/result/${id}`)
export const getDiagnosisResult = getImagingResult
+8
View File
@@ -0,0 +1,8 @@
import request from '@/utils/request'
export const listPatients = (params) => request.get('/patients', { params })
export const getPatient = (id) => request.get(`/patients/${id}`)
export const getPatientProfile = (id) => request.get(`/patients/${id}/profile`)
export const createPatient = (data) => request.post('/patients', data)
export const updatePatient = (id, data) => request.put(`/patients/${id}`, data)
export const deletePatient = (id) => request.delete(`/patients/${id}`)
+4
View File
@@ -0,0 +1,4 @@
import request from '@/utils/request'
export const getOverview = () => request.get('/stats/overview')
export const fetchOverview = getOverview
+16
View File
@@ -0,0 +1,16 @@
import request from '@/utils/request'
export const listUsers = (params) => request.get('/users', { params })
export const getUserStats = () => request.get('/users/stats')
export const createUser = (data) => request.post('/users', data)
export const updateUser = (id, data) => request.put(`/users/${id}`, data)
export const deleteUser = (id) => request.delete(`/users/${id}`)
export const setUserEnabled = (id, enabled) =>
request.patch(`/users/${id}/enabled`, { enabled })
export const uploadUserAvatar = (id, file) => {
const form = new FormData()
form.append('file', file)
return request.post(`/users/${id}/avatar`, form, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
+626
View File
@@ -0,0 +1,626 @@
<template>
<el-container class="layout">
<el-aside :width="collapsed ? '72px' : '220px'" class="aside">
<div class="aside-bg" />
<div class="aside-inner">
<div class="brand" @click="$router.push('/dashboard')">
<span class="brand-icon">
<el-icon :size="22"><OfficeBuilding /></el-icon>
</span>
<div v-show="!collapsed" class="brand-text">
<div class="brand-title">智慧医院</div>
<div class="brand-sub">医院信息管理平台</div>
</div>
</div>
<div class="aside-scroll">
<el-menu
:default-active="activeRoute"
:collapse="collapsed"
:collapse-transition="false"
router
class="side-menu"
background-color="transparent"
text-color="rgba(255,255,255,0.88)"
active-text-color="#fff"
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/patients">
<el-icon><User /></el-icon>
<span>患者管理</span>
</el-menu-item>
<el-menu-item index="/imaging">
<el-icon><PictureFilled /></el-icon>
<span>影像诊断</span>
</el-menu-item>
<el-menu-item index="/emrs">
<el-icon><Document /></el-icon>
<span>电子病历</span>
</el-menu-item>
<el-menu-item index="/appointments">
<el-icon><Calendar /></el-icon>
<span>预约挂号</span>
</el-menu-item>
<el-menu-item index="/ai-assistant">
<el-icon><ChatDotRound /></el-icon>
<span>AI 助手</span>
</el-menu-item>
<el-menu-item index="/ai-knowledge">
<el-icon><Collection /></el-icon>
<span>知识库</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/ai-settings">
<el-icon><Cpu /></el-icon>
<span>AI 配置</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/ai-yolo">
<el-icon><PictureFilled /></el-icon>
<span>YOLO 权重</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/users">
<el-icon><Setting /></el-icon>
<span>用户管理</span>
</el-menu-item>
</el-menu>
</div>
<div class="aside-footer">
<el-button text class="collapse-btn" @click="collapsed = !collapsed">
<el-icon><Fold v-if="!collapsed" /><Expand v-else /></el-icon>
<span v-if="!collapsed">收起菜单</span>
</el-button>
</div>
</div>
</el-aside>
<el-container class="main-wrap">
<el-header class="header">
<div class="header-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ pageTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-chip">
<el-avatar :size="32" class="avatar" :src="avatarSrc || undefined">
{{ avatarText }}
</el-avatar>
<span class="user-meta">
<span class="name">{{ userStore.displayName || '用户' }}</span>
<el-tag size="small" effect="plain" :type="roleTag">{{ roleText }}</el-tag>
</span>
<el-icon class="caret"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>
{{ userStore.userInfo?.username || '' }}
</el-dropdown-item>
<el-dropdown-item command="avatar">更换头像</el-dropdown-item>
<el-dropdown-item command="password">修改密码</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main ref="mainRef" class="main">
<div class="main-content">
<router-view v-slot="{ Component, route: viewRoute }">
<transition
name="page"
mode="out-in"
@before-leave="onPageBeforeLeave"
@after-enter="onPageAfterEnter"
>
<!-- 用包裹层做 key/过渡,兼容多根页面组件,不污染业务根节点 -->
<div v-if="Component" :key="viewRoute.path" class="page-view">
<component :is="Component" />
</div>
</transition>
</router-view>
</div>
</el-main>
</el-container>
<el-dialog v-model="pwdVisible" title="修改密码" width="420px" destroy-on-close align-center append-to-body>
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="90px">
<el-form-item label="原密码" prop="oldPassword">
<el-input v-model="pwdForm.oldPassword" type="password" show-password autocomplete="off" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="pwdForm.newPassword" type="password" show-password autocomplete="off" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="pwdForm.confirmPassword" type="password" show-password autocomplete="off" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdVisible = false">取 消</el-button>
<el-button type="primary" :loading="pwdSaving" @click="submitPassword">确 定</el-button>
</template>
</el-dialog>
<el-dialog v-model="avatarVisible" title="更换头像" width="420px" destroy-on-close align-center append-to-body>
<div class="avatar-panel">
<el-avatar :size="88" class="avatar preview" :src="avatarPreview || avatarSrc || undefined">
{{ avatarText }}
</el-avatar>
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
:on-change="onAvatarPick"
>
<el-button type="primary" plain>选择图片</el-button>
</el-upload>
<div class="avatar-tip">支持 jpg / png / gif / webp,不超过 2MB</div>
</div>
<template #footer>
<el-button @click="avatarVisible = false">取 消</el-button>
<el-button type="primary" :loading="avatarSaving" :disabled="!avatarFile" @click="submitAvatar">
上 传
</el-button>
</template>
</el-dialog>
</el-container>
</template>
<script setup>
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { roleLabel, roleTagType } from '@/utils/labels'
import { changePassword, uploadAvatar } from '@/api/auth'
import {
User,
Odometer,
PictureFilled,
Document,
Setting,
Fold,
Expand,
ArrowDown,
Calendar,
OfficeBuilding,
ChatDotRound,
Cpu,
Collection
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const collapsed = ref(false)
const mainRef = ref()
const titleMap = {
'/dashboard': '仪表盘',
'/patients': '患者管理',
'/imaging': '影像诊断',
'/emrs': '电子病历',
'/appointments': '预约挂号',
'/ai-assistant': 'AI 助手',
'/ai-settings': 'AI 配置',
'/ai-knowledge': '知识库',
'/ai-yolo': 'YOLO 权重',
'/users': '用户管理',
'/403': '无权访问'
}
const activeRoute = computed(() => route.path)
const pageTitle = computed(() => route.meta?.title || titleMap[route.path] || '工作台')
/** 切换页面时滚回主内容顶部,避免新页停留在旧滚动位置 */
const scrollMainToTop = () => {
const el = mainRef.value?.$el || mainRef.value
if (el && typeof el.scrollTo === 'function') {
el.scrollTo({ top: 0, behavior: 'auto' })
} else if (el) {
el.scrollTop = 0
}
}
const onPageBeforeLeave = () => {
scrollMainToTop()
}
/** 进入后触发 resize,让 ECharts 等图表按新尺寸重绘 */
const onPageAfterEnter = async () => {
await nextTick()
window.dispatchEvent(new Event('resize'))
}
watch(
() => route.path,
() => {
scrollMainToTop()
}
)
const roleText = computed(() => roleLabel(userStore.role))
const roleTag = computed(() => roleTagType(userStore.role))
const avatarText = computed(() => (userStore.displayName || 'U').slice(0, 1))
const avatarSrc = computed(() => userStore.userInfo?.avatar || '')
const pwdVisible = ref(false)
const pwdSaving = ref(false)
const pwdFormRef = ref()
const pwdForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const pwdRules = {
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '新密码至少 6 位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (_, value, callback) => {
if (value !== pwdForm.newPassword) callback(new Error('两次输入的密码不一致'))
else callback()
},
trigger: 'blur'
}
]
}
const avatarVisible = ref(false)
const avatarSaving = ref(false)
const avatarFile = ref(null)
const avatarPreview = ref('')
const handleCommand = async (cmd) => {
if (cmd === 'avatar') {
avatarFile.value = null
avatarPreview.value = ''
avatarVisible.value = true
return
}
if (cmd === 'password') {
Object.assign(pwdForm, { oldPassword: '', newPassword: '', confirmPassword: '' })
pwdVisible.value = true
return
}
if (cmd === 'logout') {
await ElMessageBox.confirm('确认退出登录?', '提示', { type: 'warning' })
userStore.reset()
router.push('/login')
}
}
const onAvatarPick = (file) => {
const raw = file.raw
if (!raw) return
if (!raw.type?.startsWith('image/')) {
ElMessage.warning('请选择图片文件')
return
}
if (raw.size > 2 * 1024 * 1024) {
ElMessage.warning('图片不能超过 2MB')
return
}
avatarFile.value = raw
if (avatarPreview.value) URL.revokeObjectURL(avatarPreview.value)
avatarPreview.value = URL.createObjectURL(raw)
}
const submitAvatar = async () => {
if (!avatarFile.value) return
avatarSaving.value = true
try {
const user = await uploadAvatar(avatarFile.value)
userStore.setUserInfo(user)
ElMessage.success('头像已更新')
avatarVisible.value = false
} finally {
avatarSaving.value = false
}
}
const submitPassword = async () => {
await pwdFormRef.value.validate()
pwdSaving.value = true
try {
await changePassword({
oldPassword: pwdForm.oldPassword,
newPassword: pwdForm.newPassword
})
ElMessage.success('密码已修改,请重新登录')
pwdVisible.value = false
userStore.reset()
router.push('/login')
} finally {
pwdSaving.value = false
}
}
</script>
<style scoped>
.layout {
height: 100vh;
max-height: 100vh;
overflow: hidden;
background: #e8f0f7;
}
.aside {
position: relative;
height: 100%;
overflow: hidden;
color: #fff;
transition: width 0.2s ease;
box-shadow: 2px 0 16px rgba(15, 61, 102, 0.2);
z-index: 20;
flex-shrink: 0;
}
.aside-bg {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(15, 61, 102, 0.92) 0%, rgba(26, 106, 162, 0.88) 55%, rgba(31, 135, 197, 0.9) 100%),
url('/images/aside-bg.jpg') center / cover no-repeat;
z-index: 0;
}
.aside-inner {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 16px 14px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
flex-shrink: 0;
}
.brand-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.16);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-title {
font-size: 16px;
font-weight: 700;
letter-spacing: 0.5px;
}
.brand-sub {
font-size: 12px;
opacity: 0.78;
margin-top: 2px;
}
.aside-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 10px 8px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
}
.aside-scroll::-webkit-scrollbar {
width: 5px;
}
.aside-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.side-menu {
border-right: none !important;
}
.side-menu :deep(.el-menu-item) {
border-radius: 10px;
margin: 4px 0;
height: 46px;
transition: background-color 0.2s ease, color 0.2s ease, padding 0.2s ease;
}
.side-menu :deep(.el-menu-item.is-active) {
background: rgba(255, 255, 255, 0.2) !important;
font-weight: 600;
}
.side-menu :deep(.el-menu-item:hover) {
background: rgba(255, 255, 255, 0.12) !important;
}
.aside-footer {
padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
flex-shrink: 0;
}
.collapse-btn {
width: 100%;
color: rgba(255, 255, 255, 0.9) !important;
justify-content: flex-start;
}
.main-wrap {
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 64px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid #e6edf5;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 10;
box-shadow: 0 1px 8px rgba(15, 61, 102, 0.06);
}
.header-right {
display: flex;
align-items: center;
}
.user-chip {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: #334155;
}
.avatar {
background: linear-gradient(135deg, #1e6091, #38bdf8);
color: #fff;
font-weight: 700;
}
.user-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
line-height: 1.2;
}
.user-meta .name {
font-size: 14px;
font-weight: 600;
}
.caret {
color: #94a3b8;
}
.main {
position: relative;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
background:
linear-gradient(160deg, rgba(240, 247, 252, 0.9) 0%, rgba(232, 242, 250, 0.88) 45%, rgba(255, 255, 255, 0.93) 100%),
url('/images/main-bg.jpg') center / cover no-repeat;
background-attachment: local;
scrollbar-width: thin;
scrollbar-color: #94a3b8 transparent;
}
.main::-webkit-scrollbar {
width: 8px;
}
.main::-webkit-scrollbar-thumb {
background: #94a3b8;
border-radius: 6px;
}
.main-content {
position: relative;
z-index: 1;
padding: 20px 24px 32px;
min-height: 100%;
/* 为 out-in 过渡预留稳定高度,减少闪白 */
isolation: isolate;
}
/* 页面视图根节点:保证 transition 作用在整页内容上 */
.page-view {
display: block;
width: 100%;
min-height: 40vh;
will-change: opacity, transform;
}
/* ---------- 业务页切换:轻量上浮淡入 / 淡出 ---------- */
.page-enter-active {
transition:
opacity 0.28s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.page-leave-active {
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(12px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-6px);
}
/* 面包屑标题轻微过渡(跟随路由文案变化) */
.header-left :deep(.el-breadcrumb__inner) {
transition: color 0.2s ease;
}
.avatar-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 8px 0 4px;
}
.avatar-panel .preview {
font-size: 28px;
}
.avatar-tip {
font-size: 12px;
color: #94a3b8;
}
/* 系统偏好:减少动效时仅保留瞬时切换 */
@media (prefers-reduced-motion: reduce) {
.page-enter-active,
.page-leave-active {
transition: none !important;
}
.page-enter-from,
.page-leave-to {
opacity: 1;
transform: none;
}
.page-view {
will-change: auto;
}
}
@media (max-width: 900px) {
.user-meta {
display: none;
}
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
+108
View File
@@ -0,0 +1,108 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { public: true }
},
{
path: '/',
component: () => import('@/layouts/BasicLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘' }
},
{
path: 'patients',
name: 'Patients',
component: () => import('@/views/Patients.vue'),
meta: { title: '患者管理' }
},
{
path: 'imaging',
name: 'Imaging',
component: () => import('@/views/Imaging.vue'),
meta: { title: '影像诊断' }
},
{
path: 'emrs',
name: 'Emrs',
component: () => import('@/views/Emrs.vue'),
meta: { title: '电子病历' }
},
{
path: 'appointments',
name: 'Appointments',
component: () => import('@/views/Appointments.vue'),
meta: { title: '预约挂号' }
},
{
path: 'ai-assistant',
name: 'AiAssistant',
component: () => import('@/views/AiAssistant.vue'),
meta: { title: 'AI 助手' }
},
{
path: 'ai-settings',
name: 'AiSettings',
component: () => import('@/views/AiSettings.vue'),
meta: { title: 'AI 配置', roles: ['ADMIN'] }
},
{
path: 'ai-yolo',
name: 'AiYolo',
component: () => import('@/views/AiYolo.vue'),
meta: { title: 'YOLO 权重', roles: ['ADMIN'] }
},
{
path: 'ai-knowledge',
name: 'AiKnowledge',
component: () => import('@/views/AiKnowledge.vue'),
meta: { title: '知识库' }
},
{
path: 'users',
name: 'Users',
component: () => import('@/views/Users.vue'),
meta: { title: '用户管理', roles: ['ADMIN'] }
},
{
path: '403',
name: 'Forbidden',
component: () => import('@/views/Forbidden.vue'),
meta: { title: '无权访问' }
}
]
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to) => {
const store = useUserStore()
// 已登录访问登录页,直接进首页
if (to.path === '/login' && store.token) {
return { path: '/dashboard' }
}
if (to.meta.public) return true
if (!store.token) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (to.meta.roles && !to.meta.roles.includes(store.role)) {
return { path: '/403' }
}
return true
})
export default router
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { login as apiLogin, getMe } from '@/api/auth'
const TOKEN_KEY = 'sh_token'
const USER_KEY = 'sh_user'
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem(TOKEN_KEY) || '',
userInfo: JSON.parse(localStorage.getItem(USER_KEY) || 'null')
}),
getters: {
role: (state) => state.userInfo?.role || '',
isAdmin: (state) => state.userInfo?.role === 'ADMIN',
displayName: (state) => state.userInfo?.realName || state.userInfo?.username || ''
},
actions: {
async login(payload) {
const data = await apiLogin(payload)
this.token = data.token
this.userInfo = data.user
localStorage.setItem(TOKEN_KEY, data.token)
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
},
async fetchMe() {
const user = await getMe()
this.userInfo = user
localStorage.setItem(USER_KEY, JSON.stringify(user))
},
setUserInfo(user) {
this.userInfo = user
localStorage.setItem(USER_KEY, JSON.stringify(user))
},
reset() {
this.token = ''
this.userInfo = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
}
})
+73
View File
@@ -0,0 +1,73 @@
/** 枚举与状态的中文展示 */
export const genderLabel = (g) =>
({ MALE: '男', FEMALE: '女', OTHER: '其他' }[g] || g || '—')
export const roleLabel = (r) =>
({ ADMIN: '管理员', DOCTOR: '医生', RADIOLOGIST: '影像医师' }[r] || r || '—')
export const roleTagType = (r) =>
({ ADMIN: 'danger', DOCTOR: 'success', RADIOLOGIST: 'warning' }[r] || 'info')
export const studyTypeLabel = (t) =>
({ X_RAY: 'X 光', CT: 'CT', MRI: 'MRI', ULTRASOUND: '超声' }[t] || t || '—')
export const imagingStatusLabel = (s) =>
({
PENDING: '待诊断',
ANALYZING: '分析中',
COMPLETED: '已完成',
ERROR: '失败'
}[s] || s || '—')
export const imagingStatusType = (s) =>
({
PENDING: 'info',
ANALYZING: 'warning',
COMPLETED: 'success',
ERROR: 'danger'
}[s] || 'info')
export const riskLevelType = (level) => {
const l = String(level || '').toUpperCase()
if (l.includes('高') || l === 'HIGH') return 'danger'
if (l.includes('中') || l === 'MEDIUM') return 'warning'
if (l.includes('低') || l === 'LOW') return 'success'
return 'info'
}
export const formatPercent = (v) => {
if (v == null || Number.isNaN(Number(v))) return '—'
const n = Number(v)
const pct = n <= 1 ? n * 100 : n
return `${pct.toFixed(1)}%`
}
export const formatDateTime = (v) => {
if (!v) return '—'
return String(v).replace('T', ' ').slice(0, 19)
}
export const appointmentStatusLabel = (s) =>
({
SCHEDULED: '已预约',
CONFIRMED: '已确认',
COMPLETED: '已完成',
CANCELLED: '已取消',
NO_SHOW: '未到诊'
}[s] || s || '—')
export const appointmentStatusType = (s) =>
({
SCHEDULED: 'info',
CONFIRMED: 'primary',
COMPLETED: 'success',
CANCELLED: 'info',
NO_SHOW: 'danger'
}[s] || 'info')
export const formatTime = (v) => {
if (!v) return '—'
return String(v).slice(0, 5)
}
+24
View File
@@ -0,0 +1,24 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
marked.setOptions({
gfm: true,
breaks: true // 单个换行也转 <br>
})
/**
* 将 Markdown 转为安全 HTML(助手消息展示用)
*/
export function renderMarkdown(text) {
if (text == null || text === '') return ''
const raw = String(text)
try {
const html = marked.parse(raw)
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true }
})
} catch {
// 解析失败时退回纯文本转义
return DOMPurify.sanitize(raw.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br/>'))
}
}
+59
View File
@@ -0,0 +1,59 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'
const service = axios.create({
baseURL: '/api',
timeout: 20000
})
service.interceptors.request.use((config) => {
const store = useUserStore()
if (store.token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${store.token}`
}
return config
})
let lastAuthTipAt = 0
service.interceptors.response.use(
(response) => {
const data = response.data
if (typeof data === 'object' && data !== null && 'code' in data) {
if (data.code === 0) {
return data.data
}
ElMessage.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || 'Error'))
}
return data
},
(error) => {
const status = error.response?.status
const msg = error.response?.data?.message || error.message
if (status === 401) {
const store = useUserStore()
store.reset()
const now = Date.now()
if (now - lastAuthTipAt > 1500) {
lastAuthTipAt = now
ElMessage.error('登录已失效,请重新登录')
}
if (router.currentRoute.value.path !== '/login') {
router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
}
} else if (status === 403) {
ElMessage.error('无权访问该资源')
} else if (error.code === 'ECONNABORTED') {
ElMessage.error('请求超时,请稍后重试')
} else {
ElMessage.error(msg || '网络异常')
}
return Promise.reject(error)
}
)
export default service
+898
View File
@@ -0,0 +1,898 @@
<template>
<div class="assistant-page">
<div class="shell">
<!-- 顶栏 -->
<div class="topbar">
<div class="topbar-left">
<div class="brand-mark">
<el-icon :size="20"><ChatDotRound /></el-icon>
</div>
<div>
<div class="title">AI 助手</div>
<div class="sub">医疗知识问答 · 支持纯模型与知识库增强</div>
</div>
</div>
<div class="topbar-right">
<el-segmented v-model="mode" :options="modeOptions" />
<el-tooltip content="刷新历史" placement="top">
<el-button :icon="Refresh" circle @click="loadHistory" :loading="historyLoading" />
</el-tooltip>
<el-tooltip content="清空历史" placement="top">
<el-button :icon="Delete" circle type="danger" plain @click="onClear" />
</el-tooltip>
</div>
</div>
<!-- 模式说明条 -->
<div class="mode-bar" :class="mode === 'RAG' ? 'rag' : 'chat'">
<el-icon><component :is="mode === 'RAG' ? Collection : Cpu" /></el-icon>
<span v-if="mode === 'RAG'">
<b>RAG 知识库</b>:先检索本地知识,再生成回答;未配置 API 时展示检索摘录
</span>
<span v-else>
<b>纯 AI 问答</b>:直接调用大模型(需管理员配置并启用 API)
</span>
<span class="msg-count" v-if="pairCount">共 {{ pairCount }} 轮对话</span>
</div>
<!-- 对话区 -->
<div ref="listRef" class="chat-list" v-loading="historyLoading">
<!-- 欢迎 / 空状态 -->
<div v-if="!messages.length && !sending" class="welcome">
<div class="welcome-icon">
<el-icon :size="36"><ChatLineRound /></el-icon>
</div>
<h3>您好,我是医院 AI 助手</h3>
<p>可咨询用药要点、检查须知、就诊流程等。以下问题可一键试问:</p>
<div class="presets">
<button
v-for="p in presets"
:key="p"
type="button"
class="preset"
@click="askPreset(p)"
>
{{ p }}
</button>
</div>
</div>
<template v-else>
<div
v-for="(m, idx) in messages"
:key="m.id || idx"
class="msg-row"
:class="m.role"
>
<div class="avatar" :class="m.role">
<el-icon v-if="m.role === 'assistant'"><Cpu /></el-icon>
<span v-else>{{ userInitial }}</span>
</div>
<div class="bubble-wrap">
<div class="meta">
<span class="who">{{ m.role === 'user' ? '我' : 'AI 助手' }}</span>
<el-tag v-if="m.mode" size="small" effect="plain" round>
{{ m.mode === 'RAG' ? 'RAG' : '纯 AI' }}
</el-tag>
<el-tag v-if="m.usedLlm" size="small" type="success" effect="plain" round>LLM</el-tag>
<el-tag v-if="m.fallback" size="small" type="warning" effect="plain" round>本地降级</el-tag>
<span v-if="m.time" class="time">{{ m.time }}</span>
</div>
<div class="bubble" :class="m.role">
<!-- 用户消息纯文本;助手消息 Markdown 渲染 -->
<div
v-if="m.role === 'assistant'"
class="content md-body"
v-html="renderMarkdown(m.content)"
/>
<div v-else class="content">{{ m.content }}</div>
<div v-if="m.sources?.length" class="sources">
<div class="sources-head" @click="m.sourcesOpen = !m.sourcesOpen">
<el-icon><Document /></el-icon>
<span>参考资料({{ m.sources.length }})</span>
<el-icon class="chev">
<ArrowUp v-if="m.sourcesOpen" />
<ArrowDown v-else />
</el-icon>
</div>
<div v-show="m.sourcesOpen !== false" class="sources-body">
<div v-for="(s, si) in m.sources" :key="s.id || si" class="source-card">
<div class="source-title">
<span class="idx">{{ si + 1 }}</span>
<b>{{ s.title || '未命名' }}</b>
<el-tag v-if="s.category" size="small" type="info" effect="plain">{{ s.category }}</el-tag>
</div>
<p>{{ s.snippet }}</p>
</div>
</div>
</div>
<div v-if="m.role === 'assistant'" class="bubble-actions">
<el-button link type="primary" size="small" @click="copyText(m.content)">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
<el-button
v-if="m.error && m.retryQuestion"
link
type="warning"
size="small"
@click="retry(m)"
>
重试
</el-button>
</div>
</div>
</div>
</div>
<!-- 正在输入 -->
<div v-if="sending" class="msg-row assistant">
<div class="avatar assistant">
<el-icon><Cpu /></el-icon>
</div>
<div class="bubble-wrap">
<div class="meta"><span class="who">AI 助手</span></div>
<div class="bubble assistant typing">
<span class="dot" /><span class="dot" /><span class="dot" />
<span class="typing-text">正在思考…</span>
</div>
</div>
</div>
</template>
</div>
<!-- 输入区 -->
<div class="composer">
<div class="composer-box">
<el-input
v-model="question"
type="textarea"
:rows="2"
resize="none"
maxlength="2000"
show-word-limit
:disabled="sending"
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
@keydown="onKeydown"
/>
<div class="composer-footer">
<div class="hints">
<el-tag size="small" effect="plain" type="info">{{ mode === 'RAG' ? 'RAG 模式' : '纯 AI 模式' }}</el-tag>
<span class="tip">回答仅供参考,不能替代执业医师诊疗意见</span>
</div>
<el-button
type="primary"
:loading="sending"
:disabled="!question.trim() || sending"
@click="onSendClick"
>
<el-icon v-if="!sending"><Promotion /></el-icon>
{{ sending ? '发送中' : '发送' }}
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ChatDotRound,
ChatLineRound,
Cpu,
Collection,
Refresh,
Delete,
Document,
ArrowUp,
ArrowDown,
CopyDocument,
Promotion
} from '@element-plus/icons-vue'
import { aiChat, aiChatHistory, clearAiChatHistory } from '@/api/ai'
import { useUserStore } from '@/stores/user'
import { renderMarkdown } from '@/utils/markdown'
const userStore = useUserStore()
const mode = ref('RAG')
const modeOptions = [
{ label: '纯 AI 问答', value: 'CHAT' },
{ label: 'RAG 知识库', value: 'RAG' }
]
const question = ref('')
const sending = ref(false)
const historyLoading = ref(false)
const messages = ref([])
const listRef = ref()
let msgSeq = 1
const presets = [
'高血压一线用药有哪些?',
'社区获得性肺炎如何初步处理?',
'胸部 CT 检查前要注意什么?',
'如何预约挂号?'
]
const userInitial = computed(() => (userStore.displayName || '我').slice(0, 1))
const pairCount = computed(() => Math.floor(messages.value.filter((m) => m.role === 'user').length))
const nowTime = () => {
const d = new Date()
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const formatHistTime = (v) => {
if (!v) return ''
const s = String(v).replace('T', ' ')
return s.length >= 16 ? s.slice(11, 16) : s
}
const scrollBottom = async (smooth = true) => {
await nextTick()
const el = listRef.value
if (!el) return
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
}
const loadHistory = async () => {
historyLoading.value = true
try {
const list = await aiChatHistory({ limit: 40 })
const rows = Array.isArray(list) ? list.slice().reverse() : []
const mapped = []
for (const h of rows) {
mapped.push({
id: `u-${h.id}`,
role: 'user',
content: h.question,
mode: h.mode,
time: formatHistTime(h.createdAt)
})
let sources = []
try {
sources = h.sourcesJson ? JSON.parse(h.sourcesJson) : []
} catch {
sources = []
}
mapped.push({
id: `a-${h.id}`,
role: 'assistant',
content: h.answer,
mode: h.mode,
sources,
sourcesOpen: false,
time: formatHistTime(h.createdAt)
})
}
messages.value = mapped
await scrollBottom(false)
} finally {
historyLoading.value = false
}
}
/** 仅接受字符串作为覆盖文本;避免 @click 把 MouseEvent 传进来 */
const send = async (text) => {
const raw = typeof text === 'string' ? text : question.value
const q = String(raw || '').trim()
if (!q || sending.value) return
const fromInput = typeof text !== 'string'
const userMsg = {
id: `local-u-${msgSeq++}`,
role: 'user',
content: q,
mode: mode.value,
time: nowTime()
}
messages.value.push(userMsg)
if (fromInput) question.value = ''
sending.value = true
await scrollBottom()
try {
const res = await aiChat({ mode: mode.value, question: q })
messages.value.push({
id: `local-a-${msgSeq++}`,
role: 'assistant',
content: res?.answer || '(无回复)',
mode: res?.mode || mode.value,
sources: res?.sources || [],
sourcesOpen: true,
usedLlm: !!res?.usedLlm,
fallback: !!res?.fallback,
time: nowTime()
})
} catch {
messages.value.push({
id: `local-e-${msgSeq++}`,
role: 'assistant',
content: '请求失败,请稍后重试。若持续失败,请管理员检查「AI 配置」中的接口与密钥。',
mode: mode.value,
fallback: true,
error: true,
retryQuestion: q,
time: nowTime()
})
} finally {
sending.value = false
await scrollBottom()
}
}
const onSendClick = () => {
send()
}
const askPreset = (text) => {
mode.value = 'RAG'
send(text)
}
const retry = (m) => {
if (m.retryQuestion) send(m.retryQuestion)
}
const onKeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
const copyText = async (text) => {
try {
await navigator.clipboard.writeText(text || '')
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.warning('复制失败,请手动选择文本')
}
}
const onClear = async () => {
await ElMessageBox.confirm('确定清空当前账号的全部对话历史?此操作不可恢复。', '清空历史', {
type: 'warning',
confirmButtonText: '清空',
cancelButtonText: '取消'
})
await clearAiChatHistory()
messages.value = []
ElMessage.success('历史已清空')
}
onMounted(loadHistory)
</script>
<style scoped>
.assistant-page {
height: calc(100vh - 108px);
min-height: 520px;
display: flex;
}
.shell {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.96);
border-radius: 16px;
border: 1px solid #e8eef5;
box-shadow: 0 8px 28px rgba(15, 61, 102, 0.06);
overflow: hidden;
min-height: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 18px;
border-bottom: 1px solid #eef2f7;
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
flex-shrink: 0;
}
.topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(135deg, #1e6091, #38bdf8);
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 2px;
font-size: 12px;
color: #94a3b8;
}
.topbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mode-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
font-size: 13px;
color: #475569;
border-bottom: 1px solid #eef2f7;
flex-shrink: 0;
}
.mode-bar.rag {
background: #f0fdf4;
color: #166534;
}
.mode-bar.chat {
background: #eff6ff;
color: #1d4ed8;
}
.mode-bar .msg-count {
margin-left: auto;
font-size: 12px;
opacity: 0.85;
}
.chat-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 18px 16px 12px;
background:
radial-gradient(circle at 10% 0%, rgba(56, 189, 248, 0.06), transparent 40%),
radial-gradient(circle at 90% 20%, rgba(30, 96, 145, 0.05), transparent 35%),
#f5f8fc;
scrollbar-width: thin;
}
.welcome {
max-width: 640px;
margin: 40px auto;
text-align: center;
padding: 24px 16px;
}
.welcome-icon {
width: 72px;
height: 72px;
margin: 0 auto 14px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #1e6091;
background: #e0f2fe;
}
.welcome h3 {
margin: 0 0 8px;
font-size: 20px;
color: #0f172a;
}
.welcome p {
margin: 0 0 18px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.presets {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.preset {
border: 1px solid #dbe3ee;
background: #fff;
color: #334155;
border-radius: 999px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
}
.preset:hover {
border-color: #93c5fd;
background: #eff6ff;
color: #1d4ed8;
}
.msg-row {
display: flex;
gap: 10px;
margin-bottom: 16px;
max-width: 900px;
margin-left: auto;
margin-right: auto;
}
.msg-row.user {
flex-direction: row-reverse;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 10px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
}
.avatar.assistant {
background: linear-gradient(135deg, #1e6091, #38bdf8);
color: #fff;
}
.avatar.user {
background: #e2e8f0;
color: #334155;
}
.bubble-wrap {
max-width: min(720px, calc(100% - 50px));
min-width: 0;
}
.msg-row.user .bubble-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 6px;
font-size: 12px;
color: #94a3b8;
}
.msg-row.user .meta {
justify-content: flex-end;
}
.who {
font-weight: 600;
color: #64748b;
}
.time {
margin-left: 2px;
}
.bubble {
padding: 12px 14px;
border-radius: 14px;
line-height: 1.65;
word-break: break-word;
}
.bubble.user {
background: linear-gradient(135deg, #dbeafe, #e0f2fe);
color: #0f172a;
border-bottom-right-radius: 4px;
}
.bubble.assistant {
background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 10px rgba(15, 61, 102, 0.04);
border-bottom-left-radius: 4px;
}
.content {
font-size: 14px;
color: #0f172a;
white-space: pre-wrap;
}
/* Markdown 渲染样式(助手消息) */
.md-body {
white-space: normal;
line-height: 1.7;
word-break: break-word;
}
.md-body :deep(p) {
margin: 0 0 0.65em;
}
.md-body :deep(p:last-child) {
margin-bottom: 0;
}
.md-body :deep(h1),
.md-body :deep(h2),
.md-body :deep(h3),
.md-body :deep(h4) {
margin: 0.9em 0 0.45em;
font-weight: 700;
color: #0f172a;
line-height: 1.35;
}
.md-body :deep(h1) { font-size: 1.25em; }
.md-body :deep(h2) { font-size: 1.12em; }
.md-body :deep(h3) { font-size: 1.05em; }
.md-body :deep(ul),
.md-body :deep(ol) {
margin: 0.4em 0 0.7em;
padding-left: 1.4em;
}
.md-body :deep(li) {
margin: 0.2em 0;
}
.md-body :deep(li > p) {
margin: 0.15em 0;
}
.md-body :deep(blockquote) {
margin: 0.5em 0;
padding: 0.35em 0.85em;
border-left: 3px solid #93c5fd;
background: #f0f9ff;
color: #334155;
border-radius: 0 6px 6px 0;
}
.md-body :deep(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
padding: 0.12em 0.4em;
border-radius: 4px;
background: #f1f5f9;
color: #be185d;
}
.md-body :deep(pre) {
margin: 0.55em 0;
padding: 10px 12px;
border-radius: 8px;
background: #0f172a;
overflow-x: auto;
}
.md-body :deep(pre code) {
padding: 0;
background: transparent;
color: #e2e8f0;
font-size: 0.88em;
line-height: 1.55;
}
.md-body :deep(a) {
color: #2563eb;
text-decoration: none;
}
.md-body :deep(a:hover) {
text-decoration: underline;
}
.md-body :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 0.6em 0;
font-size: 0.92em;
}
.md-body :deep(th),
.md-body :deep(td) {
border: 1px solid #e2e8f0;
padding: 6px 10px;
text-align: left;
}
.md-body :deep(th) {
background: #f8fafc;
font-weight: 600;
}
.md-body :deep(hr) {
border: none;
border-top: 1px solid #e2e8f0;
margin: 0.9em 0;
}
.md-body :deep(strong) {
font-weight: 700;
color: #0f172a;
}
.sources {
margin-top: 12px;
border-top: 1px dashed #e2e8f0;
padding-top: 10px;
}
.sources-head {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #475569;
cursor: pointer;
user-select: none;
}
.sources-head .chev {
margin-left: auto;
}
.sources-body {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.source-card {
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: 10px;
padding: 10px 12px;
}
.source-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
color: #0f172a;
}
.source-title .idx {
width: 18px;
height: 18px;
border-radius: 50%;
background: #dbeafe;
color: #1d4ed8;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.source-card p {
margin: 6px 0 0;
font-size: 12px;
color: #64748b;
line-height: 1.55;
}
.bubble-actions {
margin-top: 8px;
display: flex;
gap: 4px;
}
.bubble.typing {
display: flex;
align-items: center;
gap: 6px;
min-width: 88px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #94a3b8;
animation: bounce 1.2s infinite ease-in-out;
}
.dot:nth-child(2) {
animation-delay: 0.15s;
}
.dot:nth-child(3) {
animation-delay: 0.3s;
}
.typing-text {
margin-left: 4px;
font-size: 12px;
color: #94a3b8;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.5;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
.composer {
flex-shrink: 0;
padding: 12px 16px 16px;
border-top: 1px solid #eef2f7;
background: #fff;
}
.composer-box {
max-width: 900px;
margin: 0 auto;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 10px 12px 8px;
background: #fafcff;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.composer-box:focus-within {
border-color: #93c5fd;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.composer-box :deep(.el-textarea__inner) {
box-shadow: none !important;
background: transparent;
padding: 4px 4px 0;
}
.composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
flex-wrap: wrap;
}
.hints {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tip {
font-size: 12px;
color: #94a3b8;
}
@media (max-width: 768px) {
.assistant-page {
height: auto;
min-height: calc(100vh - 120px);
}
.chat-list {
min-height: 360px;
}
.sub {
display: none;
}
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<template>
<div class="page">
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">{{ isAdmin ? '知识库管理' : '临床知识库' }}</div>
<div class="sub">
供 AI 助手 RAG 与病历辅助决策检索的本地医疗知识
<span v-if="!isAdmin">(浏览模式,增删改请使用管理员账号)</span>
</div>
</div>
<div class="tools">
<el-input
v-model="keyword"
placeholder="搜索标题 / 分类 / 内容"
clearable
style="width: 240px"
@clear="reload"
@keyup.enter="reload"
/>
<el-button @click="reload">查询</el-button>
<el-button v-if="isAdmin" type="primary" @click="openForm()">新增文档</el-button>
</div>
</div>
</template>
<el-table :data="rows" v-loading="loading" stripe empty-text="暂无知识文档">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column label="状态" :width="isAdmin ? 130 : 90" align="center">
<template #default="{ row }">
<div v-if="isAdmin" class="status-switch">
<el-switch
:model-value="row.enabled !== false"
:loading="togglingId === row.id"
inline-prompt
active-text="启用"
inactive-text="停用"
style="--el-switch-on-color: #67c23a; --el-switch-off-color: #909399"
@change="(val) => toggleEnabled(row, val)"
/>
</div>
<el-tag v-else :type="row.enabled === false ? 'info' : 'success'" size="small" effect="light">
{{ row.enabled === false ? '停用' : '启用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="摘要" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" :width="isAdmin ? 180 : 100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDoc(row)">查看</el-button>
<template v-if="isAdmin">
<el-button link type="primary" @click="openForm(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="form.id ? '编辑文档' : '新增文档'"
width="640px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="200" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="14">
<el-form-item label="分类">
<el-select v-model="form.category" filterable allow-create default-first-option style="width: 100%">
<el-option label="诊疗指南" value="诊疗指南" />
<el-option label="用药说明" value="用药说明" />
<el-option label="检查须知" value="检查须知" />
<el-option label="医院制度" value="医院制度" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="10">
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="正文" prop="content">
<el-input v-model="form.content" type="textarea" :rows="10" placeholder="写入可供检索的知识正文" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保 存</el-button>
</template>
</el-dialog>
<el-dialog v-model="viewVisible" title="知识详情" width="640px" destroy-on-close align-center append-to-body class="form-dialog">
<template v-if="viewDocData">
<div class="view-meta">
<el-tag size="small">{{ viewDocData.category || '未分类' }}</el-tag>
<el-tag size="small" :type="viewDocData.enabled === false ? 'info' : 'success'">
{{ viewDocData.enabled === false ? '停用' : '启用' }}
</el-tag>
</div>
<h3 class="view-title">{{ viewDocData.title }}</h3>
<pre class="view-body">{{ viewDocData.content }}</pre>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listKnowledge,
createKnowledge,
updateKnowledge,
deleteKnowledge,
setKnowledgeEnabled
} from '@/api/ai'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin)
const rows = ref([])
const loading = ref(false)
const togglingId = ref(null)
const keyword = ref('')
const pager = reactive({ page: 1, size: 10, total: 0 })
const dialogVisible = ref(false)
const viewVisible = ref(false)
const viewDocData = ref(null)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
id: null,
title: '',
category: '诊疗指南',
content: '',
enabled: true
})
const form = reactive(emptyForm())
const rules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入正文', trigger: 'blur' }]
}
const viewDoc = (row) => {
viewDocData.value = row
viewVisible.value = true
}
const loadPage = async () => {
loading.value = true
try {
const data = await listKnowledge({
page: pager.page - 1,
size: pager.size,
keyword: keyword.value || undefined
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
}
const openForm = (row) => {
if (row) {
Object.assign(form, {
id: row.id,
title: row.title,
category: row.category || '其他',
content: row.content,
enabled: row.enabled !== false
})
} else {
Object.assign(form, emptyForm())
}
dialogVisible.value = true
}
const save = async () => {
await formRef.value.validate()
saving.value = true
try {
const payload = {
title: form.title,
category: form.category,
content: form.content,
enabled: form.enabled
}
if (form.id) await updateKnowledge(form.id, payload)
else await createKnowledge(payload)
ElMessage.success('保存成功')
dialogVisible.value = false
loadPage()
} finally {
saving.value = false
}
}
const remove = async (row) => {
await ElMessageBox.confirm(`删除知识文档「${row.title}」?`, '确认', { type: 'warning' })
await deleteKnowledge(row.id)
ElMessage.success('已删除')
loadPage()
}
/** 列表一键启用 / 停用 */
const toggleEnabled = async (row, enabled) => {
const next = !!enabled
const prev = row.enabled !== false
if (next === prev) return
togglingId.value = row.id
row.enabled = next
try {
const data = await setKnowledgeEnabled(row.id, next)
row.enabled = data?.enabled !== false
ElMessage.success(next ? `已启用「${row.title}」` : `已停用「${row.title}」(不再参与 RAG 检索)`)
} catch {
row.enabled = prev
} finally {
togglingId.value = null
}
}
onMounted(loadPage)
</script>
<style scoped>
.status-switch {
display: flex;
justify-content: center;
align-items: center;
}
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.tools {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.pager {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.view-meta {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.view-title {
margin: 0 0 12px;
color: #0f172a;
}
.view-body {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
line-height: 1.7;
color: #334155;
background: #f8fafc;
border-radius: 10px;
padding: 12px 14px;
max-height: 420px;
overflow: auto;
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="page">
<el-card shadow="never" class="panel" v-loading="loading">
<template #header>
<div class="header">
<div>
<div class="title">AI 配置</div>
<div class="sub">配置兼容 OpenAI 协议的大模型接口(Base URL / API Key / 模型)</div>
</div>
<div class="tools">
<el-button :loading="testing" @click="onTest">测试连接</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存配置</el-button>
</div>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
class="mb16"
title="支持 OpenAI / DeepSeek / 通义兼容模式 / Ollama 等提供 Chat Completions 的服务。"
description="请求路径为 {Base URL}/v1/chat/completions。保存后会同步到 AI 微服务:对话、影像报告、病历决策共用此配置。若仅改 .env 未在此启用,影像诊断仍可能走模板。"
/>
<el-form ref="formRef" :model="form" label-width="120px" style="max-width: 720px">
<el-form-item label="启用大模型">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="关闭" />
</el-form-item>
<el-form-item label="API Base URL">
<el-input v-model="form.apiBaseUrl" placeholder="https://api.openai.com" />
</el-form-item>
<el-form-item label="API Key">
<el-input
v-model="form.apiKey"
type="password"
show-password
:placeholder="form.apiKeyConfigured ? '已配置(留空则不修改)' : '请输入 API Key'"
/>
<div v-if="form.apiKeyConfigured" class="hint">当前已配置密钥(显示为脱敏值),修改时请重新完整填写</div>
</el-form-item>
<el-form-item label="模型名称">
<el-input v-model="form.model" placeholder="gpt-4o-mini / deepseek-chat" />
</el-form-item>
<el-form-item label="温度">
<el-slider
v-model="form.temperature"
:min="0"
:max="2"
:step="0.1"
show-input
:show-input-controls="false"
style="width: 100%; max-width: 100%; box-sizing: border-box"
/>
</el-form-item>
<el-form-item label="系统提示词">
<el-input v-model="form.systemPrompt" type="textarea" :rows="5" placeholder="定义助手角色与回答规范" />
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getAiSettings, saveAiSettings, testAiSettings } from '@/api/ai'
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const formRef = ref()
const form = reactive({
apiBaseUrl: 'https://api.openai.com',
apiKey: '',
apiKeyConfigured: false,
model: 'gpt-4o-mini',
enabled: false,
temperature: 0.7,
systemPrompt: ''
})
const load = async () => {
loading.value = true
try {
const data = await getAiSettings()
Object.assign(form, {
apiBaseUrl: data?.apiBaseUrl || '',
apiKey: '',
apiKeyConfigured: !!data?.apiKeyConfigured,
model: data?.model || '',
enabled: !!data?.enabled,
temperature: data?.temperature ?? 0.7,
systemPrompt: data?.systemPrompt || ''
})
} finally {
loading.value = false
}
}
const onSave = async () => {
saving.value = true
try {
const payload = {
apiBaseUrl: form.apiBaseUrl,
model: form.model,
enabled: form.enabled,
temperature: form.temperature,
systemPrompt: form.systemPrompt
}
if (form.apiKey && form.apiKey.trim()) {
payload.apiKey = form.apiKey.trim()
}
const data = await saveAiSettings(payload)
form.apiKey = ''
form.apiKeyConfigured = !!data?.apiKeyConfigured
ElMessage.success('配置已保存')
} finally {
saving.value = false
}
}
const onTest = async () => {
testing.value = true
try {
const data = await testAiSettings()
ElMessage.success(`连接成功:${data?.reply || 'OK'}`)
} catch {
/* 拦截器已提示 */
} finally {
testing.value = false
}
}
onMounted(load)
</script>
<style scoped>
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.tools {
display: flex;
gap: 8px;
}
.mb16 {
margin-bottom: 16px;
}
.hint {
margin-top: 6px;
font-size: 12px;
color: #94a3b8;
}
</style>
File diff suppressed because it is too large Load Diff
+853
View File
@@ -0,0 +1,853 @@
<template>
<div class="page">
<!-- 统计概览 -->
<el-row :gutter="12" class="stat-row">
<el-col :xs="12" :sm="8" :md="4" v-for="item in statCards" :key="item.key">
<div
class="stat-card"
:class="[{ active: item.active }, item.key]"
@click="applyQuick(item)"
>
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ item.value }}</div>
</div>
</el-col>
</el-row>
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">预约挂号</div>
<div class="sub">安排就诊时段,跟踪预约进度</div>
</div>
<el-button type="primary" @click="openForm()">新建预约</el-button>
</div>
</template>
<!-- 快捷筛选 -->
<div class="filter-bar">
<el-radio-group v-model="quickDate" size="default" @change="onQuickDate">
<el-radio-button value="all">全部日期</el-radio-button>
<el-radio-button value="today">今日</el-radio-button>
<el-radio-button value="tomorrow">明日</el-radio-button>
</el-radio-group>
<el-radio-group v-model="filters.status" size="default" @change="reload">
<el-radio-button value="">全部状态</el-radio-button>
<el-radio-button value="SCHEDULED">已预约</el-radio-button>
<el-radio-button value="CONFIRMED">已确认</el-radio-button>
<el-radio-button value="COMPLETED">已完成</el-radio-button>
<el-radio-button value="CANCELLED">已取消</el-radio-button>
<el-radio-button value="NO_SHOW">未到诊</el-radio-button>
</el-radio-group>
<div class="filter-right">
<el-input
v-model="filters.keyword"
placeholder="患者 / 科室 / 事由"
clearable
style="width: 200px"
@clear="reload"
@keyup.enter="reload"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-date-picker
v-model="filters.date"
type="date"
value-format="YYYY-MM-DD"
placeholder="指定日期"
style="width: 150px"
clearable
@change="onDatePick"
/>
<el-button @click="reload">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</div>
<el-table
:data="rows"
v-loading="loading"
stripe
empty-text="暂无预约数据"
row-class-name="appt-row"
@row-click="onRowClick"
>
<el-table-column prop="id" label="编号" width="70" />
<el-table-column label="患者" min-width="130">
<template #default="{ row }">
<div class="patient-cell">
<span class="p-name">{{ row.patientName || '—' }}</span>
<span v-if="row.patientPhone" class="p-phone">{{ row.patientPhone }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="doctorName" label="接诊医生" min-width="100" />
<el-table-column prop="department" label="科室" min-width="90">
<template #default="{ row }">
<el-tag size="small" effect="plain" type="info">{{ row.department || '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="预约时间" min-width="170">
<template #default="{ row }">
<div class="datetime-cell">
<span :class="{ 'is-today': isToday(row.appointmentDate), 'is-past': isPast(row) }">
{{ row.appointmentDate || '—' }}
</span>
<span v-if="row.appointmentTime" class="time">{{ formatTime(row.appointmentTime) }}</span>
<el-tag v-if="isToday(row.appointmentDate)" size="small" type="warning" effect="plain" class="today-tag">
今日
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="reason" label="事由" min-width="120" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="appointmentStatusType(row.status)" effect="light" size="small">
{{ appointmentStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="left">
<template #default="{ row }">
<div class="ops" @click.stop>
<el-button link type="primary" class="op-btn" @click="openDetail(row)">详情</el-button>
<el-button
v-if="canConfirm(row)"
link
type="success"
class="op-btn"
@click="changeStatus(row, 'CONFIRMED')"
>
确认
</el-button>
<el-button
v-if="canComplete(row)"
link
type="success"
class="op-btn"
@click="changeStatus(row, 'COMPLETED')"
>
完成
</el-button>
<el-button link type="primary" class="op-btn" @click="openForm(row)">编辑</el-button>
<el-dropdown trigger="click" class="op-drop" @command="(cmd) => changeStatus(row, cmd)">
<el-button link type="warning" class="op-btn">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="row.status !== 'SCHEDULED'"
command="SCHEDULED"
>
恢复预约
</el-dropdown-item>
<el-dropdown-item
v-if="row.status !== 'NO_SHOW'"
command="NO_SHOW"
>
标记未到诊
</el-dropdown-item>
<el-dropdown-item
v-if="row.status !== 'CANCELLED'"
command="CANCELLED"
divided
>
取消预约
</el-dropdown-item>
<el-dropdown-item command="__delete__" divided>
<span class="danger-text">删除记录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<!-- 新建 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="form.id ? '编辑预约' : '新建预约'"
width="600px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="患者" prop="patientId">
<el-select
v-model="form.patientId"
filterable
placeholder="搜索并选择患者"
style="width: 100%"
>
<el-option
v-for="p in patients"
:key="p.id"
:label="`${p.name}${p.phone ? ' · ' + p.phone : ''}(#${p.id})`"
:value="p.id"
/>
</el-select>
</el-form-item>
<el-form-item label="接诊医生" prop="doctorId">
<el-select
v-model="form.doctorId"
filterable
clearable
placeholder="默认当前登录账号"
style="width: 100%"
@change="onDoctorChange"
>
<el-option
v-for="d in doctors"
:key="d.id"
:label="`${d.realName || d.username}${d.department ? ' · ' + d.department : ''}`"
:value="d.id"
/>
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="科室" prop="department">
<el-select
v-model="form.department"
filterable
allow-create
default-first-option
placeholder="选择或输入科室"
style="width: 100%"
>
<el-option v-for="d in departmentOptions" :key="d" :label="d" :value="d" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" style="width: 100%">
<el-option label="已预约" value="SCHEDULED" />
<el-option label="已确认" value="CONFIRMED" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="已取消" value="CANCELLED" />
<el-option label="未到诊" value="NO_SHOW" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="预约日期" prop="appointmentDate">
<el-date-picker
v-model="form.appointmentDate"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="选择日期"
:disabled-date="disablePastDate"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="时段" prop="appointmentTime">
<el-time-select
v-model="form.appointmentTime"
start="08:00"
step="00:30"
end="18:00"
placeholder="选择时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="事由">
<el-select
v-model="form.reason"
filterable
allow-create
default-first-option
clearable
placeholder="选择或输入事由"
style="width: 100%"
>
<el-option v-for="r in reasonOptions" :key="r" :label="r" :value="r" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.notes" type="textarea" :rows="2" placeholder="可选,如特殊注意事项" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保 存</el-button>
</template>
</el-dialog>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="预约详情" size="420px" destroy-on-close append-to-body>
<template v-if="detail">
<div class="detail-status">
<el-tag :type="appointmentStatusType(detail.status)" size="large" effect="dark">
{{ appointmentStatusLabel(detail.status) }}
</el-tag>
<el-tag v-if="isToday(detail.appointmentDate)" type="warning" effect="plain">今日就诊</el-tag>
</div>
<el-descriptions :column="1" border size="default" class="detail-desc">
<el-descriptions-item label="预约编号">#{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="患者">{{ detail.patientName || '—' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detail.patientPhone || '—' }}</el-descriptions-item>
<el-descriptions-item label="接诊医生">{{ detail.doctorName || '—' }}</el-descriptions-item>
<el-descriptions-item label="科室">{{ detail.department || '—' }}</el-descriptions-item>
<el-descriptions-item label="预约时间">
{{ detail.appointmentDate || '—' }}
{{ detail.appointmentTime ? formatTime(detail.appointmentTime) : '' }}
</el-descriptions-item>
<el-descriptions-item label="事由">{{ detail.reason || '—' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detail.notes || '—' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</el-descriptions-item>
</el-descriptions>
<div class="detail-actions">
<el-button v-if="canConfirm(detail)" type="success" @click="changeStatus(detail, 'CONFIRMED')">
确认预约
</el-button>
<el-button v-if="canComplete(detail)" type="primary" @click="changeStatus(detail, 'COMPLETED')">
完成就诊
</el-button>
<el-button @click="openForm(detail); detailVisible = false">编辑</el-button>
<el-button
v-if="detail.status !== 'CANCELLED'"
type="danger"
plain
@click="changeStatus(detail, 'CANCELLED')"
>
取消预约
</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import {
listAppointments,
createAppointment,
updateAppointment,
updateAppointmentStatus,
deleteAppointment,
getAppointmentStats,
listAppointmentDoctors
} from '@/api/appointment'
import { listPatients } from '@/api/patient'
import { useUserStore } from '@/stores/user'
import {
appointmentStatusLabel,
appointmentStatusType,
formatTime,
formatDateTime
} from '@/utils/labels'
const userStore = useUserStore()
const rows = ref([])
const loading = ref(false)
const patients = ref([])
const doctors = ref([])
const pager = reactive({ page: 1, size: 10, total: 0 })
const filters = reactive({ keyword: '', status: '', date: '' })
const quickDate = ref('all')
const stats = ref({
total: 0,
today: 0,
tomorrow: 0,
scheduled: 0,
confirmed: 0,
completed: 0,
cancelled: 0,
noShow: 0,
todayPending: 0
})
const departmentOptions = ['内科', '外科', '儿科', '妇科', '骨科', '影像科', '心内科', '神经内科', '皮肤科', '急诊科']
const reasonOptions = ['初诊', '复诊', '体检', '取报告', '换药', '随访', '会诊']
const todayStr = () => new Date().toISOString().slice(0, 10)
const tomorrowStr = () => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().slice(0, 10)
}
const isToday = (date) => date && String(date).slice(0, 10) === todayStr()
const isPast = (row) => {
if (!row.appointmentDate) return false
if (['COMPLETED', 'CANCELLED', 'NO_SHOW'].includes(row.status)) return false
const d = String(row.appointmentDate).slice(0, 10)
return d < todayStr()
}
const canConfirm = (row) => row.status === 'SCHEDULED'
const canComplete = (row) => row.status === 'SCHEDULED' || row.status === 'CONFIRMED'
const statCards = computed(() => [
{
key: 'today',
label: '今日预约',
value: stats.value.today ?? 0,
active: quickDate.value === 'today' && !filters.status,
date: 'today'
},
{
key: 'pending',
label: '今日待处理',
value: stats.value.todayPending ?? 0,
active: quickDate.value === 'today' && !filters.status,
date: 'today',
status: ''
},
{
key: 'scheduled',
label: '已预约',
value: stats.value.scheduled ?? 0,
active: filters.status === 'SCHEDULED',
status: 'SCHEDULED'
},
{
key: 'confirmed',
label: '已确认',
value: stats.value.confirmed ?? 0,
active: filters.status === 'CONFIRMED',
status: 'CONFIRMED'
},
{
key: 'completed',
label: '已完成',
value: stats.value.completed ?? 0,
active: filters.status === 'COMPLETED',
status: 'COMPLETED'
},
{
key: 'total',
label: '全部预约',
value: stats.value.total ?? 0,
active: quickDate.value === 'all' && !filters.status && !filters.date,
clear: true
}
])
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
id: null,
patientId: null,
doctorId: null,
department: '内科',
appointmentDate: '',
appointmentTime: '09:00',
reason: '',
notes: '',
status: 'SCHEDULED'
})
const form = reactive(emptyForm())
const rules = {
patientId: [{ required: true, message: '请选择患者', trigger: 'change' }],
department: [{ required: true, message: '请填写科室', trigger: 'change' }],
appointmentDate: [{ required: true, message: '请选择日期', trigger: 'change' }]
}
const detailVisible = ref(false)
const detail = ref(null)
const disablePastDate = (time) => {
const start = new Date()
start.setHours(0, 0, 0, 0)
// 编辑已有记录时允许保留过去日期,新建时禁用过去
if (form.id) return false
return time.getTime() < start.getTime()
}
const loadStats = async () => {
try {
stats.value = (await getAppointmentStats()) || stats.value
} catch {
/* ignore */
}
}
const loadPage = async () => {
loading.value = true
try {
const data = await listAppointments({
page: pager.page - 1,
size: pager.size,
keyword: filters.keyword || undefined,
status: filters.status || undefined,
date: filters.date || undefined
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
loadStats()
}
const onQuickDate = (val) => {
if (val === 'today') filters.date = todayStr()
else if (val === 'tomorrow') filters.date = tomorrowStr()
else filters.date = ''
reload()
}
const onDatePick = () => {
if (!filters.date) quickDate.value = 'all'
else if (filters.date === todayStr()) quickDate.value = 'today'
else if (filters.date === tomorrowStr()) quickDate.value = 'tomorrow'
else quickDate.value = 'all'
reload()
}
const applyQuick = (item) => {
if (item.clear) {
resetFilters()
return
}
if (item.date === 'today') {
quickDate.value = 'today'
filters.date = todayStr()
}
if (item.status !== undefined) {
filters.status = item.status || ''
}
if (item.key === 'scheduled' || item.key === 'confirmed' || item.key === 'completed') {
// 按状态看全局,不锁日期
quickDate.value = 'all'
filters.date = ''
filters.status = item.status
}
reload()
}
const resetFilters = () => {
filters.keyword = ''
filters.status = ''
filters.date = ''
quickDate.value = 'all'
reload()
}
const loadOptions = async () => {
const [p, d] = await Promise.all([
listPatients({ page: 0, size: 200 }).catch(() => null),
listAppointmentDoctors().catch(() => null)
])
patients.value = p?.content || []
doctors.value = Array.isArray(d) ? d : []
}
const onDoctorChange = (id) => {
const doc = doctors.value.find((x) => x.id === id)
if (doc?.department && !form.department) {
form.department = doc.department
}
}
const openForm = async (row) => {
await loadOptions()
if (row) {
Object.assign(form, {
id: row.id,
patientId: row.patientId,
doctorId: row.doctorId || null,
department: row.department || '内科',
appointmentDate: row.appointmentDate || '',
appointmentTime: row.appointmentTime ? String(row.appointmentTime).slice(0, 5) : '',
reason: row.reason || '',
notes: row.notes || '',
status: row.status || 'SCHEDULED'
})
} else {
Object.assign(form, emptyForm())
form.appointmentDate = todayStr()
// 默认选中当前用户(若在医生列表中)
const me = doctors.value.find((d) => d.username === userStore.userInfo?.username)
if (me) {
form.doctorId = me.id
if (me.department) form.department = me.department
}
}
dialogVisible.value = true
}
const save = async () => {
await formRef.value.validate()
saving.value = true
try {
const payload = {
patientId: form.patientId,
doctorId: form.doctorId || undefined,
department: form.department,
appointmentDate: form.appointmentDate,
appointmentTime: form.appointmentTime || null,
reason: form.reason,
notes: form.notes,
status: form.status
}
if (form.id) await updateAppointment(form.id, payload)
else await createAppointment(payload)
ElMessage.success('保存成功')
dialogVisible.value = false
reload()
} finally {
saving.value = false
}
}
const openDetail = (row) => {
detail.value = { ...row }
detailVisible.value = true
}
const onRowClick = (row, column) => {
if (column?.label === '操作') return
openDetail(row)
}
const changeStatus = async (row, status) => {
if (status === '__delete__') {
await remove(row)
return
}
const label = appointmentStatusLabel(status)
await ElMessageBox.confirm(
`将「${row.patientName}」的预约状态更新为「${label}」?`,
'确认操作',
{ type: 'info' }
)
await updateAppointmentStatus(row.id, status)
ElMessage.success(`已更新为${label}`)
if (detailVisible.value && detail.value?.id === row.id) {
detail.value = { ...detail.value, status }
}
reload()
}
const remove = async (row) => {
await ElMessageBox.confirm(
`确定删除预约 #${row.id}(${row.patientName})?删除后不可恢复。`,
'删除确认',
{ type: 'warning' }
)
await deleteAppointment(row.id)
ElMessage.success('删除成功')
if (detailVisible.value && detail.value?.id === row.id) {
detailVisible.value = false
}
reload()
}
onMounted(() => {
loadPage()
loadStats()
})
</script>
<style scoped>
.stat-row {
margin-bottom: 4px;
}
.stat-card {
background: rgba(255, 255, 255, 0.94);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
border: 1px solid #eef2f7;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 61, 102, 0.08);
}
.stat-card.active {
border-color: #409eff;
box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.25);
}
.stat-card.today { border-top: 3px solid #e6a23c; }
.stat-card.pending { border-top: 3px solid #f56c6c; }
.stat-card.scheduled { border-top: 3px solid #909399; }
.stat-card.confirmed { border-top: 3px solid #409eff; }
.stat-card.completed { border-top: 3px solid #67c23a; }
.stat-card.total { border-top: 3px solid #1e6091; }
.stat-label {
font-size: 12px;
color: #64748b;
}
.stat-value {
margin-top: 4px;
font-size: 24px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
}
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.filter-right {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-left: auto;
}
.patient-cell {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.3;
}
.p-name {
font-weight: 600;
color: #0f172a;
}
.p-phone {
font-size: 12px;
color: #94a3b8;
}
.datetime-cell {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.is-today {
color: #d97706;
font-weight: 600;
}
.is-past {
color: #ef4444;
}
.time {
color: #64748b;
}
.today-tag {
margin-left: 2px;
}
/* 操作列统一间距与基线,避免 link 按钮默认 margin 导致疏密不均 */
.ops {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 4px;
height: 32px;
white-space: nowrap;
vertical-align: middle;
}
.ops :deep(.op-btn.el-button) {
margin: 0 !important;
padding: 0 4px !important;
height: 28px !important;
line-height: 28px !important;
min-height: 28px !important;
font-size: 13px;
vertical-align: middle;
}
.ops :deep(.op-drop),
.ops :deep(.el-dropdown) {
display: inline-flex;
align-items: center;
height: 28px;
line-height: 28px;
vertical-align: middle;
}
.danger-text {
color: #f56c6c;
}
.pager {
margin-top: 16px;
justify-content: flex-end;
display: flex;
}
.detail-status {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 16px;
}
.detail-desc {
margin-bottom: 20px;
}
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
:deep(.appt-row) {
cursor: pointer;
}
</style>
File diff suppressed because it is too large Load Diff
+490
View File
@@ -0,0 +1,490 @@
<template>
<div class="page">
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">电子病历</div>
<div class="sub">病历录入与 AI 辅助决策(治疗 / 护理 / 随访 + RAG)</div>
</div>
<div class="tools">
<el-input
v-model="keyword"
placeholder="患者 / 诊断 / 主诉"
clearable
style="width: 220px"
@clear="reload"
@keyup.enter="reload"
>
<template #append>
<el-button @click="reload">搜索</el-button>
</template>
</el-input>
<el-button type="primary" @click="openForm()">新增病历</el-button>
</div>
</div>
</template>
<el-alert
type="success"
:closable="false"
show-icon
class="page-tip"
title="使用提示:新建病历并保存可自动弹出 AI 建议;列表中点「AI 建议」可查看治疗、护理、随访计划及知识库引用。诊断含「肺炎/高血压/糖尿病」等关键词效果更明显。"
/>
<el-table :data="rows" v-loading="loading" stripe empty-text="暂无病历数据">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="patientName" label="患者" min-width="100" />
<el-table-column prop="doctorName" label="主诊医生" min-width="100" />
<el-table-column prop="visitDate" label="就诊日期" width="120" />
<el-table-column prop="diagnosis" label="诊断" min-width="160" show-overflow-tooltip />
<el-table-column prop="treatmentPlan" label="治疗方案" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewAi(row)">AI 建议</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<!-- append-to-body:EP 2.8 默认不 teleport,否则会被主布局 overflow 裁切 -->
<el-dialog
v-model="dialogVisible"
title="新增病历"
width="640px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
require-asterisk-position="right"
class="create-form"
>
<div class="form-two-col">
<el-form-item label="患者" prop="patientId">
<el-select v-model="form.patientId" filterable placeholder="请选择患者" style="width: 100%">
<el-option
v-for="p in patients"
:key="p.id"
:label="`${p.name}(ID:${p.id})`"
:value="p.id"
/>
</el-select>
</el-form-item>
<el-form-item label="就诊日期" prop="visitDate">
<el-date-picker
v-model="form.visitDate"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="选择日期"
/>
</el-form-item>
</div>
<el-form-item label="主诉">
<el-input v-model="form.chiefComplaint" type="textarea" :rows="2" placeholder="患者主诉" />
</el-form-item>
<div class="form-two-col">
<el-form-item label="现病史">
<el-input v-model="form.presentIllness" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="体格检查">
<el-input v-model="form.physicalExamination" type="textarea" :rows="2" />
</el-form-item>
</div>
<el-form-item label="诊断" prop="diagnosis">
<el-input
v-model="form.diagnosis"
type="textarea"
:rows="2"
placeholder="如:高血压、糖尿病、肺炎(关键词会触发 AI 建议)"
/>
</el-form-item>
<div class="form-two-col">
<el-form-item label="治疗方案">
<el-input v-model="form.treatmentPlan" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="用药">
<el-input v-model="form.medications" type="textarea" :rows="2" />
</el-form-item>
</div>
<el-form-item label="随访">
<el-input v-model="form.followUpNotes" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存并获取 AI 建议</el-button>
</template>
</el-dialog>
<el-dialog
v-model="aiDialogVisible"
title="AI 辅助决策建议"
width="760px"
destroy-on-close
align-center
append-to-body
class="form-dialog ai-dialog"
>
<div v-loading="aiLoading">
<template v-if="aiData">
<el-alert
type="info"
:closable="false"
show-icon
:title="aiData.disclaimer || 'AI 结果仅供辅助决策,须由执业医师审核。'"
style="margin-bottom: 12px"
/>
<div class="meta-row" v-if="aiData.engine">
<el-tag size="small">引擎:{{ aiData.engine }}</el-tag>
<el-tag v-if="aiData.fallback" size="small" type="warning">本地降级</el-tag>
</div>
<div class="section">
<div class="section-title">治疗建议</div>
<el-table :data="treatmentRows" size="small" empty-text="暂无治疗建议" border>
<el-table-column prop="category" label="类别" width="140" />
<el-table-column prop="content" label="内容" />
<el-table-column label="置信度" width="100">
<template #default="{ row }">{{ formatPercent(row.confidence) }}</template>
</el-table-column>
</el-table>
</div>
<div class="section">
<div class="section-title">用药建议</div>
<el-table :data="medicationRows" size="small" empty-text="暂无用药建议" border>
<el-table-column prop="name" label="药品" min-width="120" />
<el-table-column prop="dosage" label="剂量" width="140" />
<el-table-column prop="category" label="类别" width="120" />
<el-table-column label="置信度" width="100">
<template #default="{ row }">{{ formatPercent(row.confidence) }}</template>
</el-table-column>
</el-table>
</div>
<div class="section">
<div class="section-title">护理建议</div>
<template v-if="nursingRows.length">
<ul class="bullet-list">
<li v-for="(n, i) in nursingRows" :key="i">{{ n }}</li>
</ul>
</template>
<el-empty v-else description="暂无护理建议" :image-size="64" />
</div>
<div class="section">
<div class="section-title">随访计划</div>
<template v-if="followUpRows.length">
<ul class="bullet-list">
<li v-for="(n, i) in followUpRows" :key="i">{{ n }}</li>
</ul>
</template>
<el-empty v-else description="暂无随访计划" :image-size="64" />
</div>
<div class="section">
<div class="section-title">风险评估</div>
<el-table :data="riskRows" size="small" empty-text="暂无风险评估" border>
<el-table-column prop="type" label="类型" width="140" />
<el-table-column prop="description" label="说明" />
<el-table-column label="等级" width="100">
<template #default="{ row }">
<el-tag size="small" :type="riskLevelType(row.level)">{{ row.level || '—' }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="section">
<div class="section-title">药物冲突</div>
<template v-if="conflictRows.length">
<el-alert
v-for="(c, i) in conflictRows"
:key="i"
:title="c"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 8px"
/>
</template>
<el-empty v-else description="未发现明显药物冲突" :image-size="72" />
</div>
<div class="section" v-if="sourceRows.length">
<div class="section-title">知识库引用(RAG)</div>
<el-table :data="sourceRows" size="small" border>
<el-table-column prop="title" label="标题" min-width="140" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="snippet" label="摘要" min-width="200" show-overflow-tooltip />
</el-table>
</div>
</template>
<el-empty v-else-if="!aiLoading" description="暂无 AI 建议" />
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listEmrs, createEmr, deleteEmr, aiSuggestions } from '@/api/emr'
import { listPatients } from '@/api/patient'
import { formatPercent, riskLevelType } from '@/utils/labels'
const rows = ref([])
const loading = ref(false)
const keyword = ref('')
const pager = reactive({ page: 1, size: 10, total: 0 })
const patients = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
patientId: null,
visitDate: '',
chiefComplaint: '',
presentIllness: '',
physicalExamination: '',
diagnosis: '',
treatmentPlan: '',
medications: '',
followUpNotes: ''
})
const form = reactive(emptyForm())
const rules = {
patientId: [{ required: true, message: '请选择患者', trigger: 'change' }],
visitDate: [{ required: true, message: '请选择就诊日期', trigger: 'change' }],
diagnosis: [{ required: true, message: '请填写诊断', trigger: 'blur' }]
}
const aiDialogVisible = ref(false)
const aiLoading = ref(false)
const aiData = ref(null)
const treatmentRows = computed(() =>
(aiData.value?.suggestions || []).map((s) => ({
category: s.category || s.title || '—',
content: s.content || s.description || '—',
confidence: s.confidence
}))
)
const medicationRows = computed(() =>
(aiData.value?.medicationSuggestions || []).map((m) => ({
name: m.name || m.drugName || '—',
dosage: m.dosage || '—',
category: m.category || m.drugClass || '—',
confidence: m.confidence
}))
)
const riskRows = computed(() =>
(aiData.value?.riskAssessments || []).map((r) => ({
type: r.type || r.riskType || '—',
description: r.description || '—',
level: r.level || '—'
}))
)
const conflictRows = computed(() => aiData.value?.conflicts || [])
const nursingRows = computed(() => aiData.value?.nursingAdvice || aiData.value?.nursing_advice || [])
const followUpRows = computed(() => aiData.value?.followUpPlan || aiData.value?.follow_up_plan || [])
const sourceRows = computed(() =>
(aiData.value?.sources || []).map((s) => ({
title: s.title || '—',
category: s.category || '—',
snippet: s.snippet || '—'
}))
)
const loadPage = async () => {
loading.value = true
try {
const data = await listEmrs({
page: pager.page - 1,
size: pager.size,
keyword: keyword.value || undefined
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
}
const openForm = async () => {
Object.assign(form, emptyForm())
form.visitDate = new Date().toISOString().slice(0, 10)
try {
const p = await listPatients({ page: 0, size: 200 })
patients.value = p?.content || []
} catch {
patients.value = []
}
dialogVisible.value = true
}
const save = async () => {
await formRef.value.validate()
saving.value = true
try {
const res = await createEmr({ ...form })
ElMessage.success('病历已创建')
dialogVisible.value = false
loadPage()
aiData.value = res?.aiSupport || null
aiDialogVisible.value = true
} finally {
saving.value = false
}
}
const viewAi = async (row) => {
aiDialogVisible.value = true
aiLoading.value = true
aiData.value = null
try {
aiData.value = await aiSuggestions(row.id)
} catch {
aiData.value = null
} finally {
aiLoading.value = false
}
}
const remove = async (row) => {
await ElMessageBox.confirm(`删除病历 #${row.id}?`, '确认', { type: 'warning' })
await deleteEmr(row.id)
ElMessage.success('删除成功')
loadPage()
}
onMounted(loadPage)
</script>
<style scoped>
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
.page-tip {
margin-bottom: 14px;
border-radius: 10px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.tools {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.pager {
margin-top: 16px;
justify-content: flex-end;
display: flex;
}
.section {
margin-bottom: 18px;
}
.section-title {
font-weight: 700;
color: #0f172a;
margin-bottom: 8px;
padding-left: 8px;
border-left: 3px solid #409eff;
}
.meta-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.create-form {
width: 100%;
box-sizing: border-box;
}
.create-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.create-form :deep(.el-form-item__label) {
margin-bottom: 6px !important;
line-height: 1.3;
color: #475569;
font-weight: 600;
padding: 0 !important;
}
.form-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 16px;
width: 100%;
box-sizing: border-box;
}
.form-two-col :deep(.el-form-item) {
margin-bottom: 12px;
}
@media (max-width: 560px) {
.form-two-col {
grid-template-columns: 1fr;
}
}
.bullet-list {
margin: 0;
padding-left: 18px;
color: #334155;
line-height: 1.7;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<template>
<div class="wrap">
<el-result icon="warning" title="403 无权访问" sub-title="当前账号没有访问该页面的权限,请联系管理员或切换账号。">
<template #extra>
<el-button type="primary" @click="$router.push('/dashboard')">返回仪表盘</el-button>
<el-button @click="logout">切换账号</el-button>
</template>
</el-result>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const logout = () => {
userStore.reset()
router.push('/login')
}
</script>
<style scoped>
.wrap {
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 14px;
}
</style>
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
<template>
<div class="login-wrapper">
<div class="login-bg" />
<div class="login-mask" />
<el-card class="login-card" shadow="always">
<div class="header">
<div class="logo">
<el-icon :size="28"><OfficeBuilding /></el-icon>
</div>
<div>
<h2 class="title">智慧医院管理系统</h2>
<p class="subtitle">患者 · 影像 · 病历 · 预约一体化管理</p>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" size="large" @submit.prevent="onSubmit">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" clearable>
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
show-password
@keyup.enter="onSubmit"
>
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="onSubmit">
登 录
</el-button>
</el-form>
<div class="accounts">
<div class="accounts-title">测试账号</div>
<div class="account-row">
<el-tag type="danger" effect="plain">管理员</el-tag>
<code>admin / admin123</code>
<el-button link type="primary" @click="fill('admin', 'admin123')">填入</el-button>
</div>
<div class="account-row">
<el-tag type="success" effect="plain">医生</el-tag>
<code>doctor1 / pass123</code>
<el-button link type="primary" @click="fill('doctor1', 'pass123')">填入</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock, OfficeBuilding } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const fill = (u, p) => {
form.username = u
form.password = p
}
const onSubmit = async () => {
await formRef.value.validate()
loading.value = true
try {
await userStore.login(form)
ElMessage.success(`欢迎回来,${userStore.displayName}`)
const redirect = route.query.redirect || '/dashboard'
await router.replace(String(redirect))
} catch {
/* 拦截器已提示 */
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-wrapper {
position: relative;
min-height: 100vh;
height: 100vh;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-bg {
position: absolute;
inset: 0;
background: url('/images/login-bg.jpg') center / cover no-repeat;
transform: scale(1.02);
z-index: 0;
}
.login-mask {
position: absolute;
inset: 0;
background:
linear-gradient(120deg, rgba(15, 61, 102, 0.72) 0%, rgba(30, 96, 145, 0.55) 45%, rgba(56, 189, 248, 0.35) 100%);
z-index: 1;
}
.login-card {
width: 440px;
max-width: 100%;
border-radius: 18px;
border: none;
position: relative;
z-index: 2;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
box-shadow: 0 20px 50px rgba(15, 61, 102, 0.35);
}
.header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 24px;
}
.logo {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #1e6091, #38bdf8);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 18px;
color: #0f172a;
}
.subtitle {
margin: 6px 0 0;
font-size: 12px;
color: #64748b;
}
.submit {
width: 100%;
height: 44px;
font-size: 15px;
letter-spacing: 2px;
}
.accounts {
margin-top: 20px;
padding-top: 16px;
border-top: 1px dashed #e2e8f0;
}
.accounts-title {
font-size: 12px;
color: #94a3b8;
margin-bottom: 10px;
}
.account-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
font-size: 13px;
}
.account-row code {
flex: 1;
background: #f8fafc;
padding: 4px 8px;
border-radius: 6px;
color: #334155;
}
</style>
+454
View File
@@ -0,0 +1,454 @@
<template>
<div class="page">
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">患者管理</div>
<div class="sub">维护患者基础档案 · 查看 360° 诊疗档案</div>
</div>
<div class="tools">
<el-input
v-model="keyword"
placeholder="按姓名搜索"
clearable
style="width: 220px"
@clear="reload"
@keyup.enter="reload"
>
<template #append>
<el-button @click="reload">搜索</el-button>
</template>
</el-input>
<el-button type="primary" @click="openForm()">新增患者</el-button>
</div>
</div>
</template>
<el-table :data="rows" v-loading="loading" stripe empty-text="暂无患者数据" class="table">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column label="性别" width="80">
<template #default="{ row }">{{ genderLabel(row.gender) }}</template>
</el-table-column>
<el-table-column prop="age" label="年龄" width="80" />
<el-table-column prop="phone" label="电话" min-width="120" />
<el-table-column prop="idCard" label="身份证" min-width="170" show-overflow-tooltip />
<el-table-column prop="address" label="地址" min-width="140" show-overflow-tooltip />
<el-table-column prop="medicalHistory" label="既往病史" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="success" @click="openProfile(row)">档案</el-button>
<el-button link type="primary" @click="openForm(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="form.id ? '编辑患者' : '新增患者'"
width="520px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<!-- 顶栏标签 + 等宽两列,左右留白一致(避免 label-width + gutter 偏左) -->
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
require-asterisk-position="right"
class="create-form"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" maxlength="50" placeholder="请输入姓名" />
</el-form-item>
<div class="form-two-col">
<el-form-item label="性别" prop="gender">
<el-select v-model="form.gender" style="width: 100%">
<el-option label="男" value="MALE" />
<el-option label="女" value="FEMALE" />
<el-option label="其他" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number v-model="form.age" :min="0" :max="150" style="width: 100%" />
</el-form-item>
</div>
<el-form-item label="身份证">
<el-input v-model="form.idCard" maxlength="18" placeholder="选填" />
</el-form-item>
<el-form-item label="电话">
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="form.address" placeholder="选填" />
</el-form-item>
<el-form-item label="既往病史">
<el-input v-model="form.medicalHistory" type="textarea" :rows="3" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保 存</el-button>
</template>
</el-dialog>
<!-- append-to-body:避免被主布局 overflow:hidden 裁切;body 内滚动 -->
<el-drawer
v-model="profileVisible"
size="min(720px, 96vw)"
destroy-on-close
append-to-body
class="profile-drawer"
:close-on-click-modal="true"
>
<template #header>
<div class="drawer-title">
患者 360° 档案
<el-tag v-if="profile?.patient" size="small" effect="plain" type="primary" style="margin-left: 8px">
{{ profile.patient.name }}
</el-tag>
</div>
</template>
<div v-loading="profileLoading" class="profile-body">
<template v-if="profile?.patient">
<el-descriptions :column="2" border size="small" class="mb16 profile-desc">
<el-descriptions-item label="姓名">{{ profile.patient.name }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderLabel(profile.patient.gender) }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ profile.patient.age }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ profile.patient.phone || '—' }}</el-descriptions-item>
<el-descriptions-item label="身份证" :span="2">{{ profile.patient.idCard || '—' }}</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ profile.patient.address || '—' }}</el-descriptions-item>
<el-descriptions-item label="既往病史" :span="2">{{ profile.patient.medicalHistory || '—' }}</el-descriptions-item>
</el-descriptions>
<div class="mini-stat-row mb16">
<div class="mini-stat">
<span class="mini-label">影像</span>
<b>{{ profile.imagingCount ?? 0 }}</b>
</div>
<div class="mini-stat">
<span class="mini-label">病历</span>
<b>{{ profile.emrCount ?? 0 }}</b>
</div>
<div class="mini-stat">
<span class="mini-label">预约</span>
<b>{{ profile.appointmentCount ?? 0 }}</b>
</div>
</div>
<el-tabs class="profile-tabs">
<el-tab-pane :label="`影像记录 (${profile.imagingCount ?? 0})`">
<el-table :data="profile.imagingRecords || []" size="small" empty-text="暂无影像" max-height="420">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ studyTypeLabel(row.studyType) }}</template>
</el-table-column>
<el-table-column prop="bodyPart" label="部位" min-width="80" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="imagingStatusType(row.status)">
{{ imagingStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="aiDiagnosis" label="AI 诊断" min-width="140" show-overflow-tooltip />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`电子病历 (${profile.emrCount ?? 0})`">
<el-table :data="profile.medicalRecords || []" size="small" empty-text="暂无病历" max-height="420">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="visitDate" label="就诊日" width="110" />
<el-table-column prop="doctorName" label="医生" width="90" />
<el-table-column prop="diagnosis" label="诊断" min-width="160" show-overflow-tooltip />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`预约 (${profile.appointmentCount ?? 0})`">
<el-table :data="profile.appointments || []" size="small" empty-text="暂无预约" max-height="420">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="appointmentDate" label="日期" width="110" />
<el-table-column prop="department" label="科室" width="100" />
<el-table-column prop="reason" label="事由" min-width="120" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="appointmentStatusType(row.status)">
{{ appointmentStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
<el-empty v-else-if="!profileLoading" description="暂无档案数据" :image-size="80" />
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listPatients,
createPatient,
updatePatient,
deletePatient,
getPatientProfile
} from '@/api/patient'
import {
genderLabel,
studyTypeLabel,
imagingStatusLabel,
imagingStatusType,
appointmentStatusLabel,
appointmentStatusType
} from '@/utils/labels'
const rows = ref([])
const loading = ref(false)
const keyword = ref('')
const pager = reactive({ page: 1, size: 10, total: 0 })
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
id: null,
name: '',
gender: 'MALE',
age: 30,
idCard: '',
phone: '',
address: '',
medicalHistory: ''
})
const form = reactive(emptyForm())
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
age: [{ required: true, message: '请输入年龄', trigger: 'change' }]
}
const profileVisible = ref(false)
const profileLoading = ref(false)
const profile = ref(null)
const loadPage = async () => {
loading.value = true
try {
const data = await listPatients({
page: pager.page - 1,
size: pager.size,
keyword: keyword.value || undefined
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
}
const openForm = (row) => {
Object.assign(form, row ? { ...row } : emptyForm())
dialogVisible.value = true
}
const openProfile = async (row) => {
profileVisible.value = true
profileLoading.value = true
profile.value = null
try {
profile.value = await getPatientProfile(row.id)
} catch {
profile.value = null
} finally {
profileLoading.value = false
}
}
const save = async () => {
await formRef.value.validate()
saving.value = true
try {
if (form.id) await updatePatient(form.id, form)
else await createPatient(form)
ElMessage.success('保存成功')
dialogVisible.value = false
loadPage()
} finally {
saving.value = false
}
}
const remove = async (row) => {
await ElMessageBox.confirm(`确定删除患者「${row.name}」?`, '删除确认', { type: 'warning' })
await deletePatient(row.id)
ElMessage.success('删除成功')
loadPage()
}
onMounted(loadPage)
</script>
<style scoped>
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.tools {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.pager {
margin-top: 16px;
justify-content: flex-end;
display: flex;
}
.drawer-title {
font-weight: 700;
font-size: 16px;
color: #0f172a;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.mb16 {
margin-bottom: 16px;
}
.profile-body {
min-height: 200px;
padding-bottom: 8px;
}
.mini-stat-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.mini-stat {
text-align: center;
padding: 14px 10px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid #eef2f7;
color: #64748b;
font-size: 13px;
}
.mini-label {
display: block;
font-size: 12px;
color: #94a3b8;
}
.mini-stat b {
display: block;
margin-top: 6px;
font-size: 22px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
.profile-tabs :deep(.el-tabs__header) {
margin-bottom: 12px;
}
/* 弹窗表单:顶栏标签,内容区左右对齐一致 */
.create-form {
width: 100%;
box-sizing: border-box;
}
.create-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.create-form :deep(.el-form-item__label) {
margin-bottom: 6px !important;
line-height: 1.3;
color: #475569;
font-weight: 600;
padding: 0 !important;
}
.form-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 16px;
width: 100%;
box-sizing: border-box;
}
.form-two-col :deep(.el-form-item) {
margin-bottom: 14px;
}
@media (max-width: 520px) {
.form-two-col {
grid-template-columns: 1fr;
}
}
</style>
<!-- drawer 挂到 body,scoped 样式够不着外壳,用非 scoped 补全高度/滚动 -->
<style>
.profile-drawer.el-drawer {
height: 100% !important;
max-height: 100vh !important;
display: flex;
flex-direction: column;
}
.profile-drawer .el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid #eef2f7;
flex-shrink: 0;
}
.profile-drawer .el-drawer__body {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 16px 20px 24px;
box-sizing: border-box;
}
</style>
+840
View File
@@ -0,0 +1,840 @@
<template>
<div class="page">
<!-- 统计 -->
<el-row :gutter="12" class="stat-row">
<el-col :xs="12" :sm="8" :md="4" v-for="c in statCards" :key="c.key">
<div class="stat-card" :class="[{ active: c.active }, c.key]" @click="applyStat(c)">
<div class="stat-label">{{ c.label }}</div>
<div class="stat-value">{{ c.value }}</div>
</div>
</el-col>
</el-row>
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">用户管理</div>
<div class="sub">账号、角色、头像与启用状态维护</div>
</div>
<el-button type="primary" @click="openForm()">
<el-icon class="btn-icon"><Plus /></el-icon>
新增用户
</el-button>
</div>
</template>
<div class="filter-bar">
<el-radio-group v-model="filters.role" size="default" @change="reload">
<el-radio-button value="">全部角色</el-radio-button>
<el-radio-button value="ADMIN">管理员</el-radio-button>
<el-radio-button value="DOCTOR">医生</el-radio-button>
<el-radio-button value="RADIOLOGIST">影像医师</el-radio-button>
</el-radio-group>
<el-radio-group v-model="filters.enabled" size="default" @change="reload">
<el-radio-button value="">全部状态</el-radio-button>
<el-radio-button value="true">启用</el-radio-button>
<el-radio-button value="false">停用</el-radio-button>
</el-radio-group>
<div class="filter-right">
<el-input
v-model="filters.keyword"
placeholder="账号 / 姓名 / 科室 / 电话"
clearable
style="width: 240px"
@clear="reload"
@keyup.enter="reload"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button @click="reload">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</div>
<el-table :data="rows" v-loading="loading" stripe empty-text="暂无用户" class="user-table">
<el-table-column prop="id" label="ID" width="64" />
<el-table-column label="用户" min-width="200">
<template #default="{ row }">
<div class="user-cell">
<el-avatar :size="40" class="row-avatar" :src="row.avatar || undefined">
{{ (row.realName || row.username || 'U').slice(0, 1) }}
</el-avatar>
<div class="user-meta">
<div class="name-line">
<span class="name">{{ row.realName || '—' }}</span>
<el-tag v-if="isSelf(row)" size="small" type="warning" effect="plain">当前</el-tag>
</div>
<div class="username">@{{ row.username }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="角色" width="120">
<template #default="{ row }">
<el-tag :type="roleTagType(row.role)" effect="light" round>
{{ roleLabel(row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="科室" min-width="100">
<template #default="{ row }">
<span>{{ row.department || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-switch
:model-value="row.enabled !== false"
:disabled="isSelf(row) && row.enabled !== false"
inline-prompt
active-text="启"
inactive-text="停"
@change="(v) => onSwitchEnabled(row, v)"
/>
</template>
</el-table-column>
<el-table-column prop="phone" label="电话" min-width="120">
<template #default="{ row }">{{ row.phone || '—' }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.email || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right" align="left">
<template #default="{ row }">
<div class="ops">
<el-button link type="primary" class="op-btn" @click="openForm(row)">编辑</el-button>
<el-button link type="success" class="op-btn" @click="openAvatar(row)">头像</el-button>
<el-dropdown trigger="click" class="op-drop" @command="(cmd) => onMore(row, cmd)">
<el-button link type="info" class="op-btn">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="detail">查看详情</el-dropdown-item>
<el-dropdown-item
v-if="row.enabled === false"
command="enable"
>
启用账号
</el-dropdown-item>
<el-dropdown-item
v-else
command="disable"
:disabled="isSelf(row)"
>
停用账号
</el-dropdown-item>
<el-dropdown-item
command="delete"
divided
:disabled="isSelf(row)"
>
<span class="danger">删除用户</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="form.id ? '编辑用户' : '新增用户'"
width="560px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="账号" prop="username">
<el-input
v-model="form.username"
:disabled="!!form.id"
maxlength="50"
placeholder="登录用户名"
/>
</el-form-item>
<el-form-item label="密码" :prop="form.id ? undefined : 'password'">
<el-input
v-model="form.password"
:placeholder="form.id ? '留空则不修改密码' : '至少 6 位'"
show-password
maxlength="64"
/>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="姓名" prop="realName">
<el-input v-model="form.realName" placeholder="真实姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="角色" prop="role">
<el-select v-model="form.role" style="width: 100%">
<el-option label="医生" value="DOCTOR" />
<el-option label="影像医师" value="RADIOLOGIST" />
<el-option label="管理员" value="ADMIN" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="科室">
<el-select
v-model="form.department"
filterable
allow-create
default-first-option
clearable
placeholder="选择或输入科室"
style="width: 100%"
>
<el-option v-for="d in departments" :key="d" :label="d" :value="d" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="电话">
<el-input v-model="form.phone" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="邮箱">
<el-input v-model="form.email" maxlength="80" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="状态">
<el-switch
v-model="form.enabled"
:disabled="isSelfForm"
active-text="启用"
inactive-text="停用"
/>
<span v-if="isSelfForm" class="form-tip">不能停用当前登录账号</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保 存</el-button>
</template>
</el-dialog>
<!-- 头像 -->
<el-dialog v-model="avatarVisible" title="设置用户头像" width="420px" destroy-on-close align-center append-to-body>
<div class="avatar-panel">
<el-avatar :size="96" class="row-avatar preview" :src="avatarPreview || avatarTarget?.avatar || undefined">
{{ (avatarTarget?.realName || avatarTarget?.username || 'U').slice(0, 1) }}
</el-avatar>
<div class="avatar-user">
{{ avatarTarget?.realName }}
<span class="uname">@{{ avatarTarget?.username }}</span>
</div>
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
:on-change="onAvatarPick"
>
<el-button type="primary" plain>选择图片</el-button>
</el-upload>
<div class="avatar-tip">支持 jpg / png / gif / webp,不超过 2MB</div>
</div>
<template #footer>
<el-button @click="avatarVisible = false">取 消</el-button>
<el-button type="primary" :loading="avatarSaving" :disabled="!avatarFile" @click="submitAvatar">
上 传
</el-button>
</template>
</el-dialog>
<!-- 详情 -->
<el-drawer v-model="detailVisible" title="用户详情" size="400px" destroy-on-close append-to-body>
<template v-if="detail">
<div class="detail-head">
<el-avatar :size="72" class="row-avatar" :src="detail.avatar || undefined">
{{ (detail.realName || detail.username || 'U').slice(0, 1) }}
</el-avatar>
<div>
<div class="detail-name">
{{ detail.realName }}
<el-tag v-if="isSelf(detail)" size="small" type="warning" effect="plain">当前</el-tag>
</div>
<div class="detail-user">@{{ detail.username }}</div>
<el-tag :type="roleTagType(detail.role)" size="small" effect="light">
{{ roleLabel(detail.role) }}
</el-tag>
</div>
</div>
<el-descriptions :column="1" border class="detail-desc">
<el-descriptions-item label="用户 ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="科室">{{ detail.department || '—' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ detail.phone || '—' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detail.email || '—' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detail.enabled === false ? 'danger' : 'success'" size="small">
{{ detail.enabled === false ? '已停用' : '启用' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="detail-actions">
<el-button type="primary" @click="openForm(detail); detailVisible = false">编辑</el-button>
<el-button @click="openAvatar(detail); detailVisible = false">改头像</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import {
listUsers,
getUserStats,
createUser,
updateUser,
deleteUser,
setUserEnabled,
uploadUserAvatar
} from '@/api/user'
import { useUserStore } from '@/stores/user'
import { roleLabel, roleTagType } from '@/utils/labels'
const userStore = useUserStore()
const rows = ref([])
const loading = ref(false)
const pager = reactive({ page: 1, size: 10, total: 0 })
const filters = reactive({ keyword: '', role: '', enabled: '' })
const stats = ref({
total: 0,
adminCount: 0,
doctorCount: 0,
radiologistCount: 0,
enabledCount: 0,
disabledCount: 0
})
const departments = ['内科', '外科', '影像科', '儿科', '妇科', '骨科', '急诊科', '信息科', '心内科', '神经内科']
const isSelf = (row) =>
!!row &&
(row.id === userStore.userInfo?.id || row.username === userStore.userInfo?.username)
const isSelfForm = computed(() => form.id && form.id === userStore.userInfo?.id)
const statCards = computed(() => [
{
key: 'total',
label: '全部用户',
value: stats.value.total ?? 0,
active: !filters.role && !filters.enabled,
clear: true
},
{
key: 'admin',
label: '管理员',
value: stats.value.adminCount ?? 0,
active: filters.role === 'ADMIN',
role: 'ADMIN'
},
{
key: 'doctor',
label: '医生',
value: stats.value.doctorCount ?? 0,
active: filters.role === 'DOCTOR',
role: 'DOCTOR'
},
{
key: 'radio',
label: '影像医师',
value: stats.value.radiologistCount ?? 0,
active: filters.role === 'RADIOLOGIST',
role: 'RADIOLOGIST'
},
{
key: 'on',
label: '已启用',
value: stats.value.enabledCount ?? 0,
active: filters.enabled === 'true',
enabled: 'true'
},
{
key: 'off',
label: '已停用',
value: stats.value.disabledCount ?? 0,
active: filters.enabled === 'false',
enabled: 'false'
}
])
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
id: null,
username: '',
password: '',
realName: '',
role: 'DOCTOR',
department: '内科',
phone: '',
email: '',
enabled: true
})
const form = reactive(emptyForm())
const rules = {
username: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 3, max: 50, message: '账号长度 3-50', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' }
],
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
}
const avatarVisible = ref(false)
const avatarSaving = ref(false)
const avatarTarget = ref(null)
const avatarFile = ref(null)
const avatarPreview = ref('')
const detailVisible = ref(false)
const detail = ref(null)
const loadStats = async () => {
try {
stats.value = (await getUserStats()) || stats.value
} catch {
/* ignore */
}
}
const loadPage = async () => {
loading.value = true
try {
let enabled
if (filters.enabled === 'true') enabled = true
else if (filters.enabled === 'false') enabled = false
const data = await listUsers({
page: pager.page - 1,
size: pager.size,
keyword: filters.keyword || undefined,
role: filters.role || undefined,
enabled
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
loadStats()
}
const resetFilters = () => {
filters.keyword = ''
filters.role = ''
filters.enabled = ''
reload()
}
const applyStat = (c) => {
if (c.clear) {
resetFilters()
return
}
if (c.role) {
filters.role = c.role
filters.enabled = ''
}
if (c.enabled !== undefined) {
filters.enabled = c.enabled
filters.role = ''
}
reload()
}
const openForm = (row) => {
if (row) {
Object.assign(form, {
id: row.id,
username: row.username,
password: '',
realName: row.realName || '',
role: row.role || 'DOCTOR',
department: row.department || '',
phone: row.phone || '',
email: row.email || '',
enabled: row.enabled !== false
})
} else {
Object.assign(form, emptyForm())
}
dialogVisible.value = true
}
const save = async () => {
await formRef.value.validate()
if (isSelfForm.value && form.enabled === false) {
ElMessage.warning('不能停用当前登录账号')
return
}
saving.value = true
try {
const payload = {
username: form.username,
realName: form.realName,
role: form.role,
department: form.department,
phone: form.phone,
email: form.email,
enabled: form.enabled,
password: form.password || undefined
}
if (form.id && !payload.password) delete payload.password
if (form.id) await updateUser(form.id, payload)
else await createUser(payload)
ElMessage.success('保存成功')
dialogVisible.value = false
reload()
} finally {
saving.value = false
}
}
const openAvatar = (row) => {
avatarTarget.value = row
avatarFile.value = null
if (avatarPreview.value) URL.revokeObjectURL(avatarPreview.value)
avatarPreview.value = ''
avatarVisible.value = true
}
const onAvatarPick = (file) => {
const raw = file.raw
if (!raw) return
if (!raw.type?.startsWith('image/')) {
ElMessage.warning('请选择图片文件')
return
}
if (raw.size > 2 * 1024 * 1024) {
ElMessage.warning('图片不能超过 2MB')
return
}
avatarFile.value = raw
if (avatarPreview.value) URL.revokeObjectURL(avatarPreview.value)
avatarPreview.value = URL.createObjectURL(raw)
}
const submitAvatar = async () => {
if (!avatarFile.value || !avatarTarget.value) return
avatarSaving.value = true
try {
const user = await uploadUserAvatar(avatarTarget.value.id, avatarFile.value)
ElMessage.success('头像已更新')
avatarVisible.value = false
if (userStore.userInfo?.id === user.id) {
userStore.setUserInfo({ ...userStore.userInfo, ...user })
}
loadPage()
} finally {
avatarSaving.value = false
}
}
const onSwitchEnabled = async (row, next) => {
if (isSelf(row) && !next) {
ElMessage.warning('不能停用当前登录账号')
return
}
const action = next ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确定${action}账号「${row.username}」?`, '确认', { type: 'warning' })
await setUserEnabled(row.id, next)
ElMessage.success(`已${action}`)
reload()
} catch {
/* 取消 */
}
}
const onMore = async (row, cmd) => {
if (cmd === 'detail') {
detail.value = { ...row }
detailVisible.value = true
return
}
if (cmd === 'enable') {
await onSwitchEnabled(row, true)
return
}
if (cmd === 'disable') {
await onSwitchEnabled(row, false)
return
}
if (cmd === 'delete') {
await remove(row)
}
}
const remove = async (row) => {
if (isSelf(row)) {
ElMessage.warning('不能删除当前登录账号')
return
}
await ElMessageBox.confirm(
`确定删除用户「${row.realName || row.username}」?此操作不可恢复。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除' }
)
await deleteUser(row.id)
ElMessage.success('删除成功')
reload()
}
onMounted(() => {
loadPage()
loadStats()
})
</script>
<style scoped>
.stat-row {
margin-bottom: 4px;
}
.stat-card {
background: rgba(255, 255, 255, 0.94);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
border: 1px solid #eef2f7;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 61, 102, 0.08);
}
.stat-card.active {
border-color: #409eff;
box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2);
}
.stat-card.total { border-top: 3px solid #1e6091; }
.stat-card.admin { border-top: 3px solid #f56c6c; }
.stat-card.doctor { border-top: 3px solid #67c23a; }
.stat-card.radio { border-top: 3px solid #e6a23c; }
.stat-card.on { border-top: 3px solid #409eff; }
.stat-card.off { border-top: 3px solid #909399; }
.stat-label {
font-size: 12px;
color: #64748b;
}
.stat-value {
margin-top: 4px;
font-size: 24px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
}
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.btn-icon {
margin-right: 4px;
vertical-align: middle;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.filter-right {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
align-items: center;
}
.user-cell {
display: flex;
align-items: center;
gap: 12px;
}
.user-meta {
min-width: 0;
line-height: 1.35;
}
.name-line {
display: flex;
align-items: center;
gap: 6px;
}
.name {
font-weight: 600;
color: #0f172a;
}
.username {
font-size: 12px;
color: #94a3b8;
}
.row-avatar {
background: linear-gradient(135deg, #1e6091, #38bdf8);
color: #fff;
font-weight: 600;
flex-shrink: 0;
}
.danger {
color: #f56c6c;
}
/* 操作列:统一水平/垂直对齐,避免 dropdown 把「更多」顶高 */
.ops {
display: inline-flex;
align-items: center;
gap: 2px;
height: 32px;
white-space: nowrap;
vertical-align: middle;
}
.ops :deep(.op-btn) {
margin: 0 !important;
padding: 0 6px !important;
height: 28px !important;
line-height: 28px !important;
font-size: 13px;
vertical-align: middle;
}
.ops :deep(.op-drop) {
display: inline-flex;
align-items: center;
height: 28px;
line-height: 28px;
vertical-align: middle;
}
.ops :deep(.el-dropdown) {
line-height: 28px;
vertical-align: middle;
}
.pager {
margin-top: 16px;
justify-content: flex-end;
display: flex;
}
.form-tip {
margin-left: 10px;
font-size: 12px;
color: #94a3b8;
}
.avatar-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.avatar-panel .preview {
font-size: 32px;
}
.avatar-user {
font-size: 15px;
color: #0f172a;
font-weight: 600;
}
.avatar-user .uname {
margin-left: 6px;
font-weight: 400;
color: #94a3b8;
font-size: 13px;
}
.avatar-tip {
font-size: 12px;
color: #94a3b8;
}
.detail-head {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 18px;
}
.detail-name {
font-size: 18px;
font-weight: 700;
color: #0f172a;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.detail-user {
font-size: 13px;
color: #94a3b8;
margin-bottom: 8px;
}
.detail-desc {
margin-bottom: 18px;
}
.detail-actions {
display: flex;
gap: 8px;
}
</style>