Files
frontend/src/pages/PublicQuery.tsx
T
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

225 lines
7.8 KiB
TypeScript

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 { 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';
interface EmployeeSerialResult {
serialNumber: string;
companyName: string;
department: string;
employeeName: string;
isActive: boolean;
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);
const [error, setError] = useState<string | null>(null);
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);
try {
const response = await employeeSerialApi.queryAll(serialToQuery);
if (response.type === 'employee') {
setSerialType('employee');
setResult(response.data as EmployeeSerialResult);
} else {
setSerialType('company');
setResult(response.data as Serial);
}
} catch (err: any) {
setError(err.message || '查询失败');
setResult(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
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 () => {
if (!serialNumber.trim()) {
message.error('请输入授权序列号');
return;
}
setShowResult(true);
performQuery(serialNumber.trim());
};
const handleReset = () => {
setShowResult(false);
setSerialNumber('');
setResult(null);
setError(null);
};
return (
<PublicLayout>
{!showResult ? (
<Card className="query-card" bordered={false}>
<div className="query-header">
<PublicLogo />
<h1 className="query-title">
<QrcodeOutlined />
</h1>
<p className="query-subtitle"></p>
</div>
<div className="query-form">
<Input
size="large"
placeholder="请输入或扫描您的授权序列号"
value={serialNumber}
onChange={(e) => setSerialNumber(e.target.value)}
onPressEnter={handleQuery}
prefix={<SearchOutlined />}
/>
<Button
type="primary"
size="large"
block
icon={<SearchOutlined />}
onClick={handleQuery}
>
</Button>
</div>
</Card>
) : (
<Card className={`result-card show`} bordered={false}>
<div className="result-header">
<PublicLogo />
</div>
{loading ? (
<div className="loading-container">
<Spin size="large" />
<p>...</p>
</div>
) : result ? (
<div className="success-container">
{(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}`
}
/>
) : (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '72px' }} />}
title={serialType === 'employee' ? '员工身份有效' : '授权有效'}
subTitle="您的序列号已验证通过"
/>
)}
<div className="result-details">
<div className="detail-item">
<span className="label"></span>
<span className="value serial">{result.serialNumber}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{result.companyName}</span>
</div>
{serialType === 'employee' && (result as EmployeeSerialResult).department && (
<>
<div className="detail-item">
<span className="label"></span>
<span className="value">{(result as EmployeeSerialResult).department}</span>
</div>
<div className="detail-item">
<span className="label"></span>
<span className="value">{(result as EmployeeSerialResult).employeeName}</span>
</div>
</>
)}
{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>
</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>
</span>
</div>
</div>
</div>
) : (
<div className="error-container">
<Result
icon={<CloseCircleOutlined style={{ color: '#ff4d4f', fontSize: '72px' }} />}
title="无效序列号"
subTitle={error}
/>
</div>
)}
<Button size="large" block icon={<ArrowLeftOutlined />} onClick={handleReset}>
</Button>
</Card>
)}
</PublicLayout>
);
}
export default PublicQueryPage;