Files
Shixun/frontend/src/views/Users.vue
T
2026-07-31 12:27:41 +08:00

841 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>