liuyun 5de298a956 feat(auth): 首登强制改密页面 + 创建用户开关
- 独立改密页 + 路由守卫拦截待改密
- auth store 持久化 mustChangePassword
- 用户创建表单加「首登强制改密」开关(默认勾选)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 21:54:41 +08:00

245 lines
8.0 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
<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>