841 lines
23 KiB
Vue
841 lines
23 KiB
Vue
<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>
|