diff --git a/AGENTS.md b/AGENTS.md index 96998c1..9553700 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ No test or lint commands are currently configured. When adding tests, use Vitest This is a React 19 + TypeScript frontend for the Zhejiang Beifan Trace Management Platform (溯源管理平台). It provides: - Public query interface for serial number verification - Admin dashboard for QR code generation and company management +- Employee management with automatic employee serial generation +- Aftersales work-order management for admins and technicians - User authentication and profile management **Tech Stack**: React 19, TypeScript, Vite 7, Ant Design 6, React Router v7, Axios @@ -90,7 +92,8 @@ src/ - `dashboardApi` - Dashboard statistics - `employeeSerialApi` - Employee serial management - `aftersalesApi` - Aftersales work orders (admin + public) - - `usersApi` - User management (admin only); also exposes `assignable` for technician/admin picker + - `employeesApi` - Employee management (admin only): create/list/update/delete/reset password + - `usersApi` - Assignable technician/admin picker via `assignable` - Auth token automatically added via axios interceptor - All API calls return typed responses based on `src/types/index.ts` @@ -103,6 +106,18 @@ src/ - Public routes (no auth): `/login`, `/query`, `/aftersales/:serialNumber` - `PublicQuery` auto-redirects scanned `zjbf-sh-*` serials to `/aftersales/:serialNumber` - Shared public-page chrome (logo + 备案 footer) lives in `components/PublicLayout.tsx` +- `/admin/employee-serials` is the employee management page despite the legacy route name. +- Technicians should only see/use the aftersales module; admins see all admin menu items. + +### Roles and Employee Management +- `UserRole` is limited to `admin` / `technician` / `employee`. +- `admin`: full backend access. +- `technician`: work-order module access only. +- `employee`: no backend login access. +- Employee creation fields are name, phone, employee number, position, and role. +- Password field is shown and required only for `admin` and `technician`. +- Employee creation uses `employeesApi.create`, and the backend automatically creates the employee serial; do not implement a separate "create then assign code" primary flow. +- Employee rows should display generated `employeeSerials` from the employee list response. ### Aftersales Conventions - Aftersales serial format is `zjbf-sh-YYMMDDNN` (daily sequence), e.g. `zjbf-sh-26052801`. diff --git a/README.md b/README.md index e3dd1ee..6ead3cc 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,10 @@ frontend/ │ │ ├── PublicQuery.tsx │ │ ├── Dashboard.tsx │ │ ├── Manage.tsx -│ │ ├── EmployeeSerials.tsx +│ │ ├── EmployeeSerials.tsx # 员工管理(主档 + 自动员工码) │ │ ├── Aftersales.tsx # 售后工单列表(管理后台) │ │ ├── AftersalesDetail.tsx # 售后工单详情(管理后台) │ │ ├── AftersalesConfirm.tsx # 售后工单扫码确认(公开) -│ │ ├── Users.tsx # 用户管理(仅管理员) │ │ └── Profile.tsx │ ├── services/ # API 服务层 │ │ └── api.ts @@ -105,15 +104,18 @@ VITE_API_BASE_URL=/api - 查看序列号列表 - 吊销企业/序列号 - 查看序列号二维码 -- 员工管理(员工赋码生成与维护) +- 员工管理 + - 创建员工时录入姓名、电话、工号、岗位、角色 + - 角色仅保留管理员、技术员、员工 + - 管理员/技术员有后台登录权限,创建时显示并必填初始密码 + - 员工无后台权限,创建时不显示密码框 + - 创建员工后自动生成员工码,列表直接展示员工码 - 售后工单 - 技术员创建工单、填写处理结果、提交客户确认 - 服务类型:软件故障 / 硬件故障 / 售后维保 - 新建和详情字段使用“问题描述反馈” - 管理员可进行工单分配(重新分配技术员)或强制关闭工单 - 工单状态机:待处理 → 待客户确认 → 已完成 / 已退回 -- 用户管理(仅管理员可见) - - 创建技术员/管理员账号、修改角色、重置密码、删除用户 - 用户资料管理 ## License diff --git a/src/App.tsx b/src/App.tsx index 9bbf5eb..cdda7f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,11 @@ const AdminRoutes = () => { return ; }; +const AdminIndexRedirect = () => { + const user = authApi.getCurrentUser(); + return ; +}; + function App() { return ( @@ -46,7 +51,7 @@ function App() { }> }> - } /> + } /> } /> } /> } /> diff --git a/src/components/AdminLayout.tsx b/src/components/AdminLayout.tsx index 8b047d6..67e916d 100644 --- a/src/components/AdminLayout.tsx +++ b/src/components/AdminLayout.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Layout, Menu, Dropdown, Avatar, message, Modal } from 'antd'; import { @@ -19,8 +20,15 @@ function AdminLayout() { const navigate = useNavigate(); const location = useLocation(); const user = authApi.getCurrentUser(); + const isTechnician = user?.role === 'technician'; - const menuItems = [ + useEffect(() => { + if (isTechnician && !location.pathname.includes('/aftersales') && !location.pathname.includes('/profile')) { + navigate('/admin/aftersales', { replace: true }); + } + }, [isTechnician, location.pathname, navigate]); + + const adminMenuItems = [ { key: 'dashboard', icon: , @@ -46,6 +54,15 @@ function AdminLayout() { onClick: () => navigate('/admin/aftersales'), }, ]; + const technicianMenuItems = [ + { + key: 'aftersales', + icon: , + label: '售后工单', + onClick: () => navigate('/admin/aftersales'), + }, + ]; + const menuItems = isTechnician ? technicianMenuItems : adminMenuItems; const handleLogout = () => { Modal.confirm({ diff --git a/src/components/EmployeeAccountsPanel.tsx b/src/components/EmployeeAccountsPanel.tsx deleted file mode 100644 index d88f2c6..0000000 --- a/src/components/EmployeeAccountsPanel.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - Card, - Table, - Button, - Space, - Input, - Select, - Tag, - Modal, - Form, - message, - Pagination, -} from 'antd'; -import { - UsergroupAddOutlined, - PlusOutlined, - EditOutlined, - DeleteOutlined, - KeyOutlined, -} from '@ant-design/icons'; -import { usersApi, authApi } from '@/services/api'; -import type { User, UserRole, CreateUserRequest, UpdateUserRequest } from '@/types'; - -const ROLE_LABEL: Record = { - admin: '管理员', - technician: '技术员', - employee: '员工(不可登录后台)', - user: '普通用户', -}; - -const ROLE_COLOR: Record = { - admin: 'red', - technician: 'blue', - employee: 'green', - user: 'default', -}; - -function EmployeeAccountsPanel() { - const currentUser = authApi.getCurrentUser(); - - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [page, setPage] = useState(1); - const [limit, setLimit] = useState(10); - const [total, setTotal] = useState(0); - const [search, setSearch] = useState(''); - const [roleFilter, setRoleFilter] = useState(); - - const [createVisible, setCreateVisible] = useState(false); - const [createLoading, setCreateLoading] = useState(false); - const [createForm] = Form.useForm(); - - const [editingUser, setEditingUser] = useState(null); - const [editLoading, setEditLoading] = useState(false); - const [editForm] = Form.useForm(); - - const [resetPasswordUser, setResetPasswordUser] = useState(null); - const [resetLoading, setResetLoading] = useState(false); - const [resetForm] = Form.useForm<{ newPassword: string }>(); - - const loadUsers = async () => { - setLoading(true); - try { - const result = await usersApi.list({ - page, - limit, - search: search || undefined, - role: roleFilter, - }); - setUsers(result.data || []); - setTotal(result.pagination?.total || 0); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '加载员工账号列表失败'); - setUsers([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadUsers(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, limit, search, roleFilter]); - - const handleCreate = async (values: CreateUserRequest) => { - setCreateLoading(true); - try { - await usersApi.create(values); - message.success('员工账号创建成功'); - setCreateVisible(false); - createForm.resetFields(); - loadUsers(); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '创建失败'); - } finally { - setCreateLoading(false); - } - }; - - const openEdit = (user: User) => { - setEditingUser(user); - editForm.setFieldsValue({ - name: user.name, - email: user.email, - role: user.role, - }); - }; - - const handleEdit = async (values: UpdateUserRequest) => { - if (!editingUser) return; - setEditLoading(true); - try { - await usersApi.update(editingUser.id, values); - message.success('员工账号更新成功'); - setEditingUser(null); - loadUsers(); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '更新失败'); - } finally { - setEditLoading(false); - } - }; - - const handleResetPassword = async (values: { newPassword: string }) => { - if (!resetPasswordUser) return; - setResetLoading(true); - try { - await usersApi.resetPassword(resetPasswordUser.id, values.newPassword); - message.success('密码重置成功'); - setResetPasswordUser(null); - resetForm.resetFields(); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '重置失败'); - } finally { - setResetLoading(false); - } - }; - - const handleDelete = (user: User) => { - Modal.confirm({ - title: '确认删除', - content: `确定要删除员工账号 "${user.username}" 吗?此操作不可恢复!`, - okText: '确定', - okType: 'danger', - cancelText: '取消', - onOk: async () => { - try { - await usersApi.delete(user.id); - message.success('删除成功'); - loadUsers(); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '删除失败'); - } - }, - }); - }; - - const columns = [ - { - title: '用户名', - dataIndex: 'username', - key: 'username', - render: (text: string) => {text}, - }, - { - title: '姓名', - dataIndex: 'name', - key: 'name', - }, - { - title: '邮箱', - dataIndex: 'email', - key: 'email', - render: (text?: string) => text || '-', - }, - { - title: '角色', - dataIndex: 'role', - key: 'role', - width: 100, - render: (role: UserRole) => {ROLE_LABEL[role]}, - }, - { - title: '创建时间', - dataIndex: 'createdAt', - key: 'createdAt', - width: 170, - render: (date: string) => new Date(date).toLocaleString('zh-CN'), - }, - { - title: '操作', - key: 'actions', - width: 260, - render: (_: any, record: User) => ( - - - - {record.id !== currentUser?.id && ( - - )} - - ), - }, - ]; - - return ( - - - 员工账号管理 - - } - extra={ - - } - > - - { - setPage(1); - setSearch(v); - }} - onChange={(e) => { - if (!e.target.value) { - setPage(1); - setSearch(''); - } - }} - /> - - - - - - - - - - - - - - - - - - - { setPage(1); setRoleFilter(v); }} - options={(Object.keys(ROLE_LABEL) as UserRole[]).map((k) => ({ value: k, label: ROLE_LABEL[k] }))} + options={ROLE_OPTIONS} /> - +
- setCreateVisible(false)} footer={null} width={480}> + setCreateVisible(false)} + footer={null} + width={520} + >
- + - - + + + + + + + + + + + + + + + + + + + - - - + +
- setEditingUser(null)} footer={null} width={480}> -
- - - - - - - - - - - - - - - - - - -
- - setSerialsVisible(false)} footer={null} width={900}> -
{v ? '有效' : '已吊销'} }, - { title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', render: (v: string) => new Date(v).toLocaleString('zh-CN') }, - ]} - /> - ); } diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 5d62df0..d4695f8 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -15,7 +15,7 @@ function LoginPage() { const handleLogin = async (values: { username: string; password: string; remember?: boolean }) => { setLoading(true); try { - await authApi.login(values.username, values.password); + const user = await authApi.login(values.username, values.password); if (values.remember) { localStorage.setItem('rememberedUsername', values.username); @@ -25,7 +25,7 @@ function LoginPage() { message.success('登录成功!'); setTimeout(() => { - navigate('/admin/dashboard'); + navigate(user.role === 'technician' ? '/admin/aftersales' : '/admin/dashboard'); }, 500); } catch (error: any) { message.error(error.message || '登录失败,请重试'); @@ -123,4 +123,4 @@ function LoginPage() { ); } -export default LoginPage; \ No newline at end of file +export default LoginPage; diff --git a/src/pages/PublicQuery.tsx b/src/pages/PublicQuery.tsx index d374213..285d070 100644 --- a/src/pages/PublicQuery.tsx +++ b/src/pages/PublicQuery.tsx @@ -16,7 +16,7 @@ import './styles/PublicQuery.css'; interface EmployeeSerialResult { serialNumber: string; companyName: string; - department: string; + position: string; employeeName: string; isActive: boolean; createdAt: string; @@ -164,11 +164,11 @@ function PublicQueryPage() { 企业名称{result.companyName} - {serialType === 'employee' && (result as EmployeeSerialResult).department && ( + {serialType === 'employee' && (result as EmployeeSerialResult).position && ( <>
职位 - {(result as EmployeeSerialResult).department} + {(result as EmployeeSerialResult).position}
员工姓名 diff --git a/src/pages/Users.tsx b/src/pages/Users.tsx deleted file mode 100644 index 1d11327..0000000 --- a/src/pages/Users.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - Card, - Table, - Button, - Space, - Input, - Select, - Tag, - Modal, - Form, - message, - Pagination, -} from 'antd'; -import { - UserOutlined, - PlusOutlined, - EditOutlined, - DeleteOutlined, - KeyOutlined, -} from '@ant-design/icons'; -import { usersApi, authApi } from '@/services/api'; -import type { User, UserRole, CreateUserRequest, UpdateUserRequest } from '@/types'; - -const ROLE_LABEL: Record = { - admin: '管理员', - technician: '技术员', - employee: '员工(不可登录后台)', - user: '普通用户', -}; - -const ROLE_COLOR: Record = { - admin: 'red', - technician: 'blue', - employee: 'green', - user: 'default', -}; - -function UsersPage() { - const currentUser = authApi.getCurrentUser(); - - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [page, setPage] = useState(1); - const [limit, setLimit] = useState(10); - const [total, setTotal] = useState(0); - const [search, setSearch] = useState(''); - const [roleFilter, setRoleFilter] = useState(); - - const [createVisible, setCreateVisible] = useState(false); - const [createLoading, setCreateLoading] = useState(false); - const [createForm] = Form.useForm(); - - const [editingUser, setEditingUser] = useState(null); - const [editLoading, setEditLoading] = useState(false); - const [editForm] = Form.useForm(); - - const [resetPasswordUser, setResetPasswordUser] = useState(null); - const [resetLoading, setResetLoading] = useState(false); - const [resetForm] = Form.useForm<{ newPassword: string }>(); - - const loadUsers = async () => { - setLoading(true); - try { - const result = await usersApi.list({ - page, - limit, - search: search || undefined, - role: roleFilter, - }); - setUsers(result.data || []); - setTotal(result.pagination?.total || 0); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '加载用户列表失败'); - setUsers([]); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadUsers(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, limit, search, roleFilter]); - - const handleCreate = async (values: CreateUserRequest) => { - setCreateLoading(true); - try { - await usersApi.create(values); - message.success('用户创建成功'); - setCreateVisible(false); - createForm.resetFields(); - loadUsers(); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '创建失败'); - } finally { - setCreateLoading(false); - } - }; - - const openEdit = (user: User) => { - setEditingUser(user); - editForm.setFieldsValue({ - name: user.name, - email: user.email, - role: user.role, - }); - }; - - const handleEdit = async (values: UpdateUserRequest) => { - if (!editingUser) return; - setEditLoading(true); - try { - await usersApi.update(editingUser.id, values); - message.success('用户更新成功'); - setEditingUser(null); - loadUsers(); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '更新失败'); - } finally { - setEditLoading(false); - } - }; - - const handleResetPassword = async (values: { newPassword: string }) => { - if (!resetPasswordUser) return; - setResetLoading(true); - try { - await usersApi.resetPassword(resetPasswordUser.id, values.newPassword); - message.success('密码重置成功'); - setResetPasswordUser(null); - resetForm.resetFields(); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '重置失败'); - } finally { - setResetLoading(false); - } - }; - - const handleDelete = (user: User) => { - Modal.confirm({ - title: '确认删除', - content: `确定要删除用户 "${user.username}" 吗?此操作不可恢复!`, - okText: '确定', - okType: 'danger', - cancelText: '取消', - onOk: async () => { - try { - await usersApi.delete(user.id); - message.success('删除成功'); - loadUsers(); - } catch (err: any) { - message.error(err?.response?.data?.message || err.message || '删除失败'); - } - }, - }); - }; - - const columns = [ - { - title: '用户名', - dataIndex: 'username', - key: 'username', - render: (text: string) => {text}, - }, - { - title: '姓名', - dataIndex: 'name', - key: 'name', - }, - { - title: '邮箱', - dataIndex: 'email', - key: 'email', - render: (text?: string) => text || '-', - }, - { - title: '角色', - dataIndex: 'role', - key: 'role', - width: 100, - render: (role: UserRole) => {ROLE_LABEL[role]}, - }, - { - title: '创建时间', - dataIndex: 'createdAt', - key: 'createdAt', - width: 170, - render: (date: string) => new Date(date).toLocaleString('zh-CN'), - }, - { - title: '操作', - key: 'actions', - width: 260, - render: (_: any, record: User) => ( - - - - {record.id !== currentUser?.id && ( - - )} - - ), - }, - ]; - - return ( -
- - - 用户管理 - - } - extra={ - - } - > - - { - setPage(1); - setSearch(v); - }} - onChange={(e) => { - if (!e.target.value) { - setPage(1); - setSearch(''); - } - }} - /> -
-
- { - setPage(newPage); - setLimit(newLimit); - }} - showSizeChanger - showTotal={(t) => `共计 ${t} 条记录`} - /> -
- - - { - setCreateVisible(false); - createForm.resetFields(); - }} - footer={null} - width={480} - > -
- - - - - - - - - - - - - - - - - - - -