Commit 0001eec7a00d494f4a399db47a6949d2f013a574
1 parent
3545663a
feat(usr): 用户单据 API 与类型契约 create/update/detail/employees/permissions REQ-USR-001 REQ-USR-002
Showing
3 changed files
with
262 additions
and
0 deletions
frontend/src/api/types.ts
| @@ -67,3 +67,56 @@ export interface UserListQuery { | @@ -67,3 +67,56 @@ export interface UserListQuery { | ||
| 67 | pageNum: number; | 67 | pageNum: number; |
| 68 | pageSize: number; | 68 | pageSize: number; |
| 69 | } | 69 | } |
| 70 | + | ||
| 71 | +// === REQ-USR-001 / REQ-USR-002 用户单据契约(FE-04) === | ||
| 72 | + | ||
| 73 | +/** 单据模式:新增 / 修改(由路由 :id 判定,spec § 3) */ | ||
| 74 | +export type UserDetailMode = 'create' | 'edit'; | ||
| 75 | + | ||
| 76 | +/** | ||
| 77 | + * 员工名下拉项(D1)。后端 GET /api/usr/employees 原始行 | ||
| 78 | + * `iIncrement`/`sEmployeeName`/`sEmployeeNo` 在 api 层归一为此结构。 | ||
| 79 | + */ | ||
| 80 | +export interface EmployeeOption { | ||
| 81 | + value: number; | ||
| 82 | + label: string; | ||
| 83 | + sEmployeeNo: string | null; | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +/** | ||
| 87 | + * 权限项(D2/D3)。后端 GET /api/usr/permissions 原始行 | ||
| 88 | + * `iIncrement`/`sPermissionName`/`sPermissionCategory` 在 api 层归一为此结构。 | ||
| 89 | + */ | ||
| 90 | +export interface PermissionItem { | ||
| 91 | + id: number; | ||
| 92 | + name: string; | ||
| 93 | + category: string; | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +/** | ||
| 97 | + * POST /api/usr/users 请求体(create,对齐 docs/05 § REQ-USR-001)。 | ||
| 98 | + * 密码 `initialPassword` 前端不传,由后端默认 666666(BR9)。 | ||
| 99 | + */ | ||
| 100 | +export interface UserCreateReq { | ||
| 101 | + sUserName: string; | ||
| 102 | + sUserNo?: string; | ||
| 103 | + iEmployeeId?: number | null; | ||
| 104 | + sUserType: string; | ||
| 105 | + sLanguage: string; | ||
| 106 | + iCanModifyBill?: 0 | 1; | ||
| 107 | + permissionIds?: number[]; | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +/** | ||
| 111 | + * PUT /api/usr/users/{id} 请求体(edit,对齐 docs/05 § REQ-USR-002)。 | ||
| 112 | + * `sUserName` 不可改不传、密码不在本接口(BR3/BR9);`permissionIds` 全量覆盖(BR11)。 | ||
| 113 | + */ | ||
| 114 | +export interface UserUpdateReq { | ||
| 115 | + sUserNo?: string; | ||
| 116 | + iEmployeeId?: number | null; | ||
| 117 | + sUserType: string; | ||
| 118 | + sLanguage: string; | ||
| 119 | + iCanModifyBill?: 0 | 1; | ||
| 120 | + iIsVoid?: 0 | 1; | ||
| 121 | + permissionIds?: number[]; | ||
| 122 | +} |
frontend/src/api/usrApi.ts
| 1 | // REQ-USR-004: USR 模块 API 封装(登录 + 版本下拉取数)。页面只调本文件,不散用 axios。 | 1 | // REQ-USR-004: USR 模块 API 封装(登录 + 版本下拉取数)。页面只调本文件,不散用 axios。 |
| 2 | // REQ-USR-003: 新增用户列表分页查询 listUsers(GET /api/usr/users,中文键归一 D-PLAN-2)。 | 2 | // REQ-USR-003: 新增用户列表分页查询 listUsers(GET /api/usr/users,中文键归一 D-PLAN-2)。 |
| 3 | +// REQ-USR-001 / REQ-USR-002: 用户单据写/读封装(create/update/detail/employees/permissions,D1/D2/D4)。 | ||
| 3 | import request from './request'; | 4 | import request from './request'; |
| 4 | import type { | 5 | import type { |
| 5 | LoginPayload, | 6 | LoginPayload, |
| @@ -8,6 +9,10 @@ import type { | @@ -8,6 +9,10 @@ import type { | ||
| 8 | UserVO, | 9 | UserVO, |
| 9 | PageResult, | 10 | PageResult, |
| 10 | UserListQuery, | 11 | UserListQuery, |
| 12 | + UserCreateReq, | ||
| 13 | + UserUpdateReq, | ||
| 14 | + EmployeeOption, | ||
| 15 | + PermissionItem, | ||
| 11 | } from './types'; | 16 | } from './types'; |
| 12 | 17 | ||
| 13 | // 响应拦截器已拆 Result.data,故此处返回类型即业务数据本体。 | 18 | // 响应拦截器已拆 Result.data,故此处返回类型即业务数据本体。 |
| @@ -82,3 +87,71 @@ export async function listUsers(query: UserListQuery): Promise<PageResult<UserVO | @@ -82,3 +87,71 @@ export async function listUsers(query: UserListQuery): Promise<PageResult<UserVO | ||
| 82 | pageSize: page.pageSize, | 87 | pageSize: page.pageSize, |
| 83 | }; | 88 | }; |
| 84 | } | 89 | } |
| 90 | + | ||
| 91 | +// === REQ-USR-001 / REQ-USR-002 用户单据写/读封装(FE-04) === | ||
| 92 | + | ||
| 93 | +/** POST /api/usr/users —— 新增用户(body 不含密码/审计字段,BR9),返回新建主键 id */ | ||
| 94 | +export function createUser(body: UserCreateReq): Promise<{ id: number }> { | ||
| 95 | + return request.post('/usr/users', body) as unknown as Promise<{ id: number }>; | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +/** PUT /api/usr/users/{id} —— 修改用户(body 不含 sUserName,BR3;permissionIds 全量覆盖,BR11) */ | ||
| 99 | +export function updateUser(id: number, body: UserUpdateReq): Promise<{ id: number }> { | ||
| 100 | + return request.put('/usr/users/' + id, body) as unknown as Promise<{ id: number }>; | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +/** | ||
| 104 | + * edit 预填:复用 GET /api/usr/users 列表端点(「等于」匹配 + pageSize=1)定位单条(D4)。 | ||
| 105 | + * 复用 listUsers 的中文键归一,返回 records[0] ?? null。 | ||
| 106 | + */ | ||
| 107 | +export async function getUserDetail(params: { | ||
| 108 | + queryField: string; | ||
| 109 | + queryValue: string; | ||
| 110 | +}): Promise<UserVO | null> { | ||
| 111 | + const page = await listUsers({ | ||
| 112 | + queryField: params.queryField, | ||
| 113 | + matchType: '等于', | ||
| 114 | + queryValue: params.queryValue, | ||
| 115 | + pageNum: 1, | ||
| 116 | + pageSize: 1, | ||
| 117 | + }); | ||
| 118 | + return page.records[0] ?? null; | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +/** 后端原始员工行(GET /api/usr/employees,D1) */ | ||
| 122 | +interface RawEmployeeRecord { | ||
| 123 | + iIncrement: number; | ||
| 124 | + sEmployeeName: string; | ||
| 125 | + sEmployeeNo: string | null; | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +/** GET /api/usr/employees —— 员工名下拉数据源,归一为 EmployeeOption(D1) */ | ||
| 129 | +export async function listEmployees(): Promise<EmployeeOption[]> { | ||
| 130 | + const rows = (await (request.get('/usr/employees') as unknown as Promise< | ||
| 131 | + RawEmployeeRecord[] | ||
| 132 | + >)) ?? []; | ||
| 133 | + return rows.map((r) => ({ | ||
| 134 | + value: r.iIncrement, | ||
| 135 | + label: r.sEmployeeName, | ||
| 136 | + sEmployeeNo: r.sEmployeeNo ?? null, | ||
| 137 | + })); | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +/** 后端原始权限行(GET /api/usr/permissions,D2/D3) */ | ||
| 141 | +interface RawPermissionRecord { | ||
| 142 | + iIncrement: number; | ||
| 143 | + sPermissionName: string; | ||
| 144 | + sPermissionCategory: string; | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +/** GET /api/usr/permissions —— 权限分类列表数据源,归一为 PermissionItem(D2/D3) */ | ||
| 148 | +export async function listPermissions(): Promise<PermissionItem[]> { | ||
| 149 | + const rows = (await (request.get('/usr/permissions') as unknown as Promise< | ||
| 150 | + RawPermissionRecord[] | ||
| 151 | + >)) ?? []; | ||
| 152 | + return rows.map((r) => ({ | ||
| 153 | + id: r.iIncrement, | ||
| 154 | + name: r.sPermissionName, | ||
| 155 | + category: r.sPermissionCategory, | ||
| 156 | + })); | ||
| 157 | +} |
frontend/tests/unit/usrApi.userdetail.test.ts
0 → 100644
| 1 | +// REQ-USR-001 / REQ-USR-002: 用户单据 API 封装单测 | ||
| 2 | +// createUser / updateUser / getUserDetail / listEmployees / listPermissions 透传与归一(D1/D2/D4) | ||
| 3 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| 4 | + | ||
| 5 | +// 桩底层 axios 实例(request.ts 的 default 导出),沿用 usrApi.userlist.test.ts 模式 | ||
| 6 | +vi.mock('../../src/api/request', () => { | ||
| 7 | + return { | ||
| 8 | + default: { | ||
| 9 | + post: vi.fn(), | ||
| 10 | + put: vi.fn(), | ||
| 11 | + get: vi.fn(), | ||
| 12 | + }, | ||
| 13 | + }; | ||
| 14 | +}); | ||
| 15 | + | ||
| 16 | +import request from '../../src/api/request'; | ||
| 17 | +import { | ||
| 18 | + createUser, | ||
| 19 | + updateUser, | ||
| 20 | + getUserDetail, | ||
| 21 | + listEmployees, | ||
| 22 | + listPermissions, | ||
| 23 | +} from '../../src/api/usrApi'; | ||
| 24 | + | ||
| 25 | +const mockedRequest = request as unknown as { | ||
| 26 | + post: ReturnType<typeof vi.fn>; | ||
| 27 | + put: ReturnType<typeof vi.fn>; | ||
| 28 | + get: ReturnType<typeof vi.fn>; | ||
| 29 | +}; | ||
| 30 | + | ||
| 31 | +describe('usrApi 用户单据封装', () => { | ||
| 32 | + beforeEach(() => { | ||
| 33 | + vi.clearAllMocks(); | ||
| 34 | + }); | ||
| 35 | + | ||
| 36 | + it('createUser posts /usr/users with body (no password/creator/createDate)', async () => { | ||
| 37 | + mockedRequest.post.mockResolvedValue({ id: 9 }); | ||
| 38 | + const body = { | ||
| 39 | + sUserName: 'zhangsan', | ||
| 40 | + sUserNo: 'zs', | ||
| 41 | + iEmployeeId: 3, | ||
| 42 | + sUserType: '普通用户', | ||
| 43 | + sLanguage: '中文', | ||
| 44 | + iCanModifyBill: 0 as const, | ||
| 45 | + permissionIds: [1, 2], | ||
| 46 | + }; | ||
| 47 | + const ret = await createUser(body); | ||
| 48 | + expect(mockedRequest.post).toHaveBeenCalledTimes(1); | ||
| 49 | + const [url, payload] = mockedRequest.post.mock.calls[0]; | ||
| 50 | + expect(url).toBe('/usr/users'); | ||
| 51 | + expect(payload).toEqual(body); | ||
| 52 | + expect(payload).not.toHaveProperty('initialPassword'); | ||
| 53 | + expect(payload).not.toHaveProperty('sCreator'); | ||
| 54 | + expect(payload).not.toHaveProperty('tCreateDate'); | ||
| 55 | + expect(ret).toEqual({ id: 9 }); | ||
| 56 | + }); | ||
| 57 | + | ||
| 58 | + it('updateUser puts /usr/users/{id} with body (no sUserName)', async () => { | ||
| 59 | + mockedRequest.put.mockResolvedValue({ id: 7 }); | ||
| 60 | + const body = { | ||
| 61 | + sUserType: '超级管理员', | ||
| 62 | + sLanguage: '英文', | ||
| 63 | + iCanModifyBill: 1 as const, | ||
| 64 | + iIsVoid: 0 as const, | ||
| 65 | + permissionIds: [2], | ||
| 66 | + }; | ||
| 67 | + const ret = await updateUser(7, body); | ||
| 68 | + expect(mockedRequest.put).toHaveBeenCalledTimes(1); | ||
| 69 | + const [url, payload] = mockedRequest.put.mock.calls[0]; | ||
| 70 | + expect(url).toBe('/usr/users/7'); | ||
| 71 | + expect(payload).toEqual(body); | ||
| 72 | + expect(payload).not.toHaveProperty('sUserName'); | ||
| 73 | + expect(ret).toEqual({ id: 7 }); | ||
| 74 | + }); | ||
| 75 | + | ||
| 76 | + it('getUserDetail queries equals match pageSize 1 and returns records[0]', async () => { | ||
| 77 | + mockedRequest.get.mockResolvedValue({ | ||
| 78 | + records: [ | ||
| 79 | + { | ||
| 80 | + id: 7, | ||
| 81 | + sUserName: 'zhangsan', | ||
| 82 | + 员工名: '张三', | ||
| 83 | + sUserNo: 'zs', | ||
| 84 | + 部门: null, | ||
| 85 | + sUserType: '普通用户', | ||
| 86 | + sLanguage: '中文', | ||
| 87 | + iIsVoid: 0, | ||
| 88 | + tLastLoginDate: null, | ||
| 89 | + sCreator: 'admin', | ||
| 90 | + tCreateDate: '2026-01-01T00:00:00', | ||
| 91 | + }, | ||
| 92 | + ], | ||
| 93 | + total: 1, | ||
| 94 | + pageNum: 1, | ||
| 95 | + pageSize: 1, | ||
| 96 | + }); | ||
| 97 | + const ret = await getUserDetail({ queryField: '用户名', queryValue: 'zhangsan' }); | ||
| 98 | + expect(mockedRequest.get).toHaveBeenCalledTimes(1); | ||
| 99 | + const [url, config] = mockedRequest.get.mock.calls[0]; | ||
| 100 | + expect(url).toBe('/usr/users'); | ||
| 101 | + expect(config.params).toMatchObject({ | ||
| 102 | + queryField: '用户名', | ||
| 103 | + matchType: '等于', | ||
| 104 | + queryValue: 'zhangsan', | ||
| 105 | + pageNum: 1, | ||
| 106 | + pageSize: 1, | ||
| 107 | + }); | ||
| 108 | + expect(ret).not.toBeNull(); | ||
| 109 | + expect(ret!.id).toBe(7); | ||
| 110 | + expect(ret!.employeeName).toBe('张三'); | ||
| 111 | + }); | ||
| 112 | + | ||
| 113 | + it('getUserDetail returns null when records empty', async () => { | ||
| 114 | + mockedRequest.get.mockResolvedValue({ records: [], total: 0, pageNum: 1, pageSize: 1 }); | ||
| 115 | + const ret = await getUserDetail({ queryField: '用户名', queryValue: 'none' }); | ||
| 116 | + expect(ret).toBeNull(); | ||
| 117 | + }); | ||
| 118 | + | ||
| 119 | + it('listEmployees normalizes iIncrement/sEmployeeName/sEmployeeNo to EmployeeOption', async () => { | ||
| 120 | + mockedRequest.get.mockResolvedValue([ | ||
| 121 | + { iIncrement: 3, sEmployeeName: '张三', sEmployeeNo: 'zs' }, | ||
| 122 | + ]); | ||
| 123 | + const ret = await listEmployees(); | ||
| 124 | + expect(mockedRequest.get).toHaveBeenCalledWith('/usr/employees'); | ||
| 125 | + expect(ret).toEqual([{ value: 3, label: '张三', sEmployeeNo: 'zs' }]); | ||
| 126 | + }); | ||
| 127 | + | ||
| 128 | + it('listPermissions normalizes to PermissionItem', async () => { | ||
| 129 | + mockedRequest.get.mockResolvedValue([ | ||
| 130 | + { iIncrement: 1, sPermissionName: '默认显示', sPermissionCategory: '基础' }, | ||
| 131 | + ]); | ||
| 132 | + const ret = await listPermissions(); | ||
| 133 | + expect(mockedRequest.get).toHaveBeenCalledWith('/usr/permissions'); | ||
| 134 | + expect(ret).toEqual([{ id: 1, name: '默认显示', category: '基础' }]); | ||
| 135 | + }); | ||
| 136 | +}); |