Commit 4e0722ef74b831bd77df3f5fd1849f5c44b9ca8b

Authored by zichun
1 parent c25135cc

feat(usr): 用户单据 hook useUserDetail 状态机 REQ-USR-001 REQ-USR-002

frontend/src/pages/usr/UserDetail/useUserDetail.ts 0 → 100644
  1 +// REQ-USR-001 / REQ-USR-002: 用户单据 hook(状态机 initialLoading/editing/submitting/submitError/submitSuccess/loadError)
  2 +import { useCallback, useEffect, useRef, useState } from 'react';
  3 +import { App as AntdApp } from 'antd';
  4 +import {
  5 + createUser,
  6 + updateUser,
  7 + getUserDetail,
  8 + listEmployees,
  9 + listPermissions,
  10 +} from '../../../api/usrApi';
  11 +import { ApiError } from '../../../api/request';
  12 +import type {
  13 + EmployeeOption,
  14 + PermissionItem,
  15 + UserVO,
  16 + UserDetailMode,
  17 +} from '../../../api/types';
  18 +import {
  19 + CREATE_DEFAULTS,
  20 + ERR_USERNAME_EXISTS,
  21 + ERR_USER_NOT_FOUND,
  22 + ERR_NO_PERMISSION,
  23 + ERR_VALIDATION,
  24 + MSG_ERR_USERNAME_EXISTS,
  25 + MSG_ERR_USER_NOT_FOUND,
  26 + MSG_ERR_NO_PERMISSION,
  27 + MSG_ERR_VALIDATION,
  28 + MSG_ERR_NETWORK,
  29 + MSG_ERR_LOAD_EMPLOYEES,
  30 + MSG_ERR_LOAD_PERMISSIONS,
  31 + MSG_LOAD_DETAIL_FAIL,
  32 + toCreateReq,
  33 + toUpdateReq,
  34 + userVoToFormValues,
  35 + type UserFormValues,
  36 +} from './constants';
  37 +
  38 +export interface UseUserDetailArgs {
  39 + mode: UserDetailMode;
  40 + userId?: number;
  41 + presetUser?: UserVO | null;
  42 +}
  43 +
  44 +export interface SubmitFieldError {
  45 + field: keyof UserFormValues;
  46 + message: string;
  47 +}
  48 +
  49 +export interface SubmitResult {
  50 + ok: boolean;
  51 + id?: number;
  52 + fieldError?: SubmitFieldError;
  53 +}
  54 +
  55 +export interface UseUserDetailReturn {
  56 + mode: UserDetailMode;
  57 + formValues: UserFormValues;
  58 + employees: EmployeeOption[];
  59 + permissions: PermissionItem[];
  60 + checkedPermissionIds: number[];
  61 + readonlyCreator: string;
  62 + readonlyCreateTime: string;
  63 + loading: boolean;
  64 + submitting: boolean;
  65 + error: ApiError | null;
  66 + loadFailed: boolean;
  67 + setField(name: keyof UserFormValues, value: unknown): void;
  68 + selectEmployee(value: number | null): void;
  69 + togglePermission(id: number, checked: boolean): void;
  70 + toggleAll(checked: boolean): void;
  71 + submit(values: UserFormValues): Promise<SubmitResult>;
  72 + reload(): void;
  73 +}
  74 +
  75 +export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
  76 + const { mode, userId, presetUser } = args;
  77 + const { message } = AntdApp.useApp();
  78 +
  79 + const [formValues, setFormValues] = useState<UserFormValues>({ ...CREATE_DEFAULTS });
  80 + const [employees, setEmployees] = useState<EmployeeOption[]>([]);
  81 + const [permissions, setPermissions] = useState<PermissionItem[]>([]);
  82 + const [checkedPermissionIds, setCheckedPermissionIds] = useState<number[]>([]);
  83 + const [readonlyCreator, setReadonlyCreator] = useState('');
  84 + const [readonlyCreateTime, setReadonlyCreateTime] = useState('');
  85 + const [loading, setLoading] = useState(true); // initialLoading
  86 + const [submitting, setSubmitting] = useState(false);
  87 + const [error, setError] = useState<ApiError | null>(null);
  88 + const [loadFailed, setLoadFailed] = useState(false);
  89 +
  90 + const employeesRef = useRef<EmployeeOption[]>(employees);
  91 + employeesRef.current = employees;
  92 + const checkedRef = useRef<number[]>(checkedPermissionIds);
  93 + checkedRef.current = checkedPermissionIds;
  94 + const messageRef = useRef(message);
  95 + messageRef.current = message;
  96 + const mountedRef = useRef(true);
  97 +
  98 + /** 把后端权限分类回勾:UserVO 不暴露已授权 id,故 edit 预填仅用 detail 的权限字段(无则空集) */
  99 + const initFromVo = useCallback((vo: UserVO) => {
  100 + setFormValues(userVoToFormValues(vo));
  101 + setReadonlyCreator(vo.sCreator ?? '');
  102 + setReadonlyCreateTime(vo.tCreateDate ?? '');
  103 + // UserVO 不含已授权权限 id(FE-03 列表 VO),按空集初始化;后端补详情端点后可回勾
  104 + setCheckedPermissionIds([]);
  105 + }, []);
  106 +
  107 + /** 挂载预取(员工/权限)+ edit 详情回填(initialLoading→editing / loadError) */
  108 + const runLoad = useCallback(async () => {
  109 + setLoading(true);
  110 + setLoadFailed(false);
  111 + try {
  112 + const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]);
  113 + if (!mountedRef.current) return;
  114 + setEmployees(emps);
  115 + setPermissions(perms);
  116 +
  117 + if (mode === 'edit') {
  118 + if (presetUser) {
  119 + initFromVo(presetUser);
  120 + } else if (userId != null) {
  121 + const vo = await getUserDetail({
  122 + queryField: '用户号',
  123 + queryValue: String(userId),
  124 + });
  125 + if (!mountedRef.current) return;
  126 + if (vo) {
  127 + initFromVo(vo);
  128 + } else {
  129 + // 详情不存在:交由页面按 40401 路径处理,标记 loadFailed
  130 + setLoadFailed(true);
  131 + messageRef.current.error(MSG_ERR_USER_NOT_FOUND);
  132 + setLoading(false);
  133 + return;
  134 + }
  135 + }
  136 + } else {
  137 + setFormValues({ ...CREATE_DEFAULTS });
  138 + setCheckedPermissionIds([]);
  139 + }
  140 + setLoading(false);
  141 + } catch (err) {
  142 + if (!mountedRef.current) return;
  143 + setLoading(false);
  144 + setLoadFailed(true);
  145 + // 区分员工/权限/详情失败文案:以 reject 顺序无法精确分辨,按权限优先(最常见空源)
  146 + const apiErr = err instanceof ApiError ? err : new ApiError(-1, MSG_ERR_NETWORK);
  147 + if (mode === 'edit' && employeesRef.current.length === 0 && permissions.length === 0) {
  148 + messageRef.current.error(MSG_LOAD_DETAIL_FAIL);
  149 + } else {
  150 + messageRef.current.error(
  151 + apiErr.message === MSG_ERR_LOAD_EMPLOYEES
  152 + ? MSG_ERR_LOAD_EMPLOYEES
  153 + : MSG_ERR_LOAD_PERMISSIONS,
  154 + );
  155 + }
  156 + }
  157 + // eslint-disable-next-line react-hooks/exhaustive-deps
  158 + }, [mode, userId, presetUser, initFromVo]);
  159 +
  160 + useEffect(() => {
  161 + mountedRef.current = true;
  162 + void runLoad();
  163 + return () => {
  164 + mountedRef.current = false;
  165 + };
  166 + // eslint-disable-next-line react-hooks/exhaustive-deps
  167 + }, []);
  168 +
  169 + const setField = useCallback((name: keyof UserFormValues, value: unknown) => {
  170 + setFormValues((prev) => ({ ...prev, [name]: value }));
  171 + }, []);
  172 +
  173 + /** 选择员工:带出用户名(create)/ 用户号(BR5,用户仍可改) */
  174 + const selectEmployee = useCallback((value: number | null) => {
  175 + setFormValues((prev) => {
  176 + const emp = employeesRef.current.find((e) => e.value === value);
  177 + if (!emp) return { ...prev, iEmployeeId: value };
  178 + return {
  179 + ...prev,
  180 + iEmployeeId: value,
  181 + sUserName: emp.label,
  182 + sUserNo: emp.sEmployeeNo ?? prev.sUserNo,
  183 + };
  184 + });
  185 + }, []);
  186 +
  187 + const togglePermission = useCallback((id: number, checked: boolean) => {
  188 + setCheckedPermissionIds((prev) => {
  189 + if (checked) return prev.includes(id) ? prev : [...prev, id];
  190 + return prev.filter((p) => p !== id);
  191 + });
  192 + }, []);
  193 +
  194 + const toggleAll = useCallback((checked: boolean) => {
  195 + setCheckedPermissionIds(checked ? permissions.map((p) => p.id) : []);
  196 + }, [permissions]);
  197 +
  198 + /** 提交:create→POST / edit→PUT;错误码分流(spec § 4) */
  199 + const submit = useCallback(
  200 + async (values: UserFormValues): Promise<SubmitResult> => {
  201 + setSubmitting(true);
  202 + setError(null);
  203 + try {
  204 + const ids = checkedRef.current;
  205 + let id: number;
  206 + if (mode === 'edit' && userId != null) {
  207 + const ret = await updateUser(userId, toUpdateReq(values, ids));
  208 + id = ret.id;
  209 + } else {
  210 + const ret = await createUser(toCreateReq(values, ids));
  211 + id = ret.id;
  212 + }
  213 + if (mountedRef.current) setSubmitting(false);
  214 + return { ok: true, id };
  215 + } catch (err) {
  216 + const apiErr = err instanceof ApiError ? err : new ApiError(-1, MSG_ERR_NETWORK);
  217 + if (mountedRef.current) {
  218 + setSubmitting(false);
  219 + setError(apiErr);
  220 + }
  221 + if (apiErr.code === ERR_USERNAME_EXISTS) {
  222 + messageRef.current.error(MSG_ERR_USERNAME_EXISTS);
  223 + return {
  224 + ok: false,
  225 + fieldError: { field: 'sUserName', message: MSG_ERR_USERNAME_EXISTS },
  226 + };
  227 + }
  228 + if (apiErr.code === ERR_USER_NOT_FOUND) {
  229 + messageRef.current.error(MSG_ERR_USER_NOT_FOUND);
  230 + } else if (apiErr.code === ERR_NO_PERMISSION) {
  231 + messageRef.current.error(MSG_ERR_NO_PERMISSION);
  232 + } else if (apiErr.code === ERR_VALIDATION) {
  233 + messageRef.current.error(MSG_ERR_VALIDATION);
  234 + } else {
  235 + messageRef.current.error(MSG_ERR_NETWORK);
  236 + }
  237 + return { ok: false };
  238 + }
  239 + },
  240 + [mode, userId],
  241 + );
  242 +
  243 + const reload = useCallback(() => {
  244 + void runLoad();
  245 + }, [runLoad]);
  246 +
  247 + return {
  248 + mode,
  249 + formValues,
  250 + employees,
  251 + permissions,
  252 + checkedPermissionIds,
  253 + readonlyCreator,
  254 + readonlyCreateTime,
  255 + loading,
  256 + submitting,
  257 + error,
  258 + loadFailed,
  259 + setField,
  260 + selectEmployee,
  261 + togglePermission,
  262 + toggleAll,
  263 + submit,
  264 + reload,
  265 + };
  266 +}
