Compare commits

...

7 Commits

Author SHA1 Message Date
Frudrax Cheng 92b77886e4 Update background.webp 2026-06-06 17:03:12 +08:00
Frudrax Cheng 45fb34710a Update background.webp 2026-06-06 16:53:25 +08:00
Frudrax Cheng eaf58e85a8 feat: update login background 2026-06-06 14:13:00 +08:00
Frudrax Cheng d99c91533c chore: rename dashboard 2026-06-06 14:04:00 +08:00
Frudrax Cheng c4b005adb8 feat: update project order types 2026-06-06 13:58:53 +08:00
Frudrax Cheng 15a9f80b7f feat: restrict permission roles 2026-06-06 13:50:54 +08:00
Frudrax Cheng 2892cfb93d chore: rename permission module 2026-06-06 13:36:48 +08:00
15 changed files with 140 additions and 79 deletions
+12 -8
View File
@@ -24,7 +24,7 @@ This is a React 19 + TypeScript frontend for the Zhejiang Beifan Trace Coding Pl
- Permission issuance with automatic employee serial generation
- Product traceability management and public scan pages
- Project work-order management for on-site implementation records
- Aftersales work-order management for admins and technicians
- Aftersales work-order management for admins and assigned work-order roles
- User authentication and profile management
**Tech Stack**: React 19, TypeScript, Vite 7, Ant Design 6, React Router v7, Axios
@@ -95,7 +95,7 @@ src/
- `aftersalesApi` - Aftersales work orders (admin + public)
- `projectOrdersApi` - Project work orders (admin + public)
- `employeesApi` - Employee management (admin only): create/list/update/delete/reset password
- `usersApi` - Assignable technician/admin picker via `assignable`
- `usersApi` - Assignable work-order user picker via `assignable`
- Auth token automatically added via axios interceptor
- All API calls return typed responses based on `src/types/index.ts`
@@ -110,16 +110,18 @@ src/
- `PublicQuery` auto-redirects scanned `zjbf-xm-*` serials to `/project-orders/:serialNumber`.
- Product trace QR codes use `/product-traces/:serialNumber` directly.
- Shared public-page chrome (logo + 备案 footer) lives in `components/PublicLayout.tsx`
- `/admin/employee-serials` is the 权限下发 page despite the legacy route name.
- Technicians should only see/use the aftersales and project work-order modules; admins see all admin menu items.
- `/admin/employee-serials` is the 权限管理 page despite the legacy route name.
- Work-order roles should only see/use the aftersales and project work-order modules; admins see all admin menu items.
### Roles and Permission Issuance
- `UserRole` is limited to `admin` / `technician` / `employee`.
- `UserRole` includes system roles `admin`, legacy `technician`, legacy `employee`, and managed work-order roles `software_engineer`, `hardware_engineer`, `business_manager`, `project_manager`.
- `admin`: full backend access.
- `technician`: work-order module access only.
- `employee`: no backend login access.
- Managed work-order roles: login access only for assigned aftersales/project work orders.
- `technician` is legacy-compatible and should not be offered as a new role.
- `employee` is legacy/no backend login access and should not be offered as a new role.
- Employee creation fields are name, phone, employee number, position, and role.
- Password field is shown and required only for `admin` and `technician`.
- Permission management creation/edit role choices must be exactly: 软件工程师、硬件工程师、商务经理、项目经理.
- Password field is required for all four managed work-order roles.
- Employee creation uses `employeesApi.create`, and the backend automatically creates the employee permission code; do not implement a separate "create then assign code" primary flow.
- Employee rows should display generated `employeeSerials` from the employee list response.
- Employee rows should provide a QR-code view for the active employee serial, using `/query?serial=...` as the scan target.
@@ -136,6 +138,7 @@ src/
- Use label text `现场情况说明` for `issueDescription` in create/detail/public-confirm views.
- In admin detail page, use `工单分配` as the UI label for reassign action.
- Signature display text should be `客户确认签名`.
- Only admins may create aftersales work orders. Managed work-order roles may only list/view/update/submit work orders assigned to themselves.
### Product Traceability
- Admin route: `/admin/product-traces`.
@@ -147,6 +150,7 @@ src/
### Project Work Orders
- Project order serial format is `zjbf-xm-YYMMDDNN`.
- Project orders are for on-site investigation/implementation records.
- Only admins may create project work orders. Managed work-order roles may only list/view/update/submit project orders assigned to themselves.
- Completion requires site images and engineer signature, without customer signature.
- Site image limit is 18.
- Completed project orders use status text `已完成`.
+9 -9
View File
@@ -24,7 +24,7 @@ frontend/
│ │ ├── Login.tsx
│ │ ├── PublicQuery.tsx
│ │ ├── Dashboard.tsx
│ │ ├── EmployeeSerials.tsx # 权限下发(员工主档 + 自动员工码)
│ │ ├── EmployeeSerials.tsx # 权限管理(员工主档 + 自动员工码)
│ │ ├── ProductTraces.tsx # 产品溯源管理
│ │ ├── ProductTracePublic.tsx # 产品溯源扫码公开页
│ │ ├── ProjectOrders.tsx # 项目工单列表(管理后台)
@@ -101,12 +101,12 @@ VITE_API_BASE_URL=/api
### 管理后台
- 控制台(工单统计)
- 权限下发
- 企安工单中台(工单统计)
- 权限管理
- 创建员工时录入姓名、电话、工号、岗位、角色
- 角色仅保留管理员、技术员、员工
- 管理员/技术员有后台登录权限,创建时显示并必填初始密码
- 员工无后台权限,创建时不显示密码框
- 角色仅可选择:软件工程师、硬件工程师、商务经理、项目经理
- 四个角色均有后台登录权限,创建时必须设置初始密码
- 不允许通过权限管理创建管理员或普通员工
- 创建员工后自动生成员工码,列表直接展示员工码
- 支持查看员工码二维码,扫码进入公开查询页
- 员工码查询页展示姓名、电话、工号、岗位
@@ -115,14 +115,14 @@ VITE_API_BASE_URL=/api
- 字段顺序:企业名称、地址、电话、设备信息、质保期、出厂日期、产品序列号、官网链接(可选)、公众号二维码(可选)
- 公众号二维码上传到 OSS,客户扫码产品二维码后可查看产品溯源信息
- 售后工单
- 技术员创建工单、填写处理结果、提交客户确认
- 管理员创建并派单,软件工程师、硬件工程师、商务经理、项目经理只能处理分配给自己的工单
- 工单里的企业名称是售后客户信息,只保存在工单中
- 服务类型:软件故障 / 硬件故障 / 售后维保
- 新建和详情字段使用“现场情况说明”
- 管理员可进行工单分配(重新分配技术员)或强制关闭工单
- 管理员可进行工单分配(重新分配工单负责人)或强制关闭工单
- 工单状态机:待处理 → 待客户确认 → 已完成 / 已退回
- 项目工单
- 用于现场勘查、现场实施等项目任务
- 用于项目勘察、工程实施、定期维保、商务合作等项目任务
- 现场图片最多 18 张,工程师签名后提交完成
- 无客户签字环节,完成后形成项目完成电子表单
- 用户资料管理
+2 -1
View File
@@ -37,7 +37,8 @@ const AdminRoutes = () => {
const AdminIndexRedirect = () => {
const user = authApi.getCurrentUser();
return <Navigate to={user?.role === 'technician' ? 'aftersales' : 'dashboard'} replace />;
const workOrderRoles = ['technician', 'software_engineer', 'hardware_engineer', 'business_manager', 'project_manager'];
return <Navigate to={user?.role && workOrderRoles.includes(user.role) ? 'aftersales' : 'dashboard'} replace />;
};
function App() {
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+11 -9
View File
@@ -17,34 +17,36 @@ import logo from '@/assets/img/logo.png?url';
const { Header, Sider, Content } = Layout;
const WORK_ORDER_ROLES = ['technician', 'software_engineer', 'hardware_engineer', 'business_manager', 'project_manager'];
function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
const user = authApi.getCurrentUser();
const isTechnician = user?.role === 'technician';
const isWorkOrderUser = !!user?.role && WORK_ORDER_ROLES.includes(user.role);
useEffect(() => {
if (
isTechnician &&
isWorkOrderUser &&
!location.pathname.includes('/aftersales') &&
!location.pathname.includes('/project-orders') &&
!location.pathname.includes('/profile')
) {
navigate('/admin/aftersales', { replace: true });
}
}, [isTechnician, location.pathname, navigate]);
}, [isWorkOrderUser, location.pathname, navigate]);
const adminMenuItems = [
{
key: 'dashboard',
icon: <DashboardOutlined />,
label: '控制台',
label: '企安工单中台',
onClick: () => navigate('/admin/dashboard'),
},
{
key: 'employee-serials',
icon: <IdcardOutlined />,
label: '权限下发',
label: '权限管理',
onClick: () => navigate('/admin/employee-serials'),
},
{
@@ -80,7 +82,7 @@ function AdminLayout() {
onClick: () => navigate('/admin/aftersales'),
},
];
const menuItems = isTechnician ? technicianMenuItems : adminMenuItems;
const menuItems = isWorkOrderUser ? technicianMenuItems : adminMenuItems;
const handleLogout = () => {
Modal.confirm({
@@ -133,13 +135,13 @@ function AdminLayout() {
const getTitle = () => {
const path = location.pathname;
if (path.includes('/dashboard')) return '控制台';
if (path.includes('/employee-serials')) return '权限下发';
if (path.includes('/dashboard')) return '企安工单中台';
if (path.includes('/employee-serials')) return '权限管理';
if (path.includes('/product-traces')) return '产品溯源';
if (path.includes('/project-orders')) return '项目工单';
if (path.includes('/aftersales')) return '售后工单';
if (path.includes('/profile')) return '用户资料';
return '控制台';
return '企安工单中台';
};
return (
+2 -2
View File
@@ -224,7 +224,7 @@ function AftersalesPage() {
<span></span>
</Space>
}
extra={
extra={isAdmin ? (
<Button
type="primary"
icon={<PlusOutlined />}
@@ -232,7 +232,7 @@ function AftersalesPage() {
>
</Button>
}
) : null}
>
<Space style={{ marginBottom: 16, flexWrap: 'wrap' }}>
<Input.Search
+11 -3
View File
@@ -57,6 +57,14 @@ const WORK_ORDER_STATUS_COLOR: Record<AftersalesWorkOrderStatus, string> = {
rejected: 'warning',
};
const ROLE_LABEL: Record<string, string> = {
technician: '技术员(旧)',
software_engineer: '软件工程师',
hardware_engineer: '硬件工程师',
business_manager: '商务经理',
project_manager: '项目经理',
};
function statusStepIndex(status: AftersalesWorkOrderStatus): number {
switch (status) {
case 'created':
@@ -670,16 +678,16 @@ function AftersalesDetailPage() {
cancelText="取消"
>
<Form layout="vertical">
<Form.Item label="选择技术员" required>
<Form.Item label="选择工单负责人" required>
<Select
placeholder="请选择技术员或管理员"
placeholder="请选择工单负责人"
value={reassignTechnicianId}
onChange={(v) => setReassignTechnicianId(v)}
showSearch
optionFilterProp="label"
options={assignableUsers.map((u) => ({
value: u.id,
label: `${u.name}${u.username}${u.role === 'admin' ? ' · 管理员' : ' · 技术员'}`,
label: `${u.name}${u.username} · ${ROLE_LABEL[u.role] || u.role}`,
}))}
/>
</Form.Item>
+23 -19
View File
@@ -27,18 +27,32 @@ import type { User, UserRole, CreateUserRequest, UpdateUserRequest, EmployeeSeri
const ROLE_LABEL: Record<UserRole, string> = {
admin: '管理员',
technician: '技术员',
technician: '技术员(旧)',
employee: '员工',
software_engineer: '软件工程师',
hardware_engineer: '硬件工程师',
business_manager: '商务经理',
project_manager: '项目经理',
};
const ROLE_COLOR: Record<UserRole, string> = {
admin: 'red',
technician: 'blue',
technician: 'default',
employee: 'green',
software_engineer: 'blue',
hardware_engineer: 'cyan',
business_manager: 'gold',
project_manager: 'purple',
};
const BACKEND_ROLES: UserRole[] = ['admin', 'technician'];
const ROLE_OPTIONS = (Object.keys(ROLE_LABEL) as UserRole[]).map((value) => ({
const WORK_ORDER_ROLES: UserRole[] = [
'software_engineer',
'hardware_engineer',
'business_manager',
'project_manager',
];
const BACKEND_ROLES: UserRole[] = ['admin', 'technician', ...WORK_ORDER_ROLES];
const ROLE_OPTIONS = WORK_ORDER_ROLES.map((value) => ({
value,
label: ROLE_LABEL[value],
}));
@@ -103,15 +117,12 @@ function EmployeeSerialsPage() {
const openCreate = () => {
createForm.resetFields();
createForm.setFieldsValue({ role: 'employee' });
createForm.setFieldsValue({ role: 'software_engineer' });
setCreateVisible(true);
};
const handleCreate = async (values: CreateUserRequest) => {
const payload = { ...values, username: values.employeeNo };
if (!canLoginBackend(values.role)) {
delete payload.password;
}
setCreateLoading(true);
try {
@@ -290,7 +301,7 @@ function EmployeeSerialsPage() {
title={
<Space>
<UserOutlined />
<span></span>
<span></span>
</Space>
}
extra={
@@ -353,7 +364,7 @@ function EmployeeSerialsPage() {
footer={null}
width={520}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ role: 'employee' }}>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ role: 'software_engineer' }}>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input />
</Form.Item>
@@ -367,16 +378,9 @@ function EmployeeSerialsPage() {
<Input />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true, message: '请选择角色' }]}>
<Select
options={ROLE_OPTIONS}
onChange={(role: UserRole) => {
if (!canLoginBackend(role)) {
createForm.setFieldValue('password', undefined);
}
}}
/>
<Select options={ROLE_OPTIONS} />
</Form.Item>
{canLoginBackend(createRole) && (
{createRole && (
<Form.Item
name="password"
label="初始密码"
+2 -1
View File
@@ -25,7 +25,8 @@ function LoginPage() {
message.success('登录成功!');
setTimeout(() => {
navigate(user.role === 'technician' ? '/admin/aftersales' : '/admin/dashboard');
const workOrderRoles = ['technician', 'software_engineer', 'hardware_engineer', 'business_manager', 'project_manager'];
navigate(workOrderRoles.includes(user.role) ? '/admin/aftersales' : '/admin/dashboard');
}, 500);
} catch (error: any) {
message.error(error.message || '登录失败,请重试');
+12 -2
View File
@@ -4,6 +4,16 @@ import { UserOutlined, LockOutlined, SafetyOutlined, KeyOutlined, ExclamationCir
import { authApi } from '@/services/api';
import type { User } from '@/types';
const ROLE_LABEL: Record<string, string> = {
admin: '管理员',
technician: '技术员(旧)',
employee: '员工',
software_engineer: '软件工程师',
hardware_engineer: '硬件工程师',
business_manager: '商务经理',
project_manager: '项目经理',
};
function ProfilePage() {
const [profileForm] = Form.useForm();
const [passwordForm] = Form.useForm();
@@ -87,7 +97,7 @@ function ProfilePage() {
<Descriptions.Item label="用户名">{user.username}</Descriptions.Item>
<Descriptions.Item label="姓名">{user.name}</Descriptions.Item>
<Descriptions.Item label="邮箱">{user.email || '-'}</Descriptions.Item>
<Descriptions.Item label="角色">{user.role}</Descriptions.Item>
<Descriptions.Item label="角色">{ROLE_LABEL[user.role] || user.role}</Descriptions.Item>
<Descriptions.Item label="创建时间">
{new Date(user.createdAt).toLocaleString('zh-CN')}
</Descriptions.Item>
@@ -270,4 +280,4 @@ function ProfilePage() {
);
}
export default ProfilePage;
export default ProfilePage;
+5 -4
View File
@@ -16,9 +16,10 @@ import './styles/PublicQuery.css';
import './styles/AftersalesConfirm.css';
const PROJECT_TYPE_LABEL: Record<ProjectType, string> = {
survey: '现场勘查',
implementation: '现场实施',
maintenance: '项目维保',
survey: '项目勘察',
implementation: '工程实施',
maintenance: '定期维保',
business: '商务合作',
other: '其他',
};
@@ -304,7 +305,7 @@ function ProjectOrderCompletePage() {
rows={4}
value={completionNote}
onChange={(e) => setCompletionNote(e.target.value)}
placeholder="请描述现场勘查、实施过程和最终完成情况"
placeholder="请描述项目勘察、工程实施或现场完成情况"
/>
</div>
+16 -7
View File
@@ -38,9 +38,10 @@ import type {
import './styles/AftersalesDetail.css';
const PROJECT_TYPE_LABEL: Record<ProjectType, string> = {
survey: '现场勘查',
implementation: '现场实施',
maintenance: '项目维保',
survey: '项目勘察',
implementation: '工程实施',
maintenance: '定期维保',
business: '商务合作',
other: '其他',
};
@@ -56,6 +57,14 @@ const WORK_ORDER_STATUS_COLOR: Record<ProjectOrderStatus, string> = {
closed: 'success',
};
const ROLE_LABEL: Record<string, string> = {
technician: '技术员(旧)',
software_engineer: '软件工程师',
hardware_engineer: '硬件工程师',
business_manager: '商务经理',
project_manager: '项目经理',
};
function statusStepIndex(status: ProjectOrderStatus): number {
switch (status) {
case 'created':
@@ -475,7 +484,7 @@ function ProjectOrderDetailPage() {
label="完成说明"
tooltip="现场完成后填写,扫码页和电子表单会展示此内容"
>
<Input.TextArea rows={4} placeholder="请描述现场勘查、实施过程和最终完成情况" />
<Input.TextArea rows={4} placeholder="请描述项目勘察、工程实施或现场完成情况" />
</Form.Item>
{canEdit && (
@@ -640,16 +649,16 @@ function ProjectOrderDetailPage() {
cancelText="取消"
>
<Form layout="vertical">
<Form.Item label="选择技术员" required>
<Form.Item label="选择工单负责人" required>
<Select
placeholder="请选择技术员或管理员"
placeholder="请选择工单负责人"
value={reassignTechnicianId}
onChange={(v) => setReassignTechnicianId(v)}
showSearch
optionFilterProp="label"
options={assignableUsers.map((u) => ({
value: u.id,
label: `${u.name}${u.username}${u.role === 'admin' ? ' · 管理员' : ' · 技术员'}`,
label: `${u.name}${u.username} · ${ROLE_LABEL[u.role] || u.role}`,
}))}
/>
</Form.Item>
+6 -5
View File
@@ -40,9 +40,10 @@ const WORK_ORDER_STATUS_COLOR: Record<ProjectOrderStatus, string> = {
};
const PROJECT_TYPE_LABEL: Record<ProjectType, string> = {
survey: '现场勘查',
implementation: '现场实施',
maintenance: '项目维保',
survey: '项目勘察',
implementation: '工程实施',
maintenance: '定期维保',
business: '商务合作',
other: '其他',
};
@@ -218,7 +219,7 @@ function ProjectOrdersPage() {
<span></span>
</Space>
}
extra={
extra={isAdmin ? (
<Button
type="primary"
icon={<PlusOutlined />}
@@ -226,7 +227,7 @@ function ProjectOrdersPage() {
>
</Button>
}
) : null}
>
<Space style={{ marginBottom: 16, flexWrap: 'wrap' }}>
<Input.Search
+20 -7
View File
@@ -2,10 +2,13 @@
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
align-items: flex-start;
justify-content: center;
padding: 20px;
background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
padding: 48px 20px 48px clamp(32px, 8vw, 120px);
background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.6) 36%, rgba(255, 255, 255, 0.12) 66%), url('@/assets/img/background.webp');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
overflow: hidden;
}
@@ -13,9 +16,9 @@
width: 100%;
max-width: 480px;
padding: 24px;
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(14px);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.36);
border-radius: 24px;
animation: slideIn 0.5s ease-out;
}
@@ -63,6 +66,8 @@
.copyright {
margin-top: 32px;
width: 100%;
max-width: 480px;
text-align: center;
color: #6B7280;
font-size: 14px;
@@ -86,4 +91,12 @@
height: 16px;
vertical-align: middle;
margin-right: 4px;
}
}
@media (max-width: 768px) {
.login-container {
align-items: center;
padding: 20px;
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.78) 0%, rgba(255, 255, 255, 0.9) 100%), url('@/assets/img/background.webp');
}
}
+9 -2
View File
@@ -1,4 +1,11 @@
export type UserRole = 'admin' | 'technician' | 'employee';
export type UserRole =
| 'admin'
| 'technician'
| 'employee'
| 'software_engineer'
| 'hardware_engineer'
| 'business_manager'
| 'project_manager';
export interface User {
id: number;
@@ -263,7 +270,7 @@ export interface CustomerConfirmRequest {
rejectReason?: string;
}
export type ProjectType = 'survey' | 'implementation' | 'maintenance' | 'other';
export type ProjectType = 'survey' | 'implementation' | 'maintenance' | 'business' | 'other';
export type ProjectOrderStatus = 'created' | 'pending_completion' | 'closed';
export interface ProjectOrder {