diff --git a/frontend/src/pages/usr/UserDetail/constants.ts b/frontend/src/pages/usr/UserDetail/constants.ts index 26fb820..7c15105 100644 --- a/frontend/src/pages/usr/UserDetail/constants.ts +++ b/frontend/src/pages/usr/UserDetail/constants.ts @@ -102,6 +102,7 @@ export const MSG_ERR_NETWORK = '保存失败,请稍后重试'; export const MSG_ERR_LOAD_EMPLOYEES = '员工列表加载失败'; export const MSG_ERR_LOAD_PERMISSIONS = '权限列表加载失败'; export const MSG_LOAD_DETAIL_FAIL = '加载失败,点击重试'; +export const TEXT_RETRY = '点击重试'; export const MSG_CANCEL_CONFIRM = '放弃未保存的修改?'; export const MSG_FUNC_PLACEHOLDER = '功能开发中'; export const TEXT_BACK_TO_LIST = '返回列表'; diff --git a/frontend/src/pages/usr/UserDetail/index.tsx b/frontend/src/pages/usr/UserDetail/index.tsx new file mode 100644 index 0000000..15245e3 --- /dev/null +++ b/frontend/src/pages/usr/UserDetail/index.tsx @@ -0,0 +1,170 @@ +// REQ-USR-001 / REQ-USR-002: 用户单据页面容器(判 mode、装配 4 子组件、提交反馈与导航回流) +import { useEffect } from 'react'; +import { Form, Spin, Button, Modal, App as AntdApp } from 'antd'; +import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import type { UserVO } from '../../../api/types'; +import UserDetailToolbar from './UserDetailToolbar'; +import UserBasicForm from './UserBasicForm'; +import PermissionTabs from './PermissionTabs'; +import PermissionGroupList from './PermissionGroupList'; +import { useUserDetail } from './useUserDetail'; +import { + MODE_CREATE, + MODE_EDIT, + MSG_CREATE_SUCCESS, + MSG_EDIT_SUCCESS, + MSG_ERR_USER_NOT_FOUND, + MSG_LOAD_DETAIL_FAIL, + MSG_CANCEL_CONFIRM, + PATH_USER_LIST, + PATH_USER_NEW, + TEXT_BACK_TO_LIST, + TEXT_RETRY, + type UserFormValues, +} from './constants'; +import styles from './UserDetail.module.css'; + +/** Checkbox 受控值为 boolean,提交映射需 0/1(BR8) */ +function normalizeFormValues(raw: UserFormValues): UserFormValues { + return { + ...raw, + iCanModifyBill: (raw.iCanModifyBill ? 1 : 0) as 0 | 1, + }; +} + +export default function UserDetailPage() { + const navigate = useNavigate(); + const params = useParams<{ id?: string }>(); + const location = useLocation(); + const { message } = AntdApp.useApp(); + const [form] = Form.useForm(); + + const mode = params.id ? MODE_EDIT : MODE_CREATE; + const userId = params.id ? Number(params.id) : undefined; + const presetUser = (location.state as { user?: UserVO } | null)?.user ?? null; + + const detail = useUserDetail({ mode, userId, presetUser }); + const { + formValues, + employees, + permissions, + checkedPermissionIds, + readonlyCreator, + readonlyCreateTime, + loading, + submitting, + loadFailed, + notFound, + selectEmployee, + togglePermission, + toggleAll, + submit, + reload, + } = detail; + + // hook 持有的受控值回写到 AntD Form(create 默认 / edit 回填,BR1/BR2/BR6/BR17) + useEffect(() => { + form.setFieldsValue({ + ...formValues, + iCanModifyBill: (formValues.iCanModifyBill ? 1 : 0) as 0 | 1, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formValues]); + + const handleSave = async () => { + try { + const values = await form.validateFields(); + const ret = await submit(normalizeFormValues({ ...formValues, ...values })); + if (ret.ok) { + message.success(mode === MODE_CREATE ? MSG_CREATE_SUCCESS : MSG_EDIT_SUCCESS); + navigate(PATH_USER_LIST); + } else if (ret.fieldError) { + form.setFields([ + { name: ret.fieldError.field, errors: [ret.fieldError.message] }, + ]); + } + } catch { + // validateFields 失败:就近字段已展示错误,不发请求(BR12) + } + }; + + const handleCancel = () => { + if (form.isFieldsTouched()) { + Modal.confirm({ + title: MSG_CANCEL_CONFIRM, + onOk: () => navigate(PATH_USER_LIST), + }); + } else { + navigate(PATH_USER_LIST); + } + }; + + const handleNew = () => { + navigate(PATH_USER_NEW); + }; + + const handleSelectEmployee = (value: number | null) => { + selectEmployee(value); + }; + + // edit 详情不存在(40401):返回列表入口(spec § 4) + if (notFound) { + return ( +
+
+ {MSG_ERR_USER_NOT_FOUND} + +
+
+ ); + } + + // 预取/详情取数失败:重试入口(spec § 4 loadError) + if (loadFailed) { + return ( +
+
+ {MSG_LOAD_DETAIL_FAIL} + +
+
+ ); + } + + return ( +
+ void handleSave()} + onCancel={handleCancel} + onNew={handleNew} + /> + +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/pages/usr/UserDetail/useUserDetail.ts b/frontend/src/pages/usr/UserDetail/useUserDetail.ts index c9e4b79..f23d699 100644 --- a/frontend/src/pages/usr/UserDetail/useUserDetail.ts +++ b/frontend/src/pages/usr/UserDetail/useUserDetail.ts @@ -64,6 +64,7 @@ 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; @@ -86,6 +87,7 @@ 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; @@ -108,6 +110,7 @@ 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; @@ -126,8 +129,8 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { if (vo) { initFromVo(vo); } else { - // 详情不存在:交由页面按 40401 路径处理,标记 loadFailed - setLoadFailed(true); + // 详情不存在:交由页面按 40401 路径处理(返回列表入口) + setNotFound(true); messageRef.current.error(MSG_ERR_USER_NOT_FOUND); setLoading(false); return; @@ -256,6 +259,7 @@ export function useUserDetail(args: UseUserDetailArgs): UseUserDetailReturn { submitting, error, loadFailed, + notFound, setField, selectEmployee, togglePermission, diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 1d7565d..488f941 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,6 +1,6 @@ // REQ-USR-003 / REQ-USR-004: 路由表(FE 共享骨架)。 // FE-02 将 '/' 占位替换为应用外壳 + 受保护嵌套路由 + 错误边界。 -// 子路由目标内容(FE-03 用户列表 / FE-04 用户单据)由后续 FE 落地,本处仅留可挂载占位。 +// FE-04 将 /usr/users/new 与 /usr/users/:id 占位替换为真实「用户信息单据」页 UserDetailPage。 import { Routes, Route, Navigate } from 'react-router-dom'; import LoginPage from '../pages/usr/Login/LoginPage'; import RequireAuth from './RequireAuth'; @@ -9,11 +9,7 @@ import AppErrorBoundary from './AppErrorBoundary'; import AppLayout from '../layouts/AppLayout/AppLayout'; import HomePage from '../pages/home/HomePage/HomePage'; import UserListPage from '../pages/usr/UserList'; - -// FE-04 用户单据容器占位(新增 / 修改) -function UserDetailPlaceholder() { - return
; -} +import UserDetailPage from '../pages/usr/UserDetail'; export default function AppRouter() { return ( @@ -39,8 +35,8 @@ export default function AppRouter() { > } /> } /> - } /> - } /> + } /> + } /> {/* 受保护区内未匹配 → 回主页(D7) */} } /> diff --git a/frontend/tests/unit/UserDetailPage.test.tsx b/frontend/tests/unit/UserDetailPage.test.tsx new file mode 100644 index 0000000..b86f09f --- /dev/null +++ b/frontend/tests/unit/UserDetailPage.test.tsx @@ -0,0 +1,216 @@ +// REQ-USR-001 / REQ-USR-002: UserDetailPage 页面集成 + 路由接线 +// create/edit 贯通 + 提交回流 + 错误就近 + 取数失败(BR3/BR12/BR16/BR17/D4/D5) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Routes, Route, useLocation } from 'react-router-dom'; +import { renderShell } from './renderShell'; + +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; +vi.mock('antd', async () => { + const actual = await vi.importActual('antd'); + return { + ...actual, + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), + }; +}); + +vi.mock('../../src/api/usrApi', () => ({ + createUser: vi.fn(), + updateUser: vi.fn(), + getUserDetail: vi.fn(), + listEmployees: vi.fn(), + listPermissions: vi.fn(), +})); + +import { + createUser, + updateUser, + getUserDetail, + listEmployees, + listPermissions, +} from '../../src/api/usrApi'; +import UserDetailPage from '../../src/pages/usr/UserDetail'; +import { ApiError } from '../../src/api/request'; +import { ERR_USERNAME_EXISTS } from '../../src/pages/usr/UserDetail/constants'; +import type { UserVO, EmployeeOption, PermissionItem } from '../../src/api/types'; + +const mockedCreate = createUser as unknown as ReturnType; +const mockedUpdate = updateUser as unknown as ReturnType; +const mockedDetail = getUserDetail as unknown as ReturnType; +const mockedEmployees = listEmployees as unknown as ReturnType; +const mockedPermissions = listPermissions as unknown as ReturnType; + +const EMPLOYEES: EmployeeOption[] = [{ value: 3, label: '张三', sEmployeeNo: 'zs' }]; +const PERMISSIONS: PermissionItem[] = [ + { id: 1, name: '默认显示', category: '基础' }, + { id: 2, name: '高级查看', category: '基础' }, +]; + +function makeVo(over: Partial = {}): UserVO { + return { + id: 7, + sUserName: 'zhangsan', + employeeName: '张三', + sUserNo: 'zs', + departmentName: null, + sUserType: '超级管理员', + sLanguage: '英文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: 'admin', + tCreateDate: '2026-01-01T00:00:00', + ...over, + }; +} + +function LocationProbe() { + const loc = useLocation(); + return
{loc.pathname}
; +} + +function renderPage(entry: string) { + return renderShell( + <> + + + list
} /> + } /> + } /> + + , + { + initialEntries: [entry], + preloadedAuth: { + token: 't', + user: { id: 1, sUserName: '朱子纯', sUserType: '超级管理员', sLanguage: '中文' }, + }, + }, + ); +} + +async function fillValidCreateForm(user: ReturnType) { + await user.type(screen.getByTestId('field-username'), 'zhangsan'); + await user.type(screen.getByTestId('field-userno'), 'zs'); + // 语言必填 + await user.click(screen.getByTestId('select-language').querySelector('.ant-select-selector')!); + await user.click(await screen.findByText('中文')); +} + +describe('UserDetailPage 集成', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedEmployees.mockResolvedValue(EMPLOYEES); + mockedPermissions.mockResolvedValue(PERMISSIONS); + }); + + it('create mode renders empty form with defaults', async () => { + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + expect(await screen.findByText('保存后自动生成')).toBeInTheDocument(); + expect(within(screen.getByTestId('select-usertype')).getByText('普通用户')).toBeInTheDocument(); + }); + + it('create submit success navigates to /usr/users with success', async () => { + const user = userEvent.setup(); + mockedCreate.mockResolvedValue({ id: 9 }); + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + await fillValidCreateForm(user); + await user.click(screen.getByTestId('perm-check-1')); + await user.click(screen.getByTestId('btn-save')); + await waitFor(() => expect(mockedCreate).toHaveBeenCalled()); + const body = mockedCreate.mock.calls[0][0]; + expect(body.sUserName).toBe('zhangsan'); + expect(body.permissionIds).toContain(1); + expect(messageSpy.success).toHaveBeenCalledWith('用户创建成功'); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); + }); + + it('create username format invalid blocks submit', async () => { + const user = userEvent.setup(); + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + await user.type(screen.getByTestId('field-username'), 'ab'); + await user.click(screen.getByTestId('btn-save')); + expect(await screen.findByText('用户名须为 3-20 位字母数字下划线')).toBeInTheDocument(); + expect(mockedCreate).not.toHaveBeenCalled(); + }); + + it('create 40901 highlights username field', async () => { + const user = userEvent.setup(); + mockedCreate.mockRejectedValue(new ApiError(ERR_USERNAME_EXISTS, 'dup')); + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + await fillValidCreateForm(user); + await user.click(screen.getByTestId('btn-save')); + await waitFor(() => expect(mockedCreate).toHaveBeenCalled()); + 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()); + await waitFor(() => + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), + ); + 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()); + await waitFor(() => + expect((screen.getByTestId('field-username') as HTMLInputElement).value).toBe('zhangsan'), + ); + await user.click(screen.getByTestId('btn-save')); + await waitFor(() => expect(mockedUpdate).toHaveBeenCalled()); + expect(mockedUpdate.mock.calls[0][0]).toBe(7); + expect(messageSpy.success).toHaveBeenCalledWith('保存成功'); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); + }); + + it('cancel with dirty form confirms then navigates', async () => { + const user = userEvent.setup(); + renderPage('/usr/users/new'); + await waitFor(() => expect(mockedEmployees).toHaveBeenCalled()); + await user.type(screen.getByTestId('field-username'), 'dirtyuser'); + await user.click(screen.getByTestId('btn-cancel')); + // AntD Modal.confirm 弹确认 + expect((await screen.findAllByText('放弃未保存的修改?')).length).toBeGreaterThan(0); + await user.click(screen.getByRole('button', { name: /确\s*定|OK/ })); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); + }); + + it('新增 navigates to /usr/users/new', async () => { + const user = userEvent.setup(); + mockedDetail.mockResolvedValue(makeVo()); + renderPage('/usr/users/7'); + await waitFor(() => expect(mockedDetail).toHaveBeenCalled()); + await user.click(screen.getByTestId('btn-new')); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users/new')); + }); + + it('loadError shows retry; retry calls reload', async () => { + mockedPermissions.mockRejectedValueOnce(new ApiError(-1, 'net')); + renderPage('/usr/users/new'); + expect(await screen.findByTestId('userdetail-loaderror')).toBeInTheDocument(); + mockedPermissions.mockResolvedValue(PERMISSIONS); + const user = userEvent.setup(); + await user.click(within(screen.getByTestId('userdetail-loaderror')).getByText('点击重试')); + await waitFor(() => expect(screen.queryByTestId('userdetail-loaderror')).toBeNull()); + }); + + it('edit 40401 offers 返回列表', async () => { + mockedDetail.mockResolvedValue(null); + renderPage('/usr/users/7'); + expect(await screen.findByText('该用户不存在或已被删除')).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click(screen.getByText('返回列表')); + await waitFor(() => expect(screen.getByTestId('loc').textContent).toBe('/usr/users')); + }); +});