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>
This commit is contained in:
Frudrax Cheng
2026-05-26 10:51:25 +08:00
parent 11f3eda668
commit 6fef517556
12 changed files with 1505 additions and 51 deletions
+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;