Files
zkxw/web-admin/src/views/system/user/index.vue
T
2026-06-14 09:14:11 +08:00

734 lines
24 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.
<!-- 用户管理(V0.3:左组织树 + 右用户列表) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="flex gap-4">
<!-- 左侧:组织树 -->
<div class="user-dept-aside w-[280px] shrink-0 border-r border-[var(--mint-border)] pr-4">
<!-- 标题与搜索同处 panel-top:避免标题栏底边与独立输入框之间出现不可控的视觉断层 -->
<div class="panel-top user-dept-aside__head">
<h2>组织</h2>
<el-input v-model="deptKeyword" placeholder="搜索组织" clearable />
</div>
<el-tree
ref="deptTreeRef"
class="user-dept-aside__tree"
:data="deptTree"
:props="{ label: 'deptName', children: 'children' }"
node-key="id"
highlight-current
default-expand-all
:filter-node-method="filterDeptNode"
@node-click="handleDeptNodeClick"
/>
</div>
<!-- 右侧:用户列表 -->
<div class="flex-1 min-w-0">
<div class="panel-top">
<h2 class="title-bar-heading">用户列表</h2>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="账号/姓名/手机号"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择" clearable>
<el-option label="正常" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">支持按关键词、状态筛选。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增用户
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0"
@click="handleBatchResetPwd"
>
<el-icon><Key /></el-icon>
重置密码
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index: number) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="userNo"
label="用户账号"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="nickName"
label="用户名"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="deptName"
label="所属组织"
min-width="200"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ formatUserDeptDisplay(row) }}
</template>
</el-table-column>
<el-table-column
prop="phoneNo"
label="手机号"
min-width="140"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.phoneNo || '-' }}
</template>
</el-table-column>
<el-table-column
prop="status"
label="状态"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<el-tag :type="row.status === '1' ? 'success' : 'info'" size="small">
{{ row.status === '1' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="260"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="resetPwd">
<el-icon><Key /></el-icon>重置密码
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="
(val: number) => ((pagination.page = 1), (pagination.pageSize = val), loadData())
"
@current-change="(val: number) => ((pagination.page = val), loadData())"
/>
</div>
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="720px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="user-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="用户账号" prop="userNo">
<el-input v-model="formData.userNo" placeholder="登录账号" :disabled="isEdit" />
</el-form-item>
<el-form-item label="用户名" prop="nickName">
<el-input v-model="formData.nickName" placeholder="显示名称" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码" prop="password">
<el-input
v-model="formData.password"
type="password"
show-password
placeholder="登录密码"
/>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="formData.phoneNo" placeholder="可选" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="formData.email" placeholder="可选" />
</el-form-item>
<el-form-item label="性别">
<el-select v-model="formData.sex" placeholder="请选择">
<el-option label="男" value="0" />
<el-option label="女" value="1" />
<el-option label="未知" value="2" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="formData.status" placeholder="请选择">
<el-option label="正常" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
<el-form-item label="角色" style="grid-column: 1 / -1">
<el-select
v-model="formData.roleIds"
multiple
placeholder="请选择角色"
style="width: 100%"
>
<el-option
v-for="item in roleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" style="grid-column: 1 / -1">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.nickName || '用户详情'"
:icon="UserFilled"
:meta="`${viewRow?.deptName || ''}${viewRow?.deptName && viewRow?.createTime ? ' · ' : ''}${viewRow?.createTime || ''}`"
:sections="viewSections"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Close,
Check,
View,
UserFilled,
RefreshLeft,
Search,
Key,
Edit,
Delete,
More
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import {
fetchUserList,
fetchUserDetail,
fetchCreateUser,
fetchUpdateUser,
fetchDeleteUser,
fetchResetPassword,
fetchDeptTree,
fetchRoleOptions
} from '@/api/admin'
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
const roleOptions = ref<Array<{ label: string; value: string }>>([])
async function loadRoleOptions() {
try {
roleOptions.value = (await fetchRoleOptions()) || []
} catch {
roleOptions.value = []
}
}
defineOptions({ name: 'SystemUser' })
const deptKeyword = ref('')
const deptTree = ref<Api.Dept.DeptItem[]>([])
const deptTreeRef = ref()
const selectedDeptId = ref<string | null>(null)
const deptPathLabelMap = computed(() =>
buildPathLabelMapFromTreeRoots(deptTree.value, {
idKey: 'id',
nameKey: 'deptName',
childrenKey: 'children'
})
)
function formatUserDeptDisplay(row: Api.User.UserItem) {
const id = (row.deptId || '').trim()
if (!id) return '—'
return deptPathLabelMap.value.get(id) || row.deptName || '—'
}
function formatUserRoleDisplay(row: Api.User.UserItem) {
const ids = row.roleIds || []
if (!ids.length) return '—'
const labels = ids
.map((id) => roleOptions.value.find((o) => o.value === id)?.label)
.filter((name): name is string => Boolean(name))
return labels.length ? labels.join('、') : '—'
}
const searchForm = reactive<{ keyword: string; status: string }>({ keyword: '', status: '' })
const tableData = ref<Api.User.UserItem[]>([])
const selectedRows = ref<Api.User.UserItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增用户')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
const formData = reactive({
userNo: '',
nickName: '',
password: '',
deptId: '',
email: '',
phoneNo: '',
sex: '0',
avatar: '',
status: '1',
orderNum: 0,
remark: '',
roleIds: [] as string[]
})
const formRules = {
userNo: [{ required: true, message: '请输入用户账号', trigger: 'blur' }],
nickName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
function filterDeptNode(value: string, data: Api.Dept.DeptItem) {
if (!value) return true
return data.deptName.includes(value)
}
watch(deptKeyword, (val) => {
deptTreeRef.value?.filter(val)
})
function handleDeptNodeClick(data: Api.Dept.DeptItem) {
selectedDeptId.value = data.id
pagination.page = 1
loadData()
}
async function loadDeptTree() {
try {
const data = await fetchDeptTree()
deptTree.value = data || []
} catch (e) {
console.error('加载组织树失败:', e)
}
}
async function loadData() {
loading.value = true
try {
const res = await fetchUserList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
deptId: selectedDeptId.value || undefined,
status: searchForm.status || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
searchForm.status = ''
selectedDeptId.value = null
deptTreeRef.value?.setCurrentKey(null)
pagination.page = 1
loadData()
}
function resetForm() {
formData.userNo = ''
formData.nickName = ''
formData.password = ''
formData.deptId = ''
formData.email = ''
formData.phoneNo = ''
formData.sex = '0'
formData.avatar = ''
formData.status = '1'
formData.orderNum = 0
formData.remark = ''
formData.roleIds = []
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增用户'
resetForm()
if (selectedDeptId.value) {
formData.deptId = selectedDeptId.value
}
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleEdit(row: Api.User.UserItem) {
isEdit.value = true
dialogTitle.value = '编辑用户'
editingId.value = row.id
try {
const detail = await fetchUserDetail(row.id)
formData.userNo = detail.userNo
formData.nickName = detail.nickName
formData.password = ''
formData.deptId = detail.deptId || ''
formData.email = detail.email || ''
formData.phoneNo = detail.phoneNo || ''
formData.sex = detail.sex || '0'
formData.avatar = detail.avatar || ''
formData.status = detail.status || '1'
formData.orderNum = detail.orderNum ?? 0
formData.remark = detail.remark || ''
formData.roleIds = detail.roleIds ? [...detail.roleIds] : []
dialogVisible.value = true
formRef.value?.clearValidate?.()
} catch (e) {
console.error('加载用户详情失败:', e)
ElMessage.error('加载用户详情失败')
}
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
if (isEdit.value && editingId.value) {
await fetchUpdateUser(editingId.value, {
nickName: formData.nickName.trim(),
deptId: formData.deptId || undefined,
email: formData.email?.trim() || undefined,
phoneNo: formData.phoneNo?.trim() || undefined,
sex: formData.sex,
status: formData.status,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined,
roleIds: [...formData.roleIds]
})
} else {
await fetchCreateUser({
userNo: formData.userNo.trim(),
nickName: formData.nickName.trim(),
password: formData.password,
deptId: formData.deptId || undefined,
email: formData.email?.trim() || undefined,
phoneNo: formData.phoneNo?.trim() || undefined,
sex: formData.sex,
status: formData.status,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined,
roleIds: formData.roleIds.length ? formData.roleIds : undefined
})
}
dialogVisible.value = false
await loadData()
} catch {
/* 校验或接口错误已由全局处理 */
} finally {
submitLoading.value = false
}
}
const handleSelectionChange = (rows: Api.User.UserItem[]) => {
selectedRows.value = rows
}
async function handleRowDelete(row: Api.User.UserItem) {
await ElMessageBox.confirm(`确定删除用户「${row.nickName}」吗?`, '提示', { type: 'warning' })
await fetchDeleteUser(row.id)
ElMessage.success('删除成功')
selectedRows.value = selectedRows.value.filter((item) => item.id !== row.id)
await loadData()
}
async function handleRowResetPwd(row: Api.User.UserItem) {
await ElMessageBox.confirm(
`确定将用户「${row.nickName}」的密码重置为 111111 吗?`,
'提示',
{ type: 'warning' }
)
await fetchResetPassword(row.id)
ElMessage.success('密码重置成功')
}
function handleDropdownCommand(command: string, row: Api.User.UserItem) {
switch (command) {
case 'edit':
handleEdit(row)
break
case 'resetPwd':
handleRowResetPwd(row)
break
case 'delete':
handleRowDelete(row)
break
}
}
async function handleBatchDelete() {
const rows = selectedRows.value
if (!rows.length) {
ElMessage.warning('请至少选择一项')
return
}
const message =
rows.length === 1
? `确定删除用户「${rows[0].nickName}」吗?`
: `确定删除选中的 ${rows.length} 个用户吗?`
await ElMessageBox.confirm(message, '提示', { type: 'warning' })
for (const row of rows) {
await fetchDeleteUser(row.id)
}
ElMessage.success('删除成功')
selectedRows.value = []
await loadData()
}
async function handleBatchResetPwd() {
const rows = selectedRows.value
if (!rows.length) {
ElMessage.warning('请至少选择一项')
return
}
const message =
rows.length === 1
? `确定将用户「${rows[0].nickName}」的密码重置为 111111 吗?`
: `确定将选中的 ${rows.length} 个用户的密码重置为 111111 吗?`
await ElMessageBox.confirm(message, '提示', { type: 'warning' })
for (const row of rows) {
await fetchResetPassword(row.id)
}
ElMessage.success('密码重置成功')
selectedRows.value = []
}
// ---- 查看弹窗 ----
const viewVisible = ref(false)
const viewRow = ref<Api.User.UserItem | null>(null)
const viewSections = computed(() => {
const row = viewRow.value
if (!row) return []
return [
{
title: '基本信息',
fields: [
{ label: '用户账号', value: row.userNo },
{ label: '用户名', value: row.nickName },
{ label: '所属组织', value: formatUserDeptDisplay(row) },
{ label: '手机号', value: row.phoneNo },
{ label: '邮箱', value: row.email },
{
label: '性别',
value:
({ '0': '男', '1': '女', '2': '未知' } as Record<string, string>)[row.sex] || '未知'
},
{
label: '状态',
value: row.status === '1' ? '正常' : '停用',
tagType: row.status === '1' ? 'success' : 'info'
},
{ label: '角色', value: formatUserRoleDisplay(row), span: true },
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
async function handleView(row: Api.User.UserItem) {
try {
viewRow.value = await fetchUserDetail(row.id)
viewVisible.value = true
} catch (e) {
console.error('加载用户详情失败:', e)
ElMessage.error('加载用户详情失败')
}
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadDeptTree()
loadData()
loadRoleOptions()
})
</script>
<style scoped lang="scss">
/* 左栏贴合 panel 左缘圆角,避免直角白底遮住面板左下/左上圆弧 */
.user-dept-aside {
border-radius: var(--mint-radius) 0 0 var(--mint-radius);
overflow: hidden;
background: var(--mint-surface);
}
/* 分栏布局下仅保留右下圆角,避免列表区左下角与侧栏交界处出现多余弧面 */
:deep(.list-wrap),
:deep(.pagination.list-pager-bar) {
border-radius: 0 0 var(--mint-radius) 0;
}
/* 侧栏标题区纵向排布:与全局 panel-top 横排工具栏区分,收紧标题与搜索的间距节奏 */
.user-dept-aside__head {
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 8px;
}
.user-dept-aside__tree {
margin-top: 12px;
}
.user-dialog-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
</style>