From 7241d98bdaf5e90214bc8873250996e53a928018 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 17:38:23 +0800 Subject: [PATCH] feat(usr): 用户列表查询 hook useUserList 状态机 REQ-USR-003 --- frontend/src/pages/usr/UserList/useUserList.ts | 167 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/tests/unit/useUserList.test.tsx | 277 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 444 insertions(+), 0 deletions(-) create mode 100644 frontend/src/pages/usr/UserList/useUserList.ts create mode 100644 frontend/tests/unit/useUserList.test.tsx diff --git a/frontend/src/pages/usr/UserList/useUserList.ts b/frontend/src/pages/usr/UserList/useUserList.ts new file mode 100644 index 0000000..4a67fa8 --- /dev/null +++ b/frontend/src/pages/usr/UserList/useUserList.ts @@ -0,0 +1,167 @@ +// REQ-USR-003: 用户列表查询 hook(状态机 initialLoading/loading/success/empty/error/exporting) +import { useCallback, useEffect, useRef, useState } from 'react'; +import { App as AntdApp } from 'antd'; +import { listUsers } from '../../../api/usrApi'; +import { ApiError } from '../../../api/request'; +import type { UserVO, UserListQuery } from '../../../api/types'; +import { buildUserCsv, downloadCsv } from './exportUtils'; +import { + DEFAULT_QUERY, + ERR_PAGE_INVALID, + ERR_QUERY_INVALID, + EXPORT_FILENAME, + TEXT_EXPORT_FAIL, + TEXT_EXPORT_SUCCESS, + TEXT_MSG_NETWORK, + TEXT_MSG_PAGE_INVALID, + TEXT_MSG_QUERY_INVALID, +} from './constants'; + +export interface UseUserListReturn { + list: UserVO[]; + total: number; + loading: boolean; + error: ApiError | null; + query: UserListQuery; + exporting: boolean; + search(): void; + refresh(): void; + clear(): void; + setQueryField(v: string): void; + setMatchType(v: string): void; + setQueryValue(v: string): void; + changePage(pageNum: number, pageSize: number): void; + exportExcel(): Promise; +} + +export function useUserList(): UseUserListReturn { + const { message } = AntdApp.useApp(); + + const [query, setQuery] = useState({ ...DEFAULT_QUERY }); + const [list, setList] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); // initialLoading + const [error, setError] = useState(null); + const [exporting, setExporting] = useState(false); + + // 最新 query 镜像,避免 refresh / 闭包读到旧值 + const queryRef = useRef(query); + queryRef.current = query; + + const messageRef = useRef(message); + messageRef.current = message; + + /** 以给定 query 取数;同步 total/pageNum/pageSize 回显(BR15);错误码分流(spec § 4) */ + const runFetch = useCallback(async (q: UserListQuery) => { + setQuery(q); + queryRef.current = q; + setLoading(true); + setError(null); + try { + const pageData = await listUsers(q); + setList(pageData.records); + setTotal(pageData.total); + // 信任后端回显(越界回退最后一页等),同步分页当前页/页大小(BR15) + setQuery((prev) => { + const next = { ...prev, pageNum: pageData.pageNum, pageSize: pageData.pageSize }; + queryRef.current = next; + return next; + }); + setLoading(false); + } catch (err) { + const apiErr = err instanceof ApiError ? err : new ApiError(-1, TEXT_MSG_NETWORK); + setLoading(false); + if (apiErr.code === ERR_PAGE_INVALID) { + // 42201:兜底重置分页为合法值后重查(pageNum=1,pageSize 收敛 ≤100) + messageRef.current.warning(TEXT_MSG_PAGE_INVALID); + const safePageSize = Math.min(q.pageSize, 100); + void runFetch({ ...q, pageNum: 1, pageSize: safePageSize }); + return; + } + if (apiErr.code === ERR_QUERY_INVALID) { + // 40001:保留条件不自动重查 + messageRef.current.error(TEXT_MSG_QUERY_INVALID); + setError(apiErr); + return; + } + // 网络 / 超时 / 5xx 兜底 + messageRef.current.error(TEXT_MSG_NETWORK); + setError(apiErr); + } + }, []); + + // 挂载即以默认条件取数(initialLoading,BR2) + useEffect(() => { + void runFetch({ ...DEFAULT_QUERY }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const setQueryField = useCallback((v: string) => { + setQuery((prev) => ({ ...prev, queryField: v })); + }, []); + const setMatchType = useCallback((v: string) => { + setQuery((prev) => ({ ...prev, matchType: v })); + }, []); + const setQueryValue = useCallback((v: string) => { + setQuery((prev) => ({ ...prev, queryValue: v })); + }, []); + + /** 搜索:以当前条件回第 1 页取数(BR7) */ + const search = useCallback(() => { + void runFetch({ ...queryRef.current, pageNum: 1 }); + }, [runFetch]); + + /** 刷新:保持当前 query(含 pageNum)重取(BR8) */ + const refresh = useCallback(() => { + void runFetch({ ...queryRef.current }); + }, [runFetch]); + + /** 清空:重置为默认条件并全量查询(BR10) */ + const clear = useCallback(() => { + void runFetch({ ...DEFAULT_QUERY }); + }, [runFetch]); + + /** 切页 / 改页大小:改 pageSize 回第 1 页(BR11),仅切页保留页码 */ + const changePage = useCallback( + (pageNum: number, pageSize: number) => { + const cur = queryRef.current; + const sizeChanged = pageSize !== cur.pageSize; + void runFetch({ ...cur, pageNum: sizeChanged ? 1 : pageNum, pageSize }); + }, + [runFetch], + ); + + /** 导出:拉当前条件命中结果(pageSize 收敛至上限 100 一次取)→ CSV 下载(BR9 / D-PLAN-1) */ + const exportExcel = useCallback(async () => { + setExporting(true); + try { + const cur = queryRef.current; + const fetchSize = Math.min(Math.max(total, cur.pageSize, 1), 100); + const pageData = await listUsers({ ...cur, pageNum: 1, pageSize: fetchSize }); + const csv = buildUserCsv(pageData.records); + downloadCsv(EXPORT_FILENAME, csv); + messageRef.current.success(TEXT_EXPORT_SUCCESS); + } catch { + messageRef.current.error(TEXT_EXPORT_FAIL); + } finally { + setExporting(false); + } + }, [total]); + + return { + list, + total, + loading, + error, + query, + exporting, + search, + refresh, + clear, + setQueryField, + setMatchType, + setQueryValue, + changePage, + exportExcel, + }; +} diff --git a/frontend/tests/unit/useUserList.test.tsx b/frontend/tests/unit/useUserList.test.tsx new file mode 100644 index 0000000..53b721c --- /dev/null +++ b/frontend/tests/unit/useUserList.test.tsx @@ -0,0 +1,277 @@ +// REQ-USR-003: useUserList 列表查询 hook 状态机单测 +// 覆盖 initialLoading/loading/success/empty/error/exporting + BR2/BR7/BR8/BR10/BR11/BR15 + 错误码分流 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ReactNode } from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { App as AntdApp, ConfigProvider } from 'antd'; + +// 桩 message:保留 antd 其余真实导出,仅覆盖 App.useApp 返回的 message +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +// 桩 listUsers +vi.mock('../../src/api/usrApi', () => ({ + listUsers: vi.fn(), +})); + +// 桩 CSV 下载(不真正触发 jsdom 下载) +vi.mock('../../src/pages/usr/UserList/exportUtils', async () => { + const actual = await vi.importActual< + typeof import('../../src/pages/usr/UserList/exportUtils') + >('../../src/pages/usr/UserList/exportUtils'); + return { ...actual, downloadCsv: vi.fn() }; +}); + +import { listUsers } from '../../src/api/usrApi'; +import { downloadCsv } from '../../src/pages/usr/UserList/exportUtils'; +import { useUserList } from '../../src/pages/usr/UserList/useUserList'; +import { DEFAULT_QUERY } from '../../src/pages/usr/UserList/constants'; +import { ApiError } from '../../src/api/request'; +import type { UserVO } from '../../src/api/types'; + +const mockedList = listUsers as unknown as ReturnType; +const mockedDownload = downloadCsv as unknown as ReturnType; + +function makeUser(id: number, name = `u${id}`): UserVO { + return { + id, + sUserName: name, + employeeName: null, + sUserNo: null, + departmentName: null, + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2024-01-01T00:00:00', + }; +} + +function page(records: UserVO[], total: number, pageNum = 1, pageSize = 10) { + return { records, total, pageNum, pageSize }; +} + +function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function lastCallQuery() { + const calls = mockedList.mock.calls; + return calls[calls.length - 1][0]; +} + +describe('useUserList 状态机', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('mounts with default query and loads first page (initialLoading→success)', async () => { + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + // 挂载即以默认 query 调 listUsers + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + expect(mockedList.mock.calls[0][0]).toMatchObject({ + queryField: '用户名', + matchType: '包含', + pageNum: 1, + pageSize: 10, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.list).toHaveLength(2); + expect(result.current.total).toBe(2); + expect(result.current.query.pageNum).toBe(1); + }); + + it('empty records sets empty state without error', async () => { + mockedList.mockResolvedValue(page([], 0, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.list).toEqual([]); + expect(result.current.total).toBe(0); + expect(result.current.error).toBeNull(); + expect(messageSpy.error).not.toHaveBeenCalled(); + }); + + it('search resets to page 1 and refetches with current filters (BR7)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setQueryField('员工名'); + result.current.setQueryValue('李'); + }); + act(() => { + result.current.search(); + }); + await waitFor(() => { + const q = lastCallQuery(); + expect(q.queryField).toBe('员工名'); + expect(q.queryValue).toBe('李'); + expect(q.pageNum).toBe(1); + }); + }); + + it('refresh keeps current query and page (BR8)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 30, 2, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.changePage(2, 10); + }); + await waitFor(() => expect(result.current.query.pageNum).toBe(2)); + mockedList.mockClear(); + + act(() => { + result.current.refresh(); + }); + await waitFor(() => expect(mockedList).toHaveBeenCalled()); + expect(lastCallQuery().pageNum).toBe(2); + }); + + it('clear resets to DEFAULT_QUERY then refetches (BR10)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setQueryField('部门'); + result.current.setQueryValue('技术'); + }); + act(() => { + result.current.clear(); + }); + await waitFor(() => { + expect(result.current.query.queryField).toBe(DEFAULT_QUERY.queryField); + expect(result.current.query.queryValue).toBe(''); + expect(result.current.query.pageNum).toBe(1); + }); + expect(lastCallQuery().queryField).toBe('用户名'); + }); + + it('changePage refetch; changing pageSize resets to page 1 (BR11)', async () => { + mockedList.mockResolvedValue(page([makeUser(1)], 100, 3, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.changePage(3, 10); + }); + await waitFor(() => expect(lastCallQuery().pageNum).toBe(3)); + + // 改 pageSize(10→50)应回第 1 页 + act(() => { + result.current.changePage(3, 50); + }); + await waitFor(() => { + const q = lastCallQuery(); + expect(q.pageSize).toBe(50); + expect(q.pageNum).toBe(1); + }); + }); + + it('ApiError 40001 keeps filters and shows error, sets error state', async () => { + // 首次挂载成功,之后搜索失败 40001 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.setQueryValue('张'); + }); + mockedList.mockRejectedValueOnce(new ApiError(40001, '查询参数校验失败')); + act(() => { + result.current.search(); + }); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.code).toBe(40001); + expect(result.current.query.queryValue).toBe('张'); // 条件保留 + expect(messageSpy.error).toHaveBeenCalled(); + // 不自动重查:失败后调用次数 = 挂载1 + 搜索1 = 2 + expect(mockedList).toHaveBeenCalledTimes(2); + }); + + it('ApiError 42201 warns and refetches at page 1', async () => { + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // 翻到第 3 页 → 42201 → 应重置 pageNum=1 并重查 + mockedList.mockClear(); + mockedList.mockRejectedValueOnce(new ApiError(42201, '分页参数非法')); + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + act(() => { + result.current.changePage(3, 10); + }); + await waitFor(() => expect(messageSpy.warning).toHaveBeenCalled()); + await waitFor(() => expect(result.current.query.pageNum).toBe(1)); + // 重查发生 + expect(lastCallQuery().pageNum).toBe(1); + }); + + it('network error (code -1) sets error state', async () => { + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); + act(() => { + result.current.refresh(); + }); + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.code).toBe(-1); + expect(messageSpy.error).toHaveBeenCalled(); + }); + + it('response pageNum echo syncs pagination (BR15)', async () => { + // 请求 pageNum=99 越界,后端回退最后一页 pageNum=5 + mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 5, 10)); + act(() => { + result.current.changePage(99, 10); + }); + await waitFor(() => expect(result.current.query.pageNum).toBe(5)); + expect(result.current.total).toBe(50); + }); + + it('exportExcel toggles exporting and downloads (BR9)', async () => { + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.exportExcel(); + }); + expect(mockedDownload).toHaveBeenCalledTimes(1); + expect(result.current.exporting).toBe(false); + expect(messageSpy.success).toHaveBeenCalledWith('导出成功'); + }); + + it('exportExcel failure shows 导出失败', async () => { + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); + const { result } = renderHook(() => useUserList(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); + await act(async () => { + await result.current.exportExcel(); + }); + expect(messageSpy.error).toHaveBeenCalledWith('导出失败'); + expect(result.current.exporting).toBe(false); + }); +}); -- libgit2 0.22.2