Commit 09968a802bcc946954b9ab470468c5d4df59bdc0
1 parent
7cfb6ee7
feat(usr): 用户列表查询 API 与类型契约 listUsers REQ-USR-003
Showing
3 changed files
with
204 additions
and
1 deletions
frontend/src/api/types.ts
| ... | ... | @@ -27,3 +27,43 @@ export interface CompanyOption { |
| 27 | 27 | sCompanyName: string; |
| 28 | 28 | sVersion: string | null; |
| 29 | 29 | } |
| 30 | + | |
| 31 | +// === REQ-USR-003 用户列表查询契约(FE-03) === | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * 用户列表行 VO(已在 api 层把 docs/05 中文键 `员工名`/`部门` 归一为 ASCII | |
| 35 | + * `employeeName`/`departmentName`,组件/列定义统一用 ASCII 键,D-PLAN-2 / spec D9)。 | |
| 36 | + */ | |
| 37 | +export interface UserVO { | |
| 38 | + id: number; | |
| 39 | + sUserName: string; | |
| 40 | + employeeName: string | null; | |
| 41 | + sUserNo: string | null; | |
| 42 | + departmentName: string | null; | |
| 43 | + sUserType: string; | |
| 44 | + sLanguage: string; | |
| 45 | + iIsVoid: number; | |
| 46 | + tLastLoginDate: string | null; | |
| 47 | + sCreator: string; | |
| 48 | + tCreateDate: string; | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** 统一分页返回体(docs/04 § 1.4 / § 3.2) */ | |
| 52 | +export interface PageResult<T> { | |
| 53 | + records: T[]; | |
| 54 | + total: number; | |
| 55 | + pageNum: number; | |
| 56 | + pageSize: number; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** | |
| 60 | + * GET /api/usr/users 查询参数(提交给后端的 query)。 | |
| 61 | + * `queryValue` 为空字符串时由 listUsers 省略(空为全部,BR3)。 | |
| 62 | + */ | |
| 63 | +export interface UserListQuery { | |
| 64 | + queryField?: string; | |
| 65 | + matchType?: string; | |
| 66 | + queryValue?: string; | |
| 67 | + pageNum: number; | |
| 68 | + pageSize: number; | |
| 69 | +} | ... | ... |
frontend/src/api/usrApi.ts
| 1 | 1 | // REQ-USR-004: USR 模块 API 封装(登录 + 版本下拉取数)。页面只调本文件,不散用 axios。 |
| 2 | +// REQ-USR-003: 新增用户列表分页查询 listUsers(GET /api/usr/users,中文键归一 D-PLAN-2)。 | |
| 2 | 3 | import request from './request'; |
| 3 | -import type { LoginPayload, LoginResult, CompanyOption } from './types'; | |
| 4 | +import type { | |
| 5 | + LoginPayload, | |
| 6 | + LoginResult, | |
| 7 | + CompanyOption, | |
| 8 | + UserVO, | |
| 9 | + PageResult, | |
| 10 | + UserListQuery, | |
| 11 | +} from './types'; | |
| 4 | 12 | |
| 5 | 13 | // 响应拦截器已拆 Result.data,故此处返回类型即业务数据本体。 |
| 6 | 14 | // axios 实例的方法类型仍标注为 AxiosResponse,运行时已被拦截器解包,用 as unknown 桥接。 |
| ... | ... | @@ -14,3 +22,63 @@ export function login(payload: LoginPayload): Promise<LoginResult> { |
| 14 | 22 | export function fetchCompanies(): Promise<CompanyOption[]> { |
| 15 | 23 | return request.get('/usr/companies') as unknown as Promise<CompanyOption[]>; |
| 16 | 24 | } |
| 25 | + | |
| 26 | +/** 后端原始用户行(docs/05 以中文键名 `员工名`/`部门` 给出,关联职员表派生) */ | |
| 27 | +interface RawUserRecord { | |
| 28 | + id: number; | |
| 29 | + sUserName: string; | |
| 30 | + 员工名?: string | null; | |
| 31 | + employeeName?: string | null; | |
| 32 | + sUserNo: string | null; | |
| 33 | + 部门?: string | null; | |
| 34 | + departmentName?: string | null; | |
| 35 | + sUserType: string; | |
| 36 | + sLanguage: string; | |
| 37 | + iIsVoid: number; | |
| 38 | + tLastLoginDate: string | null; | |
| 39 | + sCreator: string; | |
| 40 | + tCreateDate: string; | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** 把后端中文键 `员工名`/`部门` 归一为 ASCII `employeeName`/`departmentName`(D-PLAN-2 / spec D9) */ | |
| 44 | +function normalizeUserRecord(raw: RawUserRecord): UserVO { | |
| 45 | + return { | |
| 46 | + id: raw.id, | |
| 47 | + sUserName: raw.sUserName, | |
| 48 | + employeeName: raw.employeeName ?? raw['员工名'] ?? null, | |
| 49 | + sUserNo: raw.sUserNo, | |
| 50 | + departmentName: raw.departmentName ?? raw['部门'] ?? null, | |
| 51 | + sUserType: raw.sUserType, | |
| 52 | + sLanguage: raw.sLanguage, | |
| 53 | + iIsVoid: raw.iIsVoid, | |
| 54 | + tLastLoginDate: raw.tLastLoginDate, | |
| 55 | + sCreator: raw.sCreator, | |
| 56 | + tCreateDate: raw.tCreateDate, | |
| 57 | + }; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** | |
| 61 | + * GET /api/usr/users —— 用户列表分页查询(只读,BR5)。 | |
| 62 | + * query 走 axios params;`queryValue` 为空字符串时省略(空为全部,BR3)。 | |
| 63 | + * 响应拦截器已拆 Result.data,故此处拿到 PageResult<RawUserRecord> 本体; | |
| 64 | + * 对 records 逐项做中文键→ASCII 归一后返回 PageResult<UserVO>(D-PLAN-2)。 | |
| 65 | + */ | |
| 66 | +export async function listUsers(query: UserListQuery): Promise<PageResult<UserVO>> { | |
| 67 | + const params: Record<string, unknown> = { | |
| 68 | + pageNum: query.pageNum, | |
| 69 | + pageSize: query.pageSize, | |
| 70 | + }; | |
| 71 | + if (query.queryField) params.queryField = query.queryField; | |
| 72 | + if (query.matchType) params.matchType = query.matchType; | |
| 73 | + if (query.queryValue) params.queryValue = query.queryValue; | |
| 74 | + | |
| 75 | + const page = (await (request.get('/usr/users', { params }) as unknown as Promise< | |
| 76 | + PageResult<RawUserRecord> | |
| 77 | + >)); | |
| 78 | + return { | |
| 79 | + records: (page.records ?? []).map(normalizeUserRecord), | |
| 80 | + total: page.total, | |
| 81 | + pageNum: page.pageNum, | |
| 82 | + pageSize: page.pageSize, | |
| 83 | + }; | |
| 84 | +} | ... | ... |
frontend/tests/unit/usrApi.userlist.test.ts
0 → 100644
| 1 | +// REQ-USR-003: listUsers API 封装单测(GET /api/usr/users + 中文键归一 D-PLAN-2) | |
| 2 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | |
| 3 | + | |
| 4 | +// 桩掉底层 axios 实例(request.ts 的 default 导出),沿用 usrApi.test.ts 模式 | |
| 5 | +vi.mock('../../src/api/request', () => { | |
| 6 | + return { | |
| 7 | + default: { | |
| 8 | + post: vi.fn(), | |
| 9 | + get: vi.fn(), | |
| 10 | + }, | |
| 11 | + }; | |
| 12 | +}); | |
| 13 | + | |
| 14 | +import request from '../../src/api/request'; | |
| 15 | +import { listUsers } from '../../src/api/usrApi'; | |
| 16 | + | |
| 17 | +const mockedRequest = request as unknown as { | |
| 18 | + post: ReturnType<typeof vi.fn>; | |
| 19 | + get: ReturnType<typeof vi.fn>; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +function pageBody(records: unknown[], total = 1, pageNum = 1, pageSize = 10) { | |
| 23 | + return { records, total, pageNum, pageSize }; | |
| 24 | +} | |
| 25 | + | |
| 26 | +describe('usrApi.listUsers', () => { | |
| 27 | + beforeEach(() => { | |
| 28 | + vi.clearAllMocks(); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it('listUsers gets /usr/users with query params', async () => { | |
| 32 | + mockedRequest.get.mockResolvedValue(pageBody([], 0, 2, 20)); | |
| 33 | + await listUsers({ | |
| 34 | + queryField: '用户名', | |
| 35 | + matchType: '包含', | |
| 36 | + queryValue: '李', | |
| 37 | + pageNum: 2, | |
| 38 | + pageSize: 20, | |
| 39 | + }); | |
| 40 | + expect(mockedRequest.get).toHaveBeenCalledTimes(1); | |
| 41 | + const [url, config] = mockedRequest.get.mock.calls[0]; | |
| 42 | + expect(url).toBe('/usr/users'); | |
| 43 | + expect(config.params).toMatchObject({ | |
| 44 | + queryField: '用户名', | |
| 45 | + matchType: '包含', | |
| 46 | + queryValue: '李', | |
| 47 | + pageNum: 2, | |
| 48 | + pageSize: 20, | |
| 49 | + }); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + it('listUsers omits empty queryValue', async () => { | |
| 53 | + mockedRequest.get.mockResolvedValue(pageBody([], 0, 1, 10)); | |
| 54 | + await listUsers({ | |
| 55 | + queryField: '用户名', | |
| 56 | + matchType: '包含', | |
| 57 | + queryValue: '', | |
| 58 | + pageNum: 1, | |
| 59 | + pageSize: 10, | |
| 60 | + }); | |
| 61 | + const [, config] = mockedRequest.get.mock.calls[0]; | |
| 62 | + expect(config.params).not.toHaveProperty('queryValue'); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it('listUsers normalizes chinese keys 员工名/部门 to employeeName/departmentName', async () => { | |
| 66 | + mockedRequest.get.mockResolvedValue( | |
| 67 | + pageBody( | |
| 68 | + [ | |
| 69 | + { | |
| 70 | + id: 1, | |
| 71 | + sUserName: 'a', | |
| 72 | + 员工名: '张三', | |
| 73 | + 部门: '技术', | |
| 74 | + sUserNo: 'a', | |
| 75 | + sUserType: '超级管理员', | |
| 76 | + sLanguage: '中文', | |
| 77 | + iIsVoid: 0, | |
| 78 | + tLastLoginDate: null, | |
| 79 | + sCreator: 'x', | |
| 80 | + tCreateDate: 't', | |
| 81 | + }, | |
| 82 | + ], | |
| 83 | + 1, | |
| 84 | + 2, | |
| 85 | + 20, | |
| 86 | + ), | |
| 87 | + ); | |
| 88 | + const ret = await listUsers({ pageNum: 2, pageSize: 20 }); | |
| 89 | + expect(ret.records[0].employeeName).toBe('张三'); | |
| 90 | + expect(ret.records[0].departmentName).toBe('技术'); | |
| 91 | + expect(ret.total).toBe(1); | |
| 92 | + expect(ret.pageNum).toBe(2); | |
| 93 | + expect(ret.pageSize).toBe(20); | |
| 94 | + }); | |
| 95 | +}); | ... | ... |