Commit 7241d98bdaf5e90214bc8873250996e53a928018

Authored by zichun
1 parent a6460994

feat(usr): 用户列表查询 hook useUserList 状态机 REQ-USR-003

frontend/src/pages/usr/UserList/useUserList.ts 0 → 100644
  1 +// REQ-USR-003: 用户列表查询 hook(状态机 initialLoading/loading/success/empty/error/exporting)
  2 +import { useCallback, useEffect, useRef, useState } from 'react';
  3 +import { App as AntdApp } from 'antd';
  4 +import { listUsers } from '../../../api/usrApi';
  5 +import { ApiError } from '../../../api/request';
  6 +import type { UserVO, UserListQuery } from '../../../api/types';
  7 +import { buildUserCsv, downloadCsv } from './exportUtils';
  8 +import {
  9 + DEFAULT_QUERY,
  10 + ERR_PAGE_INVALID,
  11 + ERR_QUERY_INVALID,
  12 + EXPORT_FILENAME,
  13 + TEXT_EXPORT_FAIL,
  14 + TEXT_EXPORT_SUCCESS,
  15 + TEXT_MSG_NETWORK,
  16 + TEXT_MSG_PAGE_INVALID,
  17 + TEXT_MSG_QUERY_INVALID,
  18 +} from './constants';
  19 +
  20 +export interface UseUserListReturn {
  21 + list: UserVO[];
  22 + total: number;
  23 + loading: boolean;
  24 + error: ApiError | null;
  25 + query: UserListQuery;
  26 + exporting: boolean;
  27 + search(): void;
  28 + refresh(): void;
  29 + clear(): void;
  30 + setQueryField(v: string): void;
  31 + setMatchType(v: string): void;
  32 + setQueryValue(v: string): void;
  33 + changePage(pageNum: number, pageSize: number): void;
  34 + exportExcel(): Promise<void>;
  35 +}
  36 +
  37 +export function useUserList(): UseUserListReturn {
  38 + const { message } = AntdApp.useApp();
  39 +
  40 + const [query, setQuery] = useState<UserListQuery>({ ...DEFAULT_QUERY });
  41 + const [list, setList] = useState<UserVO[]>([]);
  42 + const [total, setTotal] = useState(0);
  43 + const [loading, setLoading] = useState(true); // initialLoading
  44 + const [error, setError] = useState<ApiError | null>(null);
  45 + const [exporting, setExporting] = useState(false);
  46 +
  47 + // 最新 query 镜像,避免 refresh / 闭包读到旧值
  48 + const queryRef = useRef<UserListQuery>(query);
  49 + queryRef.current = query;
  50 +
  51 + const messageRef = useRef(message);
  52 + messageRef.current = message;
  53 +
  54 + /** 以给定 query 取数;同步 total/pageNum/pageSize 回显(BR15);错误码分流(spec § 4) */
  55 + const runFetch = useCallback(async (q: UserListQuery) => {
  56 + setQuery(q);
  57 + queryRef.current = q;
  58 + setLoading(true);
  59 + setError(null);
  60 + try {
  61 + const pageData = await listUsers(q);
  62 + setList(pageData.records);
  63 + setTotal(pageData.total);
  64 + // 信任后端回显(越界回退最后一页等),同步分页当前页/页大小(BR15)
  65 + setQuery((prev) => {
  66 + const next = { ...prev, pageNum: pageData.pageNum, pageSize: pageData.pageSize };
  67 + queryRef.current = next;
  68 + return next;
  69 + });
  70 + setLoading(false);
  71 + } catch (err) {
  72 + const apiErr = err instanceof ApiError ? err : new ApiError(-1, TEXT_MSG_NETWORK);
  73 + setLoading(false);
  74 + if (apiErr.code === ERR_PAGE_INVALID) {
  75 + // 42201:兜底重置分页为合法值后重查(pageNum=1,pageSize 收敛 ≤100)
  76 + messageRef.current.warning(TEXT_MSG_PAGE_INVALID);
  77 + const safePageSize = Math.min(q.pageSize, 100);
  78 + void runFetch({ ...q, pageNum: 1, pageSize: safePageSize });
  79 + return;
  80 + }
  81 + if (apiErr.code === ERR_QUERY_INVALID) {
  82 + // 40001:保留条件不自动重查
  83 + messageRef.current.error(TEXT_MSG_QUERY_INVALID);
  84 + setError(apiErr);
  85 + return;
  86 + }
  87 + // 网络 / 超时 / 5xx 兜底
  88 + messageRef.current.error(TEXT_MSG_NETWORK);
  89 + setError(apiErr);
  90 + }
  91 + }, []);
  92 +
  93 + // 挂载即以默认条件取数(initialLoading,BR2)
  94 + useEffect(() => {
  95 + void runFetch({ ...DEFAULT_QUERY });
  96 + // eslint-disable-next-line react-hooks/exhaustive-deps
  97 + }, []);
  98 +
  99 + const setQueryField = useCallback((v: string) => {
  100 + setQuery((prev) => ({ ...prev, queryField: v }));
  101 + }, []);
  102 + const setMatchType = useCallback((v: string) => {
  103 + setQuery((prev) => ({ ...prev, matchType: v }));
  104 + }, []);
  105 + const setQueryValue = useCallback((v: string) => {
  106 + setQuery((prev) => ({ ...prev, queryValue: v }));
  107 + }, []);
  108 +
  109 + /** 搜索:以当前条件回第 1 页取数(BR7) */
  110 + const search = useCallback(() => {
  111 + void runFetch({ ...queryRef.current, pageNum: 1 });
  112 + }, [runFetch]);
  113 +
  114 + /** 刷新:保持当前 query(含 pageNum)重取(BR8) */
  115 + const refresh = useCallback(() => {
  116 + void runFetch({ ...queryRef.current });
  117 + }, [runFetch]);
  118 +
  119 + /** 清空:重置为默认条件并全量查询(BR10) */
  120 + const clear = useCallback(() => {
  121 + void runFetch({ ...DEFAULT_QUERY });
  122 + }, [runFetch]);
  123 +
  124 + /** 切页 / 改页大小:改 pageSize 回第 1 页(BR11),仅切页保留页码 */
  125 + const changePage = useCallback(
  126 + (pageNum: number, pageSize: number) => {
  127 + const cur = queryRef.current;
  128 + const sizeChanged = pageSize !== cur.pageSize;
  129 + void runFetch({ ...cur, pageNum: sizeChanged ? 1 : pageNum, pageSize });
  130 + },
  131 + [runFetch],
  132 + );
  133 +
  134 + /** 导出:拉当前条件命中结果(pageSize 收敛至上限 100 一次取)→ CSV 下载(BR9 / D-PLAN-1) */
  135 + const exportExcel = useCallback(async () => {
  136 + setExporting(true);
  137 + try {
  138 + const cur = queryRef.current;
  139 + const fetchSize = Math.min(Math.max(total, cur.pageSize, 1), 100);
  140 + const pageData = await listUsers({ ...cur, pageNum: 1, pageSize: fetchSize });
  141 + const csv = buildUserCsv(pageData.records);
  142 + downloadCsv(EXPORT_FILENAME, csv);
  143 + messageRef.current.success(TEXT_EXPORT_SUCCESS);
  144 + } catch {
  145 + messageRef.current.error(TEXT_EXPORT_FAIL);
  146 + } finally {
  147 + setExporting(false);
  148 + }
  149 + }, [total]);
  150 +
  151 + return {
  152 + list,
  153 + total,
  154 + loading,
  155 + error,
  156 + query,
  157 + exporting,
  158 + search,
  159 + refresh,
  160 + clear,
  161 + setQueryField,
  162 + setMatchType,
  163 + setQueryValue,
  164 + changePage,
  165 + exportExcel,
  166 + };
  167 +}
