Commit 777215f34d45c6a82688b25a49cdc20212b81fd8

Authored by zichun
1 parent 11778b8c

feat(usr): add UserDetailPage full-screen form REQ-USR-001 REQ-USR-002

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 export default function UserDetailPage() { 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 +})