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,7 +13,6 @@ import {
13 MODE_EDIT, 13 MODE_EDIT,
14 MSG_CREATE_SUCCESS, 14 MSG_CREATE_SUCCESS,
15 MSG_EDIT_SUCCESS, 15 MSG_EDIT_SUCCESS,
16 - MSG_ERR_USER_NOT_FOUND,  
17 MSG_LOAD_DETAIL_FAIL, 16 MSG_LOAD_DETAIL_FAIL,
18 MSG_CANCEL_CONFIRM, 17 MSG_CANCEL_CONFIRM,
19 PATH_USER_LIST, 18 PATH_USER_LIST,
@@ -54,7 +53,6 @@ export default function UserDetailPage() { @@ -54,7 +53,6 @@ export default function UserDetailPage() {
54 loading, 53 loading,
55 submitting, 54 submitting,
56 loadFailed, 55 loadFailed,
57 - notFound,  
58 selectEmployee, 56 selectEmployee,
59 togglePermission, 57 togglePermission,
60 toggleAll, 58 toggleAll,
@@ -107,21 +105,10 @@ export default function UserDetailPage() { @@ -107,21 +105,10 @@ export default function UserDetailPage() {
107 selectEmployee(value); 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 if (loadFailed) { 112 if (loadFailed) {
126 return ( 113 return (
127 <div className={styles.page}> 114 <div className={styles.page}>
@@ -130,6 +117,11 @@ export default function UserDetailPage() { @@ -130,6 +117,11 @@ export default function UserDetailPage() {
130 <Button type="primary" onClick={() => reload()}> 117 <Button type="primary" onClick={() => reload()}>
131 {TEXT_RETRY} 118 {TEXT_RETRY}
132 </Button> 119 </Button>
  120 + {mode === MODE_EDIT && (
  121 + <Button onClick={() => navigate(PATH_USER_LIST)}>
  122 + {TEXT_BACK_TO_LIST}
  123 + </Button>
  124 + )}
133 </div> 125 </div>
134 </div> 126 </div>
135 ); 127 );
frontend/src/pages/usr/UserDetail/useUserDetail.ts
@@ -4,7 +4,6 @@ import { App as AntdApp } from &#39;antd&#39;; @@ -4,7 +4,6 @@ import { App as AntdApp } from &#39;antd&#39;;
4 import { 4 import {
5 createUser, 5 createUser,
6 updateUser, 6 updateUser,
7 - getUserDetail,  
8 listEmployees, 7 listEmployees,
9 listPermissions, 8 listPermissions,
10 } from '../../../api/usrApi'; 9 } from '../../../api/usrApi';
@@ -64,7 +63,6 @@ export interface UseUserDetailReturn { @@ -64,7 +63,6 @@ export interface UseUserDetailReturn {
64 submitting: boolean; 63 submitting: boolean;
65 error: ApiError | null; 64 error: ApiError | null;
66 loadFailed: boolean; 65 loadFailed: boolean;
67 - notFound: boolean;  
68 setField(name: keyof UserFormValues, value: unknown): void; 66 setField(name: keyof UserFormValues, value: unknown): void;
69 selectEmployee(value: number | null): void; 67 selectEmployee(value: number | null): void;
70 togglePermission(id: number, checked: boolean): void; 68 togglePermission(id: number, checked: boolean): void;
@@ -87,7 +85,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { @@ -87,7 +85,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
87 const [submitting, setSubmitting] = useState(false); 85 const [submitting, setSubmitting] = useState(false);
88 const [error, setError] = useState<ApiError | null>(null); 86 const [error, setError] = useState<ApiError | null>(null);
89 const [loadFailed, setLoadFailed] = useState(false); 87 const [loadFailed, setLoadFailed] = useState(false);
90 - const [notFound, setNotFound] = useState(false);  
91 88
92 const employeesRef = useRef<EmployeeOption[]>(employees); 89 const employeesRef = useRef<EmployeeOption[]>(employees);
93 employeesRef.current = employees; 90 employeesRef.current = employees;
@@ -110,7 +107,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { @@ -110,7 +107,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
110 const runLoad = useCallback(async () => { 107 const runLoad = useCallback(async () => {
111 setLoading(true); 108 setLoading(true);
112 setLoadFailed(false); 109 setLoadFailed(false);
113 - setNotFound(false);  
114 try { 110 try {
115 const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]); 111 const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]);
116 if (!mountedRef.current) return; 112 if (!mountedRef.current) return;
@@ -120,21 +116,18 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { @@ -120,21 +116,18 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
120 if (mode === 'edit') { 116 if (mode === 'edit') {
121 if (presetUser) { 117 if (presetUser) {
122 initFromVo(presetUser); 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 if (!mountedRef.current) return; 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 } else { 132 } else {
140 setFormValues({ ...CREATE_DEFAULTS }); 133 setFormValues({ ...CREATE_DEFAULTS });
@@ -259,7 +252,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { @@ -259,7 +252,6 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
259 submitting, 252 submitting,
260 error, 253 error,
261 loadFailed, 254 loadFailed,
262 - notFound,  
263 setField, 255 setField,
264 selectEmployee, 256 selectEmployee,
265 togglePermission, 257 togglePermission,
frontend/src/pages/usr/UserList/index.tsx
@@ -33,7 +33,12 @@ export default function UserListPage() { @@ -33,7 +33,12 @@ export default function UserListPage() {
33 const [selectedRowKey, setSelectedRowKey] = useState<number | null>(null); 33 const [selectedRowKey, setSelectedRowKey] = useState<number | null>(null);
34 34
35 const handleRowDoubleClick = (row: UserVO) => { 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 return ( 44 return (
frontend/tests/unit/UserDetailPage.test.tsx
@@ -69,7 +69,13 @@ function LocationProbe() { @@ -69,7 +69,13 @@ function LocationProbe() {
69 return <div data-testid="loc">{loc.pathname}</div>; 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 return renderShell( 79 return renderShell(
74 <> 80 <>
75 <LocationProbe /> 81 <LocationProbe />
@@ -80,7 +86,7 @@ function renderPage(entry: string) { @@ -80,7 +86,7 @@ function renderPage(entry: string) {
80 </Routes> 86 </Routes>
81 </>, 87 </>,
82 { 88 {
83 - initialEntries: [entry], 89 + initialEntries: [initialEntry],
84 preloadedAuth: { 90 preloadedAuth: {
85 token: 't', 91 token: 't',
86 user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, 92 user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' },
@@ -148,22 +154,20 @@ describe(&#39;UserDetailPage 集成&#39;, () =&gt; { @@ -148,22 +154,20 @@ describe(&#39;UserDetailPage 集成&#39;, () =&gt; {
148 expect(await screen.findByText('用户名已存在,请更换')).toBeInTheDocument(); 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 await waitFor(() => 160 await waitFor(() =>
156 expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), 161 expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
157 ); 162 );
  163 + expect(mockedDetail).not.toHaveBeenCalled();
158 expect(screen.getByTestId('field-username')).toBeDisabled(); 164 expect(screen.getByTestId('field-username')).toBeDisabled();
159 }); 165 });
160 166
161 it('edit submit success navigates to /usr/users with 保存成功', async () => { 167 it('edit submit success navigates to /usr/users with 保存成功', async () => {
162 const user = userEvent.setup(); 168 const user = userEvent.setup();
163 - mockedDetail.mockResolvedValue(makeVo());  
164 mockedUpdate.mockResolvedValue({ id: 7 }); 169 mockedUpdate.mockResolvedValue({ id: 7 });
165 - renderPage('/usr/users/7');  
166 - await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); 170 + renderPage('/usr/users/7', makeVo());
167 await waitFor(() => 171 await waitFor(() =>
168 expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), 172 expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
169 ); 173 );
@@ -188,9 +192,11 @@ describe(&#39;UserDetailPage 集成&#39;, () =&gt; { @@ -188,9 +192,11 @@ describe(&#39;UserDetailPage 集成&#39;, () =&gt; {
188 192
189 it('新增 navigates to /usr/users/new', async () => { 193 it('新增 navigates to /usr/users/new', async () => {
190 const user = userEvent.setup(); 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 await user.click(screen.getByTestId('btn-new')); 200 await user.click(screen.getByTestId('btn-new'));
195 await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new')); 201 await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new'));
196 }); 202 });
@@ -205,12 +211,16 @@ describe(&#39;UserDetailPage 集成&#39;, () =&gt; { @@ -205,12 +211,16 @@ describe(&#39;UserDetailPage 集成&#39;, () =&gt; {
205 await waitFor(() => expect(screen.queryByTestId('userdetail-loaderror')).toBeNull()); 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 renderPage('/usr/users/7'); 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 const user = userEvent.setup(); 222 const user = userEvent.setup();
213 - await user.click(screen.getByText('返回列表')); 223 + await user.click(within(loadError).getByText('返回列表'));
214 await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); 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,6 +4,10 @@ import type { ReactElement } from &#39;react&#39;;
4 import { render } from '@testing-library/react'; 4 import { render } from '@testing-library/react';
5 import { Provider } from 'react-redux'; 5 import { Provider } from 'react-redux';
6 import { MemoryRouter } from 'react-router-dom'; 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 import { App as AntdApp, ConfigProvider } from 'antd'; 11 import { App as AntdApp, ConfigProvider } from 'antd';
8 import zhCN from 'antd/locale/zh_CN'; 12 import zhCN from 'antd/locale/zh_CN';
9 import { configureStore } from '@reduxjs/toolkit'; 13 import { configureStore } from '@reduxjs/toolkit';
@@ -21,7 +25,9 @@ export function makeShellStore(preloadedAuth?: Partial&lt;AuthState&gt;) { @@ -21,7 +25,9 @@ export function makeShellStore(preloadedAuth?: Partial&lt;AuthState&gt;) {
21 } 25 }
22 26
23 export interface RenderShellOptions { 27 export interface RenderShellOptions {
24 - initialEntries?: string[]; 28 + // FE-04: 允许传 Partial<Location>(携带 navigate state),用于复刻 edit 单据经
  29 + // navigate state 透传 presetUser 的数据流,不仅限纯路径字符串。
  30 + initialEntries?: ShellInitialEntry[];
25 preloadedAuth?: Partial<AuthState>; 31 preloadedAuth?: Partial<AuthState>;
26 store?: ReturnType<typeof makeShellStore>; 32 store?: ReturnType<typeof makeShellStore>;
27 } 33 }
frontend/tests/unit/useUserDetail.test.tsx
@@ -118,17 +118,17 @@ describe(&#39;useUserDetail 状态机&#39;, () =&gt; { @@ -118,17 +118,17 @@ describe(&#39;useUserDetail 状态机&#39;, () =&gt; {
118 expect(mockedDetail).not.toHaveBeenCalled(); 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 const { result } = renderHook( 125 const { result } = renderHook(
124 () => useUserDetail({ mode: 'edit', userId: 7 }), 126 () => useUserDetail({ mode: 'edit', userId: 7 }),
125 { wrapper }, 127 { wrapper },
126 ); 128 );
127 await waitFor(() => expect(result.current.loading).toBe(false)); 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 it('edit mode with presetUser skips getUserDetail', async () => { 134 it('edit mode with presetUser skips getUserDetail', async () => {