Commit db8f9d9f96c07f340be5fa640c3725aba5a2f338

Authored by zichun
1 parent bdfcfae2

feat(usr): 用户单据页面集成与路由接线 UserDetailPage REQ-USR-001 REQ-USR-002

frontend/src/pages/usr/UserDetail/constants.ts
@@ -102,6 +102,7 @@ export const MSG_ERR_NETWORK = '保存失败,请稍后重试'; @@ -102,6 +102,7 @@ export const MSG_ERR_NETWORK = '保存失败,请稍后重试';
102 export const MSG_ERR_LOAD_EMPLOYEES = '员工列表加载失败'; 102 export const MSG_ERR_LOAD_EMPLOYEES = '员工列表加载失败';
103 export const MSG_ERR_LOAD_PERMISSIONS = '权限列表加载失败'; 103 export const MSG_ERR_LOAD_PERMISSIONS = '权限列表加载失败';
104 export const MSG_LOAD_DETAIL_FAIL = '加载失败,点击重试'; 104 export const MSG_LOAD_DETAIL_FAIL = '加载失败,点击重试';
  105 +export const TEXT_RETRY = '点击重试';
105 export const MSG_CANCEL_CONFIRM = '放弃未保存的修改?'; 106 export const MSG_CANCEL_CONFIRM = '放弃未保存的修改?';
106 export const MSG_FUNC_PLACEHOLDER = '功能开发中'; 107 export const MSG_FUNC_PLACEHOLDER = '功能开发中';
107 export const TEXT_BACK_TO_LIST = '返回列表'; 108 export const TEXT_BACK_TO_LIST = '返回列表';
frontend/src/pages/usr/UserDetail/index.tsx 0 → 100644
  1 +// REQ-USR-001 / REQ-USR-002: 用户单据页面容器(判 mode、装配 4 子组件、提交反馈与导航回流)
  2 +import { useEffect } from 'react';
  3 +import { Form, Spin, Button, Modal, App as AntdApp } from 'antd';
  4 +import { useNavigate, useParams, useLocation } from 'react-router-dom';
  5 +import type { UserVO } from '../../../api/types';
  6 +import UserDetailToolbar from './UserDetailToolbar';
  7 +import UserBasicForm from './UserBasicForm';
  8 +import PermissionTabs from './PermissionTabs';
  9 +import PermissionGroupList from './PermissionGroupList';
  10 +import { useUserDetail } from './useUserDetail';
  11 +import {
  12 + MODE_CREATE,
  13 + MODE_EDIT,
  14 + MSG_CREATE_SUCCESS,
  15 + MSG_EDIT_SUCCESS,
  16 + MSG_ERR_USER_NOT_FOUND,
  17 + MSG_LOAD_DETAIL_FAIL,
  18 + MSG_CANCEL_CONFIRM,
  19 + PATH_USER_LIST,
  20 + PATH_USER_NEW,
  21 + TEXT_BACK_TO_LIST,
  22 + TEXT_RETRY,
  23 + type UserFormValues,
  24 +} from './constants';
  25 +import styles from './UserDetail.module.css';
  26 +
  27 +/** Checkbox 受控值为 boolean,提交映射需 0/1(BR8) */
  28 +function normalizeFormValues(raw: UserFormValues): UserFormValues {
  29 + return {
  30 + ...raw,
  31 + iCanModifyBill: (raw.iCanModifyBill ? 1 : 0) as 0 | 1,
  32 + };
  33 +}
  34 +
  35 +export default function UserDetailPage() {
  36 + const navigate = useNavigate();
  37 + const params = useParams<{ id?: string }>();
  38 + const location = useLocation();
  39 + const { message } = AntdApp.useApp();
  40 + const [form] = Form.useForm<UserFormValues>();
  41 +
  42 + const mode = params.id ? MODE_EDIT : MODE_CREATE;
  43 + const userId = params.id ? Number(params.id) : undefined;
  44 + const presetUser = (location.state as { user?: UserVO } | null)?.user ?? null;
  45 +
  46 + const detail = useUserDetail({ mode, userId, presetUser });
  47 + const {
  48 + formValues,
  49 + employees,
  50 + permissions,
  51 + checkedPermissionIds,
  52 + readonlyCreator,
  53 + readonlyCreateTime,
  54 + loading,
  55 + submitting,
  56 + loadFailed,
  57 + notFound,
  58 + selectEmployee,
  59 + togglePermission,
  60 + toggleAll,
  61 + submit,
  62 + reload,
  63 + } = detail;
  64 +
  65 + // hook 持有的受控值回写到 AntD Form(create 默认 / edit 回填,BR1/BR2/BR6/BR17)
  66 + useEffect(() => {
  67 + form.setFieldsValue({
  68 + ...formValues,
  69 + iCanModifyBill: (formValues.iCanModifyBill ? 1 : 0) as 0 | 1,
  70 + });
  71 + // eslint-disable-next-line react-hooks/exhaustive-deps
  72 + }, [formValues]);
  73 +
  74 + const handleSave = async () => {
  75 + try {
  76 + const values = await form.validateFields();
  77 + const ret = await submit(normalizeFormValues({ ...formValues, ...values }));
  78 + if (ret.ok) {
  79 + message.success(mode === MODE_CREATE ? MSG_CREATE_SUCCESS : MSG_EDIT_SUCCESS);
  80 + navigate(PATH_USER_LIST);
  81 + } else if (ret.fieldError) {
  82 + form.setFields([
  83 + { name: ret.fieldError.field, errors: [ret.fieldError.message] },
  84 + ]);
  85 + }
  86 + } catch {
  87 + // validateFields 失败:就近字段已展示错误,不发请求(BR12)
  88 + }
  89 + };
  90 +
  91 + const handleCancel = () => {
  92 + if (form.isFieldsTouched()) {
  93 + Modal.confirm({
  94 + title: MSG_CANCEL_CONFIRM,
  95 + onOk: () => navigate(PATH_USER_LIST),
  96 + });
  97 + } else {
  98 + navigate(PATH_USER_LIST);
  99 + }
  100 + };
  101 +
  102 + const handleNew = () => {
  103 + navigate(PATH_USER_NEW);
  104 + };
  105 +
  106 + const handleSelectEmployee = (value: number | null) => {
  107 + selectEmployee(value);
  108 + };
  109 +
  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)
  125 + if (loadFailed) {
  126 + return (
  127 + <div className={styles.page}>
  128 + <div className={styles.loadError} data-testid="userdetail-loaderror">
  129 + <span className={styles.loadErrorText}>{MSG_LOAD_DETAIL_FAIL}</span>
  130 + <Button type="primary" onClick={() => reload()}>
  131 + {TEXT_RETRY}
  132 + </Button>
  133 + </div>
  134 + </div>
  135 + );
  136 + }
  137 +
  138 + return (
  139 + <div className={styles.page} data-testid="userdetail-page">
  140 + <UserDetailToolbar
  141 + mode={mode}
  142 + submitting={submitting}
  143 + canSave={!loading}
  144 + onSave={() => void handleSave()}
  145 + onCancel={handleCancel}
  146 + onNew={handleNew}
  147 + />
  148 + <Spin spinning={loading}>
  149 + <Form form={form} layout="vertical" component={false}>
  150 + <UserBasicForm
  151 + form={form}
  152 + mode={mode}
  153 + employees={employees}
  154 + readonlyCreator={readonlyCreator}
  155 + readonlyCreateTime={readonlyCreateTime}
  156 + onSelectEmployee={handleSelectEmployee}
  157 + />
  158 + </Form>
  159 + <PermissionTabs>
  160 + <PermissionGroupList
  161 + permissions={permissions}
  162 + checkedIds={checkedPermissionIds}
  163 + onToggle={togglePermission}
  164 + onToggleAll={toggleAll}
  165 + />
  166 + </PermissionTabs>
  167 + </Spin>
  168 + </div>
  169 + );
  170 +}
