Commit 96e88d38256f5ccbf6a77dc15c37c8736830a2fb

Authored by zichun
1 parent b9c7da30

fix(usr): 修复 review must-fix FE: FE-04 编辑预填走 navigate state 并补 loadError 返回列表入口

frontend/src/pages/usr/UserDetail/index.tsx
... ... @@ -13,7 +13,6 @@ import {
13 13 MODE_EDIT,
14 14 MSG_CREATE_SUCCESS,
15 15 MSG_EDIT_SUCCESS,
16   - MSG_ERR_USER_NOT_FOUND,
17 16 MSG_LOAD_DETAIL_FAIL,
18 17 MSG_CANCEL_CONFIRM,
19 18 PATH_USER_LIST,
... ... @@ -54,7 +53,6 @@ export default function UserDetailPage() {
54 53 loading,
55 54 submitting,
56 55 loadFailed,
57   - notFound,
58 56 selectEmployee,
59 57 togglePermission,
60 58 toggleAll,
... ... @@ -107,21 +105,10 @@ export default function UserDetailPage() {
107 105 selectEmployee(value);
108 106 };
109 107  
110   - // edit 详情不存在(40401):返回列表入口(spec § 4)
111   - if (notFound) {
112   - return (
113   - <div className={styles.page}>
114   - <div className={styles.loadError} data-testid="userdetail-notfound">
115   - <span className={styles.loadErrorText}>{MSG_ERR_USER_NOT_FOUND}</span>
116   - <Button type="primary" onClick={() => navigate(PATH_USER_LIST)}>
117   - {TEXT_BACK_TO_LIST}
118   - </Button>
119   - </div>
120   - </div>
121   - );
122   - }
123   -
124   - // 预取/详情取数失败:重试入口(spec § 4 loadError)
  108 + // 预取/详情取数失败:整页重试入口(spec § 4 loadError)。
  109 + // edit 态额外给「返回列表」——edit 预填只能来自列表行经 navigate state 透传的
  110 + // presetUser(无 by-id 读端点,详见 useUserDetail),缺 state 时重试仍会回到 loadError,
  111 + // 需经列表双击重新携带 state 进入,故提供返回列表入口(spec § 4「edit 详情失败给整页重试或返回列表」)。
125 112 if (loadFailed) {
126 113 return (
127 114 <div className={styles.page}>
... ... @@ -130,6 +117,11 @@ export default function UserDetailPage() {
130 117 <Button type="primary" onClick={() => reload()}>
131 118 {TEXT_RETRY}
132 119 </Button>
  120 + {mode === MODE_EDIT && (
  121 + <Button onClick={() => navigate(PATH_USER_LIST)}>
  122 + {TEXT_BACK_TO_LIST}
  123 + </Button>
  124 + )}
133 125 </div>
134 126 </div>
135 127 );
... ...
frontend/src/pages/usr/UserDetail/useUserDetail.ts
... ... @@ -4,7 +4,6 @@ import { App as AntdApp } from &#39;antd&#39;;
4 4 import {
5 5 createUser,
6 6 updateUser,
7   - getUserDetail,
8 7 listEmployees,
9 8 listPermissions,
10 9 } from '../../../api/usrApi';
... ... @@ -64,7 +63,6 @@ export interface UseUserDetailReturn {
64 63 submitting: boolean;
65 64 error: ApiError | null;
66 65 loadFailed: boolean;
67   - notFound: boolean;
68 66 setField(name: keyof UserFormValues, value: unknown): void;
69 67 selectEmployee(value: number | null): void;
70 68 togglePermission(id: number, checked: boolean): void;
... ... @@ -87,7 +85,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
87 85 const [submitting, setSubmitting] = useState(false);
88 86 const [error, setError] = useState<ApiError | null>(null);
89 87 const [loadFailed, setLoadFailed] = useState(false);
90   - const [notFound, setNotFound] = useState(false);
91 88  
92 89 const employeesRef = useRef<EmployeeOption[]>(employees);
93 90 employeesRef.current = employees;
... ... @@ -110,7 +107,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
110 107 const runLoad = useCallback(async () => {
111 108 setLoading(true);
112 109 setLoadFailed(false);
113   - setNotFound(false);
114 110 try {
115 111 const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]);
116 112 if (!mountedRef.current) return;
... ... @@ -120,21 +116,18 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
120 116 if (mode === 'edit') {
121 117 if (presetUser) {
122 118 initFromVo(presetUser);
123   - } else if (userId != null) {
124   - const vo = await getUserDetail({
125   - queryField: '用户号',
126   - queryValue: String(userId),
127   - });
  119 + } else {
  120 + // FE-04: edit 预填只能来自 FE-03 经 navigate state 透传的列表行(presetUser)。
  121 + // 路由 :id 是用户主键,而唯一读端点 GET /api/usr/users 的 queryField 无主键选项
  122 + // (docs/05 REQ-USR-003),故不能按主键去查「用户号」列——在真实后端必然返回空、
  123 + // 误报 40401,无法满足编辑预填(BR17 / REQ-USR-002 验收)。
  124 + // 缺 presetUser(如直接访问 URL / 刷新丢失 state)时按 loadError 处理,
  125 + // 由页面给出「返回列表」重试入口,重新经列表双击携带 state 进入。
128 126 if (!mountedRef.current) return;
129   - if (vo) {
130   - initFromVo(vo);
131   - } else {
132   - // 详情不存在:交由页面按 40401 路径处理(返回列表入口)
133   - setNotFound(true);
134   - messageRef.current.error(MSG_ERR_USER_NOT_FOUND);
135   - setLoading(false);
136   - return;
137   - }
  127 + setLoading(false);
  128 + setLoadFailed(true);
  129 + messageRef.current.error(MSG_LOAD_DETAIL_FAIL);
  130 + return;
138 131 }
139 132 } else {
140 133 setFormValues({ ...CREATE_DEFAULTS });
... ... @@ -259,7 +252,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
259 252 submitting,
260 253 error,
261 254 loadFailed,
262   - notFound,
263 255 setField,
264 256 selectEmployee,
265 257 togglePermission,
... ...
frontend/src/pages/usr/UserList/index.tsx
... ... @@ -33,7 +33,12 @@ export default function UserListPage() {
33 33 const [selectedRowKey, setSelectedRowKey] = useState<number | null>(null);
34 34  
35 35 const handleRowDoubleClick = (row: UserVO) => {
36   - navigate('/usr/users/' + row.id); // BR12
  36 + // BR12: 进入编辑单据。列表行 UserVO 已含单据预填所需全部字段,
  37 + // 经 navigate state 透传给详情页走 presetUser 分支;
  38 + // 不能仅靠 :id(路由 :id 为用户主键,而读端点 GET /api/usr/users 的 queryField
  39 + // 无主键选项,按主键去查「用户号」列在真实后端必然返回空 → 误报 40401),
  40 + // 详见 FE-04 / docs/05 REQ-USR-002。
  41 + navigate('/usr/users/' + row.id, { state: { user: row } });
37 42 };
38 43  
39 44 return (
... ...
frontend/tests/unit/UserDetailPage.test.tsx
... ... @@ -69,7 +69,13 @@ function LocationProbe() {
69 69 return <div data-testid="loc">{loc.pathname}</div>;
70 70 }
71 71  
72   -function renderPage(entry: string) {
  72 +// FE-04: edit 单据预填来自 FE-03 经 navigate state 透传的列表行(presetUser)。
  73 +// 测试入口可携带 state.user 复刻该数据流(路由 :id 仅为主键,无 by-id 读端点,
  74 +// 不能按主键查「用户号」列——详见 useUserDetail / docs/05 REQ-USR-002/003)。
  75 +function renderPage(entry: string, presetUser?: UserVO) {
  76 + const initialEntry = presetUser
  77 + ? { pathname: entry, state: { user: presetUser } }
  78 + : entry;
73 79 return renderShell(
74 80 <>
75 81 <LocationProbe />
... ... @@ -80,7 +86,7 @@ function renderPage(entry: string) {
80 86 </Routes>
81 87 </>,
82 88 {
83   - initialEntries: [entry],
  89 + initialEntries: [initialEntry],
84 90 preloadedAuth: {
85 91 token: 't',
86 92 user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' },
... ... @@ -148,22 +154,20 @@ describe(&#39;UserDetailPage 集成&#39;, () =&gt; {
148 154 expect(await screen.findByText('用户名已存在,请更换')).toBeInTheDocument();
149 155 });
150 156  
151   - it('edit mode prefills from getUserDetail and username disabled', async () => {
152   - mockedDetail.mockResolvedValue(makeVo());
153   - renderPage('/usr/users/7');
154   - await waitFor(() => expect(mockedDetail).toHaveBeenCalled());
  157 + it('edit mode prefills from navigate state (presetUser) and username disabled', async () => {
  158 + // FE-04: edit 预填走 FE-03 经 navigate state 透传的列表行,不再按主键查列表端点
  159 + renderPage('/usr/users/7', makeVo());
155 160 await waitFor(() =>
156 161 expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
157 162 );
  163 + expect(mockedDetail).not.toHaveBeenCalled();
158 164 expect(screen.getByTestId('field-username')).toBeDisabled();
159 165 });
160 166  
161 167 it('edit submit success navigates to /usr/users with 保存成功', async () => {
162 168 const user = userEvent.setup();
163   - mockedDetail.mockResolvedValue(makeVo());
164 169 mockedUpdate.mockResolvedValue({ id: 7 });
165   - renderPage('/usr/users/7');
166   - await waitFor(() => expect(mockedDetail).toHaveBeenCalled());
  170 + renderPage('/usr/users/7', makeVo());
167 171 await waitFor(() =>
168 172 expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
169 173 );
... ... @@ -188,9 +192,11 @@ describe(&#39;UserDetailPage 集成&#39;, () =&gt; {
188 192  
189 193 it('新增 navigates to /usr/users/new', async () => {
190 194 const user = userEvent.setup();
191   - mockedDetail.mockResolvedValue(makeVo());
192   - renderPage('/usr/users/7');
193   - await waitFor(() => expect(mockedDetail).toHaveBeenCalled());
  195 + // edit 经 navigate state 预填后,工具栏「新增」跳 /usr/users/new(BR14)
  196 + renderPage('/usr/users/7', makeVo());
  197 + await waitFor(() =>
  198 + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
  199 + );
194 200 await user.click(screen.getByTestId('btn-new'));
195 201 await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new'));
196 202 });
... ... @@ -205,12 +211,16 @@ describe(&#39;UserDetailPage 集成&#39;, () =&gt; {
205 211 await waitFor(() => expect(screen.queryByTestId('userdetail-loaderror')).toBeNull());
206 212 });
207 213  
208   - it('edit 40401 offers 返回列表', async () => {
209   - mockedDetail.mockResolvedValue(null);
  214 + it('edit without navigate state shows loadError offering 点击重试 + 返回列表', async () => {
  215 + // FE-04 B1 fix: edit 缺 presetUser(直接访问 URL / 刷新丢 state)→ loadError,
  216 + // 整页给「点击重试」与「返回列表」两个入口(spec § 4 loadError)。
210 217 renderPage('/usr/users/7');
211   - expect(await screen.findByText('该用户不存在或已被删除')).toBeInTheDocument();
  218 + const loadError = await screen.findByTestId('userdetail-loaderror');
  219 + expect(within(loadError).getByText('点击重试')).toBeInTheDocument();
  220 + expect(within(loadError).getByText('返回列表')).toBeInTheDocument();
  221 + expect(mockedDetail).not.toHaveBeenCalled();
212 222 const user = userEvent.setup();
213   - await user.click(screen.getByText('返回列表'));
  223 + await user.click(within(loadError).getByText('返回列表'));
214 224 await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users'));
215 225 });
216 226 });
... ...
frontend/tests/unit/renderShell.tsx
... ... @@ -4,6 +4,10 @@ import type { ReactElement } from &#39;react&#39;;
4 4 import { render } from '@testing-library/react';
5 5 import { Provider } from 'react-redux';
6 6 import { MemoryRouter } from 'react-router-dom';
  7 +
  8 +// react-router-dom 未公开 re-export InitialEntry,这里按其 MemoryRouter 接受的形态本地声明:
  9 +// 纯路径字符串,或携带 navigate state 的 Partial<Location>(FE-04 edit 预填数据流需要)。
  10 +type ShellInitialEntry = string | { pathname: string; search?: string; hash?: string; state?: unknown };
7 11 import { App as AntdApp, ConfigProvider } from 'antd';
8 12 import zhCN from 'antd/locale/zh_CN';
9 13 import { configureStore } from '@reduxjs/toolkit';
... ... @@ -21,7 +25,9 @@ export function makeShellStore(preloadedAuth?: Partial&lt;AuthState&gt;) {
21 25 }
22 26  
23 27 export interface RenderShellOptions {
24   - initialEntries?: string[];
  28 + // FE-04: 允许传 Partial<Location>(携带 navigate state),用于复刻 edit 单据经
  29 + // navigate state 透传 presetUser 的数据流,不仅限纯路径字符串。
  30 + initialEntries?: ShellInitialEntry[];
25 31 preloadedAuth?: Partial<AuthState>;
26 32 store?: ReturnType<typeof makeShellStore>;
27 33 }
... ...
frontend/tests/unit/useUserDetail.test.tsx
... ... @@ -118,17 +118,17 @@ describe(&#39;useUserDetail 状态机&#39;, () =&gt; {
118 118 expect(mockedDetail).not.toHaveBeenCalled();
119 119 });
120 120  
121   - it('edit mode prefills from getUserDetail and pre-checks permissions', async () => {
122   - mockedDetail.mockResolvedValue(makeVo());
  121 + it('edit mode without presetUser sets loadFailed without calling getUserDetail', async () => {
  122 + // FE-04 B1 fix: 路由 :id 为用户主键,无 by-id 读端点(docs/05 REQ-USR-003),
  123 + // 不能按主键查列表端点;缺 presetUser(直接访问 URL / 刷新丢 state)时按 loadError 处理,
  124 + // 由页面给出「返回列表」恢复入口。
123 125 const { result } = renderHook(
124 126 () => useUserDetail({ mode: 'edit', userId: 7 }),
125 127 { wrapper },
126 128 );
127 129 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('英文');
  130 + expect(result.current.loadFailed).toBe(true);
  131 + expect(mockedDetail).not.toHaveBeenCalled();
132 132 });
133 133  
134 134 it('edit mode with presetUser skips getUserDetail', async () => {
... ...