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 | import { Navigate, Route, Routes } from 'react-router-dom' | 1 | import { Navigate, Route, Routes } from 'react-router-dom' |
| 2 | import { useAppSelector } from './store/hooks' | 2 | import { useAppSelector } from './store/hooks' |
| 3 | import LoginPage from './pages/usr/LoginPage' | 3 | import LoginPage from './pages/usr/LoginPage' |
| 4 | +import UserListPage from './pages/usr/UserListPage' | ||
| 4 | 5 | ||
| 5 | function PrivateRoute({ children }: { children: React.ReactNode }) { | 6 | function PrivateRoute({ children }: { children: React.ReactNode }) { |
| 6 | const accessToken = useAppSelector(s => s.auth.accessToken) | 7 | const accessToken = useAppSelector(s => s.auth.accessToken) |
| @@ -12,6 +13,7 @@ export default function App() { | @@ -12,6 +13,7 @@ export default function App() { | ||
| 12 | <Routes> | 13 | <Routes> |
| 13 | <Route path="/login" element={<LoginPage />} /> | 14 | <Route path="/login" element={<LoginPage />} /> |
| 14 | <Route path="/" element={<PrivateRoute><div>主页(待实现)</div></PrivateRoute>} /> | 15 | <Route path="/" element={<PrivateRoute><div>主页(待实现)</div></PrivateRoute>} /> |
| 16 | + <Route path="/usr/users" element={<PrivateRoute><UserListPage /></PrivateRoute>} /> | ||
| 15 | <Route path="*" element={<Navigate to="/" replace />} /> | 17 | <Route path="*" element={<Navigate to="/" replace />} /> |
| 16 | </Routes> | 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 | +}) |