- 独立改密页 + 路由守卫拦截待改密 - auth store 持久化 mustChangePassword - 用户创建表单加「首登强制改密」开关(默认勾选) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
8.0 KiB
Vue
245 lines
8.0 KiB
Vue
<template>
|
||
<ty-crud
|
||
ref="crudRef"
|
||
entity-name="用户"
|
||
selection
|
||
:columns="columns"
|
||
:search-schema="searchSchema"
|
||
:form-schema="formSchema"
|
||
:list="list"
|
||
:create="create"
|
||
:update="update"
|
||
:remove="remove"
|
||
:actions="actions"
|
||
create-permission="user:create"
|
||
id-key="id"
|
||
name-key="username"
|
||
:edit-readonly-keys="['username']"
|
||
>
|
||
<template #toolbar>
|
||
<el-button v-permission="'user:export'" @click="exportUsers">导出</el-button>
|
||
</template>
|
||
<template #toolbar-right="{ selectedRows }">
|
||
<el-button
|
||
type="danger"
|
||
:disabled="selectedRows.length === 0"
|
||
@click="batchDelete(selectedRows)"
|
||
>
|
||
批量删除
|
||
</el-button>
|
||
</template>
|
||
</ty-crud>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import type { CrudAction, JsonSchema, PageQuery, TableColumn } from '@/types/crud'
|
||
import { showDialog } from '@/utils/dialog'
|
||
import { useRequest } from '@/composables/useRequest'
|
||
import {
|
||
assignUserRoles,
|
||
batchDeleteUsers,
|
||
createUser,
|
||
deleteUser,
|
||
listUsers,
|
||
lockUser,
|
||
resetPassword,
|
||
unlockUser,
|
||
updateUser,
|
||
type UserListQuery,
|
||
type UserStatus,
|
||
} from '@/api/user'
|
||
import { listOrgs } from '@/api/org'
|
||
import ResetPasswordForm from './reset-password-form.vue'
|
||
import AssignRolesForm from './assign-roles-form.vue'
|
||
|
||
// 组织列表(下拉 + 名称回显)
|
||
const { data: orgs } = useRequest((signal: AbortSignal) => listOrgs(signal))
|
||
|
||
const columns = computed<TableColumn[]>(() => {
|
||
const orgList = orgs.value ?? []
|
||
const orgName = (code: unknown): string => orgList.find((o) => o.code === code)?.name ?? (code as string) ?? '-'
|
||
return [
|
||
{ prop: 'username', label: '用户名', minWidth: 110 },
|
||
{ prop: 'nickname', label: '昵称', minWidth: 100 },
|
||
{ prop: 'email', label: '邮箱', minWidth: 160 },
|
||
{ prop: 'phone', label: '手机', minWidth: 120 },
|
||
{ prop: 'displayStatus', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'user_status' },
|
||
{ prop: 'orgCode', label: '所属组织', minWidth: 110, formatter: (v) => orgName(v) },
|
||
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
|
||
]
|
||
})
|
||
|
||
const searchSchema: JsonSchema = {
|
||
type: 'object',
|
||
properties: {
|
||
username: { type: 'string', title: '用户名', placeholder: '请输入用户名' },
|
||
nickname: { type: 'string', title: '昵称', placeholder: '请输入昵称' },
|
||
phone: { type: 'string', title: '手机', placeholder: '请输入手机号' },
|
||
status: {
|
||
type: 'string',
|
||
title: '状态',
|
||
dictCode: 'common_status',
|
||
placeholder: '全部',
|
||
},
|
||
},
|
||
}
|
||
|
||
const formSchema = computed<JsonSchema>(() => ({
|
||
type: 'object',
|
||
properties: {
|
||
username: { type: 'string', title: '用户名', placeholder: '请输入用户名', minLength: 2 },
|
||
password: {
|
||
type: 'string',
|
||
title: '密码',
|
||
format: 'password',
|
||
placeholder: '请输入初始密码',
|
||
minLength: 6,
|
||
showOn: ['create'],
|
||
},
|
||
mustChangePassword: {
|
||
type: 'boolean',
|
||
title: '首登强制改密',
|
||
default: true,
|
||
showOn: ['create'],
|
||
},
|
||
nickname: { type: 'string', title: '昵称', placeholder: '请输入昵称' },
|
||
email: { type: 'string', title: '邮箱', format: 'email', placeholder: '请输入邮箱' },
|
||
phone: { type: 'string', title: '手机', placeholder: '请输入手机号' },
|
||
status: {
|
||
type: 'string',
|
||
title: '状态',
|
||
dictCode: 'common_status',
|
||
default: 'enabled',
|
||
},
|
||
orgCode: {
|
||
type: 'string',
|
||
title: '所属组织',
|
||
enum: (orgs.value ?? []).map((o) => o.code),
|
||
enumNames: (orgs.value ?? []).map((o) => o.name),
|
||
placeholder: '请选择组织',
|
||
},
|
||
},
|
||
required: ['username', 'password'],
|
||
}))
|
||
|
||
const actions: CrudAction[] = [
|
||
'edit',
|
||
'delete',
|
||
{ label: '分配角色', type: 'primary', permission: 'user:assign-role', onClick: (row) => assignRolesAction(row) },
|
||
{ label: '重置密码', type: 'warning', permission: 'user:reset-pwd', onClick: (row) => resetPwd(row) },
|
||
{ label: '锁定', type: 'danger', permission: 'user:lock', show: (row) => !row.lockedAt, onClick: (row) => lockAction(row) },
|
||
{ label: '解锁', type: 'success', permission: 'user:unlock', show: (row) => !!row.lockedAt, onClick: (row) => unlockAction(row) },
|
||
]
|
||
|
||
function toListQuery(query: PageQuery): UserListQuery {
|
||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
|
||
return {
|
||
page: query.page,
|
||
pageSize: query.pageSize,
|
||
username: str(query.username),
|
||
nickname: str(query.nickname),
|
||
phone: str(query.phone),
|
||
status: str(query.status) as UserStatus | undefined,
|
||
}
|
||
}
|
||
|
||
const list = async (query: PageQuery, signal?: AbortSignal) => {
|
||
const result = await listUsers(toListQuery(query), signal)
|
||
return {
|
||
...result,
|
||
list: result.list.map((u) => ({ ...u, displayStatus: u.lockedAt ? 'locked' : u.status })),
|
||
}
|
||
}
|
||
const create = (data: Record<string, any>) => createUser(data)
|
||
const update = (id: string, data: Record<string, any>) => updateUser(id, data)
|
||
const remove = (id: string) => deleteUser(id)
|
||
|
||
async function resetPwd(row: Record<string, any>): Promise<void> {
|
||
const result = await showDialog({
|
||
component: ResetPasswordForm,
|
||
props: { username: String(row.username) },
|
||
title: '重置密码',
|
||
width: '420px',
|
||
})
|
||
if (result == null) return
|
||
await resetPassword(String(row.id), result.password)
|
||
ElMessage.success('密码已重置')
|
||
}
|
||
|
||
async function assignRolesAction(row: Record<string, any>): Promise<void> {
|
||
const result = await showDialog({
|
||
component: AssignRolesForm,
|
||
props: { userId: String(row.id) },
|
||
title: `分配角色(${row.username})`,
|
||
width: '480px',
|
||
})
|
||
if (result == null) return
|
||
await assignUserRoles(String(row.id), result.roleCodes)
|
||
ElMessage.success('角色已保存')
|
||
}
|
||
|
||
const crudRef = ref()
|
||
|
||
async function lockAction(row: Record<string, any>): Promise<void> {
|
||
let reason: string | undefined
|
||
try {
|
||
const res = await ElMessageBox.prompt(`锁定用户「${row.username}」`, '锁定用户', {
|
||
inputPlaceholder: '锁定原因(可选)',
|
||
confirmButtonText: '锁定',
|
||
cancelButtonText: '取消',
|
||
})
|
||
reason = res.value || undefined
|
||
} catch {
|
||
return // 用户取消
|
||
}
|
||
await lockUser(String(row.id), reason)
|
||
ElMessage.success('已锁定')
|
||
crudRef.value?.reload()
|
||
}
|
||
|
||
async function unlockAction(row: Record<string, any>): Promise<void> {
|
||
try {
|
||
await ElMessageBox.confirm(`确认解锁用户「${row.username}」?`, '提示', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
await unlockUser(String(row.id))
|
||
ElMessage.success('已解锁')
|
||
crudRef.value?.reload()
|
||
}
|
||
|
||
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
||
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个用户?`, '提示', { type: 'warning' })
|
||
const result = await batchDeleteUsers(rows.map((r) => String(r.id)))
|
||
if (result.failed.length > 0) {
|
||
ElMessage.warning(`删除完成:成功 ${result.success} 个,失败 ${result.failed.length} 个`)
|
||
} else {
|
||
ElMessage.success('删除成功')
|
||
}
|
||
crudRef.value?.reload()
|
||
}
|
||
|
||
function escapeCsv(v: unknown): string {
|
||
const s = String(v ?? '')
|
||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
||
}
|
||
|
||
async function exportUsers(): Promise<void> {
|
||
const result = await listUsers({ page: 1, pageSize: 1000 })
|
||
const headers = ['用户名', '昵称', '邮箱', '手机', '状态', '所属组织', '创建时间']
|
||
const lines = [headers.join(',')]
|
||
for (const u of result.list) {
|
||
lines.push([u.username, u.nickname, u.email, u.phone, u.status, u.orgCode, u.createdAt].map(escapeCsv).join(','))
|
||
}
|
||
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = '用户列表.csv'
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
</script>
|