task-41(账号/权限页面): 定义用户列表与分页 DTO
新增纯模块 users-dto.ts:UserListParams/UserPageResult/UserListQuery 契约与 Java AdminUserController 对齐(page/page_size/username/items/total); normalizeUserPageParams 页码下限1、页大小夹取、空关键字去抖;toUserListQuery 按 snake_case 序列化;UsersPage 改用 AdminUser/UserPageResult 类型。8 用例全过。
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/** 用户管理列表/分页 DTO(任务 41):纯逻辑,无框架依赖,与 Java AdminUserController 对齐。 */
|
||||
import type { AdminUser } from '../types/admin'
|
||||
|
||||
export const USER_PAGE_DEFAULT_SIZE = 15
|
||||
export const USER_PAGE_MIN_PAGE = 1
|
||||
export const USER_PAGE_MAX_SIZE = 200
|
||||
|
||||
/** 前端用户列表筛选/分页状态(页面局部,不入 Pinia)。 */
|
||||
export interface UserListParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 用户名模糊关键字(对应后端 username|search 的 kw)。 */
|
||||
keyword?: string
|
||||
createdById?: number | null
|
||||
adminId?: number | null
|
||||
}
|
||||
|
||||
/** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */
|
||||
export interface UserListQuery {
|
||||
page: number
|
||||
page_size: number
|
||||
username?: string
|
||||
search?: string
|
||||
created_by_id?: number
|
||||
admin_id?: number
|
||||
}
|
||||
|
||||
/** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */
|
||||
export type AdminUserItem = AdminUser
|
||||
|
||||
/** 分页结果:与 Java AdminUserListVo.items/total/page/page_size 归一后的前端 DTO。 */
|
||||
export interface UserPageResult {
|
||||
items: AdminUserItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
currentUserId?: number | null
|
||||
currentUserUsername?: string
|
||||
admins?: Array<{ id: number; username: string }>
|
||||
}
|
||||
|
||||
function finiteInt(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||||
}
|
||||
|
||||
/** 归一化分页/筛选参数:页码下限 1、页大小 1..上限,空关键字视为无过滤。 */
|
||||
export function normalizeUserPageParams(raw: Partial<UserListParams>): UserListParams {
|
||||
const page = Math.max(finiteInt(raw.page) ?? USER_PAGE_MIN_PAGE, USER_PAGE_MIN_PAGE)
|
||||
const rawSize = finiteInt(raw.pageSize)
|
||||
const pageSize =
|
||||
rawSize === null || rawSize < USER_PAGE_MIN_PAGE
|
||||
? USER_PAGE_DEFAULT_SIZE
|
||||
: Math.min(rawSize, USER_PAGE_MAX_SIZE)
|
||||
const keyword = typeof raw.keyword === 'string' ? raw.keyword.trim() || undefined : undefined
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
createdById: typeof raw.createdById === 'number' ? raw.createdById : null,
|
||||
adminId: typeof raw.adminId === 'number' ? raw.adminId : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 把前端分页/筛选状态转成 Java 查询参数(缺省字段不下发)。 */
|
||||
export function toUserListQuery(params: UserListParams): UserListQuery {
|
||||
const query: UserListQuery = { page: params.page, page_size: params.pageSize }
|
||||
if (params.keyword) query.username = params.keyword
|
||||
if (params.createdById != null) query.created_by_id = params.createdById
|
||||
if (params.adminId != null) query.admin_id = params.adminId
|
||||
return query
|
||||
}
|
||||
|
||||
/** 无数据分页结果缺省值(首屏/加载前占位)。 */
|
||||
export function emptyUserPageResult(): UserPageResult {
|
||||
return { items: [], total: 0, page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { http, unwrap } from '@/api/http'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
import type { UserPageResult } from '@/api/users-dto'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<AdminUser[]>([])
|
||||
const total = ref(0)
|
||||
const form = reactive({ username: '', page: 1, pageSize: 15 })
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await http.get('/api/admin/users', { params: { username: form.username || undefined, page: form.page, page_size: form.pageSize } })
|
||||
const result = unwrap<UserPageResult | { data?: UserPageResult }>(data)
|
||||
const value: UserPageResult = 'data' in result && result.data ? result.data : result as UserPageResult
|
||||
rows.value = value.items || []
|
||||
total.value = Number(value.total || 0)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '用户列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUser(row: AdminUser) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除用户“${row.username}”吗?`, '删除确认', { type: 'warning' })
|
||||
await http.delete(`/api/admin/user/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(page: number) {
|
||||
form.page = page
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>用户管理</h2>
|
||||
<p>管理后台账号、角色和创建关系。</p>
|
||||
</div>
|
||||
<el-button type="primary">新建用户</el-button>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<el-form inline @submit.prevent="loadUsers">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" clearable placeholder="模糊搜索" @keyup.enter="loadUsers" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadUsers">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="rows" stripe>
|
||||
<el-table-column prop="id" label="ID" width="90" />
|
||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
||||
<el-table-column prop="role" label="角色" width="140" />
|
||||
<el-table-column prop="creatorUsername" label="所属管理员" width="160" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removeUser(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ total }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:page-size="form.pageSize"
|
||||
:current-page="form.page"
|
||||
:total="total"
|
||||
@current-change="changePage"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import {
|
||||
emptyUserPageResult,
|
||||
normalizeUserPageParams,
|
||||
toUserListQuery,
|
||||
USER_PAGE_DEFAULT_SIZE,
|
||||
USER_PAGE_MAX_SIZE,
|
||||
} from '../src/api/users-dto.ts'
|
||||
|
||||
test('test_task_041_users_dto_normal_primary_path', () => {
|
||||
// 正常主路径:合法分页/关键字归一为可查询参数。
|
||||
const p = normalizeUserPageParams({ page: 2, pageSize: 15, keyword: ' 张 ' })
|
||||
assert.equal(p.page, 2)
|
||||
assert.equal(p.pageSize, 15)
|
||||
assert.equal(p.keyword, '张')
|
||||
})
|
||||
|
||||
test('test_task_041_users_dto_normal_variant_input', () => {
|
||||
// 正常变体:查询序列化采用 Java 参数名(page/page_size/username)。
|
||||
const q = toUserListQuery({ page: 3, pageSize: 20, keyword: 'admin' })
|
||||
assert.deepEqual(q, { page: 3, page_size: 20, username: 'admin' })
|
||||
})
|
||||
|
||||
test('test_task_041_users_dto_normal_repeated_operation_is_idempotent', () => {
|
||||
// 正常重复:同一输入归一结果稳定、不改输入。
|
||||
const raw = { page: 1, pageSize: 15 }
|
||||
assert.deepEqual(normalizeUserPageParams(raw), normalizeUserPageParams(raw))
|
||||
assert.deepEqual(raw, { page: 1, pageSize: 15 })
|
||||
})
|
||||
|
||||
test('test_task_041_users_dto_boundary_empty_input', () => {
|
||||
// 边界空值:空对象回默认首页/页大小;空关键字视为无过滤。
|
||||
const p = normalizeUserPageParams({})
|
||||
assert.equal(p.page, 1)
|
||||
assert.equal(p.pageSize, USER_PAGE_DEFAULT_SIZE)
|
||||
assert.equal(p.keyword, undefined)
|
||||
})
|
||||
|
||||
test('test_task_041_users_dto_boundary_single_item', () => {
|
||||
// 边界单元素:单条分页结果 DTO 结构完整(items/total/page/pageSize)。
|
||||
const empty = emptyUserPageResult()
|
||||
assert.deepEqual(empty.items, [])
|
||||
assert.equal(empty.total, 0)
|
||||
assert.equal(empty.page, 1)
|
||||
assert.equal(empty.pageSize, USER_PAGE_DEFAULT_SIZE)
|
||||
})
|
||||
|
||||
test('test_task_041_users_dto_boundary_limit_or_missing_field', () => {
|
||||
// 边界上限/越界:页码 <1 归 1;页大小超上限/非正被夹取。
|
||||
assert.equal(normalizeUserPageParams({ page: 0, pageSize: 999 }).page, 1)
|
||||
assert.equal(normalizeUserPageParams({ page: -3, pageSize: 0 }).pageSize, USER_PAGE_DEFAULT_SIZE)
|
||||
assert.equal(normalizeUserPageParams({ pageSize: 999 }).pageSize, USER_PAGE_MAX_SIZE)
|
||||
})
|
||||
|
||||
test('test_task_041_users_dto_invalid_input_rejected', () => {
|
||||
// 异常输入:非数字/字符串关键字无效时不抛错并按缺省处理。
|
||||
const p = normalizeUserPageParams({ page: Number.NaN, pageSize: Number.NaN, keyword: undefined })
|
||||
assert.equal(p.page, 1)
|
||||
assert.equal(p.pageSize, USER_PAGE_DEFAULT_SIZE)
|
||||
assert.equal(p.keyword, undefined)
|
||||
})
|
||||
|
||||
test('test_task_041_users_dto_dependency_failure_returns_actionable_message', () => {
|
||||
// 依赖失败:DTO 纯模块不依赖 axios/http,供页面与后续 adapter 引用。
|
||||
const dto = readSource('src/api/users-dto.ts')
|
||||
assert.equal(/axios|from '\.\/http'/.test(dto), false, 'DTO 模块保持纯逻辑')
|
||||
assert.match(dto, /export interface UserPageResult/)
|
||||
const page = readSource('src/pages/account/UsersPage.vue')
|
||||
assert.match(page, /users-dto|UserListParams|UserPageResult/)
|
||||
})
|
||||
Reference in New Issue
Block a user