... ...
frontend/tests/unit/useUserList.test.tsx 0 → 100644
  1 +// REQ-USR-003: useUserList 列表查询 hook 状态机单测
  2 +// 覆盖 initialLoading/loading/success/empty/error/exporting + BR2/BR7/BR8/BR10/BR11/BR15 + 错误码分流
  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 其余真实导出,仅覆盖 App.useApp 返回的 message
  9 +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: 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 +// 桩 listUsers
  19 +vi.mock('../../src/api/usrApi', () => ({
  20 + listUsers: vi.fn(),
  21 +}));
  22 +
  23 +// 桩 CSV 下载(不真正触发 jsdom 下载)
  24 +vi.mock('../../src/pages/usr/UserList/exportUtils', async () => {
  25 + const actual = await vi.importActual<
  26 + typeof import('../../src/pages/usr/UserList/exportUtils')
  27 + >('../../src/pages/usr/UserList/exportUtils');
  28 + return { ...actual, downloadCsv: vi.fn() };
  29 +});
  30 +
  31 +import { listUsers } from '../../src/api/usrApi';
  32 +import { downloadCsv } from '../../src/pages/usr/UserList/exportUtils';
  33 +import { useUserList } from '../../src/pages/usr/UserList/useUserList';
  34 +import { DEFAULT_QUERY } from '../../src/pages/usr/UserList/constants';
  35 +import { ApiError } from '../../src/api/request';
  36 +import type { UserVO } from '../../src/api/types';
  37 +
  38 +const mockedList = listUsers as unknown as ReturnType<typeof vi.fn>;
  39 +const mockedDownload = downloadCsv as unknown as ReturnType<typeof vi.fn>;
  40 +
  41 +function makeUser(id: number, name = `u${id}`): UserVO {
  42 + return {
  43 + id,
  44 + sUserName: name,
  45 + employeeName: null,
  46 + sUserNo: null,
  47 + departmentName: null,
  48 + sUserType: '普通用户',
  49 + sLanguage: '中文',
  50 + iIsVoid: 0,
  51 + tLastLoginDate: null,
  52 + sCreator: 'admin',
  53 + tCreateDate: '2024-01-01T00:00:00',
  54 + };
  55 +}
  56 +
  57 +function page(records: UserVO[], total: number, pageNum = 1, pageSize = 10) {
  58 + return { records, total, pageNum, pageSize };
  59 +}
  60 +
  61 +function wrapper({ children }: { children: ReactNode }) {
  62 + return (
  63 + <ConfigProvider>
  64 + <AntdApp>{children}</AntdApp>
  65 + </ConfigProvider>
  66 + );
  67 +}
  68 +
  69 +function lastCallQuery() {
  70 + const calls = mockedList.mock.calls;
  71 + return calls[calls.length - 1][0];
  72 +}
  73 +
  74 +describe('useUserList 状态机', () => {
  75 + beforeEach(() => {
  76 + vi.clearAllMocks();
  77 + });
  78 +
  79 + it('mounts with default query and loads first page (initialLoading→success)', async () => {
  80 + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10));
  81 + const { result } = renderHook(() => useUserList(), { wrapper });
  82 + // 挂载即以默认 query 调 listUsers
  83 + await waitFor(() => expect(mockedList).toHaveBeenCalled());
  84 + expect(mockedList.mock.calls[0][0]).toMatchObject({
  85 + queryField: '用户名',
  86 + matchType: '包含',
  87 + pageNum: 1,
  88 + pageSize: 10,
  89 + });
  90 + await waitFor(() => expect(result.current.loading).toBe(false));
  91 + expect(result.current.list).toHaveLength(2);
  92 + expect(result.current.total).toBe(2);
  93 + expect(result.current.query.pageNum).toBe(1);
  94 + });
  95 +
  96 + it('empty records sets empty state without error', async () => {
  97 + mockedList.mockResolvedValue(page([], 0, 1, 10));
  98 + const { result } = renderHook(() => useUserList(), { wrapper });
  99 + await waitFor(() => expect(result.current.loading).toBe(false));
  100 + expect(result.current.list).toEqual([]);
  101 + expect(result.current.total).toBe(0);
  102 + expect(result.current.error).toBeNull();
  103 + expect(messageSpy.error).not.toHaveBeenCalled();
  104 + });
  105 +
  106 + it('search resets to page 1 and refetches with current filters (BR7)', async () => {
  107 + mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10));
  108 + const { result } = renderHook(() => useUserList(), { wrapper });
  109 + await waitFor(() => expect(result.current.loading).toBe(false));
  110 +
  111 + act(() => {
  112 + result.current.setQueryField('员工名');
  113 + result.current.setQueryValue('李');
  114 + });
  115 + act(() => {
  116 + result.current.search();
  117 + });
  118 + await waitFor(() => {
  119 + const q = lastCallQuery();
  120 + expect(q.queryField).toBe('员工名');
  121 + expect(q.queryValue).toBe('李');
  122 + expect(q.pageNum).toBe(1);
  123 + });
  124 + });
  125 +
  126 + it('refresh keeps current query and page (BR8)', async () => {
  127 + mockedList.mockResolvedValue(page([makeUser(1)], 30, 2, 10));
  128 + const { result } = renderHook(() => useUserList(), { wrapper });
  129 + await waitFor(() => expect(result.current.loading).toBe(false));
  130 +
  131 + act(() => {
  132 + result.current.changePage(2, 10);
  133 + });
  134 + await waitFor(() => expect(result.current.query.pageNum).toBe(2));
  135 + mockedList.mockClear();
  136 +
  137 + act(() => {
  138 + result.current.refresh();
  139 + });
  140 + await waitFor(() => expect(mockedList).toHaveBeenCalled());
  141 + expect(lastCallQuery().pageNum).toBe(2);
  142 + });
  143 +
  144 + it('clear resets to DEFAULT_QUERY then refetches (BR10)', async () => {
  145 + mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10));
  146 + const { result } = renderHook(() => useUserList(), { wrapper });
  147 + await waitFor(() => expect(result.current.loading).toBe(false));
  148 +
  149 + act(() => {
  150 + result.current.setQueryField('部门');
  151 + result.current.setQueryValue('技术');
  152 + });
  153 + act(() => {
  154 + result.current.clear();
  155 + });
  156 + await waitFor(() => {
  157 + expect(result.current.query.queryField).toBe(DEFAULT_QUERY.queryField);
  158 + expect(result.current.query.queryValue).toBe('');
  159 + expect(result.current.query.pageNum).toBe(1);
  160 + });
  161 + expect(lastCallQuery().queryField).toBe('用户名');
  162 + });
  163 +
  164 + it('changePage refetch; changing pageSize resets to page 1 (BR11)', async () => {
  165 + mockedList.mockResolvedValue(page([makeUser(1)], 100, 3, 10));
  166 + const { result } = renderHook(() => useUserList(), { wrapper });
  167 + await waitFor(() => expect(result.current.loading).toBe(false));
  168 +
  169 + act(() => {
  170 + result.current.changePage(3, 10);
  171 + });
  172 + await waitFor(() => expect(lastCallQuery().pageNum).toBe(3));
  173 +
  174 + // 改 pageSize(10→50)应回第 1 页
  175 + act(() => {
  176 + result.current.changePage(3, 50);
  177 + });
  178 + await waitFor(() => {
  179 + const q = lastCallQuery();
  180 + expect(q.pageSize).toBe(50);
  181 + expect(q.pageNum).toBe(1);
  182 + });
  183 + });
  184 +
  185 + it('ApiError 40001 keeps filters and shows error, sets error state', async () => {
  186 + // 首次挂载成功,之后搜索失败 40001
  187 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
  188 + const { result } = renderHook(() => useUserList(), { wrapper });
  189 + await waitFor(() => expect(result.current.loading).toBe(false));
  190 +
  191 + act(() => {
  192 + result.current.setQueryValue('张');
  193 + });
  194 + mockedList.mockRejectedValueOnce(new ApiError(40001, '查询参数校验失败'));
  195 + act(() => {
  196 + result.current.search();
  197 + });
  198 + await waitFor(() => expect(result.current.error).not.toBeNull());
  199 + expect(result.current.error?.code).toBe(40001);
  200 + expect(result.current.query.queryValue).toBe('张'); // 条件保留
  201 + expect(messageSpy.error).toHaveBeenCalled();
  202 + // 不自动重查:失败后调用次数 = 挂载1 + 搜索1 = 2
  203 + expect(mockedList).toHaveBeenCalledTimes(2);
  204 + });
  205 +
  206 + it('ApiError 42201 warns and refetches at page 1', async () => {
  207 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
  208 + const { result } = renderHook(() => useUserList(), { wrapper });
  209 + await waitFor(() => expect(result.current.loading).toBe(false));
  210 +
  211 + // 翻到第 3 页 → 42201 → 应重置 pageNum=1 并重查
  212 + mockedList.mockClear();
  213 + mockedList.mockRejectedValueOnce(new ApiError(42201, '分页参数非法'));
  214 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
  215 + act(() => {
  216 + result.current.changePage(3, 10);
  217 + });
  218 + await waitFor(() => expect(messageSpy.warning).toHaveBeenCalled());
  219 + await waitFor(() => expect(result.current.query.pageNum).toBe(1));
  220 + // 重查发生
  221 + expect(lastCallQuery().pageNum).toBe(1);
  222 + });
  223 +
  224 + it('network error (code -1) sets error state', async () => {
  225 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
  226 + const { result } = renderHook(() => useUserList(), { wrapper });
  227 + await waitFor(() => expect(result.current.loading).toBe(false));
  228 +
  229 + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常'));
  230 + act(() => {
  231 + result.current.refresh();
  232 + });
  233 + await waitFor(() => expect(result.current.error).not.toBeNull());
  234 + expect(result.current.error?.code).toBe(-1);
  235 + expect(messageSpy.error).toHaveBeenCalled();
  236 + });
  237 +
  238 + it('response pageNum echo syncs pagination (BR15)', async () => {
  239 + // 请求 pageNum=99 越界,后端回退最后一页 pageNum=5
  240 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 1, 10));
  241 + const { result } = renderHook(() => useUserList(), { wrapper });
  242 + await waitFor(() => expect(result.current.loading).toBe(false));
  243 +
  244 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 5, 10));
  245 + act(() => {
  246 + result.current.changePage(99, 10);
  247 + });
  248 + await waitFor(() => expect(result.current.query.pageNum).toBe(5));
  249 + expect(result.current.total).toBe(50);
  250 + });
  251 +
  252 + it('exportExcel toggles exporting and downloads (BR9)', async () => {
  253 + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10));
  254 + const { result } = renderHook(() => useUserList(), { wrapper });
  255 + await waitFor(() => expect(result.current.loading).toBe(false));
  256 +
  257 + await act(async () => {
  258 + await result.current.exportExcel();
  259 + });
  260 + expect(mockedDownload).toHaveBeenCalledTimes(1);
  261 + expect(result.current.exporting).toBe(false);
  262 + expect(messageSpy.success).toHaveBeenCalledWith('导出成功');
  263 + });
  264 +
  265 + it('exportExcel failure shows 导出失败', async () => {
  266 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10));
  267 + const { result } = renderHook(() => useUserList(), { wrapper });
  268 + await waitFor(() => expect(result.current.loading).toBe(false));
  269 +
  270 + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常'));
  271 + await act(async () => {
  272 + await result.current.exportExcel();
  273 + });
  274 + expect(messageSpy.error).toHaveBeenCalledWith('导出失败');
  275 + expect(result.current.exporting).toBe(false);
  276 + });
  277 +});
... ...