Files
Ticket/web/src/features/system/users/index.vue
T
2026-05-22 18:00:14 +08:00

815 lines
22 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-shell">
<section class="page-card table-fill">
<div class="page-toolbar">
<n-form inline :show-label="false" class="toolbar-form">
<n-form-item>
<n-input v-model:value="queryForm.username" clearable placeholder="用户名" />
</n-form-item>
<n-form-item>
<n-input v-model:value="queryForm.nickname" clearable placeholder="昵称" />
</n-form-item>
<n-form-item>
<n-select
v-model:value="queryForm.status"
clearable
placeholder="状态"
:options="statusOptions"
style="width: 140px"
/>
</n-form-item>
<n-form-item>
<n-space>
<n-button type="primary" @click="handleSearch">查询</n-button>
<n-button @click="handleReset">重置</n-button>
</n-space>
</n-form-item>
</n-form>
<PermissionButton permission="system:user:create">
<n-button type="primary" @click="openCreate">新增用户</n-button>
</PermissionButton>
</div>
<div class="card-body card-body-fill table-fill">
<n-data-table
flex-height
remote
size="small"
:columns="columns"
:data="tableRows"
:loading="usersQuery.isLoading.value"
:pagination="pagination"
:row-key="(row: UserListItem) => row.id"
@update:page="onPageChange"
@update:page-size="onPageSizeChange"
/>
</div>
</section>
<n-modal
v-model:show="detailModal.visible"
preset="card"
title="用户详情"
:style="{ width: '920px', maxWidth: '92vw' }"
:mask-closable="false"
content-style="padding: 0; max-height: calc(85vh - 130px); overflow-y: auto"
>
<n-spin :show="detailModal.loading">
<template v-if="detailUser">
<div class="detail-shell">
<div class="detail-header">
<div>
<div class="detail-name">{{ detailUser.nickname || detailUser.realName || detailUser.username }}</div>
<div class="detail-username">{{ detailUser.username }}</div>
<div class="detail-tags">
<n-tag :type="statusTagType(detailUser.status)" size="small" round>
{{ detailUser.statusLabel || statusLabel(detailUser.status) }}
</n-tag>
<n-tag v-if="detailUser.orgName || detailUser.orgCode" size="small" round>
{{ detailUser.orgName || detailUser.orgCode }}
</n-tag>
</div>
</div>
<div class="detail-total">
<span>角色数</span>
<strong>{{ detailUser.roles?.length || 0 }}</strong>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">基础信息</div>
<div class="detail-grid">
<div v-for="item in baseDetailItems" :key="item.label" class="detail-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">角色信息</div>
<div class="role-tags">
<n-tag
v-for="role in detailUser.roles"
:key="role.id"
size="small"
round
type="info"
>
{{ role.name }}{{ role.code }}
</n-tag>
<span v-if="detailUser.roles.length === 0" class="empty-text">-</span>
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">系统信息</div>
<div class="detail-grid">
<div v-for="item in systemDetailItems" :key="item.label" class="detail-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</div>
</template>
</n-spin>
</n-modal>
<n-modal
v-model:show="editModal.visible"
preset="card"
:title="editModal.title"
class="w-[560px]"
>
<n-form
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-placement="left"
label-width="90"
>
<n-form-item label="用户名" path="username">
<n-input v-model:value="editForm.username" :disabled="editModal.mode === 'edit'" />
</n-form-item>
<n-form-item v-if="editModal.mode === 'create'" label="密码" path="password">
<n-input v-model:value="editForm.password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="昵称" path="nickname">
<n-input v-model:value="editForm.nickname" />
</n-form-item>
<n-form-item label="姓名" path="realName">
<n-input v-model:value="editForm.realName" />
</n-form-item>
<n-form-item label="手机号" path="phone">
<n-input v-model:value="editForm.phone" />
</n-form-item>
<n-form-item label="邮箱" path="email">
<n-input v-model:value="editForm.email" />
</n-form-item>
<n-form-item v-if="editModal.mode === 'create'" label="状态" path="status">
<n-radio-group v-model:value="editForm.status">
<n-radio-button value="ENABLED">启用</n-radio-button>
<n-radio-button value="DISABLED">禁用</n-radio-button>
</n-radio-group>
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="editModal.visible = false">取消</n-button>
<n-button type="primary" :loading="saveMutation.isPending.value" @click="handleSave"
>保存</n-button
>
</div>
</template>
</n-modal>
<n-modal v-model:show="passwordModal.visible" preset="card" title="重置密码" class="w-[460px]">
<n-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-placement="left"
label-width="90"
>
<n-form-item label="新密码" path="password">
<n-input v-model:value="passwordForm.password" type="password" show-password-on="click" />
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="passwordModal.visible = false">取消</n-button>
<n-button
type="primary"
:loading="passwordMutation.isPending.value"
@click="handleResetPassword"
>确认</n-button
>
</div>
</template>
</n-modal>
<n-modal v-model:show="rolesModal.visible" preset="card" title="分配角色" class="w-[520px]">
<n-form label-placement="left" label-width="90">
<n-form-item label="角色">
<n-select
v-model:value="rolesModal.roleIds"
multiple
clearable
filterable
:options="roleOptions"
placeholder="请选择角色"
/>
</n-form-item>
</n-form>
<template #footer>
<div class="flex justify-end gap-2">
<n-button @click="rolesModal.visible = false">取消</n-button>
<n-button
type="primary"
:loading="rolesMutation.isPending.value"
@click="handleAssignRoles"
>确认</n-button
>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { computed, h, reactive, ref } from 'vue'
import type { DataTableColumns, FormInst, FormRules } from 'naive-ui'
import { NButton, NPopconfirm, NSpace, NSwitch, NTag, useMessage } from 'naive-ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import PermissionButton from '@/components/PermissionButton.vue'
import { useAuthStore } from '@/stores/auth'
import { statusLabel, statusTagType } from '@/utils/display'
import { renderPagePrefix } from '@/utils/pagination'
import {
createUserApi,
deleteUserApi,
getUserDetailApi,
listUsersApi,
updateUserApi,
updateUserPasswordApi,
updateUserRolesApi,
updateUserStatusApi
} from '@/api/system/user'
import { listRolesApi } from '@/api/system/role'
import type {
CreateUserRequest,
UpdateUserRequest,
UserDetail,
UserListItem
} from '@/types/system/user'
const message = useMessage()
const authStore = useAuthStore()
const queryClient = useQueryClient()
const queryForm = reactive({
username: '',
nickname: '',
status: null as string | null
})
const pager = reactive({
page: 1,
pageSize: 10
})
const statusOptions = [
{ label: '启用', value: 'ENABLED' },
{ label: '禁用', value: 'DISABLED' }
]
const usersQuery = useQuery({
queryKey: computed(() => ['system', 'users', { ...queryForm, ...pager }]),
queryFn: () =>
listUsersApi({
page: pager.page,
pageSize: pager.pageSize,
username: queryForm.username || undefined,
nickname: queryForm.nickname || undefined,
status: queryForm.status || undefined
})
})
const rolesQuery = useQuery({
queryKey: ['system', 'roles', 'enabled'],
queryFn: () => listRolesApi({ page: 1, pageSize: 200, status: 'ENABLED' })
})
const roleOptions = computed(() =>
(rolesQuery.data.value?.items ?? []).map((role) => ({
label: `${role.name} (${role.code})`,
value: role.id
}))
)
const tableRows = computed(() => usersQuery.data.value?.items ?? [])
const pagination = computed(() => ({
page: pager.page,
pageSize: pager.pageSize,
itemCount: usersQuery.data.value?.total ?? 0,
pageSizes: [10, 20, 50],
showSizePicker: true,
prefix: renderPagePrefix
}))
const editFormRef = ref<FormInst | null>(null)
const detailModal = reactive({
visible: false,
loading: false
})
const detailUser = ref<UserDetail | null>(null)
const baseDetailItems = computed(() => {
const user = detailUser.value
if (!user) return []
return [
{ label: '用户ID', value: displayValue(user.id) },
{ label: '用户名', value: displayValue(user.username) },
{ label: '昵称', value: displayValue(user.nickname) },
{ label: '姓名', value: displayValue(user.realName) },
{ label: '手机号', value: displayValue(user.phone) },
{ label: '邮箱', value: displayValue(user.email) },
{ label: '组织', value: formatOrg(user) },
{ label: '状态', value: user.statusLabel || statusLabel(user.status) },
{ label: '头像', value: displayValue(user.avatar) }
]
})
const systemDetailItems = computed(() => {
const user = detailUser.value
if (!user) return []
return [
{ label: 'Token版本', value: displayValue(user.tokenVersion) },
{ label: '数据版本', value: displayValue(user.version) },
{ label: '最后登录时间', value: displayValue(user.lastLoginAt) },
{ label: '最后登录IP', value: displayValue(user.lastLoginIp) },
{ label: '创建时间', value: displayValue(user.createdAt) },
{ label: '创建人', value: displayValue(user.createdBy) },
{ label: '更新时间', value: displayValue(user.updatedAt) },
{ label: '更新人', value: displayValue(user.updatedBy) },
{ label: '删除时间', value: displayValue(user.deletedAt) },
{ label: '删除人', value: displayValue(user.deletedBy) }
]
})
const editModal = reactive({
visible: false,
mode: 'create' as 'create' | 'edit',
title: '新增用户',
id: ''
})
const editForm = reactive({
username: '',
password: '',
nickname: '',
realName: '',
phone: '',
email: '',
status: 'ENABLED'
})
const editRules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: ['input', 'blur'] }],
password: [{ required: true, message: '请输入密码', trigger: ['input', 'blur'] }]
}
const saveMutation = useMutation({
mutationFn: async () => {
if (editModal.mode === 'create') {
const payload: CreateUserRequest = {
username: editForm.username.trim(),
password: editForm.password,
nickname: editForm.nickname.trim() || undefined,
realName: editForm.realName.trim() || undefined,
phone: editForm.phone.trim() || undefined,
email: editForm.email.trim() || undefined,
status: editForm.status
}
return createUserApi(payload)
}
const payload: UpdateUserRequest = {
nickname: editForm.nickname.trim() || undefined,
realName: editForm.realName.trim() || undefined,
phone: editForm.phone.trim() || undefined,
email: editForm.email.trim() || undefined
}
return updateUserApi(editModal.id, payload)
},
onSuccess: async () => {
message.success('保存成功')
editModal.visible = false
await usersQuery.refetch()
}
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteUserApi(id),
onSuccess: async () => {
message.success('删除成功')
await usersQuery.refetch()
}
})
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
updateUserStatusApi(id, { status }),
onSuccess: async () => {
message.success('状态更新成功')
await usersQuery.refetch()
}
})
const passwordFormRef = ref<FormInst | null>(null)
const passwordModal = reactive({
visible: false,
id: ''
})
const passwordForm = reactive({
password: ''
})
const passwordRules: FormRules = {
password: [{ required: true, message: '请输入新密码', trigger: ['input', 'blur'] }]
}
const passwordMutation = useMutation({
mutationFn: () => updateUserPasswordApi(passwordModal.id, { password: passwordForm.password }),
onSuccess: async () => {
message.success('密码重置成功')
passwordModal.visible = false
passwordForm.password = ''
await queryClient.invalidateQueries({ queryKey: ['system', 'users'] })
}
})
const rolesModal = reactive({
visible: false,
id: '',
roleIds: [] as string[]
})
const rolesMutation = useMutation({
mutationFn: () => updateUserRolesApi(rolesModal.id, { roleIds: rolesModal.roleIds }),
onSuccess: async () => {
message.success('角色分配成功')
rolesModal.visible = false
await usersQuery.refetch()
}
})
function onPageChange(page: number) {
pager.page = page
}
function onPageSizeChange(pageSize: number) {
pager.pageSize = pageSize
pager.page = 1
}
function handleSearch() {
pager.page = 1
usersQuery.refetch()
}
function handleReset() {
queryForm.username = ''
queryForm.nickname = ''
queryForm.status = null
pager.page = 1
usersQuery.refetch()
}
function resetEditForm() {
editForm.username = ''
editForm.password = ''
editForm.nickname = ''
editForm.realName = ''
editForm.phone = ''
editForm.email = ''
editForm.status = 'ENABLED'
}
function openCreate() {
editModal.mode = 'create'
editModal.title = '新增用户'
editModal.id = ''
resetEditForm()
editModal.visible = true
}
function displayValue(value: unknown) {
if (value === null || value === undefined || value === '') return '-'
return String(value)
}
function formatOrg(user: UserDetail) {
if (user.orgName && user.orgCode) return `${user.orgName}${user.orgCode}`
return user.orgName || user.orgCode || user.orgId || '-'
}
async function openDetail(row: UserListItem) {
detailModal.visible = true
detailModal.loading = true
detailUser.value = null
try {
detailUser.value = await getUserDetailApi(row.id)
} catch {
message.error('查询用户详情失败')
} finally {
detailModal.loading = false
}
}
async function openEdit(row: UserListItem) {
editModal.mode = 'edit'
editModal.title = `编辑用户 - ${row.username}`
editModal.id = row.id
const detail = await getUserDetailApi(row.id)
editForm.username = detail.username
editForm.password = ''
editForm.nickname = detail.nickname ?? ''
editForm.realName = detail.realName ?? ''
editForm.phone = detail.phone ?? ''
editForm.email = detail.email ?? ''
editForm.status = detail.status
editModal.visible = true
}
async function handleSave() {
if (editModal.mode === 'create') {
await editFormRef.value?.validate()
} else {
await editFormRef.value?.validate(
(errors) => {
if (!errors) return
throw errors
},
(rule) => rule?.key !== 'password'
)
}
await saveMutation.mutateAsync()
}
function openResetPassword(row: UserListItem) {
passwordModal.id = row.id
passwordForm.password = ''
passwordModal.visible = true
}
async function handleResetPassword() {
await passwordFormRef.value?.validate()
await passwordMutation.mutateAsync()
}
async function openAssignRoles(row: UserListItem) {
const detail = await getUserDetailApi(row.id)
rolesModal.id = row.id
rolesModal.roleIds = [...detail.roleIds]
rolesModal.visible = true
}
async function handleAssignRoles() {
await rolesMutation.mutateAsync()
}
function updateStatus(row: UserListItem, checked: boolean) {
statusMutation.mutate({ id: row.id, status: checked ? 'ENABLED' : 'DISABLED' })
}
const columns = computed<DataTableColumns<UserListItem>>(() => [
{
title: '用户信息',
key: 'username',
minWidth: 180,
render: (row) =>
h('div', { class: 'leading-5' }, [
h(
'div',
{ class: 'font-medium text-slate-900' },
row.nickname || row.realName || row.username
),
h('div', { class: 'text-xs text-slate-500' }, row.username)
])
},
{
title: '角色',
key: 'roleCodes',
minWidth: 180,
render: (row) => (row.roleCodes.length > 0 ? row.roleCodes.join(', ') : '-')
},
{
title: '状态',
key: 'status',
width: 120,
render: (row) =>
h(
NTag,
{ type: statusTagType(row.status), size: 'small' },
{ default: () => statusLabel(row.status) }
)
},
{
title: '启停',
key: 'statusSwitch',
width: 110,
render: (row) =>
authStore.hasPermission('system:user:update')
? h(NSwitch, {
size: 'small',
value: row.status === 'ENABLED',
onUpdateValue: (checked: boolean) => updateStatus(row, checked)
})
: '-'
},
{
title: '操作',
key: 'actions',
width: 430,
render: (row) =>
h(
NSpace,
{ size: 6 },
{
default: () => [
h(
NButton,
{
size: 'small',
tertiary: true,
type: 'info',
class: 'action-btn',
onClick: () => openDetail(row)
},
{ default: () => '详情' }
),
authStore.hasPermission('system:user:update')
? h(
NButton,
{
size: 'small',
tertiary: true,
type: 'primary',
class: 'action-btn',
onClick: () => openEdit(row)
},
{ default: () => '编辑' }
)
: null,
authStore.hasPermission('system:user:update')
? h(
NButton,
{
size: 'small',
tertiary: true,
type: 'warning',
class: 'action-btn',
onClick: () => openResetPassword(row)
},
{ default: () => '重置密码' }
)
: null,
authStore.hasPermission('system:role:assign')
? h(
NButton,
{
size: 'small',
tertiary: true,
type: 'info',
class: 'action-btn',
onClick: () => openAssignRoles(row)
},
{ default: () => '分配角色' }
)
: null,
authStore.hasPermission('system:user:delete')
? h(
NPopconfirm,
{
onPositiveClick: () => deleteMutation.mutate(row.id)
},
{
trigger: () =>
h(
NButton,
{
size: 'small',
tertiary: true,
type: 'error',
class: 'action-btn',
loading: deleteMutation.isPending.value
},
{ default: () => '删除' }
),
default: () => `确认删除用户 ${row.username} 吗?`
}
)
: null
]
}
)
}
])
</script>
<style scoped>
.detail-shell {
padding: 0 20px 20px;
}
.detail-header {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 20px 0 16px;
border-bottom: 1px solid #eef1f5;
}
.detail-name {
color: #111827;
font-size: 18px;
font-weight: 600;
}
.detail-username {
margin-top: 4px;
color: #6b7280;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
}
.detail-tags {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.detail-total {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.detail-total span {
color: #6b7280;
font-size: 12px;
}
.detail-total strong {
color: #111827;
font-size: 24px;
font-weight: 600;
}
.detail-section {
padding-top: 18px;
}
.detail-section-title {
margin-bottom: 12px;
color: #111827;
font-size: 15px;
font-weight: 600;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.detail-item {
padding: 14px;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #fafafa;
}
.detail-item span {
display: block;
margin-bottom: 6px;
color: #6b7280;
font-size: 12px;
}
.detail-item strong {
color: #111827;
font-size: 14px;
font-weight: 500;
word-break: break-all;
}
.role-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.empty-text {
color: #9ca3af;
font-size: 13px;
}
@media (max-width: 960px) {
.detail-grid {
grid-template-columns: 1fr;
}
.detail-header {
flex-direction: column;
align-items: stretch;
}
.detail-total {
align-items: flex-start;
}
}
</style>