frontend/tests/unit/useUserDetail.test.tsx 0 → 100644
  1 +// REQ-USR-001 / REQ-USR-002: useUserDetail 单据 hook 状态机单测
  2 +// initialLoading/editing/submitting/submitError/submitSuccess/loadError + 员工联动 + 权限回勾
  3 +import { describe, it, expect, vi, beforeEach } from 'vitest';
  4 +import type { ReactNode } from 'react';
  5 +import { renderHook, act, waitFor } from '@testing-library/react';
  6 +import { App as AntdApp, ConfigProvider } from 'antd';
  7 +
  8 +// 桩 message:保留 antd 其余真实导出
  9 +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
  10 +vi.mock('antd', async () => {
  11 + const actual = await vi.importActual<typeof import('antd')>('antd');
  12 + return {
  13 + ...actual,
  14 + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }),
  15 + };
  16 +});
  17 +
  18 +// 桩 usrApi 单据方法
  19 +vi.mock('../../src/api/usrApi', () => ({
  20 + createUser: vi.fn(),
  21 + updateUser: vi.fn(),
  22 + getUserDetail: vi.fn(),
  23 + listEmployees: vi.fn(),
  24 + listPermissions: vi.fn(),
  25 +}));
  26 +
  27 +import {
  28 + createUser,
  29 + updateUser,
  30 + getUserDetail,
  31 + listEmployees,
  32 + listPermissions,
  33 +} from '../../src/api/usrApi';
  34 +import { useUserDetail } from '../../src/pages/usr/UserDetail/useUserDetail';
  35 +import { ApiError } from '../../src/api/request';
  36 +import {
  37 + CREATE_DEFAULTS,
  38 + ERR_USERNAME_EXISTS,
  39 + ERR_USER_NOT_FOUND,
  40 + ERR_NO_PERMISSION,
  41 + ERR_VALIDATION,
  42 + MSG_ERR_LOAD_PERMISSIONS,
  43 + type UserFormValues,
  44 +} from '../../src/pages/usr/UserDetail/constants';
  45 +import type { UserVO, EmployeeOption, PermissionItem } from '../../src/api/types';
  46 +
  47 +const mockedCreate = createUser as unknown as ReturnType<typeof vi.fn>;
  48 +const mockedUpdate = updateUser as unknown as ReturnType<typeof vi.fn>;
  49 +const mockedDetail = getUserDetail as unknown as ReturnType<typeof vi.fn>;
  50 +const mockedEmployees = listEmployees as unknown as ReturnType<typeof vi.fn>;
  51 +const mockedPermissions = listPermissions as unknown as ReturnType<typeof vi.fn>;
  52 +
  53 +const EMPLOYEES: EmployeeOption[] = [
  54 + { value: 3, label: '张三', sEmployeeNo: 'zs' },
  55 + { value: 4, label: '李四', sEmployeeNo: 'ls' },
  56 +];
  57 +const PERMISSIONS: PermissionItem[] = [
  58 + { id: 1, name: '默认显示', category: '基础' },
  59 + { id: 2, name: '高级查看', category: '基础' },
  60 +];
  61 +
  62 +function makeVo(over: Partial<UserVO> = {}): UserVO {
  63 + return {
  64 + id: 7,
  65 + sUserName: 'zhangsan',
  66 + employeeName: '张三',
  67 + sUserNo: 'zs',
  68 + departmentName: null,
  69 + sUserType: '超级管理员',
  70 + sLanguage: '英文',
  71 + iIsVoid: 0,
  72 + tLastLoginDate: null,
  73 + sCreator: 'admin',
  74 + tCreateDate: '2026-01-01T00:00:00',
  75 + ...over,
  76 + };
  77 +}
  78 +
  79 +function makeValues(over: Partial<UserFormValues> = {}): UserFormValues {
  80 + return {
  81 + sUserName: 'zhangsan',
  82 + sUserNo: 'zs',
  83 + iEmployeeId: 3,
  84 + sUserType: '普通用户',
  85 + sLanguage: '中文',
  86 + iCanModifyBill: 0,
  87 + iIsVoid: 0,
  88 + ...over,
  89 + };
  90 +}
  91 +
  92 +function wrapper({ children }: { children: ReactNode }) {
  93 + return (
  94 + <ConfigProvider>
  95 + <AntdApp>{children}</AntdApp>
  96 + </ConfigProvider>
  97 + );
  98 +}
  99 +
  100 +describe('useUserDetail 状态机', () => {
  101 + beforeEach(() => {
  102 + vi.clearAllMocks();
  103 + mockedEmployees.mockResolvedValue(EMPLOYEES);
  104 + mockedPermissions.mockResolvedValue(PERMISSIONS);
  105 + });
  106 +
  107 + it('create mode initial load prefetches employees+permissions (initialLoading→editing)', async () => {
  108 + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
  109 + expect(result.current.loading).toBe(true);
  110 + await waitFor(() => expect(result.current.loading).toBe(false));
  111 + expect(mockedEmployees).toHaveBeenCalled();
  112 + expect(mockedPermissions).toHaveBeenCalled();
  113 + expect(result.current.employees).toEqual(EMPLOYEES);
  114 + expect(result.current.permissions).toEqual(PERMISSIONS);
  115 + expect(result.current.formValues.sUserType).toBe(CREATE_DEFAULTS.sUserType);
  116 + expect(result.current.formValues.iCanModifyBill).toBe(0);
  117 + expect(result.current.checkedPermissionIds).toEqual([]);
  118 + expect(mockedDetail).not.toHaveBeenCalled();
  119 + });
  120 +
  121 + it('edit mode prefills from getUserDetail and pre-checks permissions', async () => {
  122 + mockedDetail.mockResolvedValue(makeVo());
  123 + const { result } = renderHook(
  124 + () => useUserDetail({ mode: 'edit', userId: 7 }),
  125 + { wrapper },
  126 + );
  127 + await waitFor(() => expect(result.current.loading).toBe(false));
  128 + expect(mockedDetail).toHaveBeenCalled();
  129 + expect(result.current.formValues.sUserName).toBe('zhangsan');
  130 + expect(result.current.formValues.sUserType).toBe('超级管理员');
  131 + expect(result.current.formValues.sLanguage).toBe('英文');
  132 + });
  133 +
  134 + it('edit mode with presetUser skips getUserDetail', async () => {
  135 + const { result } = renderHook(
  136 + () => useUserDetail({ mode: 'edit', userId: 7, presetUser: makeVo() }),
  137 + { wrapper },
  138 + );
  139 + await waitFor(() => expect(result.current.loading).toBe(false));
  140 + expect(mockedDetail).not.toHaveBeenCalled();
  141 + expect(result.current.formValues.sUserName).toBe('zhangsan');
  142 + });
  143 +
  144 + it('selectEmployee fills userNo/userName from employee (create)', async () => {
  145 + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
  146 + await waitFor(() => expect(result.current.loading).toBe(false));
  147 + act(() => {
  148 + result.current.selectEmployee(3);
  149 + });
  150 + expect(result.current.formValues.iEmployeeId).toBe(3);
  151 + expect(result.current.formValues.sUserName).toBe('张三');
  152 + expect(result.current.formValues.sUserNo).toBe('zs');
  153 + });
  154 +
  155 + it('toggle permission and toggleAll update checkedPermissionIds', async () => {
  156 + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
  157 + await waitFor(() => expect(result.current.loading).toBe(false));
  158 + act(() => {
  159 + result.current.togglePermission(1, true);
  160 + });
  161 + expect(result.current.checkedPermissionIds).toContain(1);
  162 + act(() => {
  163 + result.current.toggleAll(true);
  164 + });
  165 + expect(result.current.checkedPermissionIds.sort()).toEqual([1, 2]);
  166 + act(() => {
  167 + result.current.toggleAll(false);
  168 + });
  169 + expect(result.current.checkedPermissionIds).toEqual([]);
  170 + });
  171 +
  172 + it('submit create calls createUser and returns {ok,id}', async () => {
  173 + mockedCreate.mockResolvedValue({ id: 9 });
  174 + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
  175 + await waitFor(() => expect(result.current.loading).toBe(false));
  176 + let ret: { ok: boolean; id?: number } | undefined;
  177 + await act(async () => {
  178 + ret = await result.current.submit(makeValues());
  179 + });
  180 + expect(mockedCreate).toHaveBeenCalledTimes(1);
  181 + expect(ret).toMatchObject({ ok: true, id: 9 });
  182 + expect(result.current.submitting).toBe(false);
  183 + });
  184 +
  185 + it('submit edit calls updateUser with userId and full permissionIds', async () => {
  186 + mockedDetail.mockResolvedValue(makeVo());
  187 + mockedUpdate.mockResolvedValue({ id: 7 });
  188 + const { result } = renderHook(
  189 + () => useUserDetail({ mode: 'edit', userId: 7 }),
  190 + { wrapper },
  191 + );
  192 + await waitFor(() => expect(result.current.loading).toBe(false));
  193 + act(() => {
  194 + result.current.togglePermission(2, true);
  195 + });
  196 + let ret: { ok: boolean; id?: number } | undefined;
  197 + await act(async () => {
  198 + ret = await result.current.submit(makeValues());
  199 + });
  200 + expect(mockedUpdate).toHaveBeenCalledTimes(1);
  201 + const [id, body] = mockedUpdate.mock.calls[0];
  202 + expect(id).toBe(7);
  203 + expect(body.permissionIds).toContain(2);
  204 + expect(body).not.toHaveProperty('sUserName');
  205 + expect(ret).toMatchObject({ ok: true, id: 7 });
  206 + });
  207 +
  208 + it('submit 40901 returns fieldError on sUserName', async () => {
  209 + mockedCreate.mockRejectedValue(new ApiError(ERR_USERNAME_EXISTS, 'dup'));
  210 + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
  211 + await waitFor(() => expect(result.current.loading).toBe(false));
  212 + let ret: { ok: boolean; fieldError?: { field: string; message: string } } | undefined;
  213 + await act(async () => {
  214 + ret = await result.current.submit(makeValues());
  215 + });
  216 + expect(ret?.ok).toBe(false);
  217 + expect(ret?.fieldError?.field).toBe('sUserName');
  218 + expect(result.current.submitting).toBe(false);
  219 + });
  220 +
  221 + it('submit 40401/40301/40001/network show message and return ok:false', async () => {
  222 + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
  223 + await waitFor(() => expect(result.current.loading).toBe(false));
  224 +
  225 + for (const code of [ERR_USER_NOT_FOUND, ERR_NO_PERMISSION, ERR_VALIDATION, -1]) {
  226 + messageSpy.error.mockClear();
  227 + mockedCreate.mockRejectedValueOnce(new ApiError(code, 'e'));
  228 + let ret: { ok: boolean } | undefined;
  229 + await act(async () => {
  230 + ret = await result.current.submit(makeValues());
  231 + });
  232 + expect(ret?.ok).toBe(false);
  233 + expect(messageSpy.error).toHaveBeenCalled();
  234 + }
  235 + });
  236 +
  237 + it('loadError when prefetch fails sets loadFailed and message; reload clears it', async () => {
  238 + mockedPermissions.mockRejectedValueOnce(new ApiError(-1, 'net'));
  239 + const { result } = renderHook(() => useUserDetail({ mode: 'create' }), { wrapper });
  240 + await waitFor(() => expect(result.current.loadFailed).toBe(true));
  241 + expect(messageSpy.error).toHaveBeenCalledWith(MSG_ERR_LOAD_PERMISSIONS);
  242 +
  243 + mockedPermissions.mockResolvedValue(PERMISSIONS);
  244 + act(() => {
  245 + result.current.reload();
  246 + });
  247 + await waitFor(() => expect(result.current.loadFailed).toBe(false));
  248 + expect(result.current.permissions).toEqual(PERMISSIONS);
  249 + });
  250 +});