Commit 3090964b21361d39e3b63aa18667095020c7d01f
1 parent
ff5af471
feat(usr): 前端 UserListPage + UserFormDrawer + usr.ts + PermButton REQ-USR-001
Showing
6 changed files
with
270 additions
and
0 deletions
frontend/src/App.tsx
| 1 | 1 | import { Navigate, Route, Routes } from 'react-router-dom' |
| 2 | 2 | import { useAppSelector } from './store/hooks' |
| 3 | 3 | import LoginPage from './pages/usr/LoginPage' |
| 4 | +import UserListPage from './pages/usr/UserListPage' | |
| 4 | 5 | |
| 5 | 6 | function PrivateRoute({ children }: { children: React.ReactNode }) { |
| 6 | 7 | const accessToken = useAppSelector(s => s.auth.accessToken) |
| ... | ... | @@ -12,6 +13,7 @@ export default function App() { |
| 12 | 13 | <Routes> |
| 13 | 14 | <Route path="/login" element={<LoginPage />} /> |
| 14 | 15 | <Route path="/" element={<PrivateRoute><div>主页(待实现)</div></PrivateRoute>} /> |
| 16 | + <Route path="/usr/users" element={<PrivateRoute><UserListPage /></PrivateRoute>} /> | |
| 15 | 17 | <Route path="*" element={<Navigate to="/" replace />} /> |
| 16 | 18 | </Routes> |
| 17 | 19 | ) | ... | ... |
frontend/src/api/usr.ts
0 → 100644
| 1 | +import request from './request' | |
| 2 | + | |
| 3 | +export interface StaffVO { | |
| 4 | + sId: string | |
| 5 | + sStaffName: string | |
| 6 | +} | |
| 7 | + | |
| 8 | +export interface PermissionGroupVO { | |
| 9 | + sId: string | |
| 10 | + sGroupCode: string | |
| 11 | + sGroupName: string | |
| 12 | + sCategory: string | null | |
| 13 | +} | |
| 14 | + | |
| 15 | +export interface UserCreateReq { | |
| 16 | + userCode: string | |
| 17 | + username: string | |
| 18 | + userType: '普通用户' | '超级管理员' | |
| 19 | + language: '中文' | '英文' | '繁体' | |
| 20 | + canEditDoc?: boolean | |
| 21 | + employeeId?: string | null | |
| 22 | + permGroupIds?: string[] | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface UserCreateResp { | |
| 26 | + userId: string | |
| 27 | + userCode: string | |
| 28 | + username: string | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function getStaffs(): Promise<StaffVO[]> { | |
| 32 | + return request.get('/usr/users/staffs') | |
| 33 | +} | |
| 34 | + | |
| 35 | +export function getPermissionGroups(): Promise<PermissionGroupVO[]> { | |
| 36 | + return request.get('/usr/users/permission-groups') | |
| 37 | +} | |
| 38 | + | |
| 39 | +export function createUser(req: UserCreateReq): Promise<UserCreateResp> { | |
| 40 | + return request.post('/usr/users', req) | |
| 41 | +} | ... | ... |
frontend/src/components/PermButton.tsx
0 → 100644
| 1 | +import { Button, ButtonProps } from 'antd' | |
| 2 | +import { useAppSelector } from '../store/hooks' | |
| 3 | + | |
| 4 | +interface PermButtonProps extends ButtonProps { | |
| 5 | + permission: string | |
| 6 | +} | |
| 7 | + | |
| 8 | +export function PermButton({ permission: _permission, children, ...props }: PermButtonProps) { | |
| 9 | + const userType = useAppSelector(s => s.auth.userInfo?.userType) | |
| 10 | + if (userType !== '超级管理员') return null | |
| 11 | + return <Button {...props}>{children}</Button> | |
| 12 | +} | ... | ... |
frontend/src/pages/usr/UserFormDrawer.tsx
0 → 100644
| 1 | +import { useEffect, useState } from 'react' | |
| 2 | +import { | |
| 3 | + Drawer, Form, Input, Select, Checkbox, Button, message, Table | |
| 4 | +} from 'antd' | |
| 5 | +import type { ColumnsType } from 'antd/es/table' | |
| 6 | +import { getStaffs, getPermissionGroups, createUser, StaffVO, PermissionGroupVO, UserCreateReq } from '../../api/usr' | |
| 7 | + | |
| 8 | +interface Props { | |
| 9 | + open: boolean | |
| 10 | + onClose: () => void | |
| 11 | + onSuccess: () => void | |
| 12 | +} | |
| 13 | + | |
| 14 | +export default function UserFormDrawer({ open, onClose, onSuccess }: Props) { | |
| 15 | + const [form] = Form.useForm() | |
| 16 | + const [staffs, setStaffs] = useState<StaffVO[]>([]) | |
| 17 | + const [permGroups, setPermGroups] = useState<PermissionGroupVO[]>([]) | |
| 18 | + const [selectedPermIds, setSelectedPermIds] = useState<string[]>([]) | |
| 19 | + const [submitting, setSubmitting] = useState(false) | |
| 20 | + | |
| 21 | + useEffect(() => { | |
| 22 | + if (open) { | |
| 23 | + getStaffs().then(setStaffs).catch(() => {}) | |
| 24 | + getPermissionGroups().then(setPermGroups).catch(() => {}) | |
| 25 | + } | |
| 26 | + }, [open]) | |
| 27 | + | |
| 28 | + const permColumns: ColumnsType<PermissionGroupVO> = [ | |
| 29 | + { | |
| 30 | + title: '', | |
| 31 | + key: 'select', | |
| 32 | + width: 40, | |
| 33 | + render: (_, record) => ( | |
| 34 | + <Checkbox | |
| 35 | + checked={selectedPermIds.includes(record.sId)} | |
| 36 | + onChange={e => { | |
| 37 | + setSelectedPermIds(prev => | |
| 38 | + e.target.checked ? [...prev, record.sId] : prev.filter(id => id !== record.sId) | |
| 39 | + ) | |
| 40 | + }} | |
| 41 | + /> | |
| 42 | + ) | |
| 43 | + }, | |
| 44 | + { title: '权限组', dataIndex: 'sGroupName' }, | |
| 45 | + { title: '分类', dataIndex: 'sCategory' } | |
| 46 | + ] | |
| 47 | + | |
| 48 | + async function handleSubmit() { | |
| 49 | + try { | |
| 50 | + const values = await form.validateFields() | |
| 51 | + setSubmitting(true) | |
| 52 | + const req: UserCreateReq = { | |
| 53 | + userCode: values.userCode, | |
| 54 | + username: values.username, | |
| 55 | + userType: values.userType, | |
| 56 | + language: values.language, | |
| 57 | + canEditDoc: values.canEditDoc ?? false, | |
| 58 | + employeeId: values.employeeId ?? null, | |
| 59 | + permGroupIds: selectedPermIds | |
| 60 | + } | |
| 61 | + await createUser(req) | |
| 62 | + message.success('新增用户成功') | |
| 63 | + form.resetFields() | |
| 64 | + setSelectedPermIds([]) | |
| 65 | + onSuccess() | |
| 66 | + } catch (e: unknown) { | |
| 67 | + if (e instanceof Error) { | |
| 68 | + message.error(e.message) | |
| 69 | + } | |
| 70 | + } finally { | |
| 71 | + setSubmitting(false) | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + return ( | |
| 76 | + <Drawer | |
| 77 | + title="新增用户" | |
| 78 | + open={open} | |
| 79 | + onClose={onClose} | |
| 80 | + width={520} | |
| 81 | + footer={ | |
| 82 | + <div style={{ textAlign: 'right' }}> | |
| 83 | + <Button onClick={onClose} style={{ marginRight: 8 }}>取消</Button> | |
| 84 | + <Button type="primary" onClick={handleSubmit} loading={submitting}>确认</Button> | |
| 85 | + </div> | |
| 86 | + } | |
| 87 | + > | |
| 88 | + <Form form={form} layout="vertical"> | |
| 89 | + <Form.Item name="userCode" label="用户号" rules={[{ required: true, message: '请输入用户号' }]}> | |
| 90 | + <Input placeholder="请输入用户号" /> | |
| 91 | + </Form.Item> | |
| 92 | + <Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}> | |
| 93 | + <Input placeholder="请输入用户名" /> | |
| 94 | + </Form.Item> | |
| 95 | + <Form.Item name="userType" label="用户类型" rules={[{ required: true }]} initialValue="普通用户"> | |
| 96 | + <Select options={[{ value: '普通用户', label: '普通用户' }, { value: '超级管理员', label: '超级管理员' }]} /> | |
| 97 | + </Form.Item> | |
| 98 | + <Form.Item name="language" label="语言" rules={[{ required: true }]} initialValue="中文"> | |
| 99 | + <Select options={[{ value: '中文', label: '中文' }, { value: '英文', label: 'English' }, { value: '繁体', label: '繁體' }]} /> | |
| 100 | + </Form.Item> | |
| 101 | + <Form.Item name="canEditDoc" valuePropName="checked"> | |
| 102 | + <Checkbox>可编辑文档</Checkbox> | |
| 103 | + </Form.Item> | |
| 104 | + <Form.Item name="employeeId" label="关联员工"> | |
| 105 | + <Select | |
| 106 | + allowClear | |
| 107 | + placeholder="选择员工(可选)" | |
| 108 | + options={staffs.map(s => ({ value: s.sId, label: s.sStaffName }))} | |
| 109 | + /> | |
| 110 | + </Form.Item> | |
| 111 | + <Form.Item label="权限组"> | |
| 112 | + <Table | |
| 113 | + size="small" | |
| 114 | + columns={permColumns} | |
| 115 | + dataSource={permGroups} | |
| 116 | + rowKey="sId" | |
| 117 | + pagination={false} | |
| 118 | + /> | |
| 119 | + </Form.Item> | |
| 120 | + </Form> | |
| 121 | + </Drawer> | |
| 122 | + ) | |
| 123 | +} | ... | ... |
frontend/src/pages/usr/UserListPage.tsx
0 → 100644
| 1 | +import { useState } from 'react' | |
| 2 | +import { Table } from 'antd' | |
| 3 | +import { PermButton } from '../../components/PermButton' | |
| 4 | +import UserFormDrawer from './UserFormDrawer' | |
| 5 | + | |
| 6 | +export default function UserListPage() { | |
| 7 | + const [drawerOpen, setDrawerOpen] = useState(false) | |
| 8 | + | |
| 9 | + return ( | |
| 10 | + <div> | |
| 11 | + <div style={{ marginBottom: 16 }}> | |
| 12 | + <PermButton | |
| 13 | + permission="usr:create" | |
| 14 | + type="primary" | |
| 15 | + onClick={() => setDrawerOpen(true)} | |
| 16 | + > | |
| 17 | + 新增 | |
| 18 | + </PermButton> | |
| 19 | + </div> | |
| 20 | + <Table dataSource={[]} columns={[]} rowKey="sId" /> | |
| 21 | + <UserFormDrawer | |
| 22 | + open={drawerOpen} | |
| 23 | + onClose={() => setDrawerOpen(false)} | |
| 24 | + onSuccess={() => setDrawerOpen(false)} | |
| 25 | + /> | |
| 26 | + </div> | |
| 27 | + ) | |
| 28 | +} | ... | ... |
frontend/src/test/UserListPage.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 } from 'react-router-dom' | |
| 6 | +import { configureStore } from '@reduxjs/toolkit' | |
| 7 | +import authReducer from '../store/slices/authSlice' | |
| 8 | +import UserListPage from '../pages/usr/UserListPage' | |
| 9 | + | |
| 10 | +vi.mock('../api/usr', () => ({ | |
| 11 | + getStaffs: vi.fn().mockResolvedValue([]), | |
| 12 | + getPermissionGroups: vi.fn().mockResolvedValue([]), | |
| 13 | + createUser: vi.fn() | |
| 14 | +})) | |
| 15 | + | |
| 16 | +vi.mock('../api/request', () => ({ | |
| 17 | + default: { get: vi.fn(), post: vi.fn() } | |
| 18 | +})) | |
| 19 | + | |
| 20 | +function makeStore(userType: string) { | |
| 21 | + const store = configureStore({ reducer: { auth: authReducer } }) | |
| 22 | + store.dispatch({ | |
| 23 | + type: 'auth/setCredentials', | |
| 24 | + payload: { | |
| 25 | + accessToken: 'test-token', | |
| 26 | + refreshToken: 'test-refresh', | |
| 27 | + userInfo: { userId: 'u1', username: 'admin', userType, language: '中文', brandId: 'b1' } | |
| 28 | + } | |
| 29 | + }) | |
| 30 | + return store | |
| 31 | +} | |
| 32 | + | |
| 33 | +function renderPage(userType: string) { | |
| 34 | + const store = makeStore(userType) | |
| 35 | + return render( | |
| 36 | + <Provider store={store}> | |
| 37 | + <MemoryRouter> | |
| 38 | + <UserListPage /> | |
| 39 | + </MemoryRouter> | |
| 40 | + </Provider> | |
| 41 | + ) | |
| 42 | +} | |
| 43 | + | |
| 44 | +describe('UserListPage', () => { | |
| 45 | + beforeEach(() => { | |
| 46 | + vi.clearAllMocks() | |
| 47 | + }) | |
| 48 | + | |
| 49 | + it('superAdmin_seesNewButton', () => { | |
| 50 | + renderPage('超级管理员') | |
| 51 | + expect(screen.getByRole('button', { name: /新\s*增/ })).toBeInTheDocument() | |
| 52 | + }) | |
| 53 | + | |
| 54 | + it('normalUser_doesNotSeeNewButton', () => { | |
| 55 | + renderPage('普通用户') | |
| 56 | + expect(screen.queryByRole('button', { name: /新\s*增/ })).not.toBeInTheDocument() | |
| 57 | + }) | |
| 58 | + | |
| 59 | + it('clickNewButton_opensDrawer', async () => { | |
| 60 | + renderPage('超级管理员') | |
| 61 | + await userEvent.click(screen.getByRole('button', { name: /新\s*增/ })) | |
| 62 | + await waitFor(() => expect(screen.getByText('新增用户')).toBeInTheDocument()) | |
| 63 | + }) | |
| 64 | +}) | ... | ... |