Files
ONE-OS/web端/运维管理/车辆业务/年审管理-办理.jsx
王冕 f0e3a2cd8b feat(web): 新增年审管理列表、办理与查看页面
提供 Web 端年审任务监管台:KPI 看板与近三月执行率、待办/历史筛选导出,以及办理页草稿保存与证照同步、历史只读查看页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 14:17:57 +08:00

1116 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 【重要】必须使用 const Component 作为组件变量名
// 运维管理 - 车辆业务 - 年审管理 · 办理(分组表单页,逻辑对齐 ONE-OS 小程序)
const { useState, useMemo, useEffect, useRef } = React;
const moment = window.moment || window.dayjs;
const antd = window.antd;
const {
App,
Alert,
Button,
Col,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Spin,
Tag,
Typography,
Upload,
message,
} = antd;
const Image = antd.Image;
const { Text, Paragraph } = Typography;
const TextArea = Input.TextArea;
const AR_DRAFT_STORAGE_KEY = 'oneos_ar_operate_drafts_v1';
const AR_TASK_ID_STORAGE_KEY = 'oneos_ar_operate_task_id';
const AR_TASKS_STORAGE_KEY = 'oneos_ar_web_tasks_v1';
const AR_NAV_TARGET_KEY = 'oneos_ar_navigate_target';
const AR_NAV_EVENT = 'oneos-ar-return-list';
const CERTIFICATE_LICENSE_SYNC_KEY = 'oneos_certificate_license_sync';
/** 办理页保存/提交后返回列表Axhub 多文件原型:事件 + session 标记) */
const navigateToAnnualReviewList = (toastMsg) => {
try {
sessionStorage.setItem(AR_NAV_TARGET_KEY, 'list');
sessionStorage.removeItem(AR_TASK_ID_STORAGE_KEY);
} catch {
/* ignore */
}
try {
window.dispatchEvent(new CustomEvent(AR_NAV_EVENT));
} catch {
/* ignore */
}
if (typeof window.__axhubNavigate === 'function') {
window.__axhubNavigate('年审管理');
if (toastMsg) message.success(toastMsg);
return;
}
if (toastMsg) message.success(toastMsg);
else message.info('请切换至「年审管理」列表页查看');
};
const INSPECTION_STATION_LIST = ['汇通检测站', '平湖检测站', '嘉兴检测站', '松江检测站'];
const REPAIR_STATION_LIST = [
'杭州拱墅维修站',
'广州天河维修站',
'上海浦东维修站',
'深圳南山维修站',
'天津港保税区维修站',
];
const INSPECTION_STATION_STORAGE_KEY = 'annual-review.station.inspection';
const MAX_SERVICE_PHOTOS = 10;
const MAX_LICENSE_PHOTOS = 4;
const LICENSE_OCR_MOCK_MS = 1500;
const MOCK_CURRENT_HANDLER = '张明辉';
const EMPTY_SERVICE_FORM = { station: '', cost: '', remark: '', photos: [] };
const EMPTY_INSPECTION_FORM = { station: '', cost: '', remark: '' };
const EMPTY_LICENSE_FORM = { photos: [], inspectionValidUntil: null, ocrStatus: 'idle' };
const mapOperateStatus = (raw) => {
if (raw === '可运营' || raw === '待运营') return '库存';
return raw || '—';
};
const MOCK_TASKS = [
{
id: 'ar-1',
plateNo: '粤B58888F',
vin: 'LGHXCAE28M6789012',
brand: '福田',
model: '奥铃4.5吨冷藏车',
operateStatusRaw: '租赁',
expireDate: '2026-07-20',
daysLeft: 49,
tab: 'pending',
province: '广东省',
city: '深圳市',
executor: '',
executeTime: '',
},
{
id: 'ar-2',
plateNo: '沪A03561F',
vin: 'LMRKH9AC0R1004086',
brand: '宇通',
model: '49吨牵引车头',
operateStatusRaw: '自营',
expireDate: '2026-07-31',
daysLeft: 60,
tab: 'pending',
province: '上海市',
city: '上海市',
executor: '',
executeTime: '',
},
{
id: 'ar-3',
plateNo: '苏E33333',
vin: 'LSXCH9AE8M1094857',
brand: '陕汽',
model: '德龙X3000混动牵引车',
operateStatusRaw: '可运营',
expireDate: '2026-05-15',
daysLeft: -17,
tab: 'pending',
province: '江苏省',
city: '苏州市',
executor: '',
executeTime: '',
},
{
id: 'ar-7',
plateNo: '鲁Q88901',
vin: 'LZZ5CLSB8NC778899',
brand: '重汽',
model: '豪沃T7H牵引车',
operateStatusRaw: '租赁',
expireDate: '2026-04-10',
daysLeft: -52,
tab: 'pending',
province: '山东省',
city: '临沂市',
executor: '',
executeTime: '',
},
{
id: 'ar-8',
plateNo: '闽D55662',
vin: 'LFWNHXSD8P1122334',
brand: '金龙',
model: '凯歌纯电动厢货',
operateStatusRaw: '自营',
expireDate: '2026-04-27',
daysLeft: -35,
tab: 'pending',
province: '福建省',
city: '厦门市',
executor: '',
executeTime: '',
},
{
id: 'ar-4',
plateNo: '浙A88888',
vin: 'LMRKH9AE2P9876543',
brand: '宇通',
model: '氢燃料电池大巴',
operateStatusRaw: '待运营',
expireDate: '2026-08-10',
daysLeft: 70,
tab: 'pending',
province: '浙江省',
city: '杭州市',
executor: '',
executeTime: '',
},
{
id: 'ar-6',
plateNo: '皖B66221',
vin: 'LZZ5CLSB8NA123456',
brand: '江淮',
model: '格尔发A5',
operateStatusRaw: '库存',
expireDate: '2026-06-28',
daysLeft: 27,
tab: 'pending',
province: '安徽省',
city: '合肥市',
executor: '',
executeTime: '',
},
].map((t) => ({
...t,
operateStatus: mapOperateStatus(t.operateStatusRaw),
}));
const isFormCostEmpty = (cost) => cost === '' || cost == null;
const validateStationCost = (form, stationLabel) => {
if (form?.station && isFormCostEmpty(form.cost)) {
message.error(`请填写${stationLabel}费用`);
return false;
}
return true;
};
const validateLicenseForm = (licenseForm) => {
if (!(licenseForm?.photos || []).length) {
message.error('请上传行驶证照片');
return false;
}
if (!licenseForm?.inspectionValidUntil) {
message.error('请填写检验有效期');
return false;
}
return true;
};
const formatTaskRegion = (task) => {
if (!task?.province) return '—';
if (task.city) return `${task.province}-${task.city}`;
return task.province;
};
const formatOperateStatusDisplay = (task) => {
const base = task.operateStatus || '—';
if (base === '库存' && (task.operateStatusRaw === '可运营' || task.operateStatusRaw === '待运营')) {
return `库存(${task.operateStatusRaw}`;
}
return base;
};
const readInspectionStationList = () => {
try {
const raw = localStorage.getItem(INSPECTION_STATION_STORAGE_KEY);
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length) return parsed;
} catch {
/* ignore */
}
return [...INSPECTION_STATION_LIST];
};
const loadTasksFromStorage = () => {
try {
const raw = localStorage.getItem(AR_TASKS_STORAGE_KEY);
if (!raw) return MOCK_TASKS;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) && parsed.length ? parsed : MOCK_TASKS;
} catch {
return MOCK_TASKS;
}
};
const persistTasksToStorage = (tasks) => {
try {
localStorage.setItem(AR_TASKS_STORAGE_KEY, JSON.stringify(tasks));
} catch {
/* ignore */
}
};
const loadOperateDrafts = () => {
try {
const raw = localStorage.getItem(AR_DRAFT_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
};
const persistOperateDrafts = (drafts) => {
try {
localStorage.setItem(AR_DRAFT_STORAGE_KEY, JSON.stringify(drafts));
} catch {
/* ignore */
}
};
const getUploadPreviewUrl = (file) => {
if (!file) return '';
if (file.url) return file.url;
if (file.thumbUrl) return file.thumbUrl;
if (file.originFileObj && typeof URL !== 'undefined' && URL.createObjectURL) {
return URL.createObjectURL(file.originFileObj);
}
return '';
};
const serializeUploadFileList = (list) =>
(list || []).map((f) => ({
uid: f.uid,
name: f.name,
url: getUploadPreviewUrl(f) || '',
status: f.status || 'done',
}));
const deserializeUploadFileList = (list) =>
(list || []).map((f) => ({
uid: f.uid || `file-${f.name}-${Math.random().toString(36).slice(2, 8)}`,
name: f.name,
url: f.url,
thumbUrl: f.url,
status: f.status || 'done',
}));
const syncLicenseToCertificateManagement = (task, licenseForm, operator) => {
if (!task?.plateNo) return null;
const photos = serializeUploadFileList(licenseForm?.photos || []);
const payload = {
plateNo: task.plateNo,
vin: task.vin || '',
inspectionValidUntil: licenseForm?.inspectionValidUntil || '',
photos,
photoCount: photos.length,
operator: operator || MOCK_CURRENT_HANDLER,
source: 'annual_review',
syncedAt: new Date().toISOString(),
};
try {
const store = JSON.parse(localStorage.getItem(CERTIFICATE_LICENSE_SYNC_KEY) || '{}');
store[task.plateNo] = payload;
localStorage.setItem(CERTIFICATE_LICENSE_SYNC_KEY, JSON.stringify(store));
} catch {
/* ignore */
}
return payload;
};
const platesMatch = (a, b) => {
const na = (a || '').replace(/\s/g, '').toUpperCase();
const nb = (b || '').replace(/\s/g, '').toUpperCase();
return na.length > 0 && na === nb;
};
const getUploadFileName = (file) =>
String(file?.name || file?.originFileObj?.name || '').toLowerCase();
/** 模拟单张行驶证 OCR 车牌(文件名含 mismatch / 不一致 可测失败) */
const mockOcrLicensePlateForFile = (task, file) => {
const name = getUploadFileName(file);
if (name.includes('mismatch') || name.includes('不一致')) return '粤B00000D';
return task?.plateNo || '';
};
/** 模拟单张是否识别到检验有效期至(无有效期:文件名含 novalid / 无有效期) */
const mockOcrLicenseValidUntilForFile = (task, file, index) => {
const name = getUploadFileName(file);
if (name.includes('novalid') || name.includes('无有效期')) return null;
const base = task?.expireDate || '2026-07-20';
if (moment) {
return moment(base).add(index, 'month').endOf('month').format('YYYY-MM-DD');
}
const parts = String(base).split('-');
const year = Number(parts[0]) || new Date().getFullYear();
const month = (Number(parts[1]) || 1) + index;
const day = new Date(year, month, 0).getDate();
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
};
/** 批量 OCR逐张校验车牌多张均有有效期时取最后识别的一条 */
const runLicenseOcrResult = (task, photos) => {
const list = photos || [];
const expectedPlate = task?.plateNo || '';
for (let i = 0; i < list.length; i++) {
const recognizedPlate = mockOcrLicensePlateForFile(task, list[i]);
if (!platesMatch(recognizedPlate, expectedPlate)) {
return { ok: false };
}
}
let lastValidUntil = null;
list.forEach((file, index) => {
const validUntil = mockOcrLicenseValidUntilForFile(task, file, index);
if (validUntil) lastValidUntil = validUntil;
});
return { ok: true, validUntil: lastValidUntil };
};
const readInitialTaskId = () => {
try {
return sessionStorage.getItem(AR_TASK_ID_STORAGE_KEY) || '';
} catch {
return '';
}
};
const PAGE_STYLES = `
.ar-handle{min-height:100vh;background:#f2f3f5;font-family:Inter,Helvetica,PingFang SC,Microsoft YaHei,Arial,sans-serif}
.ar-handle-inner{max-width:1200px;margin:0 auto;padding:16px 24px 96px}
.ar-handle-top{display:flex;justify-content:flex-end;align-items:center;gap:12px;margin-bottom:12px;min-height:32px}
.ar-handle-top-extra{margin-right:auto}
.ar-form-group{background:#fff;border:1px solid #e5e6eb;border-radius:4px;margin-bottom:16px}
.ar-form-group-head{display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid #f2f3f5}
.ar-form-group-title{font-size:14px;font-weight:600;color:#1d2129}
.ar-form-group-body{padding:20px 20px 8px}
.ar-form-group-body .ant-form-item{margin-bottom:20px}
.ar-form-group-body .ant-form-item-label>label{color:#4e5969;font-size:13px}
.ar-form-label-hint{font-size:12px;font-weight:400;color:#86909c;margin-left:8px}
.ar-form-footer{position:fixed;left:0;right:0;bottom:0;z-index:100;background:#fff;border-top:1px solid #e5e6eb;box-shadow:0 -2px 8px rgba(0,0,0,.06)}
.ar-form-footer-inner{max-width:1200px;margin:0 auto;padding:12px 24px;display:flex;justify-content:flex-end;gap:12px}
.ar-ocr-banner{padding:10px 12px;border-radius:4px;font-size:13px;margin-bottom:16px}
.ar-ocr-banner--error{background:#ffece8;border:1px solid #ffccc7;color:#cb2634}
.ar-ocr-banner--done{background:#e8ffea;border:1px solid #aff0b5;color:#009a29}
.ar-ocr-mask{position:fixed;inset:0;z-index:200;background:rgba(29,33,41,.45);display:flex;align-items:center;justify-content:center}
.ar-ocr-mask-panel{background:#fff;border-radius:8px;padding:32px 48px;text-align:center}
.ar-prd-doc{max-height:65vh;overflow-y:auto;font-size:13px;line-height:1.65;color:#4e5969}
.ar-prd-highlight{background:#f0f9ff;border:1px solid #bedaff;border-radius:4px;padding:12px;margin:12px 0}
.ar-prd-h2{font-size:15px;font-weight:600;color:#1d2129;margin:16px 0 8px}
.ar-prd-h3{font-size:14px;font-weight:600;color:#1d2129;margin:12px 0 6px}
.ar-prd-ul{margin:0;padding-left:20px}
.ar-prd-ul li{margin-bottom:6px}
.ar-photo-block{width:100%}
.ar-photo-row{display:flex;align-items:flex-start;gap:12px}
.ar-photo-upload-fixed{flex-shrink:0;width:104px}
.ar-photo-upload-cell{display:block;width:100%}
.ar-photo-upload-cell .ant-upload{display:block;width:100%}
.ar-photo-upload-cell .ant-upload-select{display:block!important;width:100%!important;height:auto!important;margin:0!important;border:none!important;background:transparent!important}
.ar-photo-list-scroll{flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:10px;align-content:flex-start}
.ar-photo-slot{position:relative;width:104px;height:104px;border-radius:8px;box-sizing:border-box;flex-shrink:0}
.ar-photo-slot--add{display:flex;flex-direction:column;align-items:center;justify-content:center;border:1px dashed #c9cdd4;background:#fafafa;color:#86909c;cursor:pointer;gap:2px;transition:border-color .2s,background .2s}
.ar-photo-slot--add:hover{border-color:#165dff;background:#f0f5ff;color:#165dff}
.ar-photo-add-icon{font-size:22px;line-height:1;font-weight:300}
.ar-photo-add-text{font-size:12px;line-height:1.2}
.ar-photo-item{overflow:visible}
.ar-photo-item-thumb{width:100%;height:100%;border-radius:8px;overflow:hidden;border:none;padding:0;background:#f2f3f5;cursor:pointer;display:block}
.ar-photo-item-thumb img{width:100%;height:100%;object-fit:cover;display:block}
.ar-photo-del{position:absolute;top:-6px;right:-6px;z-index:2;width:20px;height:20px;border-radius:50%;border:none;background:rgba(29,33,41,.72);color:#fff;font-size:14px;line-height:1;cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center}
.ar-photo-del:hover{background:#cb2634}
.ar-photo-hint{margin-top:8px;font-size:12px;color:#86909c;line-height:1.4}
.ar-photo-preview-img{max-width:100%;max-height:70vh;object-fit:contain}
`;
/** 照片上传:左固定上传、右照片列表,支持批量(对齐小程序 PhotoUploadBlock */
const PhotoUploadBlock = ({ fileList, onChange, maxPhotos = MAX_SERVICE_PHOTOS }) => {
const list = fileList || [];
const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState('');
const canUpload = list.length < maxPhotos;
const handleChange = ({ fileList: nextList }) => {
onChange((nextList || []).slice(0, maxPhotos));
};
const handleRemove = (uid, e) => {
e.stopPropagation();
e.preventDefault();
onChange(list.filter((f) => f.uid !== uid));
};
const handlePreview = (file) => {
const url = getUploadPreviewUrl(file);
if (!url) return;
if (Image && typeof Image.preview === 'function') {
Image.preview({ src: url });
return;
}
setPreviewUrl(url);
setPreviewOpen(true);
};
return (
<div className="ar-photo-block">
<div className="ar-photo-row">
<div className="ar-photo-upload-fixed">
{canUpload ? (
<Upload
className="ar-photo-upload-cell"
showUploadList={false}
multiple
accept="image/*"
fileList={list}
beforeUpload={() => false}
onChange={handleChange}
>
<div className="ar-photo-slot ar-photo-slot--add" role="button" tabIndex={0}>
<span className="ar-photo-add-icon">+</span>
<span className="ar-photo-add-text">上传</span>
</div>
</Upload>
) : (
<div
className="ar-photo-slot ar-photo-slot--add"
style={{ opacity: 0.45, cursor: 'not-allowed', pointerEvents: 'none' }}
aria-hidden
>
<span className="ar-photo-add-icon">+</span>
<span className="ar-photo-add-text">已满</span>
</div>
)}
</div>
<div className="ar-photo-list-scroll">
{list.map((file) => {
const url = getUploadPreviewUrl(file);
return (
<div key={file.uid} className="ar-photo-slot ar-photo-item">
<button
type="button"
className="ar-photo-item-thumb"
onClick={() => handlePreview(file)}
aria-label="预览照片"
>
{url ? (
<img src={url} alt="" />
) : (
<span style={{ fontSize: 12, color: '#86909c' }}>图片</span>
)}
</button>
<button
type="button"
className="ar-photo-del"
aria-label="删除照片"
onClick={(e) => handleRemove(file.uid, e)}
>
×
</button>
</div>
);
})}
</div>
</div>
<div className="ar-photo-hint">最多上传 {maxPhotos} 支持批量选择</div>
<Modal
open={previewOpen}
footer={null}
onCancel={() => setPreviewOpen(false)}
centered
width={720}
destroyOnClose
title="照片预览"
>
{previewUrl ? <img className="ar-photo-preview-img" src={previewUrl} alt="预览" /> : null}
</Modal>
</div>
);
};
const FormGroup = ({ title, extra, children }) => (
<section className="ar-form-group">
<div className="ar-form-group-head">
<span className="ar-form-group-title">{title}</span>
{extra}
</div>
<div className="ar-form-group-body">{children}</div>
</section>
);
const Component = function AnnualReviewHandlePage() {
const [tasks, setTasks] = useState(() => loadTasksFromStorage());
const [taskId, setTaskId] = useState(() => {
const saved = readInitialTaskId();
const pending = loadTasksFromStorage().filter((t) => t.tab === 'pending');
if (saved && pending.some((t) => t.id === saved)) return saved;
return pending[0]?.id || '';
});
const [prdOpen, setPrdOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState({ ...EMPTY_INSPECTION_FORM });
const [licenseForm, setLicenseForm] = useState({ ...EMPTY_LICENSE_FORM });
const [m2Expanded, setM2Expanded] = useState(false);
const [m2Form, setM2Form] = useState({ ...EMPTY_SERVICE_FORM });
const [zbExpanded, setZbExpanded] = useState(false);
const [zbForm, setZbForm] = useState({ ...EMPTY_SERVICE_FORM });
const [operateDrafts, setOperateDrafts] = useState(() => loadOperateDrafts());
const licenseOcrTimerRef = useRef(null);
const pendingTasks = useMemo(() => tasks.filter((t) => t.tab === 'pending'), [tasks]);
const operateTask = useMemo(
() => pendingTasks.find((t) => t.id === taskId) || pendingTasks[0] || null,
[pendingTasks, taskId]
);
const inspectionStationOptions = useMemo(
() => readInspectionStationList().map((s) => ({ label: s, value: s })),
[]
);
const repairStationOptions = useMemo(() => REPAIR_STATION_LIST.map((s) => ({ label: s, value: s })), []);
const resetOperateForms = () => {
if (licenseOcrTimerRef.current) {
clearTimeout(licenseOcrTimerRef.current);
licenseOcrTimerRef.current = null;
}
setInspectionForm({ ...EMPTY_INSPECTION_FORM });
setLicenseForm({ ...EMPTY_LICENSE_FORM });
setM2Expanded(false);
setM2Form({ ...EMPTY_SERVICE_FORM });
setZbExpanded(false);
setZbForm({ ...EMPTY_SERVICE_FORM });
};
const applyOperateDraft = (draft) => {
if (!draft) return;
setInspectionForm({ ...EMPTY_INSPECTION_FORM, ...draft.inspectionForm });
const lf = draft.licenseForm || {};
setLicenseForm({
...EMPTY_LICENSE_FORM,
photos: deserializeUploadFileList(lf.photos),
inspectionValidUntil: lf.inspectionValidUntil ?? null,
ocrStatus: lf.ocrStatus === 'recognizing' ? 'idle' : lf.ocrStatus || 'idle',
});
setM2Expanded(!!draft.m2Expanded);
const m2 = draft.m2Form || {};
setM2Form({ ...EMPTY_SERVICE_FORM, ...m2, photos: deserializeUploadFileList(m2.photos) });
setZbExpanded(!!draft.zbExpanded);
const zb = draft.zbForm || {};
setZbForm({ ...EMPTY_SERVICE_FORM, ...zb, photos: deserializeUploadFileList(zb.photos) });
};
const collectOperateDraft = () => ({
savedAt: new Date().toISOString(),
inspectionForm: { ...inspectionForm },
licenseForm: {
photos: serializeUploadFileList(licenseForm.photos),
inspectionValidUntil: licenseForm.inspectionValidUntil,
ocrStatus: licenseForm.ocrStatus === 'recognizing' ? 'idle' : licenseForm.ocrStatus,
},
m2Expanded,
m2Form: { ...m2Form, photos: serializeUploadFileList(m2Form.photos) },
zbExpanded,
zbForm: { ...zbForm, photos: serializeUploadFileList(zbForm.photos) },
});
const upsertOperateDraft = (id, draft) => {
const next = { ...operateDrafts, [id]: draft };
setOperateDrafts(next);
persistOperateDrafts(next);
};
const removeOperateDraft = (id) => {
if (!operateDrafts[id]) return;
const next = { ...operateDrafts };
delete next[id];
setOperateDrafts(next);
persistOperateDrafts(next);
};
useEffect(() => {
if (!operateTask) return;
const draft = operateDrafts[operateTask.id];
if (draft) applyOperateDraft(draft);
else resetOperateForms();
}, [operateTask?.id]);
const runLicenseOcr = (task, photos) => {
if (licenseOcrTimerRef.current) clearTimeout(licenseOcrTimerRef.current);
setLicenseForm((p) => ({ ...p, ocrStatus: 'recognizing', inspectionValidUntil: null }));
licenseOcrTimerRef.current = setTimeout(() => {
const result = runLicenseOcrResult(task, photos);
if (!result.ok) {
setLicenseForm({ ...EMPTY_LICENSE_FORM });
message.error('行驶证车牌号不一致,请检查后重新上传');
licenseOcrTimerRef.current = null;
return;
}
setLicenseForm((p) => ({
...p,
photos: photos || [],
inspectionValidUntil: result.validUntil,
ocrStatus: result.validUntil ? 'done' : 'idle',
}));
if (result.validUntil) message.success('检验有效期识别完成');
licenseOcrTimerRef.current = null;
}, LICENSE_OCR_MOCK_MS);
};
const handleLicensePhotosChange = (fileList) => {
const list = (fileList || []).slice(0, MAX_LICENSE_PHOTOS);
if (!list.length) {
if (licenseOcrTimerRef.current) {
clearTimeout(licenseOcrTimerRef.current);
licenseOcrTimerRef.current = null;
}
setLicenseForm({ ...EMPTY_LICENSE_FORM });
return;
}
setLicenseForm((p) => ({ ...p, photos: list }));
if (operateTask) runLicenseOcr(operateTask, list);
};
const handleReset = () => {
if (!operateTask) return;
resetOperateForms();
message.info('已重置当前表单');
};
const handleSave = () => {
if (!operateTask) return;
upsertOperateDraft(operateTask.id, collectOperateDraft());
persistTasksToStorage(tasks);
navigateToAnnualReviewList('已保存,已返回列表');
};
const handleSubmit = () => {
if (!operateTask) return;
if (licenseForm.ocrStatus === 'recognizing') {
message.warning('行驶证识别中,请稍候');
return;
}
if (!validateLicenseForm(licenseForm)) return;
if (isFormCostEmpty(inspectionForm.cost)) {
message.error('请填写检测费用');
return;
}
if (!validateStationCost(m2Form, '二保')) return;
if (!validateStationCost(zbForm, '整备')) return;
const snapshot = collectOperateDraft();
const completeTime = moment
? moment().format('YYYY-MM-DD HH:mm')
: new Date().toISOString().slice(0, 16).replace('T', ' ');
const taskIdCurrent = operateTask.id;
removeOperateDraft(taskIdCurrent);
syncLicenseToCertificateManagement(operateTask, licenseForm, MOCK_CURRENT_HANDLER);
const nextTasks = tasks.map((t) =>
t.id === taskIdCurrent
? {
...t,
tab: 'history',
executor: MOCK_CURRENT_HANDLER,
executeTime: completeTime,
expireDate: licenseForm.inspectionValidUntil || t.expireDate,
operateSnapshot: snapshot,
}
: t
);
setTasks(nextTasks);
persistTasksToStorage(nextTasks);
navigateToAnnualReviewList(
'提交成功:新行驶证照片与检验有效期至已全量覆盖至车辆证照,任务已进入历史记录'
);
};
const licenseRecognizing = licenseForm.ocrStatus === 'recognizing';
const pageInner = (
<div className="ar-handle">
<style>{PAGE_STYLES}</style>
<div className="ar-handle-inner">
<div className="ar-handle-top">
{operateTask && operateDrafts[operateTask.id] ? (
<Tag className="ar-handle-top-extra" color="processing">
已保存
</Tag>
) : null}
<Button type="primary" ghost onClick={() => setPrdOpen(true)}>
需求说明
</Button>
</div>
{!operateTask ? (
<Alert type="info" showIcon message="暂无待办年审任务,请先在列表页选择待办车辆办理。" />
) : (
<Form layout="vertical" colon={false}>
<FormGroup title="车辆信息">
<Row gutter={24}>
<Col xs={24} md={8}>
<Form.Item label="车牌号">
<Input value={operateTask.plateNo} disabled />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="品牌">
<Input value={operateTask.brand} disabled />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="型号">
<Input value={operateTask.model} disabled />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="检验有效期">
<Input value={operateTask.expireDate} disabled />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="运营状态">
<Input value={formatOperateStatusDisplay(operateTask)} disabled />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="运营区域">
<Input value={formatTaskRegion(operateTask)} disabled />
</Form.Item>
</Col>
</Row>
</FormGroup>
<FormGroup title="更新行驶证">
{licenseForm.ocrStatus === 'done' && licenseForm.inspectionValidUntil && (
<div className="ar-ocr-banner ar-ocr-banner--done">
已根据行驶证照片识别检验有效期精确到月已自动补全至月末
</div>
)}
<Row gutter={24}>
<Col span={24}>
<Form.Item
required
label={
<span>
行驶证照片
<span className="ar-form-label-hint">
提交后将自动更新年审照片到车辆证照支持最多 4 张照片上传
</span>
</span>
}
>
<PhotoUploadBlock
fileList={licenseForm.photos}
onChange={handleLicensePhotosChange}
maxPhotos={MAX_LICENSE_PHOTOS}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="检验有效期" required>
<DatePicker
style={{ width: '100%' }}
value={
licenseForm.inspectionValidUntil && moment
? moment(licenseForm.inspectionValidUntil)
: null
}
onChange={(_, dateStr) =>
setLicenseForm((p) => ({ ...p, inspectionValidUntil: dateStr || null }))
}
disabled={licenseRecognizing}
placeholder="上传照片自动识别"
/>
</Form.Item>
</Col>
</Row>
</FormGroup>
<FormGroup title="检测服务站信息">
<Row gutter={24}>
<Col xs={24} md={8}>
<Form.Item label="检测服务站">
<Select
allowClear
showSearch
placeholder="请选择"
options={inspectionStationOptions}
value={inspectionForm.station || undefined}
onChange={(v) => setInspectionForm((p) => ({ ...p, station: v || '' }))}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="费用(元)" required>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
placeholder="请输入费用"
addonAfter="元"
value={inspectionForm.cost === '' ? null : Number(inspectionForm.cost)}
onChange={(v) =>
setInspectionForm((p) => ({ ...p, cost: v == null ? '' : String(v) }))
}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="备注">
<TextArea
rows={3}
placeholder="请输入检测备注信息"
maxLength={200}
showCount
value={inspectionForm.remark}
onChange={(e) => setInspectionForm((p) => ({ ...p, remark: e.target.value }))}
/>
</Form.Item>
</Col>
</Row>
</FormGroup>
{!m2Expanded ? (
<Button type="dashed" block style={{ marginBottom: 16 }} onClick={() => setM2Expanded(true)}>
+ 添加二保信息
</Button>
) : (
<FormGroup
title="二保信息"
extra={
<Button type="link" size="small" onClick={() => setM2Expanded(false)}>
收起
</Button>
}
>
<Row gutter={24}>
<Col xs={24} md={8}>
<Form.Item label="二保服务站">
<Select
allowClear
showSearch
placeholder="请选择"
options={inspectionStationOptions}
value={m2Form.station || undefined}
onChange={(v) => setM2Form((p) => ({ ...p, station: v || '' }))}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="费用(元)" required={!!m2Form.station}>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
addonAfter="元"
placeholder={m2Form.station ? '必填' : '请输入'}
value={m2Form.cost === '' ? null : Number(m2Form.cost)}
onChange={(v) => setM2Form((p) => ({ ...p, cost: v == null ? '' : String(v) }))}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="备注">
<TextArea
rows={3}
placeholder="请输入二保备注信息"
value={m2Form.remark}
onChange={(e) => setM2Form((p) => ({ ...p, remark: e.target.value }))}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="二保照片">
<PhotoUploadBlock
fileList={m2Form.photos}
onChange={(fl) => setM2Form((p) => ({ ...p, photos: fl }))}
maxPhotos={MAX_SERVICE_PHOTOS}
/>
</Form.Item>
</Col>
</Row>
</FormGroup>
)}
{!zbExpanded ? (
<Button type="dashed" block style={{ marginBottom: 16 }} onClick={() => setZbExpanded(true)}>
+ 添加整备服务站信息
</Button>
) : (
<FormGroup
title="整备服务站信息"
extra={
<Button type="link" size="small" onClick={() => setZbExpanded(false)}>
收起
</Button>
}
>
<Row gutter={24}>
<Col xs={24} md={8}>
<Form.Item label="整备服务站">
<Select
allowClear
showSearch
placeholder="请选择"
options={repairStationOptions}
value={zbForm.station || undefined}
onChange={(v) => setZbForm((p) => ({ ...p, station: v || '' }))}
/>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="费用(元)" required={!!zbForm.station}>
<InputNumber
style={{ width: '100%' }}
min={0}
precision={2}
addonAfter="元"
placeholder={zbForm.station ? '必填' : '请输入'}
value={zbForm.cost === '' ? null : Number(zbForm.cost)}
onChange={(v) => setZbForm((p) => ({ ...p, cost: v == null ? '' : String(v) }))}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="备注">
<TextArea
rows={3}
placeholder="请输入整备备注信息"
value={zbForm.remark}
onChange={(e) => setZbForm((p) => ({ ...p, remark: e.target.value }))}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="整备照片">
<PhotoUploadBlock
fileList={zbForm.photos}
onChange={(fl) => setZbForm((p) => ({ ...p, photos: fl }))}
maxPhotos={MAX_SERVICE_PHOTOS}
/>
</Form.Item>
</Col>
</Row>
</FormGroup>
)}
</Form>
)}
</div>
<div className="ar-form-footer">
<div className="ar-form-footer-inner">
<Button size="large" onClick={handleReset} disabled={!operateTask || licenseRecognizing}>
重置
</Button>
<Button size="large" onClick={handleSave} disabled={!operateTask || licenseRecognizing}>
保存
</Button>
<Button
type="primary"
size="large"
onClick={handleSubmit}
disabled={!operateTask || licenseRecognizing}
>
提交
</Button>
</div>
</div>
{licenseRecognizing && (
<div className="ar-ocr-mask" role="alertdialog" aria-busy="true" aria-label="行驶证识别中">
<div className="ar-ocr-mask-panel">
<Spin size="large" />
<div style={{ marginTop: 16, fontWeight: 600 }}>识别中</div>
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
正在识别行驶证信息请稍候
</Text>
</div>
</div>
)}
<Modal
title="年审办理 · 产品需求说明PRD"
open={prdOpen}
onCancel={() => setPrdOpen(false)}
footer={[
<Button key="ok" type="primary" onClick={() => setPrdOpen(false)}>
我已了解
</Button>,
]}
width={800}
destroyOnClose
>
<div className="ar-prd-doc">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
<Tag>模块运维管理 &gt; 车辆业务 &gt; 年审管理-办理</Tag>
<Tag>文档版本V1.0</Tag>
<Tag>适用角色产品经理 / 运维办理人 / 研发测试</Tag>
</div>
<div className="ar-prd-highlight">
<Text strong>产品目标</Text>
<Paragraph style={{ margin: '8px 0 0' }}>
Web 端以分组表单完成单车年审现场办理更新行驶证影像与检验有效期登记检测站费用可选录入二保/整备提交后与列表车辆证照台账联动
</Paragraph>
</div>
<div className="ar-prd-h2">页面结构</div>
<ul className="ar-prd-ul">
<li>右上需求说明打开本文档</li>
<li>分组车辆信息只读 更新行驶证 检测服务站 可选二保/整备</li>
<li>底部操作重置保存提交固定底栏</li>
</ul>
<div className="ar-prd-h2">保存 vs 提交核心差异</div>
<ul className="ar-prd-ul">
<li>
<Text strong>保存</Text>稿
<Text strong>自动返回年审管理列表</Text>
</li>
<li>
<Text strong>提交</Text>
<Text strong>全量覆盖</Text> 4
</li>
<li>保存不同步证照台账仅提交触发证照全量覆盖</li>
</ul>
<div className="ar-prd-h2">提交必填与联动校验</div>
<ul className="ar-prd-ul">
<li>行驶证照片至少 1 最多 4 支持批量上传左上传右缩略图</li>
<li>检验有效期至必填 OCR 自动带出或手工选择</li>
<li>检测服务站费用必填</li>
<li>二保/整备若已选服务站则对应费用必填</li>
<li>OCR 进行中不可提交车牌不一致时清空照片并提示需重新上传</li>
</ul>
<div className="ar-prd-h2">行驶证 OCR原型</div>
<ul className="ar-prd-ul">
<li>上传后逐张识别车牌任一张与待办车牌不一致 清空全部照片不写入有效期Toast行驶证车牌号不一致请检查后重新上传</li>
<li>多张均识别到检验有效期至取按上传顺序 <Text strong>最后一条</Text> </li>
<li>提交成功后以表单最终值全量覆盖证照非增量 patch</li>
</ul>
<div className="ar-prd-h2">照片交互</div>
<ul className="ar-prd-ul">
<li>点击缩略图全屏放大预览</li>
<li>点击右上角删除仅删除该张不触发预览</li>
</ul>
<div className="ar-prd-h2">入口与返回</div>
<Paragraph style={{ margin: 0 }}>
由列表待办点击办理进入通过 session 带入任务保存/提交后返回列表列表刷新草稿标签与任务状态
</Paragraph>
</div>
</Modal>
</div>
);
return App ? <App>{pageInner}</App> : pageInner;
};
if (typeof window !== 'undefined') {
window.Component = Component;
}
export default Component;