diff --git a/web端/运维管理/车辆业务/年审管理-办理.jsx b/web端/运维管理/车辆业务/年审管理-办理.jsx
new file mode 100644
index 0000000..85e4fdd
--- /dev/null
+++ b/web端/运维管理/车辆业务/年审管理-办理.jsx
@@ -0,0 +1,1115 @@
+// 【重要】必须使用 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 (
+
+
+
+ {canUpload ? (
+
false}
+ onChange={handleChange}
+ >
+
+ +
+ 上传
+
+
+ ) : (
+
+ +
+ 已满
+
+ )}
+
+
+ {list.map((file) => {
+ const url = getUploadPreviewUrl(file);
+ return (
+
+
+
+
+ );
+ })}
+
+
+
最多上传 {maxPhotos} 张,支持批量选择
+
setPreviewOpen(false)}
+ centered
+ width={720}
+ destroyOnClose
+ title="照片预览"
+ >
+ {previewUrl ?
: null}
+
+
+ );
+};
+
+const FormGroup = ({ title, extra, children }) => (
+
+
+ {title}
+ {extra}
+
+ {children}
+
+);
+
+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 = (
+
+
+
+
+ {operateTask && operateDrafts[operateTask.id] ? (
+
+ 已保存
+
+ ) : null}
+
+
+
+ {!operateTask ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {licenseRecognizing && (
+
+
+
+
识别中
+
+ 正在识别行驶证信息,请稍候
+
+
+
+ )}
+
+
setPrdOpen(false)}
+ footer={[
+ ,
+ ]}
+ width={800}
+ destroyOnClose
+ >
+
+
+ 模块:运维管理 > 车辆业务 > 年审管理-办理
+ 文档版本:V1.0
+ 适用角色:产品经理 / 运维办理人 / 研发测试
+
+
+
产品目标
+
+ 在 Web 端以分组表单完成单车年审现场办理:更新行驶证影像与检验有效期、登记检测站费用,可选录入二保/整备;提交后与列表、车辆证照台账联动。
+
+
+
一、页面结构
+
+ - 右上「需求说明」:打开本文档。
+ - 分组:车辆信息(只读)→ 更新行驶证 → 检测服务站 → 可选二保/整备。
+ - 底部操作:重置、保存、提交(固定底栏)。
+
+
二、保存 vs 提交(核心差异)
+
+ -
+ 保存:不校验任何必填项;将当前已填内容写入本地草稿;保存成功后
+ 自动返回年审管理列表;对应待办行车牌号后展示标签「已保存」。
+
+ -
+ 提交:校验必填项(见第三节);通过后任务进入历史记录,并
+ 全量覆盖车辆证照中该车的行驶证照片(最多 4 张)与「检验有效期至」;成功后返回列表。
+
+ - 保存不同步证照台账;仅提交触发证照全量覆盖。
+
+
三、提交必填与联动校验
+
+ - 行驶证照片:至少 1 张,最多 4 张,支持批量上传;左上传、右缩略图。
+ - 检验有效期至:必填(可 OCR 自动带出或手工选择)。
+ - 检测服务站费用:必填。
+ - 二保/整备:若已选服务站,则对应费用必填。
+ - OCR 进行中不可提交;车牌不一致时清空照片并提示,需重新上传。
+
+
四、行驶证 OCR(原型)
+
+ - 上传后逐张识别车牌;任一张与待办车牌不一致 → 清空全部照片、不写入有效期、Toast:行驶证车牌号不一致,请检查后重新上传。
+ - 多张均识别到「检验有效期至」时,取按上传顺序 最后一条 有效结果写入表单。
+ - 提交成功后,以表单最终值全量覆盖证照,非增量 patch。
+
+
五、照片交互
+
+ - 点击缩略图:全屏放大预览。
+ - 点击右上角删除:仅删除该张,不触发预览。
+
+
六、入口与返回
+
+ 由列表待办点击「办理」进入,通过 session 带入任务;保存/提交后返回列表,列表刷新草稿标签与任务状态。
+
+
+
+
+ );
+
+ return App ? {pageInner} : pageInner;
+};
+
+if (typeof window !== 'undefined') {
+ window.Component = Component;
+}
+
+export default Component;
diff --git a/web端/运维管理/车辆业务/年审管理-查看.jsx b/web端/运维管理/车辆业务/年审管理-查看.jsx
new file mode 100644
index 0000000..0838c4e
--- /dev/null
+++ b/web端/运维管理/车辆业务/年审管理-查看.jsx
@@ -0,0 +1,367 @@
+// 【重要】必须使用 const Component 作为组件变量名
+// 运维管理 - 车辆业务 - 年审管理 · 查看(只读分组表单,打开即展示样例记录)
+
+const { useState } = React;
+const antd = window.antd;
+const { App, Button, Col, Form, Input, Modal, Row, Typography } = antd;
+const Image = antd.Image;
+
+const { Text, Paragraph } = Typography;
+const TextArea = Input.TextArea;
+
+/** 固定样例:打开页面即展示本条年审查看记录 */
+const SAMPLE_VIEW_RECORD = {
+ plateNo: '苏A88991',
+ brand: '解放',
+ model: 'J6P牵引车',
+ operateStatus: '自营',
+ expireDate: '2026-03-10',
+ province: '江苏省',
+ city: '南京市',
+ executor: '张明辉',
+ executeTime: '2026-03-08 14:20',
+ inspectionForm: {
+ station: '汇通检测站',
+ cost: '320',
+ remark: '车辆已完成上线检测,报告已归档。',
+ },
+ licenseForm: {
+ inspectionValidUntil: '2026-03-10',
+ photos: [
+ {
+ uid: 'sample-license-1',
+ name: '行驶证正面.jpg',
+ url: 'https://picsum.photos/seed/ar-view-license-1/240/180',
+ },
+ {
+ uid: 'sample-license-2',
+ name: '行驶证副页.jpg',
+ url: 'https://picsum.photos/seed/ar-view-license-2/240/180',
+ },
+ ],
+ },
+ m2Form: {
+ station: '汇通检测站',
+ cost: '180',
+ remark: '二保已完成,机油机滤已更换。',
+ photos: [
+ {
+ uid: 'sample-m2-1',
+ name: '二保现场1.jpg',
+ url: 'https://picsum.photos/seed/ar-view-m2-1/240/180',
+ },
+ ],
+ },
+ zbForm: null,
+};
+
+const formatTaskRegion = (task) => {
+ if (!task?.province) return '—';
+ if (task.city) return `${task.province}-${task.city}`;
+ return task.province;
+};
+
+const formatDisplayMoney = (cost) => {
+ if (cost === '' || cost == null) return '—';
+ const n = Number(cost);
+ return Number.isFinite(n) ? `${n.toFixed(2)} 元` : `${cost} 元`;
+};
+
+const getPhotoUrl = (file) => file?.url || file?.thumbUrl || '';
+
+const hasServiceBlock = (form) =>
+ !!(form?.station || form?.cost || form?.remark || (form?.photos || []).length);
+
+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 88px}
+.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;font-size:13px;color:#4e5969}
+.ar-form-group{background:#fff;border:1px solid #e5e6eb;border-radius:4px;margin-bottom:16px}
+.ar-form-group-head{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-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}
+.ar-photo-list{display:flex;flex-wrap:wrap;gap:10px}
+.ar-photo-slot{width:104px;height:104px;border-radius:8px;overflow:hidden;border:none;padding:0;background:#f2f3f5;cursor:pointer}
+.ar-photo-slot img{width:100%;height:100%;object-fit:cover;display:block}
+.ar-handle .ant-input[disabled],.ar-handle textarea.ant-input[disabled]{color:#1d2129!important;-webkit-text-fill-color:#1d2129!important;background:#f7f8fa!important;cursor:default!important}
+.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}
+`;
+
+const FormGroup = ({ title, children }) => (
+
+
+ {title}
+
+ {children}
+
+);
+
+const ReadonlyInput = ({ value }) => (
+
+);
+
+const ReadonlyTextArea = ({ value }) => (
+
+);
+
+const PhotoReadonlyGallery = ({ photos, emptyText = '暂无照片' }) => {
+ const list = photos || [];
+ const [previewOpen, setPreviewOpen] = useState(false);
+ const [previewUrl, setPreviewUrl] = useState('');
+
+ const handlePreview = (file) => {
+ const url = getPhotoUrl(file);
+ if (!url) return;
+ if (Image && typeof Image.preview === 'function') {
+ Image.preview({ src: url });
+ return;
+ }
+ setPreviewUrl(url);
+ setPreviewOpen(true);
+ };
+
+ if (!list.length) {
+ return (
+
+ {emptyText}
+
+ );
+ }
+
+ return (
+ <>
+
+ {list.map((file) => {
+ const url = getPhotoUrl(file);
+ return (
+
+ );
+ })}
+
+ setPreviewOpen(false)}
+ centered
+ width={720}
+ destroyOnClose
+ title="照片预览"
+ >
+ {previewUrl ? (
+
+ ) : null}
+
+ >
+ );
+};
+
+const Component = function AnnualReviewViewPage() {
+ const [prdOpen, setPrdOpen] = useState(false);
+ const viewTask = SAMPLE_VIEW_RECORD;
+
+ const inspectionForm = viewTask.inspectionForm || {};
+ const licenseForm = viewTask.licenseForm || {};
+ const m2Form = viewTask.m2Form;
+ const zbForm = viewTask.zbForm;
+
+ const pageInner = (
+
+
+
+
+
+ {viewTask.plateNo} · 年审办理记录(只读样例)
+
+
+
+
+
+
+
+
+
+
setPrdOpen(false)}
+ footer={[
+ ,
+ ]}
+ width={720}
+ destroyOnClose
+ >
+
+
+
+ 本页为只读查看样式稿,内置一条样例办理记录(苏A88991),打开即可预览分组表单与照片展示效果,无需从列表跳转。
+
+
+
+
+
+ );
+
+ return App ? {pageInner} : pageInner;
+};
+
+if (typeof window !== 'undefined') {
+ window.Component = Component;
+}
+
+export default Component;
diff --git a/web端/运维管理/车辆业务/年审管理.jsx b/web端/运维管理/车辆业务/年审管理.jsx
new file mode 100644
index 0000000..dea5799
--- /dev/null
+++ b/web端/运维管理/车辆业务/年审管理.jsx
@@ -0,0 +1,1728 @@
+// 【重要】必须使用 const Component 作为组件变量名
+// 运维管理 - 车辆业务 - 年审管理(Web 列表页,逻辑对齐 ONE-OS 小程序年审管理)
+
+const { useState, useMemo, useEffect } = React;
+const moment = window.moment || window.dayjs;
+const antd = window.antd;
+const {
+ Alert,
+ App,
+ Badge,
+ Button,
+ Card,
+ Cascader,
+ Col,
+ DatePicker,
+ Form,
+ Input,
+ Modal,
+ Row,
+ Select,
+ Space,
+ Table,
+ Tabs,
+ Tag,
+ Tooltip,
+ Typography,
+ message,
+} = antd;
+
+const { Text, Paragraph } = Typography;
+const { RangePicker } = DatePicker;
+
+const PROVINCE_CITY_MAP = {
+ 广东省: ['广州市', '深圳市', '东莞市'],
+ 江苏省: ['南京市', '苏州市', '无锡市'],
+ 浙江省: ['杭州市', '宁波市', '温州市'],
+ 上海市: ['上海市'],
+ 安徽省: ['合肥市', '芜湖市'],
+ 山东省: ['临沂市'],
+ 福建省: ['厦门市'],
+};
+
+const HANDLER_OPTIONS = [
+ { label: '全部', value: '' },
+ { label: '张明辉', value: '张明辉' },
+ { label: '李晓彤', value: '李晓彤' },
+ { label: '王建国', value: '王建国' },
+];
+
+const MOCK_CURRENT_HANDLER = '张明辉';
+/** 列表 KPI、执行率统计锚定日(与原型演示一致) */
+const AR_ANCHOR_DATE = '2026-06-01';
+const AR_TASK_ID_STORAGE_KEY = 'oneos_ar_operate_task_id';
+const AR_TASKS_STORAGE_KEY = 'oneos_ar_web_tasks_v1';
+const AR_DRAFT_STORAGE_KEY = 'oneos_ar_operate_drafts_v1';
+const AR_NAV_TARGET_KEY = 'oneos_ar_navigate_target';
+const AR_NAV_EVENT = 'oneos-ar-return-list';
+
+const loadTasksFromStorage = () => {
+ try {
+ const raw = localStorage.getItem(AR_TASKS_STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) && parsed.length ? parsed : null;
+ } catch {
+ return null;
+ }
+};
+
+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 */
+ }
+};
+
+/** 列表「已保存」标签示例:对应待办 ar-1、ar-2(办理页可续填) */
+const MOCK_SAMPLE_DRAFTS = {
+ 'ar-1': {
+ savedAt: '2026-06-01T10:30:00.000Z',
+ inspectionForm: {
+ station: '汇通检测站',
+ cost: '280',
+ remark: '检测已完成,待提交年审结果',
+ },
+ licenseForm: {
+ photos: [
+ {
+ uid: 'sample-ar-1-license-1',
+ name: '行驶证正面.jpg',
+ url: 'https://picsum.photos/seed/ar-license-ar1/240/180',
+ status: 'done',
+ },
+ {
+ uid: 'sample-ar-1-license-2',
+ name: '行驶证副页.jpg',
+ url: 'https://picsum.photos/seed/ar-license-ar1b/240/180',
+ status: 'done',
+ },
+ ],
+ inspectionValidUntil: '2026-07-31',
+ ocrStatus: 'done',
+ },
+ m2Expanded: false,
+ m2Form: { station: '', cost: '', remark: '', photos: [] },
+ zbExpanded: false,
+ zbForm: { station: '', cost: '', remark: '', photos: [] },
+ },
+ 'ar-2': {
+ savedAt: '2026-06-01T14:15:00.000Z',
+ inspectionForm: {
+ station: '平湖检测站',
+ cost: '320',
+ remark: '',
+ },
+ licenseForm: {
+ photos: [],
+ inspectionValidUntil: null,
+ ocrStatus: 'idle',
+ },
+ m2Expanded: true,
+ m2Form: {
+ station: '汇通检测站',
+ cost: '180',
+ remark: '二保已做,整备待补',
+ photos: [],
+ },
+ zbExpanded: false,
+ zbForm: { station: '', cost: '', remark: '', photos: [] },
+ },
+};
+
+/** 合并示例草稿与本地已保存数据(本地同 taskId 优先);无本地数据时写入示例供办理页反写 */
+const loadOperateDraftsForDisplay = () => {
+ const stored = loadOperateDrafts();
+ const merged = { ...MOCK_SAMPLE_DRAFTS, ...stored };
+ try {
+ if (!localStorage.getItem(AR_DRAFT_STORAGE_KEY)) {
+ persistOperateDrafts(MOCK_SAMPLE_DRAFTS);
+ }
+ } catch {
+ /* ignore */
+ }
+ return merged;
+};
+
+const DEFAULT_FILTER = {
+ provinceCity: null,
+ expireRange: null,
+ handler: '',
+ executeTimeRange: null,
+};
+
+const mapOperateStatus = (raw) => {
+ if (raw === '可运营' || raw === '待运营') return '库存';
+ return raw || '—';
+};
+
+/** 执行率 KPI 演示:6/7/8 月检验有效期任务(与 AR_ANCHOR_DATE 对齐) */
+const EXECUTION_RATE_DEMO_PLANS = [
+ { month: '2026-06', done: 34, pending: 24 },
+ { month: '2026-07', done: 42, pending: 16 },
+ { month: '2026-08', done: 18, pending: 11 },
+];
+
+const EXECUTION_RATE_DEMO_HANDLERS = ['张明辉', '张明辉', '李晓彤', '王建国'];
+
+const buildExecutionRateDemoTasks = () => {
+ const rows = [];
+ EXECUTION_RATE_DEMO_PLANS.forEach((plan, monthIdx) => {
+ let seq = 0;
+ const monthTag = plan.month.replace('-', '');
+ const baseDay = monthIdx * 3 + 1;
+ for (let i = 0; i < plan.done; i += 1) {
+ seq += 1;
+ const handler = EXECUTION_RATE_DEMO_HANDLERS[i % EXECUTION_RATE_DEMO_HANDLERS.length];
+ const day = String(((i + baseDay) % 27) + 1).padStart(2, '0');
+ const expireDate = `${plan.month}-${day}`;
+ rows.push({
+ id: `ar-rate-${plan.month}-done-${i}`,
+ plateNo: `粤Y${monthTag}${String(seq).padStart(3, '0')}`,
+ vin: `LRATED${plan.month}D${String(i).padStart(4, '0')}`,
+ brand: '解放',
+ model: '执行率演示车',
+ operateStatusRaw: '自营',
+ operateStatus: '自营',
+ expireDate,
+ daysLeft: 0,
+ tab: 'history',
+ province: '广东省',
+ city: '深圳市',
+ executor: handler,
+ assignee: handler,
+ executeTime: `${plan.month}-${day} ${String(9 + (i % 8)).padStart(2, '0')}:30`,
+ });
+ }
+ for (let i = 0; i < plan.pending; i += 1) {
+ seq += 1;
+ const handler = EXECUTION_RATE_DEMO_HANDLERS[(i + monthIdx) % EXECUTION_RATE_DEMO_HANDLERS.length];
+ const day = String(((i + baseDay + 7) % 27) + 1).padStart(2, '0');
+ const expireDate = `${plan.month}-${day}`;
+ const daysLeft = monthIdx === 0 ? 12 + (i % 10) : monthIdx === 1 ? 35 + (i % 15) : 55 + (i % 12);
+ rows.push({
+ id: `ar-rate-${plan.month}-pending-${i}`,
+ plateNo: `粤Y${monthTag}${String(seq).padStart(3, '0')}`,
+ vin: `LRATEP${plan.month}P${String(i).padStart(4, '0')}`,
+ brand: '福田',
+ model: '执行率演示车',
+ operateStatusRaw: '租赁',
+ operateStatus: '租赁',
+ expireDate,
+ daysLeft,
+ tab: 'pending',
+ province: '广东省',
+ city: '深圳市',
+ executor: '',
+ assignee: handler,
+ executeTime: '',
+ });
+ }
+ });
+ return rows;
+};
+
+const EXECUTION_RATE_DEMO_TASKS = buildExecutionRateDemoTasks();
+
+const mergeExecutionRateDemoTasks = (tasks) => {
+ const list = Array.isArray(tasks) ? [...tasks] : [];
+ const idSet = new Set(list.map((t) => t.id));
+ EXECUTION_RATE_DEMO_TASKS.forEach((t) => {
+ if (!idSet.has(t.id)) {
+ list.push(t);
+ idSet.add(t.id);
+ }
+ });
+ return list;
+};
+
+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: '',
+ },
+ {
+ id: 'ar-h-jun',
+ plateNo: '苏B88112',
+ vin: 'LZZ5CLSB8NC556677',
+ brand: '解放',
+ model: 'J7牵引车',
+ operateStatusRaw: '自营',
+ expireDate: '2026-06-05',
+ daysLeft: 0,
+ tab: 'history',
+ province: '江苏省',
+ city: '无锡市',
+ executor: '张明辉',
+ executeTime: '2026-06-03 11:20',
+ assignee: '张明辉',
+ },
+ {
+ id: 'ar-h1',
+ plateNo: '苏A88991',
+ vin: 'LSVAM4187C2123456',
+ brand: '解放',
+ model: 'J6P牵引车',
+ operateStatusRaw: '自营',
+ expireDate: '2026-03-10',
+ daysLeft: 0,
+ tab: 'history',
+ province: '江苏省',
+ city: '南京市',
+ executor: '张明辉',
+ executeTime: '2026-03-08 14:20',
+ },
+ {
+ id: 'ar-h2',
+ plateNo: '粤A11223',
+ vin: 'LFWNHXSD8P7654321',
+ brand: '比亚迪',
+ model: 'T5纯电轻卡',
+ operateStatusRaw: '待运营',
+ expireDate: '2026-02-20',
+ daysLeft: 0,
+ tab: 'history',
+ province: '广东省',
+ city: '广州市',
+ executor: '李晓彤',
+ executeTime: '2026-02-18 09:45',
+ },
+ {
+ id: 'ar-h3',
+ plateNo: '京A55667',
+ vin: 'LZZ5CLSB8NB654321',
+ brand: '东风',
+ model: '天龙KL',
+ operateStatusRaw: '租赁',
+ expireDate: '2026-01-15',
+ daysLeft: 0,
+ tab: 'history',
+ province: '广东省',
+ city: '东莞市',
+ executor: '王建国',
+ executeTime: '2026-01-12 16:30',
+ },
+].map((t, idx) => ({
+ ...t,
+ operateStatus: mapOperateStatus(t.operateStatusRaw),
+ assignee:
+ t.assignee
+ ?? (t.tab === 'pending'
+ ? ['张明辉', '张明辉', '李晓彤', '王建国', '张明辉', '李晓彤', '张明辉'][idx % 7] || MOCK_CURRENT_HANDLER
+ : ''),
+}));
+
+const getTaskExpireMonthKey = (task) => {
+ if (!task?.expireDate || !moment) return '';
+ return moment(task.expireDate).startOf('month').format('YYYY-MM');
+};
+
+const isAnnualReviewCompleted = (task) => task.tab === 'history';
+
+const getTaskResponsibleUser = (task) => task.executor || task.assignee || '';
+
+/** 运维主管看全量;运维专员仅统计本人负责/办理的任务 */
+const taskInRoleScope = (task, isSupervisor, currentUser) => {
+ if (isSupervisor) return true;
+ return getTaskResponsibleUser(task) === currentUser;
+};
+
+const buildThreeMonthWindows = (anchorDate = AR_ANCHOR_DATE) => {
+ const base = moment(anchorDate).startOf('month');
+ return [0, 1, 2].map((offset) => {
+ const m = base.clone().add(offset, 'month');
+ return {
+ key: `m${offset}`,
+ title: `${m.format('M')}月执行率`,
+ monthLabel: m.format('YYYY年M月'),
+ monthKey: m.format('YYYY-MM'),
+ };
+ });
+};
+
+const computeMonthExecutionRate = (allTasks, monthKey, isSupervisor, currentUser) => {
+ const scoped = (allTasks || []).filter((t) => {
+ if (isAnnualReviewExcluded(t)) return false;
+ if (getTaskExpireMonthKey(t) !== monthKey) return false;
+ return taskInRoleScope(t, isSupervisor, currentUser);
+ });
+ const total = scoped.length;
+ const done = scoped.filter(isAnnualReviewCompleted).length;
+ const rate = total === 0 ? null : Math.round((done / total) * 100);
+ return { total, done, pending: total - done, rate };
+};
+
+const isAnnualReviewExcluded = (task) =>
+ task.operateStatusRaw === '退出运营' || task.operateStatus === '退出运营';
+
+const getOverdueDays = (task) => {
+ if (task?.daysLeft != null && task.daysLeft < 0) return -task.daysLeft;
+ if (moment && task?.expireDate) {
+ const overdue = moment().startOf('day').diff(moment(task.expireDate).startOf('day'), 'days');
+ return overdue > 0 ? overdue : 0;
+ }
+ return 0;
+};
+
+const getDaysLeftForSort = (task) => {
+ if (task?.daysLeft != null) return task.daysLeft;
+ if (moment && task?.expireDate) {
+ return moment(task.expireDate).startOf('day').diff(moment().startOf('day'), 'days');
+ }
+ return Number.MAX_SAFE_INTEGER;
+};
+
+const sortPendingTasks = (tasks) =>
+ [...tasks].sort((a, b) => {
+ const overdueDiff = getOverdueDays(b) - getOverdueDays(a);
+ if (overdueDiff !== 0) return overdueDiff;
+ return getDaysLeftForSort(a) - getDaysLeftForSort(b);
+ });
+
+const buildMockLicensePhotos = (taskId) => [
+ {
+ uid: `license-${taskId}-1`,
+ name: '行驶证.jpg',
+ url: `https://picsum.photos/seed/ar-license-${taskId}/240/180`,
+ status: 'done',
+ },
+];
+
+const buildSampleHistorySnapshot = (task) => ({
+ inspectionForm: {
+ station: task.id === 'ar-h2' ? '平湖检测站' : '汇通检测站',
+ cost: task.id === 'ar-h3' ? '450' : '320',
+ remark: task.id === 'ar-h1' ? '已通过年检' : '',
+ },
+ licenseForm: {
+ photos: buildMockLicensePhotos(task.id),
+ inspectionValidUntil: task.expireDate,
+ ocrStatus: 'done',
+ },
+ m2Expanded: task.id === 'ar-h1',
+ m2Form:
+ task.id === 'ar-h1'
+ ? { station: '汇通检测站', cost: '180', remark: '二保已完成', photos: [] }
+ : { station: '', cost: '', remark: '', photos: [] },
+ zbExpanded: task.id === 'ar-h3',
+ zbForm:
+ task.id === 'ar-h3'
+ ? { station: '广州天河维修站', cost: '520', remark: '整备完成', photos: [] }
+ : { station: '', cost: '', remark: '', photos: [] },
+});
+
+const getHistoryServiceForms = (task) => {
+ const snap = task?.operateSnapshot;
+ if (snap) {
+ return { m2: snap.m2Form || {}, zb: snap.zbForm || {} };
+ }
+ return { m2: task?.m2Form || {}, zb: task?.zbForm || {} };
+};
+
+const formatListMoney = (cost) => {
+ if (cost === '' || cost == null) return '—';
+ const n = Number(cost);
+ return Number.isFinite(n) ? n.toFixed(2) : String(cost);
+};
+
+const getM2StationName = (task) => getHistoryServiceForms(task).m2?.station || '—';
+const getM2Cost = (task) => formatListMoney(getHistoryServiceForms(task).m2?.cost);
+const getZbStationName = (task) => getHistoryServiceForms(task).zb?.station || '—';
+const getZbCost = (task) => formatListMoney(getHistoryServiceForms(task).zb?.cost);
+
+const formatTaskRegion = (task) => {
+ if (!task?.province) return '—';
+ if (task.city) return `${task.province}-${task.city}`;
+ return task.province;
+};
+
+const isTaskOverdue = (task) =>
+ task?.daysLeft != null ? task.daysLeft < 0 : getOverdueDays(task) > 0;
+
+const escapeCsvCell = (val) => {
+ const text = val == null ? '' : String(val);
+ if (/[",\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
+ return text;
+};
+
+const mapTasksForList = (rawTasks) =>
+ (rawTasks || [])
+ .filter((t) => !isAnnualReviewExcluded(t))
+ .map((t) =>
+ t.tab === 'history'
+ ? { ...t, operateSnapshot: t.operateSnapshot || buildSampleHistorySnapshot(t) }
+ : t
+ );
+
+const downloadCsv = (filename, headers, rows) => {
+ const bom = '\uFEFF';
+ const lines = [headers.map(escapeCsvCell).join(',')].concat(
+ rows.map((row) => row.map(escapeCsvCell).join(','))
+ );
+ const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ link.click();
+ URL.revokeObjectURL(url);
+};
+
+const buildRegionCascaderOptions = () =>
+ Object.entries(PROVINCE_CITY_MAP).map(([province, cities]) => ({
+ label: province,
+ value: province,
+ children: cities.map((c) => ({ label: c, value: c })),
+ }));
+
+const AR_ICONS = {
+ vehicle: (
+
+ ),
+ warning: (
+
+ ),
+ success: (
+
+ ),
+ rate: (
+
+ ),
+ info: (
+
+ ),
+};
+
+const PAGE_STYLES = `
+.ar-web{min-height:100vh;background:#f7f8fa;font-family:Inter,Helvetica,PingFang SC,Microsoft YaHei,Arial,sans-serif}
+.ar-web .main-content{padding:20px 24px 32px;max-width:1440px}
+.ar-web .ar-page-top-bar{display:flex;justify-content:flex-end;margin-bottom:8px}
+.ar-web .filter-card-toolbar{display:flex;justify-content:flex-end;margin-bottom:12px}
+.ar-web .ar-alert-stats-row{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px;margin-top:12px;margin-bottom:0}
+@media (max-width:1200px){
+ .ar-web .ar-alert-stats-row{display:flex;flex-wrap:nowrap;overflow-x:auto;gap:12px;padding-bottom:4px;-webkit-overflow-scrolling:touch}
+ .ar-web .ar-alert-stats-row .ar-alert-card{flex:0 0 168px;max-width:42vw}
+}
+.ar-web .ar-alert-card{display:flex;align-items:center;gap:12px;padding:14px 40px 14px 16px;border-radius:12px;border:1px solid #e2e8f0;background:#fff;position:relative;overflow:visible}
+.ar-web .ar-alert-card-clickable{cursor:pointer;transition:box-shadow .2s ease,border-color .2s ease,transform .2s ease}
+.ar-web .ar-alert-card-clickable:hover{box-shadow:0 4px 14px rgba(15,23,42,.08)}
+.ar-web .ar-alert-card-active{box-shadow:0 0 0 2px rgba(22,93,255,.18)!important;border-color:#165dff!important}
+.ar-web .ar-alert-card-icon{flex-shrink:0;width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center}
+.ar-web .ar-alert-card-val{font-size:26px;font-weight:800;line-height:1.1;color:#0f172a;font-variant-numeric:tabular-nums}
+.ar-web .ar-alert-card-title{font-size:13px;font-weight:600;color:#334155;margin-top:2px}
+.ar-web .ar-alert-card-body{flex:1;min-width:0}
+.ar-web .ar-alert-card-info{position:absolute;top:10px;right:10px;width:22px;height:22px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;color:#94a3b8;background:rgba(148,163,184,.12);cursor:help;z-index:1}
+.ar-web .ar-alert-card-info:hover{color:#475569;background:rgba(148,163,184,.22)}
+.ar-web .ar-rate-card{background:linear-gradient(135deg,#eff6ff 0%,#fff 55%);border-color:#bfdbfe}
+.ar-web .ar-rate-card .ar-alert-card-icon{background:#dbeafe;color:#2563eb}
+.ar-web .ar-rate-card .ar-alert-card-val{color:#1d4ed8}
+.ar-web .ar-rate-card--hover-tip{cursor:default}
+.ar-web .ar-alert-card--total{background:linear-gradient(135deg,#f8fafc 0%,#fff 100%)}
+.ar-web .ar-alert-card--total .ar-alert-card-icon{background:#e2e8f0;color:#475569}
+.ar-web .ar-alert-card--normal{background:linear-gradient(135deg,#ecfdf5 0%,#fff 55%);border-color:#bbf7d0}
+.ar-web .ar-alert-card--normal .ar-alert-card-icon{background:#d1fae5;color:#059669}
+.ar-web .ar-alert-card--normal .ar-alert-card-val{color:#047857}
+.ar-web .ar-alert-card--expired{background:linear-gradient(135deg,#fef2f2 0%,#fff 55%);border-color:#fecaca}
+.ar-web .ar-alert-card--expired .ar-alert-card-icon{background:#fee2e2;color:#dc2626}
+.ar-web .ar-alert-card--expired .ar-alert-card-val{color:#b91c1c}
+.ar-web .filter-card,.ar-web .table-card{border:1px solid #e5e6eb!important;border-radius:10px!important;box-shadow:none!important}
+.ar-web .filter-card .ant-card-body{padding:16px 20px 8px}
+.ar-web .filter-form .ant-form-item{margin-bottom:16px}
+.ar-web .filter-form .ant-form-item-control-input,.ar-web .filter-form .ant-select,.ar-web .filter-form .ant-picker,.ar-web .filter-form .ant-cascader{width:100%!important}
+.ar-web .filter-form .ant-select-selector{width:100%!important}
+.ar-web .filter-actions{display:flex;justify-content:flex-end;gap:8px;padding-bottom:12px;margin-top:4px}
+.ar-web .table-card .ant-card-body{padding:0}
+.ar-web .table-list-bar{display:flex;justify-content:flex-end;align-items:center;padding:12px 20px 0}
+.ar-web .table-inner{padding:0 20px 16px}
+.ar-web .plate-cell{font-weight:600;color:#1d2129;font-size:14px}
+.ar-web .plate-cell-with-tag{display:inline-flex;align-items:center;flex-wrap:wrap;gap:8px}
+.ar-web .ar-prd-ul{margin:0;padding-left:20px}
+.ar-web .ar-prd-ul li{margin-bottom:6px}
+.ar-web .action-link{color:#165dff;cursor:pointer;user-select:none}
+.ar-web .action-link:hover{text-decoration:underline}
+.ar-web .action-link+.action-link{margin-left:12px}
+.ar-web .filter-tags{margin-top:8px;display:flex;flex-wrap:wrap;gap:8px}
+.ar-web .ar-prd-doc{max-height:65vh;overflow-y:auto;font-size:13px;line-height:1.65;color:#4e5969}
+.ar-web .ar-prd-h2{font-size:15px;font-weight:600;color:#1d2129;margin:16px 0 8px}
+.ar-web .ar-prd-h3{font-size:14px;font-weight:600;color:#1d2129;margin:12px 0 6px}
+.ar-web .ar-prd-highlight{background:#f0f9ff;border:1px solid #bedaff;border-radius:8px;padding:12px 14px;margin:12px 0}
+@media (prefers-reduced-motion: reduce){
+ .ar-web .ar-alert-card-clickable{transition:none}
+ .ar-web .ar-alert-card-clickable:hover{transform:none}
+}
+`;
+
+const Component = function AnnualReviewWebList() {
+ const regionOptions = useMemo(() => buildRegionCascaderOptions(), []);
+
+ const [mainTab, setMainTab] = useState('pending');
+ /** all=待办任务 | overdue=已逾期 | completed=已完成 */
+ const [kpiActive, setKpiActive] = useState('all');
+ const [plateInput, setPlateInput] = useState('');
+ const [appliedPlate, setAppliedPlate] = useState('');
+ const [filterDraft, setFilterDraft] = useState({ ...DEFAULT_FILTER });
+ const [filterApplied, setFilterApplied] = useState({ ...DEFAULT_FILTER });
+ const [filterExpanded, setFilterExpanded] = useState(true);
+ const [tasks, setTasks] = useState(() => {
+ const stored = loadTasksFromStorage();
+ const base = mergeExecutionRateDemoTasks(stored || MOCK_TASKS);
+ return base.filter((t) => !isAnnualReviewExcluded(t)).map((t) =>
+ t.tab === 'history'
+ ? { ...t, operateSnapshot: t.operateSnapshot || buildSampleHistorySnapshot(t) }
+ : t
+ );
+ });
+ const [prdOpen, setPrdOpen] = useState(false);
+ const [operateDrafts, setOperateDrafts] = useState(() => loadOperateDraftsForDisplay());
+ const [rangePickerKey, setRangePickerKey] = useState(0);
+ const [tablePage, setTablePage] = useState(1);
+ const [tablePageSize, setTablePageSize] = useState(10);
+ /** false=运维专员(仅本人任务);true=运维主管(全量) */
+ const [viewAsSupervisor, setViewAsSupervisor] = useState(false);
+
+ useEffect(() => {
+ setTablePage(1);
+ }, [mainTab, kpiActive, appliedPlate, filterApplied]);
+
+ const syncFromHandlePage = () => {
+ setOperateDrafts(loadOperateDraftsForDisplay());
+ const stored = loadTasksFromStorage();
+ if (stored) setTasks(mapTasksForList(mergeExecutionRateDemoTasks(stored)));
+ };
+
+ useEffect(() => {
+ const handler = () => syncFromHandlePage();
+ window.addEventListener(AR_NAV_EVENT, handler);
+ window.addEventListener('focus', handler);
+ const onStorage = (e) => {
+ if (!e.key || e.key === AR_DRAFT_STORAGE_KEY || e.key === AR_TASKS_STORAGE_KEY) {
+ handler();
+ }
+ };
+ window.addEventListener('storage', onStorage);
+ try {
+ if (sessionStorage.getItem(AR_NAV_TARGET_KEY) === 'list') {
+ sessionStorage.removeItem(AR_NAV_TARGET_KEY);
+ handler();
+ }
+ } catch {
+ /* ignore */
+ }
+ return () => {
+ window.removeEventListener(AR_NAV_EVENT, handler);
+ window.removeEventListener('focus', handler);
+ window.removeEventListener('storage', onStorage);
+ };
+ }, []);
+
+ const pendingSource = useMemo(() => tasks.filter((t) => t.tab === 'pending'), [tasks]);
+
+ const listStats = useMemo(() => {
+ const pending = pendingSource;
+ const overdue = pending.filter((t) => isTaskOverdue(t)).length;
+ const completed = tasks.filter((t) => t.tab === 'history').length;
+ return {
+ pendingTotal: pending.length,
+ overdue,
+ completed,
+ };
+ }, [pendingSource, tasks]);
+
+ const monthWindows = useMemo(() => buildThreeMonthWindows(AR_ANCHOR_DATE), []);
+
+ const executionRateCards = useMemo(() => {
+ const isSupervisor = viewAsSupervisor;
+ const currentUser = MOCK_CURRENT_HANDLER;
+ return monthWindows.map((win) => {
+ const stat = computeMonthExecutionRate(tasks, win.monthKey, isSupervisor, currentUser);
+ return {
+ ...win,
+ ...stat,
+ tooltipText: `已处理:${stat.done}/本月任务数:${stat.total}`,
+ };
+ });
+ }, [tasks, monthWindows, viewAsSupervisor]);
+
+ const activeFilterTags = useMemo(() => {
+ const f = filterApplied;
+ const tags = [];
+ if (f.provinceCity && f.provinceCity[0]) {
+ tags.push({
+ key: 'region',
+ label: `运营区域:${f.provinceCity[1] ? `${f.provinceCity[0]} / ${f.provinceCity[1]}` : f.provinceCity[0]}`,
+ });
+ }
+ if (f.expireRange && f.expireRange[0] && f.expireRange[1] && moment) {
+ tags.push({
+ key: 'expire',
+ label: `到期时间:${moment(f.expireRange[0]).format('YYYY-MM-DD')} ~ ${moment(f.expireRange[1]).format('YYYY-MM-DD')}`,
+ });
+ }
+ if (mainTab === 'history') {
+ if (f.handler) tags.push({ key: 'handler', label: `办理人:${f.handler}` });
+ if (f.executeTimeRange && f.executeTimeRange[0] && f.executeTimeRange[1] && moment) {
+ tags.push({
+ key: 'done',
+ label: `完成时间:${moment(f.executeTimeRange[0]).format('YYYY-MM-DD')} ~ ${moment(f.executeTimeRange[1]).format('YYYY-MM-DD')}`,
+ });
+ }
+ }
+ if (appliedPlate) tags.push({ key: 'plate', label: `车牌:${appliedPlate}` });
+ return tags;
+ }, [filterApplied, appliedPlate, mainTab]);
+
+ const filteredList = useMemo(() => {
+ const f = filterApplied;
+ const plateKey = (appliedPlate || '').trim().toLowerCase();
+
+ const list = tasks.filter((t) => {
+ if (t.tab !== mainTab) return false;
+ if (plateKey && !t.plateNo.toLowerCase().includes(plateKey)) return false;
+ if (f.provinceCity && f.provinceCity[0]) {
+ if (t.province !== f.provinceCity[0]) return false;
+ if (f.provinceCity[1] && t.city !== f.provinceCity[1]) return false;
+ }
+ if (f.expireRange && f.expireRange[0] && f.expireRange[1] && moment) {
+ const exp = moment(t.expireDate).startOf('day').valueOf();
+ const start = moment(f.expireRange[0]).startOf('day').valueOf();
+ const end = moment(f.expireRange[1]).endOf('day').valueOf();
+ if (exp < start || exp > end) return false;
+ }
+ if (mainTab === 'history') {
+ if (f.handler && t.executor !== f.handler) return false;
+ if (f.executeTimeRange && f.executeTimeRange[0] && f.executeTimeRange[1] && moment) {
+ const done = moment(t.executeTime);
+ if (!done.isValid()) return false;
+ const start = moment(f.executeTimeRange[0]).startOf('day').valueOf();
+ const end = moment(f.executeTimeRange[1]).endOf('day').valueOf();
+ const doneVal = done.valueOf();
+ if (doneVal < start || doneVal > end) return false;
+ }
+ }
+ return true;
+ });
+
+ let scoped = list;
+ if (kpiActive === 'overdue' && mainTab === 'pending') {
+ scoped = scoped.filter((t) => isTaskOverdue(t));
+ }
+
+ if (mainTab === 'pending') return sortPendingTasks(scoped);
+ if (mainTab === 'history' && moment) {
+ return [...scoped].sort((a, b) => {
+ const ta = moment(a.executeTime);
+ const tb = moment(b.executeTime);
+ if (ta.isValid() && tb.isValid()) return tb.valueOf() - ta.valueOf();
+ return String(b.executeTime || '').localeCompare(String(a.executeTime || ''));
+ });
+ }
+ return scoped;
+ }, [tasks, mainTab, kpiActive, appliedPlate, filterApplied]);
+
+ const renderUrgencyTag = (task) => {
+ if (mainTab !== 'pending') return null;
+ const days = task.daysLeft;
+ if (days == null) return —;
+ if (days < 0) {
+ return (
+
+ 已逾期 {Math.abs(days)} 天
+
+ );
+ }
+ if (days <= 30) {
+ return (
+
+ 剩余 {days} 天
+
+ );
+ }
+ return 剩余 {days} 天;
+ };
+
+ const goHandlePage = (task) => {
+ try {
+ sessionStorage.setItem(AR_TASK_ID_STORAGE_KEY, task.id);
+ persistTasksToStorage(tasks);
+ } catch {
+ /* ignore */
+ }
+ message.info('已带入车辆信息,请打开「年审管理-办理」页面继续办理');
+ };
+
+ const goViewPage = (task) => {
+ try {
+ sessionStorage.setItem(AR_TASK_ID_STORAGE_KEY, task.id);
+ persistTasksToStorage(tasks);
+ } catch {
+ /* ignore */
+ }
+ message.info('已带入历史记录,请打开「年审管理-查看」页面');
+ };
+
+ const handleKpiClick = (key) => {
+ setKpiActive(key);
+ if (key === 'completed') {
+ setMainTab('history');
+ } else {
+ setMainTab('pending');
+ }
+ };
+
+ const formatUrgencyText = (task) => {
+ const days = task?.daysLeft;
+ if (days == null) return '';
+ if (days < 0) return `已逾期 ${Math.abs(days)} 天`;
+ return `剩余 ${days} 天`;
+ };
+
+ const handleExportList = () => {
+ if (!filteredList.length) {
+ message.warning('当前列表无可导出数据');
+ return;
+ }
+ const isPending = mainTab === 'pending';
+ const tabLabel = kpiActive === 'overdue' ? '已逾期' : kpiActive === 'completed' ? '已完成' : '待办任务';
+ const headers = isPending
+ ? ['序号', '车牌号', '品牌', '型号', '紧急程度', '运营状态', '运营区域', '检验到期日']
+ : [
+ '序号',
+ '车牌号',
+ '品牌',
+ '型号',
+ '运营状态',
+ '运营区域',
+ '检验有效期至',
+ '二保服务站名称',
+ '二保费用',
+ '整备服务站名称',
+ '整备费用',
+ '办理人',
+ '完成时间',
+ ];
+ const rows = filteredList.map((row, index) =>
+ isPending
+ ? [
+ index + 1,
+ row.plateNo,
+ row.brand,
+ row.model,
+ formatUrgencyText(row),
+ row.operateStatus,
+ formatTaskRegion(row),
+ row.expireDate,
+ ]
+ : [
+ index + 1,
+ row.plateNo,
+ row.brand,
+ row.model,
+ row.operateStatus,
+ formatTaskRegion(row),
+ row.expireDate,
+ getM2StationName(row) === '—' ? '' : getM2StationName(row),
+ getM2Cost(row) === '—' ? '' : getM2Cost(row),
+ getZbStationName(row) === '—' ? '' : getZbStationName(row),
+ getZbCost(row) === '—' ? '' : getZbCost(row),
+ row.executor,
+ row.executeTime,
+ ]
+ );
+ const dateStr = moment ? moment().format('YYYYMMDD_HHmm') : 'export';
+ downloadCsv(`年审管理_${tabLabel}_${dateStr}.csv`, headers, rows);
+ message.success(`已导出 ${filteredList.length} 条记录`);
+ };
+
+ const indexColumn = useMemo(
+ () => ({
+ title: '序号',
+ key: 'serial',
+ width: 64,
+ fixed: 'left',
+ align: 'center',
+ render: (_, __, index) => (tablePage - 1) * tablePageSize + index + 1,
+ }),
+ [tablePage, tablePageSize]
+ );
+
+ const plateColumnPending = useMemo(
+ () => ({
+ title: '车牌号',
+ dataIndex: 'plateNo',
+ key: 'plateNo',
+ fixed: 'left',
+ width: 168,
+ render: (_, row) => (
+
+ {row.plateNo}
+ {operateDrafts[row.id] ? 已保存 : null}
+
+ ),
+ }),
+ [operateDrafts]
+ );
+
+ const plateColumnHistory = useMemo(
+ () => ({
+ title: '车牌号',
+ dataIndex: 'plateNo',
+ key: 'plateNo',
+ fixed: 'left',
+ width: 112,
+ render: (v) => {v},
+ }),
+ []
+ );
+
+ const brandModelColumns = useMemo(
+ () => [
+ { title: '品牌', dataIndex: 'brand', key: 'brand', width: 88, ellipsis: true },
+ { title: '型号', dataIndex: 'model', key: 'model', width: 168, ellipsis: true },
+ ],
+ []
+ );
+
+ const applySearch = () => {
+ setAppliedPlate(plateInput.trim());
+ setFilterApplied({ ...filterDraft });
+ setTablePage(1);
+ message.success('已按筛选条件更新列表');
+ };
+
+ const resetFilters = () => {
+ setPlateInput('');
+ setAppliedPlate('');
+ const next = { ...DEFAULT_FILTER };
+ setFilterDraft(next);
+ setFilterApplied(next);
+ setRangePickerKey((k) => k + 1);
+ message.info('已重置筛选条件');
+ };
+
+ const removeFilterTag = (key) => {
+ if (key === 'plate') {
+ setPlateInput('');
+ setAppliedPlate('');
+ return;
+ }
+ const next = { ...filterApplied };
+ if (key === 'region') next.provinceCity = null;
+ if (key === 'expire') next.expireRange = null;
+ if (key === 'handler') next.handler = '';
+ if (key === 'done') next.executeTimeRange = null;
+ setFilterDraft(next);
+ setFilterApplied(next);
+ if (key === 'expire' || key === 'done') setRangePickerKey((k) => k + 1);
+ };
+
+ const pendingColumns = [
+ indexColumn,
+ plateColumnPending,
+ ...brandModelColumns,
+ {
+ title: '紧急程度',
+ key: 'urgency',
+ width: 120,
+ render: (_, row) => renderUrgencyTag(row),
+ },
+ {
+ title: '运营状态',
+ dataIndex: 'operateStatus',
+ key: 'operateStatus',
+ width: 96,
+ render: (v) => ,
+ },
+ {
+ title: '运营区域',
+ key: 'region',
+ width: 160,
+ render: (_, row) => formatTaskRegion(row),
+ },
+ {
+ title: '检验到期日',
+ dataIndex: 'expireDate',
+ key: 'expireDate',
+ width: 120,
+ },
+ {
+ title: '操作',
+ key: 'action',
+ fixed: 'right',
+ width: 88,
+ render: (_, row) => (
+ goHandlePage(row)} role="button" tabIndex={0}>
+ 办理
+
+ ),
+ },
+ ];
+
+ const historyColumns = [
+ indexColumn,
+ plateColumnHistory,
+ ...brandModelColumns,
+ {
+ title: '运营状态',
+ dataIndex: 'operateStatus',
+ key: 'operateStatus',
+ width: 96,
+ render: (v) => ,
+ },
+ {
+ title: '运营区域',
+ key: 'region',
+ width: 160,
+ render: (_, row) => formatTaskRegion(row),
+ },
+ {
+ title: '检验有效期至',
+ dataIndex: 'expireDate',
+ key: 'expireDate',
+ width: 120,
+ },
+ {
+ title: '二保服务站名称',
+ key: 'm2Station',
+ width: 128,
+ ellipsis: true,
+ render: (_, row) => getM2StationName(row),
+ },
+ {
+ title: '二保费用',
+ key: 'm2Cost',
+ width: 96,
+ align: 'right',
+ render: (_, row) => getM2Cost(row),
+ },
+ {
+ title: '整备服务站名称',
+ key: 'zbStation',
+ width: 128,
+ ellipsis: true,
+ render: (_, row) => getZbStationName(row),
+ },
+ {
+ title: '整备费用',
+ key: 'zbCost',
+ width: 96,
+ align: 'right',
+ render: (_, row) => getZbCost(row),
+ },
+ {
+ title: '办理人',
+ dataIndex: 'executor',
+ key: 'executor',
+ width: 100,
+ },
+ {
+ title: '完成时间',
+ dataIndex: 'executeTime',
+ key: 'executeTime',
+ width: 160,
+ defaultSortOrder: 'descend',
+ sorter: (a, b) => String(b.executeTime).localeCompare(String(a.executeTime)),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ fixed: 'right',
+ width: 88,
+ render: (_, row) => (
+ goViewPage(row)} role="button" tabIndex={0}>
+ 查看
+
+ ),
+ },
+ ];
+
+ const kpiAlertCards = [
+ {
+ key: 'all',
+ kpi: 'all',
+ type: 'total',
+ title: '待办任务',
+ desc: '当前待办理年审任务总数(不含退出运营车辆)。点击卡片筛选待办列表。',
+ val: listStats.pendingTotal,
+ icon: AR_ICONS.vehicle,
+ },
+ {
+ key: 'overdue',
+ kpi: 'overdue',
+ type: 'expired',
+ title: '已逾期',
+ desc: '检验到期日已过仍未办结的待办任务。点击后列表仅展示已逾期车辆,排序优先逾期最久。',
+ val: listStats.overdue,
+ icon: AR_ICONS.warning,
+ },
+ {
+ key: 'completed',
+ kpi: 'completed',
+ type: 'normal',
+ title: '已完成',
+ desc: '已提交办结并进入历史记录的年审任务数。点击切换至历史 Tab。',
+ val: listStats.completed,
+ icon: AR_ICONS.success,
+ },
+ ];
+
+ const renderKpiInfoIcon = (desc) => (
+
+ e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+ {AR_ICONS.info}
+
+
+ );
+
+ const statCards = (
+
+ {kpiAlertCards.map((card) => (
+
handleKpiClick(card.kpi)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => e.key === 'Enter' && handleKpiClick(card.kpi)}
+ >
+ {renderKpiInfoIcon(card.desc)}
+
{card.icon}
+
+
{card.val}
+
{card.title}
+
+
+ ))}
+ {executionRateCards.map((card) => (
+
+
+
{AR_ICONS.rate}
+
+
{card.rate == null ? '—' : `${card.rate}%`}
+
{card.title}
+
+
+
+ ))}
+
+ );
+
+ const pageContent = (
+
+
+
+
+
+ 查看视角
+
+
+
+
+
+
+ {filterExpanded && (
+
+ )}
+ {activeFilterTags.length > 0 && (
+
+ {activeFilterTags.map((t) => (
+ removeFilterTag(t.key)}>
+ {t.label}
+
+ ))}
+
+ )}
+
+
+ {statCards}
+
+
+
+
+
+
+
`共 ${total} 条`,
+ onChange: (page, pageSize) => {
+ setTablePage(page);
+ setTablePageSize(pageSize);
+ },
+ }}
+ locale={{
+ emptyText: (
+
+ 暂无符合条件的年审任务
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+ 📋
+
+ 年审管理 · 产品需求说明(PRD)
+
+ }
+ onCancel={() => setPrdOpen(false)}
+ footer={[
+ ,
+ ]}
+ width={980}
+ centered
+ style={{ top: 20 }}
+ bodyStyle={{ maxHeight: '72vh', overflowY: 'auto', padding: '12px 24px 24px' }}
+ destroyOnClose
+ >
+
+
+ 本页:年审任务列表(监管台)
+ 模块路径:运维管理 > 车辆业务 > 年审管理
+ 文档版本:V1.1
+ 读者:产品 / 运维主管 / 运维专员 / 研发测试
+
+
+
本文档说明「年审管理」列表页能力}
+ description="单车分组办理、OCR、提交与证照回写在独立页面「年审管理-办理」;历史只读查看在「年审管理-查看」。列表通过 session 任务 ID + 本地任务库/草稿库与办理页联动,逻辑对齐 ONE-OS 小程序年审管理。"
+ />
+
+
+
+
+
+
产品定位(本页)
+
+ 监管台:对车辆年审任务进行预警看板、执行率跟踪、筛选检索、导出、进入单车办理,不承担分组表单录入本身。
+
+
+ 价值:让运维团队「先看全局风险与完成度 → 再按车办理」,待办与历史一体管理,办结后回写证照检验有效期。
+
+
+
+
端到端主流程
+
+ -
+ 进入列表 → 默认待办视图,KPI 一行展示任务概况 + 近 3 个月执行率
+
+ -
+ 点击 KPI / 筛选查询 → 缩小待处理车辆范围(KPI 计数为全量任务口径,见规则②)
+
+ -
+ 待办办理 →「办理」进入办理页,保存草稿或提交办结
+
+ -
+ 办结归档 → 任务进历史,列表切至已完成;证照检验有效期全量覆盖
+
+ -
+ 历史追溯 →「查看」只读回看检测/行驶证/二保/整备录入内容
+
+
+
+
【重点】五条必读业务规则
+
+
+ ① 任务来源与范围
+
+ {' '}
+ — 待办由车辆检验有效期驱动生成;退出运营车辆不在本模块展示、不参与统计。
+
+
+
+ ② KPI vs 筛选
+
+ {' '}
+ — 待办总数 / 已逾期 / 已完成、执行率均按全量任务库统计,不随列表筛选变化;点击 KPI 仅切换列表视图/范围,可与筛选叠加。
+
+
+
+ ③ 逾期与紧急程度
+
+ {' '}
+ — 检验到期日早于今日为已逾期;待办列表展示紧急标签:已逾期(红)、剩余 ≤30 天(橙)、其余(灰)。待办排序:逾期越久越靠前,其次剩余天数越少越靠前。
+
+
+
+ ④ 草稿与办结
+
+ {' '}
+ — 办理页「保存」不写证照、不校验,回列表显示
+
+ 已保存
+
+ ;「提交」校验通过后清草稿、任务转历史,并同步证照。
+
+
+
+ ⑤ 证照回写
+
+ {' '}
+ — 提交时以新行驶证照片(最多 4 张)+ 检验有效期至对「证照管理」对应车辆做全量覆盖(非增量);保存不同步证照。
+
+
+
+
+
列表视图切换(无独立 Tab 栏)
+
+ 通过 KPI 卡片切换:待办任务、已逾期(仍属待办池)、已完成(切换为历史列表)。当前视图决定表格列与筛选项(历史多出办理人、完成时间)。
+
+
+
+
+
+
+
1. 页面结构(自上而下)
+
+ - 右上「查看需求说明」(本文档);原型另提供「查看视角」切换专员/主管(正式环境由登录角色决定)
+ - 筛选区:车牌、运营区域、检验到期时间;历史视图增加办理人、完成时间
+ - KPI 看板:一行 6 卡(3 任务指标 + 3 月执行率)
+ - 任务表格 + 导出
+
+
+
2. 筛选与标签
+
+ - 点击「查询」后条件生效;「重置」清空;已生效条件以可关闭 Tag 展示
+ - 车牌支持模糊匹配;运营区域为省/市级联;检验到期时间为闭区间
+ - 筛选仅作用于列表,不改变 KPI / 执行率数字
+
+
+
3. 待办列表字段
+
+ 序号、车牌号(含「已保存」标签)、品牌、型号、紧急程度、运营状态、运营区域、检验到期日、操作(办理)。
+
+
+ 运营状态展示规则:主数据「可运营」「待运营」统一显示为「库存」,与证照模块一致。
+
+
+
4. 历史列表字段
+
+ 序号、车牌号、品牌、型号、运营状态、运营区域、检验有效期至、二保服务站名称/费用、整备服务站名称/费用、办理人、完成时间、操作(查看)。未办理二保/整备时名称与费用显示「—」。
+
+
+
5. 导出
+
+ 表格右上「导出」:导出当前筛选结果为 CSV(UTF-8 BOM),列与当前列表视图一致;无数据时提示不可导出。
+
+
+
+
+
+
+
任务 KPI(前三卡,可点击)
+
+
+
+ | 卡片 |
+ 统计口径 |
+ 点击行为 |
+
+
+
+
+ | 待办任务 |
+ tab=待办 的任务总数 |
+ 列表切待办,展示全部待办 |
+
+
+ | 已逾期 |
+ 待办中检验到期日 < 今日 |
+ 列表切待办,仅展示逾期车 |
+
+
+ | 已完成 |
+ tab=历史 的任务总数 |
+ 列表切历史 |
+
+
+
+
+ 卡片左侧图标垂直居中;指标说明见右上角 ⓘ 悬浮提示。
+
+
+
近 3 个月执行率(后三卡)
+
+ -
+ 月份:以系统当前月为基准,自动取当月、下月、下下月,标题示例「6月执行率」「7月执行率」「8月执行率」
+
+ -
+ 本月任务数:检验有效期(到期日)落在该自然月的任务条数(待办 + 历史合计,不含退出运营)
+
+ -
+ 已处理数:上述任务中已办结(进入历史)的条数
+
+ -
+ 展示:大数显示「已执行比例」= round(已处理 ÷ 本月任务数 × 100%);无任务时显示「—」
+
+ -
+ 悬浮:鼠标悬停卡片显示「已处理:x/本月任务数:y」,例:已处理:35/本月任务数:60
+
+ -
+ 角色:运维主管统计全量;运维专员仅统计本人为办理人/负责人的任务(正式环境按登录账号,勿依赖原型下拉)
+
+
+
+
+
+
+
+
办理页「年审管理-办理」
+
+ - 列表「办理」→ session 写入当前任务 ID,并持久化任务库 → 打开办理页
+ - 分组模块:检测信息、行驶证(OCR)、二保(可选)、整备(可选)
+ -
+ 保存:本地草稿,不校验,不同步证照;返回列表显示「已保存」
+
+ -
+ 提交:必填校验通过后 → 任务转历史、记录办理人与完成时间、清除草稿、回写证照检验有效期与行驶证照片
+
+ - 返回列表:自定义事件 + session 标记,列表自动刷新任务与草稿状态
+
+
+
查看页「年审管理-查看」
+
+ - 历史行「查看」→ 带入任务 ID → 只读展示办结快照(布局同办理页)
+ - 不可编辑、不可再次提交;用于稽核与客服查询
+
+
+
与小程序 / 证照的关系
+
+ - 任务生成、紧急程度、办理字段口径与 ONE-OS 小程序年审管理对齐
+ - 办结写回「证照管理」对应车牌的行驶证影像与检验有效期(全量覆盖)
+
+
+
+
+
+
+
数据与权限(上线预期)
+
+ - 任务主数据由后端按车辆检验有效期与运营状态下发;前端不手工造任务(原型含演示数据)
+ - 办理人、完成时间在提交时写入;历史列表可按办理人、完成时间筛选
+ - 执行率按自然月滚动,每月 1 日「下下月」窗口前移
+ - 专员仅看本人执行率;主管/管理员看团队全量
+
+
+
原型演示说明
+
+ - 本地存储键:任务库 oneos_ar_web_tasks_v1、草稿 oneos_ar_operate_drafts_v1
+ - 内置执行率样例车(车牌粤Y…)用于演示 6/7/8 月比例,合并进任务库且不与用户数据重复 ID
+ - 待办「粤B58888F」「沪A03561F」默认带草稿,演示「已保存」续填
+
+
+
+
+
+
+
功能验收清单
+
+ - 6 张 KPI 同一行展示;任务 KPI 可点击切换列表;执行率悬停显示已处理/本月任务数
+ - 筛选查询、Tag 关闭、重置行为正确;筛选不改变 KPI 数字
+ - 待办排序符合逾期优先规则;历史按完成时间倒序
+ - 办理 → 保存 → 列表「已保存」→ 续填 → 提交 → 进历史、标签消失、证照回写
+ - 历史「查看」只读;导出列与当前视图一致
+ - 退出运营车辆不出现在列表与统计中
+ - 专员/主管执行率口径符合 PRD,与列表筛选独立
+
+
+
非功能
+
+ - 表格支持横向滚动与表头吸顶;空列表友好提示
+ - KPI 卡片支持键盘 Enter 触发(待办三卡)
+ - 与办理/查看页 session 联动,返回后列表状态刷新
+
+
+
+
+
+
+
+
+ );
+
+ return App ? {pageContent} : pageContent;
+};
+
+if (typeof window !== 'undefined') {
+ window.Component = Component;
+}
+
+export default Component;