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
+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>