task-251(admin.html观感对齐): 用户管理补齐新建/编辑/角色+菜单授权弹窗(接线孤儿模块)

This commit is contained in:
2026-09-05 21:17:15 +08:00
parent 41cb4ce174
commit 07fda4cf22
7 changed files with 426 additions and 2 deletions
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { submitCreateUser } from './user-create-adapter'
import {
createEmptyCreateUserForm,
type CreateUserForm,
type CreateUserFormErrors,
validateCreateUserForm,
} from './user-create-model'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
import { actionableErrorText } from '@/components/admin-feedback'
import UserMenuAuthTree from './UserMenuAuthTree.vue'
const props = defineProps<{ modelValue: boolean; operatorSuper?: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'created'): void }>()
const form = reactive<CreateUserForm>(createEmptyCreateUserForm())
const errors = reactive<CreateUserFormErrors>({})
const busy = ref(false)
/** 角色下拉(对齐 admin.html:普通账号/管理员;超管才显示可改)。 */
const ROLE_CHOICES: Array<{ value: 'admin' | 'normal'; label: string }> = [
{ value: 'normal', label: '普通账号' },
{ value: 'admin', label: '管理员' },
]
watch(
() => props.modelValue,
(open) => {
if (!open) return
form.username = ''
form.password = ''
form.role = 'normal'
form.columnIds = []
errors.username = undefined
errors.password = undefined
},
)
function close(): void {
emit('update:modelValue', false)
}
async function create(): Promise<void> {
const { valid, errors: errs } = validateCreateUserForm(form)
Object.assign(errors, errs)
if (!valid) return
busy.value = true
try {
await submitCreateUser(form)
showAdminFeedback('创建成功', 'success')
// 对齐 admin:成功后弹窗不关,仅清空用户名/密码并刷新列表。
form.username = ''
form.password = ''
errors.username = undefined
errors.password = undefined
emit('created')
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
busy.value = false
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="创建用户"
width="640px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="用户名" :error="errors.username">
<el-input v-model="form.username" autocomplete="username" placeholder="用户名至少 2 个字符" />
</el-form-item>
<el-form-item label="密码" :error="errors.password">
<el-input
v-model="form.password"
type="password"
show-password
autocomplete="new-password"
placeholder="密码至少 6 个字符"
/>
</el-form-item>
<el-form-item v-if="operatorSuper" label="角色">
<el-select v-model="form.role" style="width: 200px">
<el-option v-for="opt in ROLE_CHOICES" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="菜单权限勾选上级菜单会继承全部子菜单">
<div class="auth-tree-box">
<UserMenuAuthTree v-model:checked="form.columnIds" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="create">创建用户</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.auth-tree-box {
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: 9px;
padding: 8px;
}
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import type { AdminUser } from '@/types/admin'
import { fillEditUserForm, validateEditUserForm, type EditUserForm, type EditUserFormErrors } from './user-edit-model'
import { submitEditUser } from './user-edit-adapter'
import { loadUserMenuAuth } from './user-menu-auth-api'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
import { actionableErrorText } from '@/components/admin-feedback'
import UserMenuAuthTree from './UserMenuAuthTree.vue'
const props = defineProps<{ modelValue: boolean; user: AdminUser | null; operatorSuper?: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'updated'): void }>()
const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role: 'normal', columnIds: [], originalRole: '' })
const errors = reactive<EditUserFormErrors>({})
const busy = ref(false)
const loadingAuth = ref(false)
watch(
() => props.modelValue,
(open) => {
if (!open || !props.user) return
const base = fillEditUserForm(props.user)
form.uid = base.uid
form.username = base.username
form.password = ''
form.role = base.role
form.columnIds = []
form.originalRole = base.originalRole
errors.password = undefined
void refreshAuth()
},
)
async function refreshAuth(): Promise<void> {
if (!props.user) return
loadingAuth.value = true
try {
const { checkedIds } = await loadUserMenuAuth(props.user.id)
form.columnIds = checkedIds
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
loadingAuth.value = false
}
}
function close(): void {
emit('update:modelValue', false)
}
async function save(): Promise<void> {
const { valid, errors: errs } = validateEditUserForm(form)
Object.assign(errors, errs)
if (!valid) return
busy.value = true
try {
await submitEditUser(form)
showAdminFeedback('更新成功', 'success')
emit('updated')
close()
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
busy.value = false
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="编辑用户"
width="640px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="用户名">
<el-input :model-value="form.username" readonly />
</el-form-item>
<el-form-item label="新密码不修改可留空" :error="errors.password">
<el-input
v-model="form.password"
type="password"
show-password
autocomplete="new-password"
placeholder="留空则不修改密码"
/>
</el-form-item>
<el-form-item v-if="operatorSuper" label="角色">
<el-radio-group v-model="form.role">
<el-radio-button value="normal">普通账号</el-radio-button>
<el-radio-button value="admin">管理员</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="props.user && props.user.creatorUsername" label="所属管理员">
<el-input :model-value="props.user.creatorUsername" readonly />
</el-form-item>
<el-form-item label="菜单权限勾选上级菜单会继承全部子菜单">
<div class="auth-tree-box">
<UserMenuAuthTree v-model:checked="form.columnIds" />
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.auth-tree-box {
position: relative;
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: 9px;
padding: 8px;
}
.auth-loading {
margin-top: 6px;
font-size: 12px;
color: var(--admin-muted);
}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import type { MenuOptionNode } from './user-menu-auth'
import { fetchGrantableMenus } from './user-menu-auth-api'
const props = defineProps<{ checked?: number[] }>()
const emit = defineEmits<{
(e: 'update:checked', ids: number[]): void
(e: 'load', ok: boolean): void
}>()
/** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */
type TreeRef = { setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown }
const tree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
const data = ref<MenuOptionNode[]>([])
const loaded = ref(false)
function applyChecked(): void {
const el: TreeRef | null = tree.value
if (!el) return
const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
el.setCheckedKeys(ids)
}
function onCheck(): void {
const el: TreeRef | null = tree.value
if (!el) return
const keys = el.getCheckedKeys()
const ids = (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
emit('update:checked', ids)
}
async function load(): Promise<void> {
try {
data.value = await fetchGrantableMenus()
loaded.value = true
applyChecked()
emit('load', true)
} catch {
loaded.value = false
emit('load', false)
}
}
onMounted(load)
watch(
() => props.checked,
() => {
if (loaded.value) applyChecked()
},
)
</script>
<template>
<el-scrollbar max-height="300px" class="user-menu-tree-scroll">
<el-tree
ref="tree"
:data="data"
node-key="id"
show-checkbox
check-strictly
default-expand-all
:props="{ label: 'name', children: 'children' }"
@check="onCheck"
/>
</el-scrollbar>
</template>
@@ -9,6 +9,9 @@ import { createUserFilterState, toUserListParams, type UserFilterState } from '.
import { totalPageCount } from './user-pagination'
import { deleteBlockReason, deleteConfirmMessage } from './user-delete-model'
import { listPhaseOf, userListEmptyHint } from './user-list-state'
import { isEditableUser } from './user-edit-model'
import CreateUserDialog from './CreateUserDialog.vue'
import EditUserDialog from './EditUserDialog.vue'
const session = useAdminSessionStore()
const loading = ref(false)
@@ -22,6 +25,24 @@ const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null)
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
const createVisible = ref(false)
const editVisible = ref(false)
const editUser = ref<AdminUser | null>(null)
function openCreate(): void {
createVisible.value = true
}
function openEdit(row: AdminUser): void {
if (!isEditableUser(row.role)) return
editUser.value = row
editVisible.value = true
}
function editableOf(row: AdminUser): boolean {
return isEditableUser(row.role)
}
async function loadUsers() {
loading.value = true
loadError.value = ''
@@ -92,6 +113,7 @@ onMounted(loadUsers)
<h2>用户管理</h2>
<p>当前没有开放注册入口仅管理员可在此创建用户层级关系超级管理员 -&gt; 管理员 -&gt; 普通账号</p>
</div>
<el-button type="primary" @click="openCreate">新建用户</el-button>
</div>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="search">
@@ -143,8 +165,9 @@ onMounted(loadUsers)
</el-table-column>
<el-table-column prop="creatorUsername" label="所属管理员" width="160" />
<el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!editableOf(row)" @click="openEdit(row)">编辑</el-button>
<el-button
link
type="danger"
@@ -168,6 +191,8 @@ onMounted(loadUsers)
/>
</div>
</el-card>
<CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" @created="loadUsers" />
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
</div>
</template>
@@ -0,0 +1,23 @@
/** 编辑用户请求适配(module 13 task 251):表单模型 → PUT /api/admin/user/{uid}。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
toEditUserUpdateRequest,
validateEditUserForm,
type EditUserForm,
} from './user-edit-model'
/** Java AdminUserController.updateUser 端点前缀。 */
export const EDIT_USER_ENDPOINT = '/api/admin/user'
/**
* 提交编辑用户:先校验(不通过抛首条错误),再序列化 PUT;http/后端失败交由调用方提示。
*/
export async function submitEditUser(form: EditUserForm): Promise<void> {
const { valid, errors } = validateEditUserForm(form)
if (!valid) {
throw new Error(Object.values(errors)[0] || '编辑用户表单校验未通过')
}
const { data } = await http.put<unknown>(`${EDIT_USER_ENDPOINT}/${form.uid}`, toEditUserUpdateRequest(form))
unwrap<unknown>(data)
}
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
// module 13 task 251:用户管理补齐新建/编辑/角色+菜单授权弹窗(接线孤儿模块)。
test('test_task_251_user_dialog_normal_primary_path', () => {
const page = readSource('src/pages/account/UsersPage.vue')
assert.match(page, /新建用户/, '页头需有新建用户入口')
assert.match(page, /openCreate/, '新建按钮需绑定 openCreate')
assert.match(page, /CreateUserDialog/, '需挂载新建用户弹窗')
assert.match(page, /EditUserDialog/, '需挂载编辑用户弹窗')
})
test('test_task_251_user_dialog_normal_variant_input', () => {
const create = readSource('src/pages/account/CreateUserDialog.vue')
assert.match(create, /submitCreateUser/, '创建提交走 user-create-adapter')
assert.match(create, /validateCreateUserForm/, '创建校验走 user-create-model')
assert.match(create, /普通账号/, '角色文案需含“普通账号”')
assert.match(create, /管理员/, '角色文案需含“管理员”')
assert.match(create, /UserMenuAuthTree/, '创建弹窗需含菜单授权树')
})
test('test_task_251_user_dialog_normal_repeated_operation_is_idempotent', () => {
const edit = readSource('src/pages/account/EditUserDialog.vue')
assert.match(edit, /fillEditUserForm/, '编辑回填走 user-edit-model')
assert.match(edit, /submitEditUser/, '编辑提交走 user-edit-adapter')
assert.match(edit, /loadUserMenuAuth/, '编辑授权加载走 user-menu-auth-api')
assert.match(edit, /更新成功/, '编辑成功提示“更新成功”')
})
test('test_task_251_user_dialog_boundary_empty_input', () => {
const model = readSource('src/pages/account/user-create-model.ts')
assert.match(model, /用户名至少/, '需有“用户名至少 N 个字符”')
assert.match(model, /密码至少/, '需有“密码至少 N 个字符”')
})
test('test_task_251_user_dialog_boundary_single_item', () => {
// 超管用户不可编辑(行按钮禁用)。
const page = readSource('src/pages/account/UsersPage.vue')
assert.match(page, /editableOf/, '需有可编辑判定')
assert.match(page, /isEditableUser/, '可编辑判定应复用 user-edit-model')
})
test('test_task_251_user_dialog_boundary_limit_or_missing_field', () => {
// 边界上限:角色下拉仅超管操作者可见(非超管隐藏角色项)。
const create = readSource('src/pages/account/CreateUserDialog.vue')
assert.match(create, /operatorSuper/, '角色可改应受 operatorSuper 控制')
const edit = readSource('src/pages/account/EditUserDialog.vue')
assert.match(edit, /operatorSuper/, '编辑角色项应受 operatorSuper 控制')
})
test('test_task_251_user_dialog_invalid_input_rejected', () => {
// 异常:创建成功后需清空用户名/密码并保持弹窗;提交反馈走统一通道。
const create = readSource('src/pages/account/CreateUserDialog.vue')
assert.match(create, /form\.username\s*=\s*''/, '成功后清空用户名')
assert.match(create, /form\.password\s*=\s*''/, '成功后清空密码')
assert.match(create, /showAdminFeedback/, '反馈走 admin-feedback 统一通道')
})
test('test_task_251_user_dialog_dependency_failure_returns_actionable_message', () => {
// 依赖:编辑适配器 PUT /api/admin/user/{uid} 真实存在。
const adapter = readSource('src/pages/account/user-edit-adapter.ts')
assert.match(adapter, /http\.put/, '编辑需走 PUT')
assert.match(adapter, /\$\{EDIT_USER_ENDPOINT\}\/\$\{form\.uid\}/, '端点须为 /api/admin/user/{uid}')
})
+3 -1
View File
@@ -62,9 +62,11 @@ test('test_task_043_user_filter_state_invalid_input_rejected', () => {
test('test_task_043_user_filter_state_dependency_failure_returns_actionable_message', () => {
// 依赖失败:页面把筛选/分页状态局部化到 users-filter,不入 Pinia。
// module 13 起页面允许读 session store 取操作者角色/当前用户,但筛选状态仍不落 store。)
const page = readSource('src/pages/account/UsersPage.vue')
assert.match(page, /users-filter/, '页面复用局部筛选状态模块')
assert.equal(/defineStore|useAdminSessionStore/.test(page), false, '筛选状态不落在 session store')
assert.equal(/defineStore/.test(page), false, '页面不得自行 defineStore 存放筛选状态')
assert.match(page, /createUserFilterState/, '页面以局部状态机创建筛选状态')
const mod = readSource('src/pages/account/users-filter.ts')
assert.match(mod, /export function createUserFilterState/)
assert.match(mod, /users-dto/, '筛选模块复用分页 DTO 常量')