frontend/src/pages/usr/UserDetail/useUserDetail.ts
@@ -64,6 +64,7 @@ export interface UseUserDetailReturn { @@ -64,6 +64,7 @@ export interface UseUserDetailReturn {
64 submitting: boolean; 64 submitting: boolean;
65 error: ApiError | null; 65 error: ApiError | null;
66 loadFailed: boolean; 66 loadFailed: boolean;
  67 + notFound: boolean;
67 setField(name: keyof UserFormValues, value: unknown): void; 68 setField(name: keyof UserFormValues, value: unknown): void;
68 selectEmployee(value: number | null): void; 69 selectEmployee(value: number | null): void;
69 togglePermission(id: number, checked: boolean): void; 70 togglePermission(id: number, checked: boolean): void;
@@ -86,6 +87,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { @@ -86,6 +87,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
86 const [submitting, setSubmitting] = useState(false); 87 const [submitting, setSubmitting] = useState(false);
87 const [error, setError] = useState<ApiError | null>(null); 88 const [error, setError] = useState<ApiError | null>(null);
88 const [loadFailed, setLoadFailed] = useState(false); 89 const [loadFailed, setLoadFailed] = useState(false);
  90 + const [notFound, setNotFound] = useState(false);
89 91
90 const employeesRef = useRef<EmployeeOption[]>(employees); 92 const employeesRef = useRef<EmployeeOption[]>(employees);
91 employeesRef.current = employees; 93 employeesRef.current = employees;
@@ -108,6 +110,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { @@ -108,6 +110,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
108 const runLoad = useCallback(async () => { 110 const runLoad = useCallback(async () => {
109 setLoading(true); 111 setLoading(true);
110 setLoadFailed(false); 112 setLoadFailed(false);
  113 + setNotFound(false);
111 try { 114 try {
112 const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]); 115 const [emps, perms] = await Promise.all([listEmployees(), listPermissions()]);
113 if (!mountedRef.current) return; 116 if (!mountedRef.current) return;
@@ -126,8 +129,8 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { @@ -126,8 +129,8 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
126 if (vo) { 129 if (vo) {
127 initFromVo(vo); 130 initFromVo(vo);
128 } else { 131 } else {
129 - // 详情不存在:交由页面按 40401 路径处理,标记 loadFailed  
130 - setLoadFailed(true); 132 + // 详情不存在:交由页面按 40401 路径处理(返回列表入口)
  133 + setNotFound(true);
131 messageRef.current.error(MSG_ERR_USER_NOT_FOUND); 134 messageRef.current.error(MSG_ERR_USER_NOT_FOUND);
132 setLoading(false); 135 setLoading(false);
133 return; 136 return;
@@ -256,6 +259,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { @@ -256,6 +259,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn {
256 submitting, 259 submitting,
257 error, 260 error,
258 loadFailed, 261 loadFailed,
  262 + notFound,
259 setField, 263 setField,
260 selectEmployee, 264 selectEmployee,
261 togglePermission, 265 togglePermission,
frontend/src/router/index.tsx
1 // REQ-USR-003 / REQ-USR-004: 路由表(FE 共享骨架)。 1 // REQ-USR-003 / REQ-USR-004: 路由表(FE 共享骨架)。
2 // FE-02 将 '/' 占位替换为应用外壳 + 受保护嵌套路由 + 错误边界。 2 // FE-02 将 '/' 占位替换为应用外壳 + 受保护嵌套路由 + 错误边界。
3 -// 子路由目标内容(FE-03 用户列表 / FE-04 用户单据)由后续 FE 落地,本处仅留可挂载占位 3 +// FE-04 将 /usr/users/new 与 /usr/users/:id 占位替换为真实「用户信息单据」页 UserDetailPage
4 import { Routes, Route, Navigate } from 'react-router-dom'; 4 import { Routes, Route, Navigate } from 'react-router-dom';
5 import LoginPage from '../pages/usr/Login/LoginPage'; 5 import LoginPage from '../pages/usr/Login/LoginPage';
6 import RequireAuth from './RequireAuth'; 6 import RequireAuth from './RequireAuth';
@@ -9,11 +9,7 @@ import AppErrorBoundary from &#39;./AppErrorBoundary&#39;; @@ -9,11 +9,7 @@ import AppErrorBoundary from &#39;./AppErrorBoundary&#39;;
9 import AppLayout from '../layouts/AppLayout/AppLayout'; 9 import AppLayout from '../layouts/AppLayout/AppLayout';
10 import HomePage from '../pages/home/HomePage/HomePage'; 10 import HomePage from '../pages/home/HomePage/HomePage';
11 import UserListPage from '../pages/usr/UserList'; 11 import UserListPage from '../pages/usr/UserList';
12 -  
13 -// FE-04 用户单据容器占位(新增 / 修改)  
14 -function UserDetailPlaceholder() {  
15 - return <div data-testid="fe04-userdetail-placeholder" />;  
16 -} 12 +import UserDetailPage from '../pages/usr/UserDetail';
17 13
18 export default function AppRouter() { 14 export default function AppRouter() {
19 return ( 15 return (
@@ -39,8 +35,8 @@ export default function AppRouter() { @@ -39,8 +35,8 @@ export default function AppRouter() {
39 > 35 >
40 <Route index element={<HomePage />} /> 36 <Route index element={<HomePage />} />
41 <Route path="/usr/users" element={<UserListPage />} /> 37 <Route path="/usr/users" element={<UserListPage />} />
42 - <Route path="/usr/users/new" element={<UserDetailPlaceholder />} />  
43 - <Route path="/usr/users/:id" element={<UserDetailPlaceholder />} /> 38 + <Route path="/usr/users/new" element={<UserDetailPage />} />
  39 + <Route path="/usr/users/:id" element={<UserDetailPage />} />
44 {/* 受保护区内未匹配 → 回主页(D7) */} 40 {/* 受保护区内未匹配 → 回主页(D7) */}
45 <Route path="*" element={<Navigate to="/" replace />} /> 41 <Route path="*" element={<Navigate to="/" replace />} />
46 </Route> 42 </Route>
frontend/tests/unit/UserDetailPage.test.tsx 0 → 100644
  1 +// REQ-USR-001 / REQ-USR-002: UserDetailPage 页面集成 + 路由接线
  2 +// create/edit 贯通 + 提交回流 + 错误就近 + 取数失败(BR3/BR12/BR16/BR17/D4/D5)
  3 +import { describe, it, expect, vi, beforeEach } from 'vitest';
  4 +import { screen, waitFor, within } from '@testing-library/react';
  5 +import userEvent from '@testing-library/user-event';
  6 +import { Routes, Route, useLocation } from 'react-router-dom';
  7 +import { renderShell } from './renderShell';
  8 +
  9 +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
  10 +vi.mock('antd', async () => {
  11 + const actual = await vi.importActual<typeof import('antd')>('antd');
  12 + return {
  13 + ...actual,
  14 + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }),
  15 + };
  16 +});
  17 +
  18 +vi.mock('../../src/api/usrApi', () => ({
  19 + createUser: vi.fn(),
  20 + updateUser: vi.fn(),
  21 + getUserDetail: vi.fn(),
  22 + listEmployees: vi.fn(),
  23 + listPermissions: vi.fn(),
  24 +}));
  25 +
  26 +import {
  27 + createUser,
  28 + updateUser,
  29 + getUserDetail,
  30 + listEmployees,
  31 + listPermissions,
  32 +} from '../../src/api/usrApi';
  33 +import UserDetailPage from '../../src/pages/usr/UserDetail';
  34 +import { ApiError } from '../../src/api/request';
  35 +import { ERR_USERNAME_EXISTS } from '../../src/pages/usr/UserDetail/constants';
  36 +import type { UserVO, EmployeeOption, PermissionItem } from '../../src/api/types';
  37 +
  38 +const mockedCreate = createUser as unknown as ReturnType<typeof vi.fn>;
  39 +const mockedUpdate = updateUser as unknown as ReturnType<typeof vi.fn>;
  40 +const mockedDetail = getUserDetail as unknown as ReturnType<typeof vi.fn>;
  41 +const mockedEmployees = listEmployees as unknown as ReturnType<typeof vi.fn>;
  42 +const mockedPermissions = listPermissions as unknown as ReturnType<typeof vi.fn>;
  43 +
  44 +const EMPLOYEES: EmployeeOption[] = [{ value: 3, label: '张三', sEmployeeNo: 'zs' }];
  45 +const PERMISSIONS: PermissionItem[] = [
  46 + { id: 1, name: '默认显示', category: '基础' },
  47 + { id: 2, name: '高级查看', category: '基础' },
  48 +];
  49 +
  50 +function makeVo(over: Partial<UserVO> = {}): UserVO {
  51 + return {
  52 + id: 7,
  53 + sUserName: 'zhangsan',
  54 + employeeName: '张三',
  55 + sUserNo: 'zs',
  56 + departmentName: null,
  57 + sUserType: '超级管理员',
  58 + sLanguage: '英文',
  59 + iIsVoid: 0,
  60 + tLastLoginDate: null,
  61 + sCreator: 'admin',
  62 + tCreateDate: '2026-01-01T00:00:00',
  63 + ...over,
  64 + };
  65 +}
  66 +
  67 +function LocationProbe() {
  68 + const loc = useLocation();
  69 + return <div data-testid="loc">{loc.pathname}</div>;
  70 +}
  71 +
  72 +function renderPage(entry: string) {
  73 + return renderShell(
  74 + <>
  75 + <LocationProbe />
  76 + <Routes>
  77 + <Route path="/usr/users" element={<div data-testid="list-sentinel">list</div>} />
  78 + <Route path="/usr/users/new" element={<UserDetailPage />} />
  79 + <Route path="/usr/users/:id" element={<UserDetailPage />} />
  80 + </Routes>
  81 + </>,
  82 + {
  83 + initialEntries: [entry],
  84 + preloadedAuth: {
  85 + token: 't',
  86 + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' },
  87 + },
  88 + },
  89 + );
  90 +}
  91 +
  92 +async function fillValidCreateForm(user: ReturnType<typeof userEvent.setup>) {
  93 + await user.type(screen.getByTestId('field-username'), 'zhangsan');
  94 + await user.type(screen.getByTestId('field-userno'), 'zs');
  95 + // 语言必填
  96 + await user.click(screen.getByTestId('select-language').querySelector('.ant-select-selector')!);
  97 + await user.click(await screen.findByText('中文'));
  98 +}
  99 +
  100 +describe('UserDetailPage 集成', () => {
  101 + beforeEach(() => {
  102 + vi.clearAllMocks();
  103 + mockedEmployees.mockResolvedValue(EMPLOYEES);
  104 + mockedPermissions.mockResolvedValue(PERMISSIONS);
  105 + });
  106 +
  107 + it('create mode renders empty form with defaults', async () => {
  108 + renderPage('/usr/users/new');
  109 + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
  110 + expect(await screen.findByText('保存后自动生成')).toBeInTheDocument();
  111 + expect(within(screen.getByTestId('select-usertype')).getByText('普通用户')).toBeInTheDocument();
  112 + });
  113 +
  114 + it('create submit success navigates to /usr/users with success', async () => {
  115 + const user = userEvent.setup();
  116 + mockedCreate.mockResolvedValue({ id: 9 });
  117 + renderPage('/usr/users/new');
  118 + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
  119 + await fillValidCreateForm(user);
  120 + await user.click(screen.getByTestId('perm-check-1'));
  121 + await user.click(screen.getByTestId('btn-save'));
  122 + await waitFor(() => expect(mockedCreate).toHaveBeenCalled());
  123 + const body = mockedCreate.mock.calls[0][0];
  124 + expect(body.sUserName).toBe('zhangsan');
  125 + expect(body.permissionIds).toContain(1);
  126 + expect(messageSpy.success).toHaveBeenCalledWith('用户创建成功');
  127 + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users'));
  128 + });
  129 +
  130 + it('create username format invalid blocks submit', async () => {
  131 + const user = userEvent.setup();
  132 + renderPage('/usr/users/new');
  133 + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
  134 + await user.type(screen.getByTestId('field-username'), 'ab');
  135 + await user.click(screen.getByTestId('btn-save'));
  136 + expect(await screen.findByText('用户名须为 3-20 位字母数字下划线')).toBeInTheDocument();
  137 + expect(mockedCreate).not.toHaveBeenCalled();
  138 + });
  139 +
  140 + it('create 40901 highlights username field', async () => {
  141 + const user = userEvent.setup();
  142 + mockedCreate.mockRejectedValue(new ApiError(ERR_USERNAME_EXISTS, 'dup'));
  143 + renderPage('/usr/users/new');
  144 + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
  145 + await fillValidCreateForm(user);
  146 + await user.click(screen.getByTestId('btn-save'));
  147 + await waitFor(() => expect(mockedCreate).toHaveBeenCalled());
  148 + expect(await screen.findByText('用户名已存在,请更换')).toBeInTheDocument();
  149 + });
  150 +
  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());
  155 + await waitFor(() =>
  156 + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
  157 + );
  158 + expect(screen.getByTestId('field-username')).toBeDisabled();
  159 + });
  160 +
  161 + it('edit submit success navigates to /usr/users with 保存成功', async () => {
  162 + const user = userEvent.setup();
  163 + mockedDetail.mockResolvedValue(makeVo());
  164 + mockedUpdate.mockResolvedValue({ id: 7 });
  165 + renderPage('/usr/users/7');
  166 + await waitFor(() => expect(mockedDetail).toHaveBeenCalled());
  167 + await waitFor(() =>
  168 + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'),
  169 + );
  170 + await user.click(screen.getByTestId('btn-save'));
  171 + await waitFor(() => expect(mockedUpdate).toHaveBeenCalled());
  172 + expect(mockedUpdate.mock.calls[0][0]).toBe(7);
  173 + expect(messageSpy.success).toHaveBeenCalledWith('保存成功');
  174 + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users'));
  175 + });
  176 +
  177 + it('cancel with dirty form confirms then navigates', async () => {
  178 + const user = userEvent.setup();
  179 + renderPage('/usr/users/new');
  180 + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled());
  181 + await user.type(screen.getByTestId('field-username'), 'dirtyuser');
  182 + await user.click(screen.getByTestId('btn-cancel'));
  183 + // AntD Modal.confirm 弹确认
  184 + expect((await screen.findAllByText('放弃未保存的修改?')).length).toBeGreaterThan(0);
  185 + await user.click(screen.getByRole('button', { name: /确\s*定|OK/ }));
  186 + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users'));
  187 + });
  188 +
  189 + it('新增 navigates to /usr/users/new', async () => {
  190 + const user = userEvent.setup();
  191 + mockedDetail.mockResolvedValue(makeVo());
  192 + renderPage('/usr/users/7');
  193 + await waitFor(() => expect(mockedDetail).toHaveBeenCalled());
  194 + await user.click(screen.getByTestId('btn-new'));
  195 + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new'));
  196 + });
  197 +
  198 + it('loadError shows retry; retry calls reload', async () => {
  199 + mockedPermissions.mockRejectedValueOnce(new ApiError(-1, 'net'));
  200 + renderPage('/usr/users/new');
  201 + expect(await screen.findByTestId('userdetail-loaderror')).toBeInTheDocument();
  202 + mockedPermissions.mockResolvedValue(PERMISSIONS);
  203 + const user = userEvent.setup();
  204 + await user.click(within(screen.getByTestId('userdetail-loaderror')).getByText('点击重试'));
  205 + await waitFor(() => expect(screen.queryByTestId('userdetail-loaderror')).toBeNull());
  206 + });
  207 +
  208 + it('edit 40401 offers 返回列表', async () => {
  209 + mockedDetail.mockResolvedValue(null);
  210 + renderPage('/usr/users/7');
  211 + expect(await screen.findByText('该用户不存在或已被删除')).toBeInTheDocument();
  212 + const user = userEvent.setup();
  213 + await user.click(screen.getByText('返回列表'));
  214 + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users'));
  215 + });
  216 +});