Commit 777215f34d45c6a82688b25a49cdc20212b81fd8
1 parent
11778b8c
feat(usr): add UserDetailPage full-screen form REQ-USR-001 REQ-USR-002
Showing
2 changed files
with
334 additions
and
1 deletions
frontend/src/pages/usr/UserDetailPage.tsx
| 1 | +import { useEffect, useState } from 'react' | |
| 2 | +import { useNavigate, useLocation, useParams } from 'react-router-dom' | |
| 3 | +import { message } from 'antd' | |
| 4 | +import { useAppDispatch } from '../../store/hooks' | |
| 5 | +import { openTab } from '../../store/slices/tabsSlice' | |
| 6 | +import { getStaffs, getPermissionGroups, createUser, updateUser } from '../../api/usr' | |
| 7 | +import type { StaffVO, PermissionGroupVO, UserListItemVO, UserCreateReq, UserUpdateReq } from '../../api/usr' | |
| 8 | + | |
| 9 | +// REQ-USR-001: 增加用户 | |
| 10 | +// REQ-USR-002: 修改用户 | |
| 1 | 11 | export default function UserDetailPage() { |
| 2 | - return null | |
| 12 | + const { id } = useParams() | |
| 13 | + const navigate = useNavigate() | |
| 14 | + const location = useLocation() | |
| 15 | + const dispatch = useAppDispatch() | |
| 16 | + | |
| 17 | + const isNew = !id | |
| 18 | + const rowData = location.state as UserListItemVO | null | |
| 19 | + | |
| 20 | + const [staffs, setStaffs] = useState<StaffVO[]>([]) | |
| 21 | + const [permGroups, setPermGroups] = useState<PermissionGroupVO[]>([]) | |
| 22 | + const [selectedPermIds, setSelectedPermIds] = useState<string[]>([]) | |
| 23 | + const [submitting, setSubmitting] = useState(false) | |
| 24 | + const [activePermTab, setActivePermTab] = useState(0) | |
| 25 | + | |
| 26 | + const [userCode, setUserCode] = useState('') | |
| 27 | + const [username, setUsername] = useState('') | |
| 28 | + const [userType, setUserType] = useState<'普通用户' | '超级管理员'>('普通用户') | |
| 29 | + const [language, setLanguage] = useState<'中文' | '英文' | '繁体'>('中文') | |
| 30 | + const [canEditDoc, setCanEditDoc] = useState(false) | |
| 31 | + const [isDisabled, setIsDisabled] = useState(false) | |
| 32 | + const [employeeId, setEmployeeId] = useState<string | null>(null) | |
| 33 | + | |
| 34 | + useEffect(() => { | |
| 35 | + if (!isNew && !rowData) { | |
| 36 | + navigate('/usr/users', { replace: true }) | |
| 37 | + return | |
| 38 | + } | |
| 39 | + dispatch(openTab({ id: 'userdetail', title: '用户信息单据', path: location.pathname, closable: true })) | |
| 40 | + getStaffs().then(setStaffs).catch(() => {}) | |
| 41 | + getPermissionGroups().then(setPermGroups).catch(() => {}) | |
| 42 | + if (!isNew && rowData) { | |
| 43 | + setUserType(rowData.sUserType as '普通用户' | '超级管理员') | |
| 44 | + setLanguage(rowData.sLanguage as '中文' | '英文' | '繁体') | |
| 45 | + setCanEditDoc(rowData.bCanEditDoc === 1) | |
| 46 | + setIsDisabled(rowData.bIsDisabled === 1) | |
| 47 | + } | |
| 48 | + }, []) // eslint-disable-line react-hooks/exhaustive-deps | |
| 49 | + | |
| 50 | + async function handleSave() { | |
| 51 | + if (isNew) { | |
| 52 | + if (!userCode.trim()) { message.error('请输入用户号'); return } | |
| 53 | + if (!username.trim()) { message.error('请输入用户名'); return } | |
| 54 | + } | |
| 55 | + setSubmitting(true) | |
| 56 | + try { | |
| 57 | + if (isNew) { | |
| 58 | + const req: UserCreateReq = { userCode, username, userType, language, canEditDoc, employeeId, permGroupIds: selectedPermIds } | |
| 59 | + await createUser(req) | |
| 60 | + message.success('新增用户成功') | |
| 61 | + } else { | |
| 62 | + const req: UserUpdateReq = { userType, language, canEditDoc, isDisabled, employeeId, permGroupIds: selectedPermIds } | |
| 63 | + await updateUser(id!, req) | |
| 64 | + message.success('修改用户成功') | |
| 65 | + } | |
| 66 | + navigate('/usr/users') | |
| 67 | + } catch (e: unknown) { | |
| 68 | + if (e instanceof Error) message.error(e.message) | |
| 69 | + } finally { | |
| 70 | + setSubmitting(false) | |
| 71 | + } | |
| 72 | + } | |
| 73 | + | |
| 74 | + const PERM_TABS = ['权限组', '客户查看权限', '供应商查看权限', '人员查看权限', '工序查看权限', '司机查看权限'] | |
| 75 | + | |
| 76 | + const fieldBg = 'var(--color-field-bg-edit)' | |
| 77 | + const readonlyBg = 'var(--color-form-bg-readonly)' | |
| 78 | + const inputStyle: React.CSSProperties = { flex: 1, height: 28, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 10px', background: fieldBg, minWidth: 0, fontSize: 13, fontFamily: 'inherit' } | |
| 79 | + const roStyle: React.CSSProperties = { flex: 1, height: 28, border: '1px solid #d5d8de', borderRadius: 2, padding: '0 10px', background: readonlyBg, color: '#444', display: 'flex', alignItems: 'center', fontSize: 13, minWidth: 0, overflow: 'hidden' } | |
| 80 | + const selectStyle: React.CSSProperties = { ...inputStyle, padding: '0 8px' } | |
| 81 | + const cellStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px' } | |
| 82 | + const lblStyle: React.CSSProperties = { minWidth: 88, color: '#333', fontSize: 13, textAlign: 'right', flexShrink: 0 } | |
| 83 | + const reqLblStyle: React.CSSProperties = { ...lblStyle, color: 'var(--color-field-label-req)' } | |
| 84 | + | |
| 85 | + return ( | |
| 86 | + <div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> | |
| 87 | + {/* Dark toolbar */} | |
| 88 | + <div style={{ background: 'var(--color-toolbar-bg)', color: 'var(--color-toolbar-text)', display: 'flex', alignItems: 'center', gap: 6, padding: '0 8px', height: 38, flexShrink: 0 }}> | |
| 89 | + {[ | |
| 90 | + { label: '⊕ 新增', onClick: () => navigate('/usr/users/new'), disabled: false }, | |
| 91 | + { label: '✎ 修改', onClick: undefined, disabled: true }, | |
| 92 | + { label: '🗑 删除', onClick: undefined, disabled: true, title: '功能待实现' }, | |
| 93 | + { label: '💾 保存', onClick: handleSave, disabled: submitting }, | |
| 94 | + { label: '✕ 取消', onClick: () => navigate('/usr/users'), disabled: false }, | |
| 95 | + { label: '作废', onClick: undefined, disabled: true, title: '功能待实现' }, | |
| 96 | + { label: '重置密码', onClick: undefined, disabled: true, title: '功能待实现' }, | |
| 97 | + ].map(btn => ( | |
| 98 | + <button | |
| 99 | + key={btn.label} | |
| 100 | + onClick={btn.onClick} | |
| 101 | + disabled={btn.disabled} | |
| 102 | + title={btn.title} | |
| 103 | + style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 12px', color: btn.disabled ? '#666' : 'var(--color-toolbar-text)', cursor: btn.disabled ? 'not-allowed' : 'pointer', fontSize: 13, borderRadius: 2, border: 'none', background: 'transparent', fontFamily: 'inherit', opacity: btn.disabled ? 0.5 : 1 }} | |
| 104 | + > | |
| 105 | + {btn.label} | |
| 106 | + </button> | |
| 107 | + ))} | |
| 108 | + <span style={{ flex: 1 }} /> | |
| 109 | + <span style={{ padding: '6px 8px', color: '#cfd2d8', cursor: 'pointer' }}>⚙</span> | |
| 110 | + </div> | |
| 111 | + | |
| 112 | + {/* 3-column form grid */} | |
| 113 | + <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', background: '#fff', padding: '10px 14px', borderBottom: '1px solid var(--color-table-border)', flexShrink: 0 }}> | |
| 114 | + {/* Row 1: 创建时间 / 制单人 / 员工名 */} | |
| 115 | + <div style={cellStyle}> | |
| 116 | + <span style={lblStyle}>创建时间:</span> | |
| 117 | + <div style={roStyle}>{isNew ? '' : rowData?.tCreateDate}</div> | |
| 118 | + </div> | |
| 119 | + <div style={cellStyle}> | |
| 120 | + <span style={lblStyle}>制单人:</span> | |
| 121 | + <div style={roStyle}>{isNew ? '保存后自动生成' : rowData?.sCreatorUsername}</div> | |
| 122 | + </div> | |
| 123 | + <div style={cellStyle}> | |
| 124 | + <span style={reqLblStyle}>*员工名:</span> | |
| 125 | + <select value={employeeId ?? ''} onChange={e => setEmployeeId(e.target.value || null)} style={{ ...selectStyle, backgroundImage: 'url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'10\' height=\'10\' viewBox=\'0 0 10 10\'><path d=\'M2 3l3 4 3-4z\' fill=\'%23888\'/></svg>")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 8px center', paddingRight: 24 }}> | |
| 126 | + <option value="">— 不关联 —</option> | |
| 127 | + {staffs.map(s => <option key={s.sId} value={s.sId}>{s.sStaffName}</option>)} | |
| 128 | + </select> | |
| 129 | + </div> | |
| 130 | + {/* Row 2: 用户名 / 类型 / 语言 */} | |
| 131 | + <div style={cellStyle}> | |
| 132 | + <span style={isNew ? reqLblStyle : lblStyle}>{isNew ? '*用户名:' : '用户名:'}</span> | |
| 133 | + {isNew | |
| 134 | + ? <input type="text" value={username} onChange={e => setUsername(e.target.value)} placeholder="请输入用户名" style={inputStyle} /> | |
| 135 | + : <div style={roStyle}>{rowData?.sUsername}</div>} | |
| 136 | + </div> | |
| 137 | + <div style={cellStyle}> | |
| 138 | + <span style={reqLblStyle}>*类型:</span> | |
| 139 | + <select value={userType} onChange={e => setUserType(e.target.value as typeof userType)} style={selectStyle}> | |
| 140 | + <option value="普通用户">普通用户</option> | |
| 141 | + <option value="超级管理员">超级管理员</option> | |
| 142 | + </select> | |
| 143 | + </div> | |
| 144 | + <div style={cellStyle}> | |
| 145 | + <span style={reqLblStyle}>*语言:</span> | |
| 146 | + <select value={language} onChange={e => setLanguage(e.target.value as typeof language)} style={selectStyle}> | |
| 147 | + <option value="中文">中文</option> | |
| 148 | + <option value="英文">English</option> | |
| 149 | + <option value="繁体">繁體</option> | |
| 150 | + </select> | |
| 151 | + </div> | |
| 152 | + {/* Row 3: 用户号 / (empty) / 单据修改权限 */} | |
| 153 | + <div style={cellStyle}> | |
| 154 | + <span style={isNew ? reqLblStyle : lblStyle}>{isNew ? '*用户号:' : '用户号:'}</span> | |
| 155 | + {isNew | |
| 156 | + ? <input type="text" value={userCode} onChange={e => setUserCode(e.target.value)} placeholder="请输入用户号" style={inputStyle} /> | |
| 157 | + : <div style={roStyle}>{rowData?.sUserCode}</div>} | |
| 158 | + </div> | |
| 159 | + <div style={cellStyle} /> | |
| 160 | + <div style={cellStyle}> | |
| 161 | + <span style={lblStyle}>单据修改权限:</span> | |
| 162 | + <input type="checkbox" aria-hidden="true" checked={canEditDoc} onChange={e => setCanEditDoc(e.target.checked)} style={{ width: 14, height: 14 }} /> | |
| 163 | + </div> | |
| 164 | + </div> | |
| 165 | + | |
| 166 | + {/* Permission tabs row */} | |
| 167 | + <div style={{ display: 'flex', background: '#fff', borderBottom: '1px solid var(--color-table-border)', padding: '0 6px', flexShrink: 0 }}> | |
| 168 | + {PERM_TABS.map((tab, i) => ( | |
| 169 | + <div | |
| 170 | + key={tab} | |
| 171 | + onClick={() => setActivePermTab(i)} | |
| 172 | + style={{ padding: '11px 18px', fontSize: 14, color: i === activePermTab ? 'var(--color-tab-active)' : '#444', cursor: 'pointer', borderBottom: i === activePermTab ? `2px solid var(--color-tab-active)` : 'none', marginRight: 4 }} | |
| 173 | + > | |
| 174 | + {tab} | |
| 175 | + </div> | |
| 176 | + ))} | |
| 177 | + </div> | |
| 178 | + | |
| 179 | + {/* Permission list */} | |
| 180 | + <div style={{ flex: 1, background: '#fff', overflow: 'auto' }}> | |
| 181 | + <div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '10px 14px', borderBottom: '1px solid var(--color-table-border)', background: 'var(--color-table-header-bg)', fontWeight: 500, color: '#222', fontSize: 13 }}> | |
| 182 | + <span style={{ width: 14, height: 14, border: '1px solid #b8bcc3', display: 'inline-block', flexShrink: 0 }} /> | |
| 183 | + <span>权限分类</span> | |
| 184 | + </div> | |
| 185 | + {activePermTab === 0 | |
| 186 | + ? permGroups.map(pg => ( | |
| 187 | + <div key={pg.sId} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '10px 14px', borderBottom: '1px solid var(--color-table-border)', fontSize: 13, color: '#333' }}> | |
| 188 | + <input | |
| 189 | + type="checkbox" | |
| 190 | + checked={selectedPermIds.includes(pg.sId)} | |
| 191 | + onChange={e => setSelectedPermIds(prev => e.target.checked ? [...prev, pg.sId] : prev.filter(x => x !== pg.sId))} | |
| 192 | + style={{ width: 14, height: 14, flexShrink: 0 }} | |
| 193 | + /> | |
| 194 | + <span>{pg.sGroupName}</span> | |
| 195 | + </div> | |
| 196 | + )) | |
| 197 | + : <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 100, color: '#aaa' }}>功能待实现</div>} | |
| 198 | + </div> | |
| 199 | + </div> | |
| 200 | + ) | |
| 3 | 201 | } | ... | ... |
frontend/src/test/UserDetailPage.test.tsx
0 → 100644
| 1 | +import { describe, it, expect, vi, beforeEach } from 'vitest' | |
| 2 | +import { render, screen, waitFor } from '@testing-library/react' | |
| 3 | +import userEvent from '@testing-library/user-event' | |
| 4 | +import { Provider } from 'react-redux' | |
| 5 | +import { MemoryRouter, Routes, Route } from 'react-router-dom' | |
| 6 | +import { configureStore } from '@reduxjs/toolkit' | |
| 7 | +import authReducer from '../store/slices/authSlice' | |
| 8 | +import tabsReducer from '../store/slices/tabsSlice' | |
| 9 | +import UserDetailPage from '../pages/usr/UserDetailPage' | |
| 10 | + | |
| 11 | +vi.mock('../api/usr', () => ({ | |
| 12 | + getStaffs: vi.fn().mockResolvedValue([{ sId: 's1', sStaffName: '张三' }]), | |
| 13 | + getPermissionGroups: vi.fn().mockResolvedValue([{ sId: 'pg1', sGroupCode: 'usr:create', sGroupName: '新增用户', sCategory: '用户管理' }]), | |
| 14 | + createUser: vi.fn().mockResolvedValue({ userId: 'u2', userCode: 'UC002', username: 'bob' }), | |
| 15 | + updateUser: vi.fn().mockResolvedValue({ userId: 'u1', username: 'alice', updatedAt: '2026-05-08T10:00:00' }), | |
| 16 | + getUserList: vi.fn().mockResolvedValue({ total: 0, page: 1, pageSize: 20, list: [] }), | |
| 17 | +})) | |
| 18 | + | |
| 19 | +vi.mock('../api/request', () => ({ | |
| 20 | + default: { get: vi.fn(), post: vi.fn(), put: vi.fn() }, | |
| 21 | +})) | |
| 22 | + | |
| 23 | +function makeStore() { | |
| 24 | + const store = configureStore({ reducer: { auth: authReducer, tabs: tabsReducer } }) | |
| 25 | + store.dispatch({ | |
| 26 | + type: 'auth/setCredentials', | |
| 27 | + payload: { accessToken: 'tok', refreshToken: 'ref', userInfo: { userId: 'u0', username: 'admin', userType: '超级管理员', language: '中文', brandId: 'b1' } }, | |
| 28 | + }) | |
| 29 | + return store | |
| 30 | +} | |
| 31 | + | |
| 32 | +const rowData = { | |
| 33 | + sId: 'u1', sUsername: 'alice', sUserCode: 'UC001', sUserType: '普通用户', | |
| 34 | + sLanguage: '中文', bCanEditDoc: 0, bIsDisabled: 0, tLastLoginDate: null, | |
| 35 | + sCreatorUsername: 'admin', tCreateDate: '2026-01-01 00:00:00', | |
| 36 | + sStaffName: null, sDepartment: null, | |
| 37 | +} | |
| 38 | + | |
| 39 | +function renderNew() { | |
| 40 | + return render( | |
| 41 | + <Provider store={makeStore()}> | |
| 42 | + <MemoryRouter initialEntries={['/usr/users/new']}> | |
| 43 | + <Routes> | |
| 44 | + <Route path="/usr/users/new" element={<UserDetailPage />} /> | |
| 45 | + <Route path="/usr/users" element={<div>UserList</div>} /> | |
| 46 | + </Routes> | |
| 47 | + </MemoryRouter> | |
| 48 | + </Provider> | |
| 49 | + ) | |
| 50 | +} | |
| 51 | + | |
| 52 | +function renderEdit(state = rowData) { | |
| 53 | + return render( | |
| 54 | + <Provider store={makeStore()}> | |
| 55 | + <MemoryRouter initialEntries={[{ pathname: '/usr/users/u1', state }]}> | |
| 56 | + <Routes> | |
| 57 | + <Route path="/usr/users/:id" element={<UserDetailPage />} /> | |
| 58 | + <Route path="/usr/users" element={<div>UserList</div>} /> | |
| 59 | + </Routes> | |
| 60 | + </MemoryRouter> | |
| 61 | + </Provider> | |
| 62 | + ) | |
| 63 | +} | |
| 64 | + | |
| 65 | +describe('UserDetailPage', () => { | |
| 66 | + beforeEach(() => { vi.clearAllMocks() }) | |
| 67 | + | |
| 68 | + it('newMode_showsSaveButton', () => { | |
| 69 | + renderNew() | |
| 70 | + expect(screen.getByRole('button', { name: /保存/ })).toBeInTheDocument() | |
| 71 | + }) | |
| 72 | + | |
| 73 | + it('newMode_showsPermissionGroupTab', async () => { | |
| 74 | + renderNew() | |
| 75 | + await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument()) | |
| 76 | + }) | |
| 77 | + | |
| 78 | + it('editMode_showsUsernameReadonly', async () => { | |
| 79 | + renderEdit() | |
| 80 | + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()) | |
| 81 | + }) | |
| 82 | + | |
| 83 | + it('editMode_noState_redirectsToList', async () => { | |
| 84 | + render( | |
| 85 | + <Provider store={makeStore()}> | |
| 86 | + <MemoryRouter initialEntries={['/usr/users/u1']}> | |
| 87 | + <Routes> | |
| 88 | + <Route path="/usr/users/:id" element={<UserDetailPage />} /> | |
| 89 | + <Route path="/usr/users" element={<div>UserList</div>} /> | |
| 90 | + </Routes> | |
| 91 | + </MemoryRouter> | |
| 92 | + </Provider> | |
| 93 | + ) | |
| 94 | + await waitFor(() => expect(screen.getByText('UserList')).toBeInTheDocument()) | |
| 95 | + }) | |
| 96 | + | |
| 97 | + it('newMode_save_callsCreateUser', async () => { | |
| 98 | + const { createUser } = await import('../api/usr') | |
| 99 | + renderNew() | |
| 100 | + await userEvent.type(screen.getByPlaceholderText('请输入用户号'), 'UC002') | |
| 101 | + await userEvent.type(screen.getByPlaceholderText('请输入用户名'), 'bob') | |
| 102 | + await userEvent.click(screen.getByRole('button', { name: /保存/ })) | |
| 103 | + await waitFor(() => expect(vi.mocked(createUser)).toHaveBeenCalledWith( | |
| 104 | + expect.objectContaining({ userCode: 'UC002', username: 'bob' }) | |
| 105 | + )) | |
| 106 | + }) | |
| 107 | + | |
| 108 | + it('editMode_save_callsUpdateUser', async () => { | |
| 109 | + const { updateUser } = await import('../api/usr') | |
| 110 | + renderEdit() | |
| 111 | + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()) | |
| 112 | + await userEvent.click(screen.getByRole('button', { name: /保存/ })) | |
| 113 | + await waitFor(() => expect(vi.mocked(updateUser)).toHaveBeenCalledWith( | |
| 114 | + 'u1', | |
| 115 | + expect.objectContaining({ userType: '普通用户' }) | |
| 116 | + )) | |
| 117 | + }) | |
| 118 | + | |
| 119 | + it('permCheckbox_togglesSelection', async () => { | |
| 120 | + renderNew() | |
| 121 | + await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument()) | |
| 122 | + const cb = screen.getByRole('checkbox') | |
| 123 | + expect(cb).not.toBeChecked() | |
| 124 | + await userEvent.click(cb) | |
| 125 | + expect(cb).toBeChecked() | |
| 126 | + await userEvent.click(cb) | |
| 127 | + expect(cb).not.toBeChecked() | |
| 128 | + }) | |
| 129 | + | |
| 130 | + it('cancelButton_navigatesToList', async () => { | |
| 131 | + renderNew() | |
| 132 | + await userEvent.click(screen.getByRole('button', { name: /取消/ })) | |
| 133 | + await waitFor(() => expect(screen.getByText('UserList')).toBeInTheDocument()) | |
| 134 | + }) | |
| 135 | +}) | ... | ... |