From 96e88d38256f5ccbf6a77dc15c37c8736830a2fb Mon Sep 17 00:00:00 2001 From: zichun Date: Tue, 2 Jun 2026 09:17:01 +0800 Subject: [PATCH] fix(usr): 修复 review must-fix FE: FE-04 编辑预填走 navigate state 并补 loadError 返回列表入口 --- frontend/src/pages/usr/UserDetail/index.tsx | 26 +++++++++----------------- frontend/src/pages/usr/UserDetail/useUserDetail.ts | 30 +++++++++++------------------- frontend/src/pages/usr/UserList/index.tsx | 7 ++++++- frontend/tests/unit/UserDetailPage.test.tsx | 42 ++++++++++++++++++++++++++---------------- frontend/tests/unit/renderShell.tsx | 8 +++++++- frontend/tests/unit/useUserDetail.test.tsx | 12 ++++++------ 6 files changed, 65 insertions(+), 60 deletions(-) diff --git a/frontend/src/pages/usr/UserDetail/index.tsx b/frontend/src/pages/usr/UserDetail/index.tsx index 15245e3..2ff9ed0 100644 --- a/frontend/src/pages/usr/UserDetail/index.tsx +++ b/frontend/src/pages/usr/UserDetail/index.tsx @@ -13,7 +13,6 @@ import { MODE_EDIT, MSG_CREATE_SUCCESS, MSG_EDIT_SUCCESS, - MSG_ERR_USER_NOT_FOUND, MSG_LOAD_DETAIL_FAIL, MSG_CANCEL_CONFIRM, PATH_USER_LIST, @@ -54,7 +53,6 @@ export default function UserDetailPage() { loading, submitting, loadFailed, - notFound, selectEmployee, togglePermission, toggleAll, @@ -107,21 +105,10 @@ export default function UserDetailPage() { selectEmployee(value); }; - // edit 详情不存在(40401):返回列表入口(spec § 4) - if (notFound) { - return ( -
-
- {MSG_ERR_USER_NOT_FOUND} - -
-
- ); - } - - // 预取/详情取数失败:重试入口(spec § 4 loadError) + // 预取/详情取数失败:整页重试入口(spec § 4 loadError)。 + // edit 态额外给「返回列表」——edit 预填只能来自列表行经 navigate state 透传的 + // presetUser(无 by-id 读端点,详见 useUserDetail),缺 state 时重试仍会回到 loadError, + // 需经列表双击重新携带 state 进入,故提供返回列表入口(spec § 4「edit 详情失败给整页重试或返回列表」)。 if (loadFailed) { return (
@@ -130,6 +117,11 @@ export default function UserDetailPage() { + {mode === MODE_EDIT && ( + + )}
); diff --git a/frontend/src/pages/usr/UserDetail/useUserDetail.ts b/frontend/src/pages/usr/UserDetail/useUserDetail.ts index f23d699..e36b224 100644 --- a/frontend/src/pages/usr/UserDetail/useUserDetail.ts +++ b/frontend/src/pages/usr/UserDetail/useUserDetail.ts @@ -4,7 +4,6 @@ import { App as AntdApp } from 'antd'; import { createUser, updateUser, - getUserDetail, listEmployees, listPermissions, } from '../../../api/usrApi'; @@ -64,7 +63,6 @@ export interface UseUserDetailReturn { submitting: boolean; error: ApiError | null; loadFailed: boolean; - notFound: boolean; setField(name: keyof UserFormValues, value: unknown): void; selectEmployee(value: number | null): void; togglePermission(id: number, checked: boolean): void; @@ -87,7 +85,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [loadFailed, setLoadFailed] = useState(false); - const [notFound, setNotFound] = useState(false); const employeesRef = useRef(employees); employeesRef.current = employees; @@ -110,7 +107,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { const runLoad = useCallback(async () => { setLoading(true); setLoadFailed(false); - setNotFound(false); try { const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]); if (!mountedRef.current) return; @@ -120,21 +116,18 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { if (mode === 'edit') { if (presetUser) { initFromVo(presetUser); - } else if (userId != null) { - const vo = await getUserDetail({ - queryField: '用户号', - queryValue: String(userId), - }); + } else { + // FE-04: edit 预填只能来自 FE-03 经 navigate state 透传的列表行(presetUser)。 + // 路由 :id 是用户主键,而唯一读端点 GET /api/usr/users 的 queryField 无主键选项 + // (docs/05 REQ-USR-003),故不能按主键去查「用户号」列——在真实后端必然返回空、 + // 误报 40401,无法满足编辑预填(BR17 / REQ-USR-002 验收)。 + // 缺 presetUser(如直接访问 URL / 刷新丢失 state)时按 loadError 处理, + // 由页面给出「返回列表」重试入口,重新经列表双击携带 state 进入。 if (!mountedRef.current) return; - if (vo) { - initFromVo(vo); - } else { - // 详情不存在:交由页面按 40401 路径处理(返回列表入口) - setNotFound(true); - messageRef.current.error(MSG_ERR_USER_NOT_FOUND); - setLoading(false); - return; - } + setLoading(false); + setLoadFailed(true); + messageRef.current.error(MSG_LOAD_DETAIL_FAIL); + return; } } else { setFormValues({ ...CREATE_DEFAULTS }); @@ -259,7 +252,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { submitting, error, loadFailed, - notFound, setField, selectEmployee, togglePermission, diff --git a/frontend/src/pages/usr/UserList/index.tsx b/frontend/src/pages/usr/UserList/index.tsx index 1fb4064..64d1271 100644 --- a/frontend/src/pages/usr/UserList/index.tsx +++ b/frontend/src/pages/usr/UserList/index.tsx @@ -33,7 +33,12 @@ export default function UserListPage() { const [selectedRowKey, setSelectedRowKey] = useState(null); const handleRowDoubleClick = (row: UserVO) => { - navigate('/usr/users/' + row.id); // BR12 + // BR12: 进入编辑单据。列表行 UserVO 已含单据预填所需全部字段, + // 经 navigate state 透传给详情页走 presetUser 分支; + // 不能仅靠 :id(路由 :id 为用户主键,而读端点 GET /api/usr/users 的 queryField + // 无主键选项,按主键去查「用户号」列在真实后端必然返回空 → 误报 40401), + // 详见 FE-04 / docs/05 REQ-USR-002。 + navigate('/usr/users/' + row.id, { state: { user: row } }); }; return ( diff --git a/frontend/tests/unit/UserDetailPage.test.tsx b/frontend/tests/unit/UserDetailPage.test.tsx index b86f09f..ac7a023 100644 --- a/frontend/tests/unit/UserDetailPage.test.tsx +++ b/frontend/tests/unit/UserDetailPage.test.tsx @@ -69,7 +69,13 @@ function LocationProbe() { return
{loc.pathname}
; } -function renderPage(entry: string) { +// FE-04: edit 单据预填来自 FE-03 经 navigate state 透传的列表行(presetUser)。 +// 测试入口可携带 state.user 复刻该数据流(路由 :id 仅为主键,无 by-id 读端点, +// 不能按主键查「用户号」列——详见 useUserDetail / docs/05 REQ-USR-002/003)。 +function renderPage(entry: string, presetUser?: UserVO) { + const initialEntry = presetUser + ? { pathname: entry, state: { user: presetUser } } + : entry; return renderShell( <> @@ -80,7 +86,7 @@ function renderPage(entry: string) { , { - initialEntries: [entry], + initialEntries: [initialEntry], preloadedAuth: { token: 't', user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, @@ -148,22 +154,20 @@ describe('UserDetailPage 集成', () => { expect(await screen.findByText('用户名已存在,请更换')).toBeInTheDocument(); }); - it('edit mode prefills from getUserDetail and username disabled', async () => { - mockedDetail.mockResolvedValue(makeVo()); - renderPage('/usr/users/7'); - await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); + it('edit mode prefills from navigate state (presetUser) and username disabled', async () => { + // FE-04: edit 预填走 FE-03 经 navigate state 透传的列表行,不再按主键查列表端点 + renderPage('/usr/users/7', makeVo()); await waitFor(() => expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), ); + expect(mockedDetail).not.toHaveBeenCalled(); expect(screen.getByTestId('field-username')).toBeDisabled(); }); it('edit submit success navigates to /usr/users with 保存成功', async () => { const user = userEvent.setup(); - mockedDetail.mockResolvedValue(makeVo()); mockedUpdate.mockResolvedValue({ id: 7 }); - renderPage('/usr/users/7'); - await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); + renderPage('/usr/users/7', makeVo()); await waitFor(() => expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), ); @@ -188,9 +192,11 @@ describe('UserDetailPage 集成', () => { it('新增 navigates to /usr/users/new', async () => { const user = userEvent.setup(); - mockedDetail.mockResolvedValue(makeVo()); - renderPage('/usr/users/7'); - await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); + // edit 经 navigate state 预填后,工具栏「新增」跳 /usr/users/new(BR14) + renderPage('/usr/users/7', makeVo()); + await waitFor(() => + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), + ); await user.click(screen.getByTestId('btn-new')); await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new')); }); @@ -205,12 +211,16 @@ describe('UserDetailPage 集成', () => { await waitFor(() => expect(screen.queryByTestId('userdetail-loaderror')).toBeNull()); }); - it('edit 40401 offers 返回列表', async () => { - mockedDetail.mockResolvedValue(null); + it('edit without navigate state shows loadError offering 点击重试 + 返回列表', async () => { + // FE-04 B1 fix: edit 缺 presetUser(直接访问 URL / 刷新丢 state)→ loadError, + // 整页给「点击重试」与「返回列表」两个入口(spec § 4 loadError)。 renderPage('/usr/users/7'); - expect(await screen.findByText('该用户不存在或已被删除')).toBeInTheDocument(); + const loadError = await screen.findByTestId('userdetail-loaderror'); + expect(within(loadError).getByText('点击重试')).toBeInTheDocument(); + expect(within(loadError).getByText('返回列表')).toBeInTheDocument(); + expect(mockedDetail).not.toHaveBeenCalled(); const user = userEvent.setup(); - await user.click(screen.getByText('返回列表')); + await user.click(within(loadError).getByText('返回列表')); await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); }); }); diff --git a/frontend/tests/unit/renderShell.tsx b/frontend/tests/unit/renderShell.tsx index f6c0b01..f4d7d4f 100644 --- a/frontend/tests/unit/renderShell.tsx +++ b/frontend/tests/unit/renderShell.tsx @@ -4,6 +4,10 @@ import type { ReactElement } from 'react'; import { render } from '@testing-library/react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; + +// react-router-dom 未公开 re-export InitialEntry,这里按其 MemoryRouter 接受的形态本地声明: +// 纯路径字符串,或携带 navigate state 的 Partial(FE-04 edit 预填数据流需要)。 +type ShellInitialEntry = string | { pathname: string; search?: string; hash?: string; state?: unknown }; import { App as AntdApp, ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { configureStore } from '@reduxjs/toolkit'; @@ -21,7 +25,9 @@ export function makeShellStore(preloadedAuth?: Partial) { } export interface RenderShellOptions { - initialEntries?: string[]; + // FE-04: 允许传 Partial(携带 navigate state),用于复刻 edit 单据经 + // navigate state 透传 presetUser 的数据流,不仅限纯路径字符串。 + initialEntries?: ShellInitialEntry[]; preloadedAuth?: Partial; store?: ReturnType; } diff --git a/frontend/tests/unit/useUserDetail.test.tsx b/frontend/tests/unit/useUserDetail.test.tsx index 2d374e2..351d05a 100644 --- a/frontend/tests/unit/useUserDetail.test.tsx +++ b/frontend/tests/unit/useUserDetail.test.tsx @@ -118,17 +118,17 @@ describe('useUserDetail 状态机', () => { expect(mockedDetail).not.toHaveBeenCalled(); }); - it('edit mode prefills from getUserDetail and pre-checks permissions', async () => { - mockedDetail.mockResolvedValue(makeVo()); + it('edit mode without presetUser sets loadFailed without calling getUserDetail', async () => { + // FE-04 B1 fix: 路由 :id 为用户主键,无 by-id 读端点(docs/05 REQ-USR-003), + // 不能按主键查列表端点;缺 presetUser(直接访问 URL / 刷新丢 state)时按 loadError 处理, + // 由页面给出「返回列表」恢复入口。 const { result } = renderHook( () => useUserDetail({ mode: 'edit', userId: 7 }), { wrapper }, ); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(mockedDetail).toHaveBeenCalled(); - expect(result.current.formValues.sUserName).toBe('zhangsan'); - expect(result.current.formValues.sUserType).toBe('超级管理员'); - expect(result.current.formValues.sLanguage).toBe('英文'); + expect(result.current.loadFailed).toBe(true); + expect(mockedDetail).not.toHaveBeenCalled(); }); it('edit mode with presetUser skips getUserDetail', async () => { -- libgit2 0.22.2