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 () => {