feat(F006): 用户列表按企业列表UI标准升级 - 渐变头像单元/两行信息列(姓名工号/部门角色)/状态圆点胶囊(全局st变量暗色自适应)/用户类型下拉筛选/超管徽章/批量删除/清理老式死样式

This commit is contained in:
zou 2026-08-18 15:04:01 +08:00
parent 99bc522b24
commit 90d6561746

View File

@ -1,30 +1,25 @@
<script setup lang="ts">
/**
* F006 用户管理 - 套老 vue user.vue 结构 + v1.8 设计规范
* F006 用户管理 - 2026-08-18 按企业列表 UI 标准升级
*
* 后端端点 (SysUserController):
* GET /sys/user/page 分页列表
* GET /sys/user/{id} 详情
* POST /sys/user 新增
* PUT /sys/user 修改
* GET /sys/user/page 分页列表 (支持 username/userType 过滤)
* PUT /sys/user/updateStatus 启用/停用
* PUT /sys/user/password 修改密码(自己)
* PUT /sys/user/resetPassword 重置密码(管理员)
* DELETE /sys/user 删除
* DELETE /sys/user 删除 (支持批量)
*
* 字段(来自 SysUserDTO):
* id / username / password(WRITE_ONLY) / realName / headUrl
* gender(0=男/1=女/2=保密) / email / mobile / deptId
* superAdmin(0=否/1=是) / status(0=停用/1=正常) / remark
* identifier / employeeId(工号) / userType(1=试用/2=正式/3=外包/99=AI公司)
* joinDate / invitationUrl / invitationCode / xcxQrCode / operationId
* createDate / deptName / roleNames / roleIdList / postIdList
* id / username / realName / headUrl / gender / email / mobile / deptId
* superAdmin(0=否/1=是) / status(0=停用/1=正常) / employeeId(工号)
* userType(1=试用/2=正式/3=外包/99=AI公司) / createDate / deptName / roleNames
*
* 设计规范:§16 列表页 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
* UI 标准对齐企业列表: 头像渐变单元 / 两行信息列 / 圆点状态胶囊(全局 --st-* 变量暗色自适应) /
* 搜索栏补用户类型下拉 / selection 批量删除 / 清理老式死样式
*/
import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import { User, Phone, Clock } from '@element-plus/icons-vue'
import { httpGet, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
@ -35,42 +30,38 @@ interface SysUser {
username: string
realName: string
headUrl?: string | null
gender?: number | null
email?: string | null
mobile?: string | null
deptId?: string | null
deptName?: string | null
superAdmin?: number
status: 0 | 1
remark?: string | null
identifier?: string | null
employeeId?: string | null
userType?: number
joinDate?: string | number | null
createDate: string | number
roleNames?: string | null
roleIdList?: number[] | null
postIdList?: number[] | null
raw: any
}
const STATUS_MAP: Record<number, { label: string; type: string }> = {
1: { label: '正常', type: 'success' },
0: { label: '停用', type: 'danger' }
const STATUS_META: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '正常', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
0: { label: '停用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const USER_TYPE_MAP: Record<number, { label: string; type: string }> = {
1: { label: '试用期', type: 'info' },
2: { label: '正式', type: 'success' },
3: { label: '外包', type: 'warning' },
99: { label: 'AI 公司', type: 'info' }
const USER_TYPE_MAP: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '试用期', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
2: { label: '正式', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
3: { label: '外包', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
99: { label: 'AI 公司', color: 'var(--st-lv2-fg)', bg: 'var(--st-lv2-bg)' }
}
const GENDER_MAP: Record<number, string> = {
0: '男',
1: '女',
2: '保密'
}
// 头像品牌渐变色组 (无 headUrl 时交替使用, 与企业列表同源风格)
const AVATAR_GRADIENTS = [
'linear-gradient(135deg, #0b5394, #6fb1e8)',
'linear-gradient(135deg, #2563eb, #93c5fd)',
'linear-gradient(135deg, #0e7490, #67e8f9)',
'linear-gradient(135deg, #7c3aed, #c4b5fd)',
'linear-gradient(135deg, #be123c, #fda4af)'
]
const list = ref<SysUser[]>([])
const total = ref(0)
@ -78,6 +69,8 @@ const loading = ref(false)
const page = ref(1)
const limit = ref(10)
const loadError = ref<string | null>(null)
const selectedIds = ref<string[]>([])
const selectedCount = ref(0)
const filters = reactive({
username: '',
@ -88,6 +81,14 @@ const resetPwdVisible = ref(false)
const resetPwdTarget = ref<SysUser | null>(null)
const newPassword = ref('')
function avatarStyle(row: SysUser, index: number) {
return { background: AVATAR_GRADIENTS[index % AVATAR_GRADIENTS.length] }
}
function initial(row: SysUser): string {
const name = row.realName && row.realName !== '--' ? row.realName : row.username
return (name || '用').trim().charAt(0)
}
function fmtDate(ts?: string | number | null) {
if (!ts) return '--'
const n = typeof ts === 'string' ? Date.parse(ts) : ts
@ -113,22 +114,14 @@ async function load() {
username: it.username || '--',
realName: it.realName || '--',
headUrl: it.headUrl,
gender: it.gender,
email: it.email,
mobile: it.mobile,
deptId: it.deptId ? String(it.deptId) : null,
deptName: it.deptName || '--',
superAdmin: it.superAdmin ?? 0,
status: (it.status ?? 0) as 0 | 1,
remark: it.remark,
identifier: it.identifier,
employeeId: it.employeeId || '--',
userType: it.userType,
joinDate: it.joinDate,
createDate: it.createDate,
roleNames: it.roleNames,
roleIdList: it.roleIdList,
postIdList: it.postIdList,
raw: it
}))
total.value = data.total || 0
@ -151,6 +144,7 @@ function onReset() {
}
function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id); selectedCount.value = rows.length }
async function toggleStatus(row: SysUser) {
const newStatus = row.status === 1 ? 0 : 1
@ -165,10 +159,7 @@ async function toggleStatus(row: SysUser) {
return
}
try {
await httpPut('/sys/user/updateStatus', {
id: Number(row.id),
status: newStatus
})
await httpPut('/sys/user/updateStatus', { id: Number(row.id), status: newStatus })
ElMessage.success(`${action}成功`)
load()
} catch (e: any) {
@ -184,14 +175,8 @@ function resetPwdHandle(row: SysUser) {
async function resetPwdConfirm() {
if (!resetPwdTarget.value) return
if (!newPassword.value.trim()) {
ElMessage.warning('请输入新密码')
return
}
if (newPassword.value.length < 6) {
ElMessage.warning('密码至少 6 位')
return
}
if (!newPassword.value.trim()) { ElMessage.warning('请输入新密码'); return }
if (newPassword.value.length < 6) { ElMessage.warning('密码至少 6 位'); return }
try {
await httpPut('/sys/user/resetPassword', {
id: Number(resetPwdTarget.value.id),
@ -208,28 +193,35 @@ async function resetPwdConfirm() {
async function deleteHandle(row: SysUser) {
try {
await ElMessageBox.confirm(
`确认删除用户 ${row.username} 吗?删除后无法恢复。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
} catch {
return
}
await ElMessageBox.confirm(`确认删除用户 ${row.username} 吗?删除后无法恢复。`, '删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
try {
await httpDelete(`/sys/user/${row.id}`)
ElMessage.success('删除成功')
load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '删除失败')
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
}
async function batchDelete() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择用户'); return }
try {
await ElMessageBox.confirm(`确认删除已选 ${selectedIds.value.length} 个用户?删除后无法恢复。`, '批量删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
} catch { return }
let ok = 0
for (const id of selectedIds.value) {
try { await httpDelete(`/sys/user/${id}`); ok++ } catch {/* 单条失败跳过 */}
}
ElMessage.success(`已删除 ${ok} 个用户`)
load()
}
onMounted(() => { load() })
</script>
<template>
<div class="rr-sys-user">
<div class="rr-page">
<PageHeader title="后台用户" :sub="`共 ${total} 个用户`">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</PageHeader>
@ -237,68 +229,116 @@ onMounted(() => { load() })
<SearchBar
v-model:filters="filters"
:fields='[
{ key: "username", label: "用户名", type: "text", placeholder: "用户名" }
{ key: "username", label: "用户名", type: "text", placeholder: "用户名" },
{ key: "userType", label: "用户类型", type: "select", placeholder: "全部类型",
options: [
{ value: 1, label: "试用期" },
{ value: 2, label: "正式" },
{ value: 3, label: "外包" },
{ value: 99, label: "AI 公司" }
] }
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card">
<!-- 批量操作栏 -->
<div class="batch-bar" :class="{ 'has-selection': selectedCount > 0 }">
<span v-if="selectedCount > 0" class="batch-count">
已选 <strong>{{ selectedCount }}</strong> 个用户
</span>
<span v-else class="batch-hint">勾选用户后可批量删除</span>
<span class="batch-actions">
<el-button size="small" type="danger" plain :disabled="!selectedCount" @click="batchDelete">批量删除</el-button>
</span>
</div>
<DataTable
:columns="[
{ prop: 'username', label: '用户名', width: 200, slot: 'username' },
{ prop: 'realName', label: '姓名', width: 140 },
{ prop: 'employeeId', label: '工号', width: 120 },
{ prop: 'mobile', label: '手机号', width: 130 },
{ prop: 'userType', label: '类型', width: 100, slot: 'userType' },
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
{ label: '操作', width: 140, slot: 'action' }
{ type: 'selection', width: 48, label: '' },
{ label: '用户', minWidth: 220, slot: 'user' },
{ prop: 'mobile', label: '手机号', width: 130, slot: 'mobile' },
{ label: '部门 / 角色', minWidth: 180, slot: 'deptRole' },
{ prop: 'userType', label: '类型', width: 100, slot: 'userType', align: 'center' },
{ prop: 'status', label: '状态', width: 90, slot: 'status', align: 'center' },
{ label: '创建时间', width: 165, slot: 'createDate' },
{ label: '操作', width: 170, slot: 'action', fixed: 'right', align: 'center' }
]"
:data="list" :borderless="true"
:data="list"
:borderless="true"
:loading="loading"
:total="total"
:page="page"
:limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]"
:show-pagination="true"
:page-sizes="[10, 20, 50, 100]"
:error="loadError"
empty-text="暂无用户"
row-key="id"
@page-change="onPageChange"
@limit-change="onLimitChange"
@selection-change="onSelectionChange"
>
<template #username="{ row }">
<code class="rr-username-code">{{ row.username }}</code>
<!-- 用户单元: 头像 + 用户名 + 姓名/工号 -->
<template #user="{ row, index }">
<div class="user-cell">
<span class="user-avatar" :style="avatarStyle(row, index)">
<el-image v-if="row.headUrl" :src="row.headUrl" fit="cover" class="user-avatar-img" />
<template v-else>{{ initial(row) }}</template>
</span>
<span class="user-text">
<span class="user-name">
{{ row.username }}
<el-tag v-if="row.superAdmin === 1" size="small" type="warning" effect="light" class="super-tag">超管</el-tag>
</span>
<span class="user-sub">{{ row.realName }}{{ row.employeeId && row.employeeId !== '--' ? ` · ${row.employeeId}` : '' }}</span>
</span>
</div>
</template>
<!-- 手机号 -->
<template #mobile="{ row }">
<span class="phone-cell"><el-icon><Phone /></el-icon>{{ row.mobile || '--' }}</span>
</template>
<!-- 部门/角色 -->
<template #deptRole="{ row }">
<div class="two-line">
<span class="line-main">{{ row.deptName }}</span>
<span class="line-sub">{{ row.roleNames || '未分配角色' }}</span>
</div>
</template>
<!-- 类型 -->
<template #userType="{ row }">
<el-tag
v-if="row.userType != null && USER_TYPE_MAP[row.userType]"
:type="USER_TYPE_MAP[row.userType].type as any"
size="small"
effect="light"
>{{ USER_TYPE_MAP[row.userType].label }}</el-tag>
<span v-if="row.userType != null && USER_TYPE_MAP[row.userType]" class="chip"
:style="{ color: USER_TYPE_MAP[row.userType].color, background: USER_TYPE_MAP[row.userType].bg }">
{{ USER_TYPE_MAP[row.userType].label }}
</span>
<span v-else class="rr-muted">--</span>
</template>
<!-- 状态: 圆点胶囊 -->
<template #status="{ row }">
<el-tag
:type="STATUS_MAP[row.status].type as any"
size="small"
effect="light"
>{{ STATUS_MAP[row.status].label }}</el-tag>
<span class="status-tag"
:style="{ color: STATUS_META[row.status].color, background: STATUS_META[row.status].bg }">
<span class="status-dot"></span>{{ STATUS_META[row.status].label }}
</span>
</template>
<!-- 创建时间 -->
<template #createDate="{ row }">
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
<span class="date-cell"><el-icon><Clock /></el-icon>{{ fmtDate(row.createDate) }}</span>
</template>
<!-- 操作 -->
<template #action="{ row }">
<TableActions
:row="row"
:actions="[
{ label: (r: any) => r.status === 1 ? '停用' : '启用', type: (r: any) => r.status === 1 ? 'warning' : 'success', onClick: toggleStatus },
{ label: '重置密码', type: 'primary', onClick: resetPwdHandle },
{ label: '重置密码', type: 'info', onClick: resetPwdHandle },
{ label: '删除', type: 'danger', onClick: deleteHandle }
]"
/>
@ -311,11 +351,10 @@ onMounted(() => { load() })
v-model="resetPwdVisible"
title="重置密码"
width="480"
:style="{ borderRadius: '12px', overflow: 'hidden' }"
align-center
destroy-on-close
>
<p v-if="resetPwdTarget" style="margin-bottom: 16px;">
<p v-if="resetPwdTarget" style="margin: 0 0 16px; color: var(--ink-2)">
用户: <code class="rr-username-code">{{ resetPwdTarget.username }}</code>
({{ resetPwdTarget.realName }})
</p>
@ -330,9 +369,8 @@ onMounted(() => { load() })
/>
</el-form-item>
</el-form>
<el-alert type="warning" :closable="false" style="margin-top: 8px;">
<p>重置后请通过安全渠道告知用户</p>
<p>用户下次登录需使用新密码</p>
<el-alert type="warning" :closable="false" style="margin-top: 8px">
<p style="margin: 0">重置后请通过安全渠道告知用户,下次登录需使用新密码</p>
</el-alert>
<template #footer>
<el-button @click="resetPwdVisible = false">取消</el-button>
@ -342,78 +380,64 @@ onMounted(() => { load() })
</div>
</template>
<style scoped>
.rr-sys-user {
padding: 16px;
<style scoped lang="scss">
.rr-page { padding: 16px; }
/* ===== 批量操作栏 (对齐企业列表) ===== */
.batch-bar {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
transition: background .2s;
}
.batch-bar.has-selection { background: var(--primary-soft, #e0ecff); }
.batch-count { font-size: 13px; color: var(--ink-2); }
.batch-count strong { color: var(--primary); font-size: 15px; }
.batch-hint { font-size: 12px; color: var(--ink-3); }
.batch-actions { margin-left: auto; }
/* ===== 用户单元 ===== */
.user-cell { display: flex; align-items: center; gap: 10px; }
.user-avatar {
flex: 0 0 38px; width: 38px; height: 38px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 16px; font-weight: 700; flex-shrink: 0;
}
.user-avatar-img { width: 100%; height: 100%; border-radius: 10px; }
.user-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.user-name {
display: flex; align-items: center; gap: 6px;
font-size: 14px; font-weight: 600; color: var(--ink);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.super-tag { margin-left: 2px; }
.user-sub {
font-size: 12px; color: var(--ink-3);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rr-page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
/* ===== 单元格通用 ===== */
.two-line { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.line-main { font-size: 13px; color: var(--ink); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.line-sub { font-size: 12px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.phone-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
.date-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--ink-2); font-family: 'SF Mono', Menlo, Consolas, monospace; }
.rr-page-titles {
display: flex;
flex-direction: column;
gap: 2px;
/* ===== 类型/状态胶囊 (对齐企业列表, 使用全局 --st-* 变量暗色自适应) ===== */
.chip {
display: inline-flex; align-items: center; width: fit-content;
font-size: 12px; font-weight: 500; padding: 2px 12px; border-radius: 20px; white-space: nowrap;
}
.rr-page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.rr-page-sub {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rr-page-actions {
display: flex;
gap: 8px;
}
.rr-filters-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.rr-username-input { width: 240px; }
.rr-type-select { width: 140px; }
.rr-list-card {
background: var(--el-bg-color);
border-radius: 12px;
padding: 4px;
.status-tag {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 500; padding: 3px 12px; border-radius: 20px; white-space: nowrap;
}
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.rr-username-code {
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
padding: 2px 6px;
background: var(--el-fill-color-light);
border-radius: 4px;
color: var(--el-color-primary);
}
.rr-time {
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--el-text-color-regular);
}
.rr-muted {
color: var(--el-text-color-placeholder);
font-size: 12px;
font-size: 12px; padding: 2px 6px;
background: var(--fill-color-light, var(--surface-2));
border-radius: 4px; color: var(--el-color-primary);
}
.rr-muted { color: var(--ink-3); font-size: 12px; }
</style>