Compare commits

...

3 Commits

Author SHA1 Message Date
Frudrax Cheng f61004ba12 Show aftersales stats and recent orders on dashboard
- Adds a second row of cards (total / pending / closed / rejected)
- New "最近售后工单" table linking to detail pages
- DashboardStats type extended; dashboardApi maps backend overview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:04:25 +08:00
Frudrax Cheng eab66bc3e9 Add user management page and technician picker for reassign
- New /admin/users page (admin only) for creating technicians,
  editing role/email, resetting passwords, deleting users
- AftersalesDetail reassign modal now uses a searchable Select
  populated from /api/users/assignable instead of raw user ID input
- Menu entry only shown to admins

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:58:02 +08:00
Frudrax Cheng 6fef517556 Add aftersales work order frontend pages
- Public scan-to-confirm page (/aftersales/:sn) with phone last-4 verification
- Admin list + detail pages with state machine, QR generation, reassign, force-close
- PublicLayout extracted from PublicQuery so both pages share logo + 备案 chrome
- PublicQuery auto-redirects scanned zjbf-sh-* serials to the aftersales page
- AdminLayout: new 售后工单 menu entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:51:25 +08:00
14 changed files with 2212 additions and 56 deletions
+6
View File
@@ -88,6 +88,9 @@ src/
- `serialApi` - Serial number management
- `companyApi` - Company management
- `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
- Auth token automatically added via axios interceptor
- All API calls return typed responses based on `src/types/index.ts`
@@ -97,6 +100,9 @@ src/
- Admin pages wrapped with `<AdminRoutes>` layout component
- Use `useNavigate()` for programmatic navigation
- Use `useLocation()` to get current path
- 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`
### State Management
- Use React hooks (`useState`, `useEffect`) for local component state
+16 -2
View File
@@ -18,13 +18,18 @@
frontend/
├── src/
│ ├── components/ # 通用组件
│ │ ── AdminLayout.tsx
│ │ ── AdminLayout.tsx
│ │ └── PublicLayout.tsx # 公开页统一布局(Logo + 备案)
│ ├── pages/ # 页面组件
│ │ ├── Login.tsx
│ │ ├── PublicQuery.tsx
│ │ ├── Dashboard.tsx
│ │ ├── Generate.tsx
│ │ ├── Manage.tsx
│ │ ├── EmployeeSerials.tsx
│ │ ├── Aftersales.tsx # 售后工单列表(管理后台)
│ │ ├── AftersalesDetail.tsx # 售后工单详情(管理后台)
│ │ ├── AftersalesConfirm.tsx # 售后工单扫码确认(公开)
│ │ ├── Users.tsx # 用户管理(仅管理员)
│ │ └── Profile.tsx
│ ├── services/ # API 服务层
│ │ └── api.ts
@@ -86,6 +91,8 @@ VITE_API_BASE_URL=/api
- 用户登录
- 公开查询序列号(支持二维码扫描)
- 扫描到 `zjbf-sh-*` 售后码时自动跳转到售后确认页
- 售后工单确认页(扫码 → 手机号末四位校验 → 已授权/未授权)
### 管理后台
@@ -98,6 +105,13 @@ VITE_API_BASE_URL=/api
- 查看序列号列表
- 吊销企业/序列号
- 查看序列号二维码
- 员工管理(员工赋码生成与维护)
- 售后工单
- 技术员创建工单、填写处理结果、提交客户确认
- 管理员可重新分配技术员或强制关闭工单
- 工单状态机:待处理 → 待客户确认 → 已完成 / 已退回
- 用户管理(仅管理员可见)
- 创建技术员/管理员账号、修改角色、重置密码、删除用户
- 用户资料管理
## License
+10 -6
View File
@@ -7,6 +7,10 @@ import DashboardPage from './pages/Dashboard';
import ManagePage from './pages/Manage';
import ProfilePage from './pages/Profile';
import EmployeeSerialsPage from './pages/EmployeeSerials';
import AftersalesPage from './pages/Aftersales';
import AftersalesDetailPage from './pages/AftersalesDetail';
import AftersalesConfirmPage from './pages/AftersalesConfirm';
import UsersPage from './pages/Users';
const PrivateRoute = () => {
const user = authApi.getCurrentUser();
@@ -25,11 +29,7 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
};
const AdminRoutes = () => {
return (
<AdminLayout>
<Outlet />
</AdminLayout>
);
return <AdminLayout />;
};
function App() {
@@ -43,13 +43,17 @@ function App() {
</PublicRoute>
} />
<Route path="/query" element={<PublicQueryPage />} />
<Route path="/aftersales/:serialNumber" element={<AftersalesConfirmPage />} />
<Route element={<PrivateRoute />}>
<Route element={<AdminRoutes />}>
<Route path="/admin" element={<Navigate to="dashboard" replace />} />
<Route path="/admin/dashboard" element={<DashboardPage />} />
<Route path="/admin/manage" element={<ManagePage />} />
<Route path="/admin/employee-serials" element={<EmployeeSerialsPage />} />
<Route path="/admin/aftersales" element={<AftersalesPage />} />
<Route path="/admin/aftersales/:serialNumber" element={<AftersalesDetailPage />} />
<Route path="/admin/users" element={<UsersPage />} />
<Route path="/admin/profile" element={<ProfilePage />} />
</Route>
</Route>
+22
View File
@@ -7,6 +7,8 @@ import {
LogoutOutlined,
ExclamationCircleOutlined,
IdcardOutlined,
ToolOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import { authApi } from '@/services/api';
import './styles/AdminLayout.css';
@@ -38,6 +40,22 @@ function AdminLayout() {
label: '员工管理',
onClick: () => navigate('/admin/employee-serials'),
},
{
key: 'aftersales',
icon: <ToolOutlined />,
label: '售后工单',
onClick: () => navigate('/admin/aftersales'),
},
...(user?.role === 'admin'
? [
{
key: 'users',
icon: <UsergroupAddOutlined />,
label: '用户管理',
onClick: () => navigate('/admin/users'),
},
]
: []),
];
const handleLogout = () => {
@@ -83,6 +101,8 @@ function AdminLayout() {
if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/manage')) return 'manage';
if (path.includes('/employee-serials')) return 'employee-serials';
if (path.includes('/aftersales')) return 'aftersales';
if (path.includes('/users')) return 'users';
if (path.includes('/profile')) return 'profile';
return 'dashboard';
};
@@ -92,6 +112,8 @@ function AdminLayout() {
if (path.includes('/dashboard')) return '控制台';
if (path.includes('/manage')) return '企业管理';
if (path.includes('/employee-serials')) return '员工管理';
if (path.includes('/aftersales')) return '售后工单';
if (path.includes('/users')) return '用户管理';
if (path.includes('/profile')) return '用户资料';
return '控制台';
};
+47
View File
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import logo from '@/assets/img/logo.png?url';
import beian from '@/assets/img/beian.png?url';
import '@/pages/styles/PublicQuery.css';
interface PublicLayoutProps {
children: ReactNode;
}
function PublicLayout({ children }: PublicLayoutProps) {
return (
<div className="public-query-container">
{children}
<div className="copyright">
<p>
Copyright © 2026 . All Rights Reserved. |{' '}
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
ICP备2025170226号-4
</a>
</p>
<p>
<a
href="https://beian.mps.gov.cn/#/query/webSearch?code=33011002018371"
target="_blank"
rel="noopener noreferrer"
>
<img
src={beian}
alt="备案图标"
style={{ height: '20px', marginRight: '5px', verticalAlign: 'middle' }}
/>
33011002018371
</a>
</p>
</div>
</div>
);
}
export const PublicLogo = () => (
<div className="query-logo">
<img src={logo} alt="Logo" style={{ height: '24px' }} />
</div>
);
export default PublicLayout;
+391
View File
@@ -0,0 +1,391 @@
import { useEffect, useState } from 'react';
import {
Card,
Table,
Input,
Button,
Space,
message,
Modal,
Tag,
Form,
Select,
Pagination,
} from 'antd';
import {
ToolOutlined,
PlusOutlined,
DeleteOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { aftersalesApi, authApi } from '@/services/api';
import type {
AftersalesOrder,
AftersalesWorkOrderStatus,
AftersalesServiceType,
CreateAftersalesRequest,
} from '@/types';
const WORK_ORDER_STATUS_LABEL: Record<AftersalesWorkOrderStatus, string> = {
created: '待处理',
pending_confirmation: '待客户确认',
closed: '已完成',
rejected: '已退回',
};
const WORK_ORDER_STATUS_COLOR: Record<AftersalesWorkOrderStatus, string> = {
created: 'default',
pending_confirmation: 'processing',
closed: 'success',
rejected: 'warning',
};
const SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
software: '软件',
hardware: '硬件',
other: '其他',
};
function AftersalesPage() {
const navigate = useNavigate();
const currentUser = authApi.getCurrentUser();
const isAdmin = currentUser?.role === 'admin';
const [orders, setOrders] = useState<AftersalesOrder[]>([]);
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 [workOrderStatus, setWorkOrderStatus] = useState<AftersalesWorkOrderStatus | undefined>();
const [serviceType, setServiceType] = useState<AftersalesServiceType | undefined>();
const [mineOnly, setMineOnly] = useState(false);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<CreateAftersalesRequest>();
const loadOrders = async () => {
setLoading(true);
try {
const result = await aftersalesApi.list({
page,
limit,
search: search || undefined,
workOrderStatus,
serviceType,
mine: isAdmin ? mineOnly : undefined,
});
setOrders(result.data || []);
setTotal(result.pagination?.total || 0);
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '加载工单列表失败');
setOrders([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadOrders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, limit, search, workOrderStatus, serviceType, mineOnly]);
const handleCreate = async (values: CreateAftersalesRequest) => {
setCreating(true);
try {
const order = await aftersalesApi.create(values);
message.success(`工单创建成功:${order.serialNumber}`);
setCreateModalVisible(false);
createForm.resetFields();
navigate(`/admin/aftersales/${order.serialNumber}`);
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '创建失败');
} finally {
setCreating(false);
}
};
const handleDelete = (order: AftersalesOrder) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除工单 ${order.serialNumber} 吗?此操作不可恢复!`,
okText: '确定',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await aftersalesApi.delete(order.serialNumber);
message.success('删除成功');
loadOrders();
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '删除失败');
}
},
});
};
const columns = [
{
title: '工单号',
dataIndex: 'serialNumber',
key: 'serialNumber',
width: 180,
render: (sn: string) => (
<span style={{ fontFamily: 'monospace', color: '#165DFF' }}>{sn}</span>
),
},
{
title: '客户公司',
dataIndex: 'companyName',
key: 'companyName',
},
{
title: '联系人',
dataIndex: 'contactName',
key: 'contactName',
width: 100,
},
{
title: '服务类型',
dataIndex: 'serviceType',
key: 'serviceType',
width: 100,
render: (type: AftersalesServiceType) => SERVICE_TYPE_LABEL[type] || type,
},
{
title: '负责技术员',
key: 'technician',
width: 120,
render: (_: any, record: AftersalesOrder) => record.technician?.name || '-',
},
{
title: '工单状态',
dataIndex: 'workOrderStatus',
key: 'workOrderStatus',
width: 130,
render: (status: AftersalesWorkOrderStatus, record: AftersalesOrder) => (
<Space size={4}>
<Tag color={WORK_ORDER_STATUS_COLOR[status]}>
{WORK_ORDER_STATUS_LABEL[status]}
</Tag>
{record.rejectCount > 0 && (
<Tag color="orange" style={{ marginLeft: 0 }}>
退 {record.rejectCount}
</Tag>
)}
</Space>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 170,
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 180,
render: (_: any, record: AftersalesOrder) => (
<Space>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/admin/aftersales/${record.serialNumber}`)}
>
</Button>
{isAdmin && (
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
>
</Button>
)}
</Space>
),
},
];
return (
<div>
<Card
title={
<Space>
<ToolOutlined />
<span></span>
</Space>
}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalVisible(true)}
>
</Button>
}
>
<Space style={{ marginBottom: 16, flexWrap: 'wrap' }}>
<Input.Search
placeholder="搜索工单号/公司/联系人"
allowClear
style={{ width: 260 }}
onSearch={(v) => {
setPage(1);
setSearch(v);
}}
onChange={(e) => {
if (!e.target.value) {
setPage(1);
setSearch('');
}
}}
/>
<Select
placeholder="工单状态"
allowClear
style={{ width: 160 }}
value={workOrderStatus}
onChange={(v) => {
setPage(1);
setWorkOrderStatus(v);
}}
options={(Object.keys(WORK_ORDER_STATUS_LABEL) as AftersalesWorkOrderStatus[]).map(
(k) => ({ value: k, label: WORK_ORDER_STATUS_LABEL[k] })
)}
/>
<Select
placeholder="服务类型"
allowClear
style={{ width: 130 }}
value={serviceType}
onChange={(v) => {
setPage(1);
setServiceType(v);
}}
options={(Object.keys(SERVICE_TYPE_LABEL) as AftersalesServiceType[]).map((k) => ({
value: k,
label: SERVICE_TYPE_LABEL[k],
}))}
/>
{isAdmin && (
<Button
type={mineOnly ? 'primary' : 'default'}
onClick={() => {
setPage(1);
setMineOnly(!mineOnly);
}}
>
{mineOnly ? '查看全部' : '我负责的'}
</Button>
)}
</Space>
<Table
columns={columns}
dataSource={orders}
rowKey="serialNumber"
loading={loading}
pagination={false}
/>
<div style={{ marginTop: 16 }}>
<Pagination
current={page}
pageSize={limit}
total={total}
onChange={(newPage, newLimit) => {
setPage(newPage);
setLimit(newLimit);
}}
showSizeChanger
showTotal={(t) => `共计 ${t} 条记录`}
/>
</div>
</Card>
<Modal
title="新建售后工单"
open={createModalVisible}
onCancel={() => {
setCreateModalVisible(false);
createForm.resetFields();
}}
footer={null}
width={560}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
<Form.Item
name="companyName"
label="客户公司"
rules={[{ required: true, message: '请输入客户公司' }]}
>
<Input placeholder="例如:浙江北凡科技" />
</Form.Item>
<Form.Item
name="companyAddress"
label="公司位置"
rules={[{ required: true, message: '请输入公司位置' }]}
>
<Input placeholder="例如:杭州市西湖区文三路 100 号" />
</Form.Item>
<Form.Item
name="contactName"
label="联系人"
rules={[{ required: true, message: '请输入联系人' }]}
>
<Input placeholder="联系人姓名" />
</Form.Item>
<Form.Item
name="contactPhone"
label="联系电话"
rules={[
{ required: true, message: '请输入联系电话' },
{ pattern: /^\d{11}$/, message: '请输入 11 位手机号' },
]}
>
<Input placeholder="11 位手机号" maxLength={11} />
</Form.Item>
<Form.Item
name="serviceType"
label="服务类型"
rules={[{ required: true, message: '请选择服务类型' }]}
>
<Select
placeholder="请选择"
options={(Object.keys(SERVICE_TYPE_LABEL) as AftersalesServiceType[]).map((k) => ({
value: k,
label: SERVICE_TYPE_LABEL[k],
}))}
/>
</Form.Item>
<Form.Item
name="issueDescription"
label="问题反馈"
rules={[{ required: true, message: '请填写问题反馈' }]}
>
<Input.TextArea rows={4} placeholder="请描述客户反馈的问题" />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setCreateModalVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={creating}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default AftersalesPage;
+275
View File
@@ -0,0 +1,275 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Input, Button, Spin, Result, message, Modal } from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { aftersalesApi } from '@/services/api';
import type { AftersalesPublicView, AftersalesServiceType } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import './styles/PublicQuery.css';
import './styles/AftersalesConfirm.css';
const SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
software: '软件',
hardware: '硬件',
other: '其他',
};
function AftersalesConfirmPage() {
const { serialNumber = '' } = useParams<{ serialNumber: string }>();
const [order, setOrder] = useState<AftersalesPublicView | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [phoneLast4, setPhoneLast4] = useState('');
const [submitting, setSubmitting] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [showRejectDialog, setShowRejectDialog] = useState(false);
const loadOrder = async () => {
setLoading(true);
setError(null);
try {
const data = await aftersalesApi.publicQuery(serialNumber);
setOrder(data);
} catch (err: any) {
setError(err?.response?.data?.message || err.message || '查询失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (serialNumber) {
loadOrder();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serialNumber]);
const validatePhoneInput = () => {
if (!/^\d{4}$/.test(phoneLast4)) {
message.error('请输入 4 位手机号');
return false;
}
return true;
};
const handleAuthorize = async () => {
if (!validatePhoneInput()) return;
setSubmitting(true);
try {
const updated = await aftersalesApi.customerConfirm(serialNumber, {
phoneLast4,
action: 'authorize',
});
setOrder(updated);
message.success('感谢您的确认,工单已完成');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '提交失败');
} finally {
setSubmitting(false);
}
};
const handleReject = async () => {
if (!validatePhoneInput()) return;
setShowRejectDialog(true);
};
const confirmReject = async () => {
setSubmitting(true);
try {
const updated = await aftersalesApi.customerConfirm(serialNumber, {
phoneLast4,
action: 'reject',
rejectReason: rejectReason.trim() || undefined,
});
setOrder(updated);
setShowRejectDialog(false);
message.success('已通知技术员重新处理');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '提交失败');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<PublicLayout>
<Card className="query-card" bordered={false}>
<div className="loading-container">
<Spin size="large" />
<p>...</p>
</div>
</Card>
</PublicLayout>
);
}
if (error || !order) {
return (
<PublicLayout>
<Card className="query-card" bordered={false}>
<Result
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
title="工单不存在"
subTitle={error || '请检查您扫描的二维码是否正确'}
/>
</Card>
</PublicLayout>
);
}
const isPending = order.workOrderStatus === 'pending_confirmation';
const isClosed = order.workOrderStatus === 'closed';
const isRejected = order.workOrderStatus === 'rejected';
const isCreated = order.workOrderStatus === 'created';
const renderStatusBanner = () => {
if (isClosed) {
return (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '64px' }} />}
title="工单已完成"
subTitle={
order.confirmedAt
? `确认时间:${new Date(order.confirmedAt).toLocaleString('zh-CN')}`
: undefined
}
/>
);
}
if (isRejected) {
return (
<Result
icon={<ExclamationCircleOutlined style={{ color: '#faad14', fontSize: '64px' }} />}
title="工单已退回"
subTitle="技术员将重新处理,请耐心等待"
/>
);
}
if (isCreated) {
return (
<Result
icon={<ClockCircleOutlined style={{ color: '#1677ff', fontSize: '64px' }} />}
title="工单处理中"
subTitle="技术员尚未提交客户确认,请稍后再来查看"
/>
);
}
return null;
};
return (
<PublicLayout>
<Card className="query-card aftersales-confirm-card" bordered={false}>
<div className="query-header">
<PublicLogo />
<h1 className="aftersales-title"></h1>
<p className="aftersales-serial">{order.serialNumber}</p>
</div>
{renderStatusBanner()}
<div className="result-details aftersales-details">
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.companyName}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.companyAddress}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.contactName}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{SERVICE_TYPE_LABEL[order.serviceType] || order.serviceType}</span>
</div>
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.issueDescription}</span>
</div>
{order.resolutionNote && (
<div className="detail-item detail-item-block">
<span className="label"></span>
<span className="value value-block">{order.resolutionNote}</span>
</div>
)}
{order.technicianName && (
<div className="detail-item">
<span className="label"></span>
<span className="value">{order.technicianName}</span>
</div>
)}
<div className="detail-item">
<span className="label"></span>
<span className="value">{new Date(order.createdAt).toLocaleString('zh-CN')}</span>
</div>
</div>
{isPending && (
<>
<div className="aftersales-phone-verify">
<p className="aftersales-phone-tip"> 4 </p>
<Input
size="large"
placeholder="手机号后 4 位"
maxLength={4}
value={phoneLast4}
onChange={(e) => setPhoneLast4(e.target.value.replace(/\D/g, ''))}
inputMode="numeric"
/>
</div>
<div className="aftersales-actions">
<Button
size="large"
danger
block
loading={submitting}
onClick={handleReject}
>
</Button>
<Button
type="primary"
size="large"
block
loading={submitting}
onClick={handleAuthorize}
>
</Button>
</div>
</>
)}
</Card>
<Modal
title="请说明退回原因(可选)"
open={showRejectDialog}
onCancel={() => setShowRejectDialog(false)}
onOk={confirmReject}
okText="确认退回"
cancelText="取消"
confirmLoading={submitting}
>
<Input.TextArea
rows={4}
placeholder="请简要说明未授权的原因,方便技术员改进"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
maxLength={200}
/>
</Modal>
</PublicLayout>
);
}
export default AftersalesConfirmPage;
+469
View File
@@ -0,0 +1,469 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card,
Form,
Input,
Select,
Button,
Space,
Tag,
message,
Modal,
Spin,
Steps,
Descriptions,
Divider,
} from 'antd';
import {
ArrowLeftOutlined,
QrcodeOutlined,
SaveOutlined,
SendOutlined,
StopOutlined,
UserSwitchOutlined,
} from '@ant-design/icons';
import { aftersalesApi, authApi, usersApi } from '@/services/api';
import type {
AftersalesOrder,
AftersalesServiceType,
AftersalesWorkOrderStatus,
UpdateAftersalesRequest,
User,
} from '@/types';
const SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
software: '软件',
hardware: '硬件',
other: '其他',
};
const WORK_ORDER_STATUS_LABEL: Record<AftersalesWorkOrderStatus, string> = {
created: '待处理',
pending_confirmation: '待客户确认',
closed: '已完成',
rejected: '已退回',
};
const WORK_ORDER_STATUS_COLOR: Record<AftersalesWorkOrderStatus, string> = {
created: 'default',
pending_confirmation: 'processing',
closed: 'success',
rejected: 'warning',
};
function statusStepIndex(status: AftersalesWorkOrderStatus): number {
switch (status) {
case 'created':
return 0;
case 'pending_confirmation':
return 1;
case 'closed':
case 'rejected':
return 2;
}
}
function AftersalesDetailPage() {
const { serialNumber = '' } = useParams<{ serialNumber: string }>();
const navigate = useNavigate();
const currentUser = authApi.getCurrentUser();
const isAdmin = currentUser?.role === 'admin';
const [order, setOrder] = useState<AftersalesOrder | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const [qrModalVisible, setQrModalVisible] = useState(false);
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [qrUrl, setQrUrl] = useState('');
const [reassignModalVisible, setReassignModalVisible] = useState(false);
const [reassignTechnicianId, setReassignTechnicianId] = useState<number | undefined>();
const [assignableUsers, setAssignableUsers] = useState<User[]>([]);
const loadOrder = async () => {
setLoading(true);
try {
const data = await aftersalesApi.get(serialNumber);
setOrder(data);
form.setFieldsValue({
companyAddress: data.companyAddress,
contactName: data.contactName,
contactPhone: data.contactPhone,
serviceType: data.serviceType,
issueDescription: data.issueDescription,
resolutionNote: data.resolutionNote,
});
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '加载工单失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (serialNumber) loadOrder();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serialNumber]);
const openReassign = async () => {
setReassignModalVisible(true);
if (assignableUsers.length === 0) {
try {
const users = await usersApi.assignable();
setAssignableUsers(users);
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '加载技术员列表失败');
}
}
};
const handleSave = async (values: UpdateAftersalesRequest) => {
if (!order) return;
setSaving(true);
try {
const updated = await aftersalesApi.update(order.serialNumber, values);
setOrder(updated);
message.success('保存成功');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleSubmitForConfirmation = async () => {
if (!order) return;
const resolutionNote = form.getFieldValue('resolutionNote')?.trim();
if (!resolutionNote) {
message.error('请先填写处理结果');
return;
}
Modal.confirm({
title: '确认提交',
content: '提交后工单将进入"待客户确认"状态,客户扫码确认后才能关闭工单。',
okText: '提交',
cancelText: '取消',
onOk: async () => {
setSubmitting(true);
try {
const updated = await aftersalesApi.submit(order.serialNumber, resolutionNote);
setOrder(updated);
message.success('已提交客户确认');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '提交失败');
} finally {
setSubmitting(false);
}
},
});
};
const handleGenerateQrCode = async () => {
if (!order) return;
try {
const result = await aftersalesApi.generateQrCode(order.serialNumber);
const data = result.qrCodeData.startsWith('data:')
? result.qrCodeData
: `data:image/png;base64,${result.qrCodeData}`;
setQrCodeDataUrl(data);
setQrUrl(result.queryUrl);
setQrModalVisible(true);
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '生成二维码失败');
}
};
const handleDownloadQR = () => {
if (!order || !qrCodeDataUrl) return;
const link = document.createElement('a');
link.download = `${order.serialNumber}.png`;
link.href = qrCodeDataUrl;
link.click();
};
const handleForceClose = () => {
if (!order) return;
Modal.confirm({
title: '强制关闭工单',
content: '强制关闭后工单状态将变为"已完成",且不可恢复。',
okText: '强制关闭',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const updated = await aftersalesApi.forceClose(order.serialNumber);
setOrder(updated);
message.success('工单已强制关闭');
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '强制关闭失败');
}
},
});
};
const handleReassign = async () => {
if (!order || !reassignTechnicianId) {
message.error('请输入新的技术员 ID');
return;
}
try {
const updated = await aftersalesApi.reassign(order.serialNumber, reassignTechnicianId);
setOrder(updated);
message.success('重新分配成功');
setReassignModalVisible(false);
setReassignTechnicianId(undefined);
} catch (err: any) {
message.error(err?.response?.data?.message || err.message || '重新分配失败');
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '80px 0' }}>
<Spin size="large" />
</div>
);
}
if (!order) {
return null;
}
const isClosed = order.workOrderStatus === 'closed';
const isPendingCustomer = order.workOrderStatus === 'pending_confirmation';
const canSubmit =
!isClosed &&
!isPendingCustomer &&
(isAdmin || order.technicianId === currentUser?.id);
const canEdit = !isClosed && (isAdmin || order.technicianId === currentUser?.id);
const stepStatus =
order.workOrderStatus === 'rejected'
? 'error'
: isClosed
? 'finish'
: 'process';
return (
<div>
<Card
title={
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/admin/aftersales')}
/>
<span style={{ fontFamily: 'monospace', color: '#165DFF' }}>
{order.serialNumber}
</span>
<Tag color={WORK_ORDER_STATUS_COLOR[order.workOrderStatus]}>
{WORK_ORDER_STATUS_LABEL[order.workOrderStatus]}
</Tag>
{order.rejectCount > 0 && <Tag color="orange">退 {order.rejectCount} </Tag>}
</Space>
}
extra={
<Space>
<Button icon={<QrcodeOutlined />} onClick={handleGenerateQrCode}>
</Button>
{isAdmin && !isClosed && (
<>
<Button icon={<UserSwitchOutlined />} onClick={openReassign}>
</Button>
<Button danger icon={<StopOutlined />} onClick={handleForceClose}>
</Button>
</>
)}
</Space>
}
>
<Steps
current={statusStepIndex(order.workOrderStatus)}
status={stepStatus}
items={[
{ title: '工单创建', description: new Date(order.createdAt).toLocaleString('zh-CN') },
{
title: '待客户确认',
description: order.scannedAt
? `客户扫码 ${new Date(order.scannedAt).toLocaleString('zh-CN')}`
: undefined,
},
{
title: order.workOrderStatus === 'rejected' ? '客户退回' : '工单完成',
description: order.confirmedAt
? new Date(order.confirmedAt).toLocaleString('zh-CN')
: undefined,
},
]}
/>
<Divider />
<Descriptions column={2} size="small" bordered style={{ marginBottom: 24 }}>
<Descriptions.Item label="客户公司">{order.companyName}</Descriptions.Item>
<Descriptions.Item label="负责技术员">
{order.technician?.name || '-'}
</Descriptions.Item>
<Descriptions.Item label="创建人">{order.creator?.name || '-'}</Descriptions.Item>
<Descriptions.Item label="授权状态">
{order.authorizationStatus === 'authorized'
? '已授权'
: order.authorizationStatus === 'unauthorized'
? '未授权'
: '待确认'}
</Descriptions.Item>
</Descriptions>
<Form form={form} layout="vertical" onFinish={handleSave} disabled={!canEdit}>
<Form.Item
name="companyAddress"
label="公司位置"
rules={[{ required: true, message: '请输入公司位置' }]}
>
<Input />
</Form.Item>
<Space style={{ display: 'flex' }} size={16}>
<Form.Item
name="contactName"
label="联系人"
rules={[{ required: true, message: '请输入联系人' }]}
style={{ flex: 1 }}
>
<Input />
</Form.Item>
<Form.Item
name="contactPhone"
label="联系电话"
rules={[
{ required: true, message: '请输入联系电话' },
{ pattern: /^\d{11}$/, message: '请输入 11 位手机号' },
]}
style={{ flex: 1 }}
>
<Input maxLength={11} />
</Form.Item>
<Form.Item
name="serviceType"
label="服务类型"
rules={[{ required: true, message: '请选择服务类型' }]}
style={{ flex: 1 }}
>
<Select
options={(Object.keys(SERVICE_TYPE_LABEL) as AftersalesServiceType[]).map((k) => ({
value: k,
label: SERVICE_TYPE_LABEL[k],
}))}
/>
</Form.Item>
</Space>
<Form.Item
name="issueDescription"
label="问题反馈"
rules={[{ required: true, message: '请填写问题反馈' }]}
>
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item
name="resolutionNote"
label="处理结果"
tooltip="维修完成后填写,提交客户确认后客户可在公开页看到此内容"
>
<Input.TextArea rows={4} placeholder="请描述维修过程和最终处理结果" />
</Form.Item>
{canEdit && (
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
</Button>
{canSubmit && (
<Button
type="primary"
icon={<SendOutlined />}
loading={submitting}
onClick={handleSubmitForConfirmation}
>
</Button>
)}
</Space>
</Form.Item>
)}
</Form>
</Card>
<Modal
title="售后工单二维码"
open={qrModalVisible}
onCancel={() => setQrModalVisible(false)}
footer={[
<Button key="download" onClick={handleDownloadQR}>
</Button>,
<Button key="close" type="primary" onClick={() => setQrModalVisible(false)}>
</Button>,
]}
>
<div style={{ textAlign: 'center' }}>
{qrCodeDataUrl && (
<>
<img src={qrCodeDataUrl} alt="QR Code" style={{ width: 220, height: 220 }} />
<p
style={{
marginTop: 12,
fontFamily: 'monospace',
fontSize: 16,
fontWeight: 'bold',
color: '#165DFF',
}}
>
{order.serialNumber}
</p>
<p style={{ fontSize: 12, color: '#888', wordBreak: 'break-all' }}>{qrUrl}</p>
</>
)}
</div>
</Modal>
<Modal
title="重新分配技术员"
open={reassignModalVisible}
onCancel={() => {
setReassignModalVisible(false);
setReassignTechnicianId(undefined);
}}
onOk={handleReassign}
okText="确认"
cancelText="取消"
>
<Form layout="vertical">
<Form.Item label="选择技术员" required>
<Select
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' ? ' · 管理员' : ' · 技术员'}`,
}))}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default AftersalesDetailPage;
+136 -4
View File
@@ -1,10 +1,44 @@
import { useEffect, useState } from 'react';
import { Card, Row, Col, Statistic, Table, Spin, message, Tag } from 'antd';
import { TeamOutlined, KeyOutlined, CheckCircleOutlined, UserOutlined } from '@ant-design/icons';
import {
TeamOutlined,
KeyOutlined,
CheckCircleOutlined,
UserOutlined,
ToolOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { dashboardApi } from '@/services/api';
import type { DashboardStats } from '@/types';
import type {
DashboardStats,
AftersalesServiceType,
AftersalesWorkOrderStatus,
} from '@/types';
const SERVICE_TYPE_LABEL: Record<AftersalesServiceType, string> = {
software: '软件',
hardware: '硬件',
other: '其他',
};
const WORK_ORDER_STATUS_LABEL: Record<AftersalesWorkOrderStatus, string> = {
created: '待处理',
pending_confirmation: '待客户确认',
closed: '已完成',
rejected: '已退回',
};
const WORK_ORDER_STATUS_COLOR: Record<AftersalesWorkOrderStatus, string> = {
created: 'default',
pending_confirmation: 'processing',
closed: 'success',
rejected: 'warning',
};
function DashboardPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<DashboardStats | null>(null);
@@ -26,7 +60,14 @@ function DashboardPage() {
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '400px' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '400px',
}}
>
<Spin size="large" />
</div>
);
@@ -77,6 +118,49 @@ function DashboardPage() {
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: '24px' }}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="售后工单总数"
value={stats?.totalAftersales || 0}
prefix={<ToolOutlined />}
valueStyle={{ color: '#165DFF' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="待客户确认"
value={stats?.pendingConfirmation || 0}
prefix={<ClockCircleOutlined />}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="已完成工单"
value={stats?.closedAftersales || 0}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="已退回工单"
value={stats?.rejectedAftersales || 0}
prefix={<ExclamationCircleOutlined />}
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
</Row>
<Card title="最近生成的序列号" style={{ marginBottom: '24px' }}>
<Table
columns={[
@@ -114,8 +198,56 @@ function DashboardPage() {
pagination={false}
/>
</Card>
<Card title="最近售后工单">
<Table
columns={[
{
title: '工单号',
dataIndex: 'serialNumber',
key: 'serialNumber',
render: (sn: string) => (
<a
style={{ fontFamily: 'monospace', color: '#165DFF' }}
onClick={() => navigate(`/admin/aftersales/${sn}`)}
>
{sn}
</a>
),
},
{ title: '客户公司', dataIndex: 'companyName', key: 'companyName' },
{
title: '服务类型',
dataIndex: 'serviceType',
key: 'serviceType',
render: (type: AftersalesServiceType) =>
SERVICE_TYPE_LABEL[type] || type,
},
{ title: '处理人', dataIndex: 'technicianName', key: 'technicianName' },
{
title: '状态',
dataIndex: 'workOrderStatus',
key: 'workOrderStatus',
render: (status: AftersalesWorkOrderStatus) => (
<Tag color={WORK_ORDER_STATUS_COLOR[status]}>
{WORK_ORDER_STATUS_LABEL[status]}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
},
]}
dataSource={stats?.recentAftersales || []}
rowKey="serialNumber"
pagination={false}
/>
</Card>
</div>
);
}
export default DashboardPage;
export default DashboardPage;
+50 -42
View File
@@ -1,11 +1,17 @@
import { useState, useEffect } from 'react';
import { Input, Button, Card, message, Spin, Result, Tag } from 'antd';
import { QrcodeOutlined, SearchOutlined, ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import {
QrcodeOutlined,
SearchOutlined,
ArrowLeftOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { employeeSerialApi } from '@/services/api';
import type { Serial } from '@/types';
import PublicLayout, { PublicLogo } from '@/components/PublicLayout';
import './styles/PublicQuery.css';
import logo from '@/assets/img/logo.png?url';
import beian from '@/assets/img/beian.png?url';
interface EmployeeSerialResult {
serialNumber: string;
@@ -16,7 +22,10 @@ interface EmployeeSerialResult {
createdAt: string;
}
const AFTERSALES_PREFIX = 'zjbf-sh-';
function PublicQueryPage() {
const navigate = useNavigate();
const [serialNumber, setSerialNumber] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<Serial | EmployeeSerialResult | null>(null);
@@ -24,7 +33,14 @@ function PublicQueryPage() {
const [showResult, setShowResult] = useState(false);
const [serialType, setSerialType] = useState<'company' | 'employee'>('company');
const isAftersalesSerial = (sn: string) => sn.toLowerCase().startsWith(AFTERSALES_PREFIX);
const performQuery = async (serialToQuery: string) => {
if (isAftersalesSerial(serialToQuery)) {
navigate(`/aftersales/${serialToQuery.toLowerCase()}`, { replace: true });
return;
}
setLoading(true);
setError(null);
setResult(null);
@@ -50,10 +66,15 @@ function PublicQueryPage() {
const urlParams = new URLSearchParams(window.location.search);
const serialFromUrl = urlParams.get('serial');
if (serialFromUrl) {
if (isAftersalesSerial(serialFromUrl)) {
navigate(`/aftersales/${serialFromUrl.toLowerCase()}`, { replace: true });
return;
}
setSerialNumber(serialFromUrl);
setShowResult(true);
performQuery(serialFromUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleQuery = async () => {
@@ -73,13 +94,11 @@ function PublicQueryPage() {
};
return (
<div className="public-query-container">
<PublicLayout>
{!showResult ? (
<Card className="query-card" bordered={false}>
<div className="query-header">
<div className="query-logo">
<img src={logo} alt="Logo" style={{ height: '24px' }} />
</div>
<PublicLogo />
<h1 className="query-title">
<QrcodeOutlined />
</h1>
@@ -109,9 +128,7 @@ function PublicQueryPage() {
) : (
<Card className={`result-card show`} bordered={false}>
<div className="result-header">
<div className="query-logo">
<img src={logo} alt="Logo" style={{ height: '24px' }} />
</div>
<PublicLogo />
</div>
{loading ? (
<div className="loading-container">
@@ -123,20 +140,21 @@ function PublicQueryPage() {
{(result as any).isActive === false || (result as any).status === 'inactive' ? (
<Result
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
title={serialType === 'employee' ? "员工身份已吊销" : "授权已吊销"}
subTitle={serialType === 'employee'
? `序列号验证通过,但已被吊销。企业:${(result as EmployeeSerialResult).companyName}`
: `序列号验证通过,但已被吊销。企业:${(result as Serial).companyName}`
title={serialType === 'employee' ? '员工身份已吊销' : '授权已吊销'}
subTitle={
serialType === 'employee'
? `序列号验证通过,但已被吊销。企业:${(result as EmployeeSerialResult).companyName}`
: `序列号验证通过,但已被吊销。企业:${(result as Serial).companyName}`
}
/>
) : (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '72px' }} />}
title={serialType === 'employee' ? "员工身份有效" : "授权有效"}
title={serialType === 'employee' ? '员工身份有效' : '授权有效'}
subTitle="您的序列号已验证通过"
/>
)}
<div className="result-details">
<div className="detail-item">
<span className="label"></span>
@@ -161,14 +179,24 @@ function PublicQueryPage() {
{serialType !== 'employee' && (result as Serial).validUntil && (
<div className="detail-item">
<span className="label"></span>
<span className="value">{new Date((result as Serial).validUntil).toLocaleString('zh-CN')}</span>
<span className="value">
{new Date((result as Serial).validUntil).toLocaleString('zh-CN')}
</span>
</div>
)}
<div className="detail-item">
<span className="label"></span>
<span className="value status">
<Tag color={(result as any).isActive === false || (result as any).status === 'inactive' ? 'red' : 'green'}>
{(result as any).isActive === false || (result as any).status === 'inactive' ? '已吊销' : '有效'}
<Tag
color={
(result as any).isActive === false || (result as any).status === 'inactive'
? 'red'
: 'green'
}
>
{(result as any).isActive === false || (result as any).status === 'inactive'
? '已吊销'
: '有效'}
</Tag>
</span>
</div>
@@ -184,33 +212,13 @@ function PublicQueryPage() {
</div>
)}
<Button
size="large"
block
icon={<ArrowLeftOutlined />}
onClick={handleReset}
>
<Button size="large" block icon={<ArrowLeftOutlined />} onClick={handleReset}>
</Button>
</Card>
)}
<div className="copyright">
<p>
Copyright © 2026 . All Rights Reserved. |
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
ICP备2025170226号-4
</a>
</p>
<p>
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=33011002018371" target="_blank" rel="noopener noreferrer">
<img src={beian} alt="备案图标" style={{ height: '20px', marginRight: '5px', verticalAlign: 'middle' }} />
33011002018371
</a>
</p>
</div>
</div>
</PublicLayout>
);
}
export default PublicQueryPage;
export default PublicQueryPage;
+431
View File
@@ -0,0 +1,431 @@
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<UserRole, string> = {
admin: '管理员',
technician: '技术员',
user: '普通用户',
};
const ROLE_COLOR: Record<UserRole, string> = {
admin: 'red',
technician: 'blue',
user: 'default',
};
function UsersPage() {
const currentUser = authApi.getCurrentUser();
const [users, setUsers] = useState<User[]>([]);
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<UserRole | undefined>();
const [createVisible, setCreateVisible] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
const [createForm] = Form.useForm<CreateUserRequest>();
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editLoading, setEditLoading] = useState(false);
const [editForm] = Form.useForm<UpdateUserRequest>();
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(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) => <span style={{ fontFamily: 'monospace' }}>{text}</span>,
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
render: (text?: string) => text || '-',
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 100,
render: (role: UserRole) => <Tag color={ROLE_COLOR[role]}>{ROLE_LABEL[role]}</Tag>,
},
{
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) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
>
</Button>
<Button
type="link"
size="small"
icon={<KeyOutlined />}
onClick={() => setResetPasswordUser(record)}
>
</Button>
{record.id !== currentUser?.id && (
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
>
</Button>
)}
</Space>
),
},
];
return (
<div>
<Card
title={
<Space>
<UserOutlined />
<span></span>
</Space>
}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateVisible(true)}
>
</Button>
}
>
<Space style={{ marginBottom: 16 }}>
<Input.Search
placeholder="搜索用户名/姓名/邮箱"
allowClear
style={{ width: 260 }}
onSearch={(v) => {
setPage(1);
setSearch(v);
}}
onChange={(e) => {
if (!e.target.value) {
setPage(1);
setSearch('');
}
}}
/>
<Select
placeholder="角色筛选"
allowClear
style={{ width: 140 }}
value={roleFilter}
onChange={(v) => {
setPage(1);
setRoleFilter(v);
}}
options={(Object.keys(ROLE_LABEL) as UserRole[]).map((k) => ({
value: k,
label: ROLE_LABEL[k],
}))}
/>
</Space>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={false}
/>
<div style={{ marginTop: 16 }}>
<Pagination
current={page}
pageSize={limit}
total={total}
onChange={(newPage, newLimit) => {
setPage(newPage);
setLimit(newLimit);
}}
showSizeChanger
showTotal={(t) => `共计 ${t} 条记录`}
/>
</div>
</Card>
<Modal
title="新建用户"
open={createVisible}
onCancel={() => {
setCreateVisible(false);
createForm.resetFields();
}}
footer={null}
width={480}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ role: 'technician' }}>
<Form.Item
name="username"
label="用户名"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 50, message: '用户名长度 3-50' },
]}
>
<Input placeholder="登录用用户名" />
</Form.Item>
<Form.Item
name="password"
label="初始密码"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少 6 位' },
]}
>
<Input.Password placeholder="至少 6 位" />
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input />
</Form.Item>
<Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '请输入有效邮箱' }]}>
<Input />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true, message: '请选择角色' }]}>
<Select
options={(Object.keys(ROLE_LABEL) as UserRole[]).map((k) => ({
value: k,
label: ROLE_LABEL[k],
}))}
/>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setCreateVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={createLoading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={editingUser ? `编辑用户:${editingUser.username}` : '编辑用户'}
open={!!editingUser}
onCancel={() => setEditingUser(null)}
footer={null}
width={480}
>
<Form form={editForm} layout="vertical" onFinish={handleEdit}>
<Form.Item name="name" label="姓名">
<Input />
</Form.Item>
<Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '请输入有效邮箱' }]}>
<Input />
</Form.Item>
<Form.Item name="role" label="角色">
<Select
options={(Object.keys(ROLE_LABEL) as UserRole[]).map((k) => ({
value: k,
label: ROLE_LABEL[k],
}))}
/>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setEditingUser(null)}></Button>
<Button type="primary" htmlType="submit" loading={editLoading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title={resetPasswordUser ? `重置密码:${resetPasswordUser.username}` : '重置密码'}
open={!!resetPasswordUser}
onCancel={() => {
setResetPasswordUser(null);
resetForm.resetFields();
}}
footer={null}
width={420}
>
<Form form={resetForm} layout="vertical" onFinish={handleResetPassword}>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少 6 位' },
]}
>
<Input.Password placeholder="至少 6 位" />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button
onClick={() => {
setResetPasswordUser(null);
resetForm.resetFields();
}}
>
</Button>
<Button type="primary" htmlType="submit" loading={resetLoading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
export default UsersPage;
+54
View File
@@ -0,0 +1,54 @@
.aftersales-confirm-card {
max-width: 560px;
}
.aftersales-title {
font-size: clamp(1.4rem, 4vw, 1.75rem);
font-weight: bold;
color: #165DFF;
text-align: center;
margin: 12px 0 4px;
}
.aftersales-serial {
text-align: center;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #4B5563;
margin: 0 0 16px;
letter-spacing: 1px;
}
.aftersales-details .detail-item-block {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.aftersales-details .value-block {
white-space: pre-wrap;
word-break: break-word;
text-align: left;
width: 100%;
}
.aftersales-phone-verify {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.aftersales-phone-tip {
color: #4B5563;
margin: 0 0 12px;
font-size: 14px;
}
.aftersales-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.aftersales-actions > button {
flex: 1;
}
+178 -1
View File
@@ -1,5 +1,20 @@
import axios from 'axios';
import type { ApiResponse, AuthResponse, User, EmployeeSerial, EmployeeSerialResponse } from '@/types';
import type {
User,
EmployeeSerial,
EmployeeSerialResponse,
AftersalesOrder,
AftersalesPublicView,
AftersalesListFilter,
AftersalesListResponse,
CreateAftersalesRequest,
UpdateAftersalesRequest,
CustomerConfirmRequest,
CreateUserRequest,
UpdateUserRequest,
UserListFilter,
UserListResponse,
} from '@/types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
@@ -197,6 +212,10 @@ export const dashboardApi = {
totalEmployeeSerials: data.overview?.totalEmployeeSerials || 0,
activeSerials: data.overview?.activeSerials || 0,
inactiveSerials: data.overview?.inactiveSerials || 0,
totalAftersales: data.overview?.totalAftersales || 0,
pendingConfirmation: data.overview?.pendingConfirmation || 0,
closedAftersales: data.overview?.closedAftersales || 0,
rejectedAftersales: data.overview?.rejectedAftersales || 0,
monthlyData: data.monthlyStats || [],
recentCompanies: data.recentCompanies?.map((c: any) => ({
id: c.companyName,
@@ -212,6 +231,7 @@ export const dashboardApi = {
createdAt: s.createdAt,
type: s.type,
})) || [],
recentAftersales: data.recentAftersales || [],
};
}
throw new Error('获取统计数据失败');
@@ -316,4 +336,161 @@ export const employeeSerialApi = {
}
throw new Error(response.data.error || '删除员工序列号失败');
},
};
export const aftersalesApi = {
create: async (data: CreateAftersalesRequest) => {
const response = await apiClient.post('/aftersales', data);
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '创建工单失败');
},
list: async (filter?: AftersalesListFilter) => {
const params = new URLSearchParams();
if (filter?.page && filter.page > 1) params.append('page', String(filter.page));
if (filter?.limit && filter.limit !== 20) params.append('limit', String(filter.limit));
if (filter?.search) params.append('search', filter.search);
if (filter?.workOrderStatus) params.append('workOrderStatus', filter.workOrderStatus);
if (filter?.serviceType) params.append('serviceType', filter.serviceType);
if (filter?.technicianId) params.append('technicianId', String(filter.technicianId));
if (filter?.mine) params.append('mine', 'true');
const url = params.toString() ? `/aftersales?${params.toString()}` : '/aftersales';
const response = await apiClient.get(url);
if (response.data) {
return response.data as AftersalesListResponse;
}
throw new Error('获取售后工单列表失败');
},
get: async (serialNumber: string) => {
const response = await apiClient.get(`/aftersales/${encodeURIComponent(serialNumber)}`);
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '查询工单失败');
},
update: async (serialNumber: string, data: UpdateAftersalesRequest) => {
const response = await apiClient.patch(`/aftersales/${encodeURIComponent(serialNumber)}`, data);
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '更新工单失败');
},
submit: async (serialNumber: string, resolutionNote: string) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/submit`, {
resolutionNote,
});
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '提交客户确认失败');
},
generateQrCode: async (serialNumber: string, baseUrl?: string) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/qrcode`, {
baseUrl,
});
if (response.data.qrCodeData) {
return response.data as { qrCodeData: string; queryUrl: string };
}
throw new Error(response.data.error || '生成二维码失败');
},
reassign: async (serialNumber: string, technicianId: number) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/reassign`, {
technicianId,
});
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '重新分配失败');
},
forceClose: async (serialNumber: string) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/force-close`);
if (response.data.order) {
return response.data.order as AftersalesOrder;
}
throw new Error(response.data.error || '强制关闭失败');
},
delete: async (serialNumber: string) => {
const response = await apiClient.delete(`/aftersales/${encodeURIComponent(serialNumber)}`);
if (response.data.message) {
return true;
}
throw new Error(response.data.error || '删除工单失败');
},
publicQuery: async (serialNumber: string) => {
const response = await apiClient.get(`/aftersales/${encodeURIComponent(serialNumber)}/query`);
if (response.data.order) {
return response.data.order as AftersalesPublicView;
}
throw new Error(response.data.error || '查询工单失败');
},
customerConfirm: async (serialNumber: string, data: CustomerConfirmRequest) => {
const response = await apiClient.post(`/aftersales/${encodeURIComponent(serialNumber)}/confirm`, data);
if (response.data.order) {
return response.data.order as AftersalesPublicView;
}
throw new Error(response.data.error || '提交确认失败');
},
};
export const usersApi = {
list: async (filter?: UserListFilter) => {
const params = new URLSearchParams();
if (filter?.page && filter.page > 1) params.append('page', String(filter.page));
if (filter?.limit && filter.limit !== 20) params.append('limit', String(filter.limit));
if (filter?.role) params.append('role', filter.role);
if (filter?.search) params.append('search', filter.search);
const url = params.toString() ? `/users?${params.toString()}` : '/users';
const response = await apiClient.get(url);
return response.data as UserListResponse;
},
create: async (data: CreateUserRequest) => {
const response = await apiClient.post('/users', data);
if (response.data.user) {
return response.data.user as User;
}
throw new Error(response.data.error || '创建用户失败');
},
update: async (id: number, data: UpdateUserRequest) => {
const response = await apiClient.patch(`/users/${id}`, data);
if (response.data.user) {
return response.data.user as User;
}
throw new Error(response.data.error || '更新用户失败');
},
resetPassword: async (id: number, newPassword: string) => {
const response = await apiClient.post(`/users/${id}/reset-password`, { newPassword });
if (response.data.message) {
return true;
}
throw new Error(response.data.error || '重置密码失败');
},
delete: async (id: number) => {
const response = await apiClient.delete(`/users/${id}`);
if (response.data.message) {
return true;
}
throw new Error(response.data.error || '删除用户失败');
},
assignable: async () => {
const response = await apiClient.get('/users/assignable');
return (response.data?.data || []) as User[];
},
};
+127 -1
View File
@@ -1,12 +1,40 @@
export type UserRole = 'admin' | 'technician' | 'user';
export interface User {
id: number;
username: string;
name: string;
email?: string;
role: string;
role: UserRole;
createdAt: string;
}
export interface CreateUserRequest {
username: string;
password: string;
name: string;
email?: string;
role: UserRole;
}
export interface UpdateUserRequest {
name?: string;
email?: string;
role?: UserRole;
}
export interface UserListFilter {
page?: number;
limit?: number;
role?: UserRole;
search?: string;
}
export interface UserListResponse {
data: User[];
pagination: EmployeeSerialPagination;
}
export interface Company {
id: number;
name: string;
@@ -71,12 +99,26 @@ export interface ApiResponse<T> {
error?: string;
}
export interface DashboardRecentAftersales {
serialNumber: string;
companyName: string;
serviceType: AftersalesServiceType;
workOrderStatus: AftersalesWorkOrderStatus;
authorizationStatus: AftersalesAuthorizationStatus;
technicianName: string;
createdAt: string;
}
export interface DashboardStats {
totalCompanies: number;
totalSerials: number;
totalEmployeeSerials: number;
activeSerials: number;
inactiveSerials: number;
totalAftersales: number;
pendingConfirmation: number;
closedAftersales: number;
rejectedAftersales: number;
monthlyData: Array<{
month: string;
companies: number;
@@ -84,6 +126,7 @@ export interface DashboardStats {
}>;
recentCompanies: Company[];
recentSerials: Serial[];
recentAftersales: DashboardRecentAftersales[];
}
export interface CompanyFilter {
@@ -117,4 +160,87 @@ export interface EmployeeSerialPagination {
export interface EmployeeSerialResponse {
data: EmployeeSerial[];
pagination: EmployeeSerialPagination;
}
export type AftersalesServiceType = 'software' | 'hardware' | 'other';
export type AftersalesWorkOrderStatus = 'created' | 'pending_confirmation' | 'closed' | 'rejected';
export type AftersalesAuthorizationStatus = 'pending' | 'authorized' | 'unauthorized';
export interface AftersalesOrder {
id: number;
serialNumber: string;
companyName: string;
companyAddress: string;
contactName: string;
contactPhone: string;
serviceType: AftersalesServiceType;
issueDescription: string;
resolutionNote: string;
workOrderStatus: AftersalesWorkOrderStatus;
authorizationStatus: AftersalesAuthorizationStatus;
technicianId?: number;
createdBy?: number;
scannedAt?: string;
confirmedAt?: string;
rejectCount: number;
createdAt: string;
updatedAt: string;
technician?: User;
creator?: User;
}
export interface AftersalesPublicView {
serialNumber: string;
companyName: string;
companyAddress: string;
contactName: string;
serviceType: AftersalesServiceType;
issueDescription: string;
resolutionNote: string;
workOrderStatus: AftersalesWorkOrderStatus;
authorizationStatus: AftersalesAuthorizationStatus;
technicianName: string;
createdAt: string;
confirmedAt?: string;
}
export interface CreateAftersalesRequest {
companyName: string;
companyAddress: string;
contactName: string;
contactPhone: string;
serviceType: AftersalesServiceType;
issueDescription: string;
technicianId?: number;
}
export interface UpdateAftersalesRequest {
companyAddress?: string;
contactName?: string;
contactPhone?: string;
serviceType?: AftersalesServiceType;
issueDescription?: string;
resolutionNote?: string;
technicianId?: number;
}
export interface AftersalesListFilter {
page?: number;
limit?: number;
search?: string;
workOrderStatus?: AftersalesWorkOrderStatus;
serviceType?: AftersalesServiceType;
technicianId?: number;
mine?: boolean;
}
export interface AftersalesListResponse {
data: AftersalesOrder[];
pagination: EmployeeSerialPagination;
}
export interface CustomerConfirmRequest {
phoneLast4: string;
action: 'authorize' | 'reject';
rejectReason?: string;
}