task-251(admin.html观感对齐): 用户管理补齐新建/编辑/角色+菜单授权弹窗(接线孤儿模块)
This commit is contained in:
@@ -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>当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</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)
|
||||
}
|
||||
@@ -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}')
|
||||
})
|
||||
@@ -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 常量')
|
||||
|
||||
Reference in New Issue
Block a user