From 0001eec7a00d494f4a399db47a6949d2f013a574 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 18:09:26 +0800 Subject: [PATCH] feat(usr): 用户单据 API 与类型契约 create/update/detail/employees/permissions REQ-USR-001 REQ-USR-002 --- frontend/src/api/types.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/api/usrApi.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/tests/unit/usrApi.userdetail.test.ts | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+), 0 deletions(-) create mode 100644 frontend/tests/unit/usrApi.userdetail.test.ts diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 1c01c02..5669b0d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -67,3 +67,56 @@ export interface UserListQuery { pageNum: number; pageSize: number; } + +// === REQ-USR-001 / REQ-USR-002 用户单据契约(FE-04) === + +/** 单据模式:新增 / 修改(由路由 :id 判定,spec § 3) */ +export type UserDetailMode = 'create' | 'edit'; + +/** + * 员工名下拉项(D1)。后端 GET /api/usr/employees 原始行 + * `iIncrement`/`sEmployeeName`/`sEmployeeNo` 在 api 层归一为此结构。 + */ +export interface EmployeeOption { + value: number; + label: string; + sEmployeeNo: string | null; +} + +/** + * 权限项(D2/D3)。后端 GET /api/usr/permissions 原始行 + * `iIncrement`/`sPermissionName`/`sPermissionCategory` 在 api 层归一为此结构。 + */ +export interface PermissionItem { + id: number; + name: string; + category: string; +} + +/** + * POST /api/usr/users 请求体(create,对齐 docs/05 § REQ-USR-001)。 + * 密码 `initialPassword` 前端不传,由后端默认 666666(BR9)。 + */ +export interface UserCreateReq { + sUserName: string; + sUserNo?: string; + iEmployeeId?: number | null; + sUserType: string; + sLanguage: string; + iCanModifyBill?: 0 | 1; + permissionIds?: number[]; +} + +/** + * PUT /api/usr/users/{id} 请求体(edit,对齐 docs/05 § REQ-USR-002)。 + * `sUserName` 不可改不传、密码不在本接口(BR3/BR9);`permissionIds` 全量覆盖(BR11)。 + */ +export interface UserUpdateReq { + sUserNo?: string; + iEmployeeId?: number | null; + sUserType: string; + sLanguage: string; + iCanModifyBill?: 0 | 1; + iIsVoid?: 0 | 1; + permissionIds?: number[]; +} diff --git a/frontend/src/api/usrApi.ts b/frontend/src/api/usrApi.ts index 2ade4ba..0c02db3 100644 --- a/frontend/src/api/usrApi.ts +++ b/frontend/src/api/usrApi.ts @@ -1,5 +1,6 @@ // REQ-USR-004: USR 模块 API 封装(登录 + 版本下拉取数)。页面只调本文件,不散用 axios。 // REQ-USR-003: 新增用户列表分页查询 listUsers(GET /api/usr/users,中文键归一 D-PLAN-2)。 +// REQ-USR-001 / REQ-USR-002: 用户单据写/读封装(create/update/detail/employees/permissions,D1/D2/D4)。 import request from './request'; import type { LoginPayload, @@ -8,6 +9,10 @@ import type { UserVO, PageResult, UserListQuery, + UserCreateReq, + UserUpdateReq, + EmployeeOption, + PermissionItem, } from './types'; // 响应拦截器已拆 Result.data,故此处返回类型即业务数据本体。 @@ -82,3 +87,71 @@ export async function listUsers(query: UserListQuery): Promise { + return request.post('/usr/users', body) as unknown as Promise<{ id: number }>; +} + +/** PUT /api/usr/users/{id} —— 修改用户(body 不含 sUserName,BR3;permissionIds 全量覆盖,BR11) */ +export function updateUser(id: number, body: UserUpdateReq): Promise<{ id: number }> { + return request.put('/usr/users/' + id, body) as unknown as Promise<{ id: number }>; +} + +/** + * edit 预填:复用 GET /api/usr/users 列表端点(「等于」匹配 + pageSize=1)定位单条(D4)。 + * 复用 listUsers 的中文键归一,返回 records[0] ?? null。 + */ +export async function getUserDetail(params: { + queryField: string; + queryValue: string; +}): Promise { + const page = await listUsers({ + queryField: params.queryField, + matchType: '等于', + queryValue: params.queryValue, + pageNum: 1, + pageSize: 1, + }); + return page.records[0] ?? null; +} + +/** 后端原始员工行(GET /api/usr/employees,D1) */ +interface RawEmployeeRecord { + iIncrement: number; + sEmployeeName: string; + sEmployeeNo: string | null; +} + +/** GET /api/usr/employees —— 员工名下拉数据源,归一为 EmployeeOption(D1) */ +export async function listEmployees(): Promise { + const rows = (await (request.get('/usr/employees') as unknown as Promise< + RawEmployeeRecord[] + >)) ?? []; + return rows.map((r) => ({ + value: r.iIncrement, + label: r.sEmployeeName, + sEmployeeNo: r.sEmployeeNo ?? null, + })); +} + +/** 后端原始权限行(GET /api/usr/permissions,D2/D3) */ +interface RawPermissionRecord { + iIncrement: number; + sPermissionName: string; + sPermissionCategory: string; +} + +/** GET /api/usr/permissions —— 权限分类列表数据源,归一为 PermissionItem(D2/D3) */ +export async function listPermissions(): Promise { + const rows = (await (request.get('/usr/permissions') as unknown as Promise< + RawPermissionRecord[] + >)) ?? []; + return rows.map((r) => ({ + id: r.iIncrement, + name: r.sPermissionName, + category: r.sPermissionCategory, + })); +} diff --git a/frontend/tests/unit/usrApi.userdetail.test.ts b/frontend/tests/unit/usrApi.userdetail.test.ts new file mode 100644 index 0000000..854722a --- /dev/null +++ b/frontend/tests/unit/usrApi.userdetail.test.ts @@ -0,0 +1,136 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据 API 封装单测 +// createUser / updateUser / getUserDetail / listEmployees / listPermissions 透传与归一(D1/D2/D4) +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// 桩底层 axios 实例(request.ts 的 default 导出),沿用 usrApi.userlist.test.ts 模式 +vi.mock('../../src/api/request', () => { + return { + default: { + post: vi.fn(), + put: vi.fn(), + get: vi.fn(), + }, + }; +}); + +import request from '../../src/api/request'; +import { + createUser, + updateUser, + getUserDetail, + listEmployees, + listPermissions, +} from '../../src/api/usrApi'; + +const mockedRequest = request as unknown as { + post: ReturnType; + put: ReturnType; + get: ReturnType; +}; + +describe('usrApi 用户单据封装', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('createUser posts /usr/users with body (no password/creator/createDate)', async () => { + mockedRequest.post.mockResolvedValue({ id: 9 }); + const body = { + sUserName: 'zhangsan', + sUserNo: 'zs', + iEmployeeId: 3, + sUserType: '普通用户', + sLanguage: '中文', + iCanModifyBill: 0 as const, + permissionIds: [1, 2], + }; + const ret = await createUser(body); + expect(mockedRequest.post).toHaveBeenCalledTimes(1); + const [url, payload] = mockedRequest.post.mock.calls[0]; + expect(url).toBe('/usr/users'); + expect(payload).toEqual(body); + expect(payload).not.toHaveProperty('initialPassword'); + expect(payload).not.toHaveProperty('sCreator'); + expect(payload).not.toHaveProperty('tCreateDate'); + expect(ret).toEqual({ id: 9 }); + }); + + it('updateUser puts /usr/users/{id} with body (no sUserName)', async () => { + mockedRequest.put.mockResolvedValue({ id: 7 }); + const body = { + sUserType: '超级管理员', + sLanguage: '英文', + iCanModifyBill: 1 as const, + iIsVoid: 0 as const, + permissionIds: [2], + }; + const ret = await updateUser(7, body); + expect(mockedRequest.put).toHaveBeenCalledTimes(1); + const [url, payload] = mockedRequest.put.mock.calls[0]; + expect(url).toBe('/usr/users/7'); + expect(payload).toEqual(body); + expect(payload).not.toHaveProperty('sUserName'); + expect(ret).toEqual({ id: 7 }); + }); + + it('getUserDetail queries equals match pageSize 1 and returns records[0]', async () => { + mockedRequest.get.mockResolvedValue({ + records: [ + { + id: 7, + sUserName: 'zhangsan', + 员工名: '张三', + sUserNo: 'zs', + 部门: null, + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2026-01-01T00:00:00', + }, + ], + total: 1, + pageNum: 1, + pageSize: 1, + }); + const ret = await getUserDetail({ queryField: '用户名', queryValue: 'zhangsan' }); + expect(mockedRequest.get).toHaveBeenCalledTimes(1); + const [url, config] = mockedRequest.get.mock.calls[0]; + expect(url).toBe('/usr/users'); + expect(config.params).toMatchObject({ + queryField: '用户名', + matchType: '等于', + queryValue: 'zhangsan', + pageNum: 1, + pageSize: 1, + }); + expect(ret).not.toBeNull(); + expect(ret!.id).toBe(7); + expect(ret!.employeeName).toBe('张三'); + }); + + it('getUserDetail returns null when records empty', async () => { + mockedRequest.get.mockResolvedValue({ records: [], total: 0, pageNum: 1, pageSize: 1 }); + const ret = await getUserDetail({ queryField: '用户名', queryValue: 'none' }); + expect(ret).toBeNull(); + }); + + it('listEmployees normalizes iIncrement/sEmployeeName/sEmployeeNo to EmployeeOption', async () => { + mockedRequest.get.mockResolvedValue([ + { iIncrement: 3, sEmployeeName: '张三', sEmployeeNo: 'zs' }, + ]); + const ret = await listEmployees(); + expect(mockedRequest.get).toHaveBeenCalledWith('/usr/employees'); + expect(ret).toEqual([{ value: 3, label: '张三', sEmployeeNo: 'zs' }]); + }); + + it('listPermissions normalizes to PermissionItem', async () => { + mockedRequest.get.mockResolvedValue([ + { iIncrement: 1, sPermissionName: '默认显示', sPermissionCategory: '基础' }, + ]); + const ret = await listPermissions(); + expect(mockedRequest.get).toHaveBeenCalledWith('/usr/permissions'); + expect(ret).toEqual([{ id: 1, name: '默认显示', category: '基础' }]); + }); +}); -- libgit2 0.22.2