From d29e2a821bb14f657c3da6b8e036383a1c8f4b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=86=95?= Date: Tue, 2 Jun 2026 14:29:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=AE=8C=E5=96=84=E8=AF=81?= =?UTF-8?q?=E7=85=A7=E7=AE=A1=E7=90=86=E5=88=97=E8=A1=A8=E5=B9=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=8D=95=E8=BD=A6=E7=BB=B4=E6=8A=A4=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表页增强 KPI 看板、筛选与批量导出/上传及 PRD;维护能力拆至证照管理-编辑页,支持八类证照分卡保存与 OCR 校验。 Co-authored-by: Cursor --- web端/运维管理/车辆业务/证照管理-编辑.jsx | 2830 +++++++++++++++++ web端/运维管理/车辆业务/证照管理.jsx | 3376 +++++++++++++++++++-- 2 files changed, 5913 insertions(+), 293 deletions(-) create mode 100644 web端/运维管理/车辆业务/证照管理-编辑.jsx diff --git a/web端/运维管理/车辆业务/证照管理-编辑.jsx b/web端/运维管理/车辆业务/证照管理-编辑.jsx new file mode 100644 index 0000000..676e0d8 --- /dev/null +++ b/web端/运维管理/车辆业务/证照管理-编辑.jsx @@ -0,0 +1,2830 @@ +// 【重要】必须使用 const Component 作为组件变量名 +// 运维管理 - 车辆业务 - 证照管理(资质维护页,由台账列表「管理」进入) + +const { useState, useEffect, useMemo, useRef } = React; +const moment = window.moment || window.dayjs; +const antd = window.antd; +const { + Form, + Input, + Select, + Button, + DatePicker, + Card, + Row, + Col, + Space, + Badge, + Alert, + InputNumber, + Divider, + Switch, + Spin, + Tooltip, + Modal, + Progress, + Popover, + Tabs, + Tag, + message +} = antd; + +const LC_LICENSES_STORAGE_KEY = 'oneos_lc_licenses_v1'; +const LC_EDIT_PLATE_KEY = 'oneos_lc_edit_plate'; +const LC_NAV_TARGET_KEY = 'oneos_lc_navigate_target'; +const LC_NAV_EVENT = 'oneos-lc-return-list'; + +const loadLicensesFromStorage = () => { + try { + const raw = localStorage.getItem(LC_LICENSES_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +}; + +const persistLicensesToStorage = (data) => { + try { + localStorage.setItem(LC_LICENSES_STORAGE_KEY, JSON.stringify(data)); + } catch { + /* ignore */ + } +}; + +const navigateToLicenseList = (toastMsg) => { + try { + sessionStorage.setItem(LC_NAV_TARGET_KEY, 'list'); + sessionStorage.removeItem(LC_EDIT_PLATE_KEY); + } catch { + /* ignore */ + } + try { + window.dispatchEvent(new CustomEvent(LC_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 createEmptyLicenseRecord = () => ({ + driverLicense: { photos: [], regDate: '', issueDate: '', scrapDate: '', expireDate: '', updateType: '直接上传', updateTime: '', updateUser: '' }, + transportLicense: { photos: [], licenseNo: '', issueDate: '', expireDate: '', inspectValidUntil: '', updateTime: '', updateUser: '' }, + registrationCert: { photos: [] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '', updateTime: '', updateUser: '' }, + hydrogenCard: { cardNo: '', cardType: '中石化加氢卡', balance: 0, issueDate: '', issueUser: '' }, + safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' }, + pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' } +}); + +const mergeLicenseStatusTypes = (a, b) => { + const rank = { error: 0, warning: 1, default: 2, success: 3, processing: 2 }; + const ra = rank[a] ?? 9; + const rb = rank[b] ?? 9; + return ra <= rb ? a : b; +}; + +const loadPlateLicenseBundle = (master, plate) => { + if (master[plate]) return JSON.parse(JSON.stringify(master[plate])); + return createEmptyLicenseRecord(); +}; + +/** 特种设备使用标识:与台账列表一致,提前 60 天临期感知,≤15 天高危提示 */ +const SPECIAL_EQUIP_DECAL_WARN_DAYS = 60; +const SPECIAL_EQUIP_DECAL_CRITICAL_DAYS = 15; + +// 常用矢量图标,保证 100% 渲染且支持高保真样式 +const ICONS = { + ocr: , + upload: , + camera: , + warning: , + card: , + success: , + delete: , + shield: , + vehicle: , + edit: , + filter: +}; + +// 预设的车辆列表(扩充至 5 辆以完美支持台账数据测试) +const MOCK_VEHICLES = [ + { plateNo: '沪A03561F', brand: '宇通', model: '49吨牵引车头', vin: 'LMRKH9AC0R1004086', status: '自营' }, + { plateNo: '粤B58888F', brand: '福田', model: '奥铃4.5吨冷藏车', vin: 'LGHXCAE28M6789012', status: '租赁' }, + { plateNo: '苏E33333', brand: '陕汽', model: '德龙X3000混动牵引车', vin: 'LSXCH9AE8M1094857', status: '自营' }, + { plateNo: '京A12345', brand: '东风', model: 'DFH1180厢式货车', vin: 'LKLG7C4E4NA774759', status: '退出运营' }, + { plateNo: '浙A88888', brand: '宇通', model: '氢燃料电池大巴', vin: 'LMRKH9AE2P9876543', status: '库存' } +]; + +// 模拟预设的所有车辆证照档案数据库 +const INITIAL_LICENSE_DATA = { + '沪A03561F': { + driverLicense: { + photos: ['https://picsum.photos/seed/license1/600/400', 'https://picsum.photos/seed/license2/600/400'], + regDate: '2024-06-05', + issueDate: '2024-06-05', + scrapDate: '2039-06-04', + expireDate: '2026-06-30', // 行驶证 29 天后到期(临期警告) + updateType: '直接上传', + updateTime: '2026-05-28 14:32:00', + updateUser: '李明辉', + shNextEvaluation: '2026-12-05' + }, + transportLicense: { + photos: ['https://picsum.photos/seed/transport/600/400'], + licenseNo: '交字310115102345号', + issueDate: '2024-07-12', + expireDate: '2026-07-31', // 证件有效期 60 天后到期(临期临界) + inspectValidUntil: '2026-07-20', // 审验有效期 49 天后(临期) + updateTime: '2026-05-10 11:20:00', + updateUser: '陈高伟' + }, + registrationCert: { photos: ['https://picsum.photos/seed/regcert1/600/400'] }, + specialEquipCert: { photos: ['https://picsum.photos/seed/spec1/600/400'] }, + specialEquipDecal: { photos: ['https://picsum.photos/seed/spec2/600/400'], nextInspectDate: '2026-07-20', updateTime: '2026-05-20 16:45:00', updateUser: '系统管理员' }, + hydrogenCard: { cardNo: 'H2-9988-7766-5544', cardType: '中石化加氢卡', balance: 12850.50, issueDate: '2025-01-15 14:30', issueUser: '能源管理部-张晓' }, + safetyValve: { photos: ['https://picsum.photos/seed/valve/600/400'], inspectDate: '2025-10-10', nextInspectDate: '2026-10-09' }, + pressureGauge: { photos: ['https://picsum.photos/seed/gauge/600/400'], inspectDate: '2025-12-15', nextInspectDate: '2026-06-14' } + }, + '粤B58888F': { + driverLicense: { photos: [], regDate: '', issueDate: '', scrapDate: '', expireDate: '', updateType: '直接上传', updateTime: '', updateUser: '' }, + transportLicense: { + photos: ['https://picsum.photos/seed/trans_ocr/600/400'], + licenseNo: '粤字440301102947号', + issueDate: '2024-07-20', + expireDate: '2026-07-20', + inspectValidUntil: '2026-08-15', + updateTime: '2026-05-15 09:30:00', + updateUser: '黄志杰' + }, + registrationCert: { photos: [] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '' }, + hydrogenCard: { cardNo: 'H2-5566-4433-2211', cardType: '中石化加氢卡', balance: 5200.00, issueDate: '2025-03-10 10:15', issueUser: '能源管理部-张晓' }, + safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' }, + pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' } + }, + '京A12345': { + driverLicense: { photos: [], regDate: '', issueDate: '', scrapDate: '', expireDate: '', updateType: '直接上传', updateTime: '', updateUser: '' }, + transportLicense: { photos: [], licenseNo: '', issueDate: '', expireDate: '', inspectValidUntil: '', updateTime: '', updateUser: '' }, + registrationCert: { photos: [] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '' }, + hydrogenCard: { cardNo: '', cardType: '中石化加氢卡', balance: 0, issueDate: '', issueUser: '' }, + safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' }, + pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' } + }, + '苏E33333': { + driverLicense: { + photos: ['https://picsum.photos/seed/su_lic/600/400'], + regDate: '2024-05-16', + issueDate: '2024-05-16', + scrapDate: '2039-05-15', + expireDate: '2026-05-15', // 已过期(逾期 17 天) + updateType: '直接上传', + updateTime: '2026-05-01 10:00:00', + updateUser: '王东东' + }, + transportLicense: { + photos: ['https://picsum.photos/seed/su_trans/600/400'], + licenseNo: '苏字320501104829号', + issueDate: '2024-08-10', + expireDate: '2026-08-10', + inspectValidUntil: '2026-08-10', + updateTime: '2026-05-12 15:40:00', + updateUser: '王东东' + }, + registrationCert: { photos: [] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '' }, + hydrogenCard: { cardNo: '', cardType: '中石化加氢卡', balance: 0, issueDate: '', issueUser: '' }, + safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' }, + pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' } + }, + '浙A88888': { + driverLicense: { + photos: ['https://picsum.photos/seed/zhe1/600/400'], + regDate: '2025-01-01', + issueDate: '2025-01-01', + scrapDate: '2040-01-01', + expireDate: '2027-12-31', // 正常 + updateType: '直接上传', + updateTime: '2026-01-10 11:00:00', + updateUser: '张小凡' + }, + transportLicense: { + photos: ['https://picsum.photos/seed/zhe2/600/400'], + licenseNo: '浙字330101582910号', + issueDate: '2025-01-05', + expireDate: '2027-12-31', + inspectValidUntil: '2027-12-31', + updateTime: '2026-01-10 11:00:00', + updateUser: '张小凡' + }, + registrationCert: { photos: ['https://picsum.photos/seed/zhe3/600/400'] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '' }, + hydrogenCard: { cardNo: 'H2-8888-6666-5555', cardType: '中石化加氢卡', balance: 8800.00, issueDate: '2025-05-20 16:30', issueUser: '能源管理部-张晓' }, + safetyValve: { photos: ['https://picsum.photos/seed/zhe_v/600/400'], inspectDate: '2025-12-20', nextInspectDate: '2026-12-19' }, + pressureGauge: { photos: ['https://picsum.photos/seed/zhe_g/600/400'], inspectDate: '2025-12-20', nextInspectDate: '2026-12-19' } + } +}; + +const PAGE_STYLE = ` +.lc-edit-page { + font-family: system-ui, -apple-system, sans-serif; + color: #1e293b; +} +.lc-page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} +.lc-page-title { + font-size: 22px; + font-weight: 700; + color: #0f172a; + margin: 0; +} +.lc-layout-row { + display: flex; + gap: 24px; + flex: 1; + min-height: 0; +} +.lc-sidebar { + width: 320px; + flex-shrink: 0; + height: 100%; + display: flex; + flex-direction: column; +} +.lc-content-area { + flex: 1; + min-width: 0; + height: 100%; + overflow-y: auto; + padding-right: 12px; + padding-bottom: 80px; +} +.lc-content-area::-webkit-scrollbar { + width: 6px; +} +.lc-content-area::-webkit-scrollbar-track { + background: transparent; +} +.lc-content-area::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} +.lc-content-area::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} +.lc-sticky-card { + height: 100%; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; +} +.lc-sidebar-card { + border-radius: 16px !important; + border: 1px solid #e2e8f0 !important; + box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.05) !important; + overflow: hidden !important; +} +.lc-sidebar-card-index { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.lc-sidebar-card-index .ant-card-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 12px 16px !important; +} +.lc-sidebar-info-row { + display: flex; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px dashed #f1f5f9; +} +.lc-sidebar-info-row:last-child { + border-bottom: none; +} +.lc-sidebar-label { + font-size: 13px; + color: #64748b; +} +.lc-sidebar-val { + font-size: 13px; + font-weight: 600; + color: #0f172a; +} +.lc-nav-list { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + overflow-y: auto; + padding-right: 4px; +} +.lc-nav-list::-webkit-scrollbar { + width: 4px; +} +.lc-nav-list::-webkit-scrollbar-track { + background: transparent; +} +.lc-nav-list::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 2px; +} +.lc-nav-list::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} +.lc-nav-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: #475569; + background: #f8fafc; + border: 1px solid #f1f5f9; + cursor: pointer; + transition: all .2s ease; +} +.lc-nav-item:hover { + background: #f1f5f9; + border-color: #cbd5e1; + color: #0f172a; +} +.lc-nav-item.active { + background: #ecfdf5; + border-color: #a7f3d0; + color: #065f46; + font-weight: 600; + box-shadow: 0 2px 8px -2px rgba(16, 185, 129, 0.1); +} +.lc-card { + border-radius: 16px !important; + border: 1px solid #e2e8f0 !important; + box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.05) !important; + transition: border-color .3s, box-shadow .3s; + overflow: hidden !important; +} +.lc-card:hover { + border-color: #cbd5e1 !important; + box-shadow: 0 10px 25px -5px rgba(15, 23, 42, 0.08) !important; +} +.lc-card .ant-card-head { + border-bottom: 1px solid #f1f5f9 !important; + background: #fafbfc; + padding: 14px 24px !important; +} +.lc-card-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0f172a; +} +.lc-card-subtitle { + font-size: 11px; + font-weight: 400; + color: #94a3b8; + margin-top: 2px; +} +.lc-upload-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} +.lc-upload-box { + aspect-ratio: 4/3; + border: 1.5px dashed #cbd5e1; + border-radius: 12px; + background: #f8fafc; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + color: #64748b; + transition: all .2s ease; + position: relative; + overflow: hidden; +} +.lc-upload-box:hover { + border-color: #10b981; + background: #ecfdf5; + color: #10b981; +} +.lc-image-thumb { + width: 100%; + height: 100%; + object-fit: cover; +} +.lc-image-mask { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.6); + opacity: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + transition: opacity .2s ease; + color: #fff; +} +.lc-upload-box:hover .lc-image-mask { + opacity: 1; +} +.lc-image-action-btn { + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(255,255,255,0.15); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background .2s; +} +.lc-image-action-btn:hover { + background: rgba(255,255,255,0.3); +} +.lc-image-action-btn.delete:hover { + background: #ef4444; +} +.lc-ocr-tag { + background: #fef3c7; + color: #d97706; + border: 1px solid #fde68a; + font-size: 11px; + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 4px; +} +.lc-ocr-scanline { + position: absolute; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, transparent, #10b981, transparent); + animation: lc-scan 1.5s infinite linear; + z-index: 10; + box-shadow: 0 0 8px #10b981; +} +@keyframes lc-scan { + 0% { top: 0%; } + 50% { top: 100%; } + 100% { top: 0%; } +} +.lc-sh-badge { + background: #f5f3ff; + color: #7c3aed; + border: 1px solid #ddd6fe; + font-size: 11px; + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; +} +.lc-form-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px 20px; +} +.lc-form-grid .ant-form-item { + margin-bottom: 0 !important; + display: flex !important; + flex-direction: column !important; +} +.lc-form-grid .ant-form-item-label { + padding-bottom: 6px !important; + text-align: left !important; +} +.lc-form-grid .ant-form-item-control { + width: 100% !important; +} +.lc-form-grid .ant-form-item-control-input-content { + display: flex !important; + width: 100% !important; +} +.lc-form-grid .ant-form-item-control-input-content > * { + width: 100% !important; +} +.lc-form-group-title { + font-size: 13px; + font-weight: 600; + color: #475569; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 6px; +} +.lc-h2-refuel-card { + width: 100%; + height: 160px; + border-radius: 16px; + background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); + position: relative; + overflow: hidden; + padding: 20px; + color: #fff; + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.15); +} +.lc-h2-refuel-card::before { + content: ""; + position: absolute; + top: -20%; + right: -20%; + width: 180px; + height: 180px; + border-radius: 50%; + background: radial-gradient(circle, rgba(16, 185, 129, 0.2) 0%, transparent 70%); + filter: blur(20px); +} +.lc-h2-card-logo { + font-size: 16px; + font-weight: 800; + letter-spacing: 0.05em; + background: linear-gradient(90deg, #10b981, #34d399); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + display: flex; + align-items: center; + gap: 6px; +} +.lc-h2-card-number { + font-size: 18px; + font-weight: 700; + font-family: monospace; + letter-spacing: 0.1em; + color: #f1f5f9; +} +.lc-h2-card-balance { + font-size: 24px; + font-weight: 800; + font-family: monospace; + color: #34d399; +} +.lc-h2-card-meta { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #94a3b8; +} +.lc-page-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(12px); + border-top: 1px solid #e2e8f0; + padding: 12px 24px; + display: flex; + justify-content: flex-end; + gap: 12px; + z-index: 100; + box-shadow: 0 -4px 20px rgba(15, 23, 42, 0.03); +} +.lc-ocr-flash { + animation: flash-green .4s ease-out 2; +} +@keyframes flash-green { + 0% { background-color: transparent; } + 50% { background-color: rgba(16, 185, 129, 0.15); } + 100% { background-color: transparent; } +} + +/* ==================== 列表台账页面专属样式 ==================== */ +.lc-filter-card.ant-card { + border-radius: 16px !important; + border: 1px solid #e2e8f0 !important; + box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.03) !important; + margin-bottom: 16px; +} +.lc-filter-card > .ant-card-head { + border-bottom: 1px solid #f1f5f9 !important; + min-height: auto; + padding: 12px 20px !important; +} +.lc-filter-card > .ant-card-head .ant-card-head-title { + font-size: 15px !important; + font-weight: 700 !important; + color: #0f172a !important; + padding: 0 !important; +} +.lc-filter-card > .ant-card-body { + padding: 16px 20px 20px !important; +} +.lc-filter-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px 24px; +} +@media (max-width: 1100px) { + .lc-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 720px) { + .lc-filter-grid { grid-template-columns: 1fr; } +} +.lc-filter-field { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.lc-filter-field-label { + flex: 0 0 72px; + text-align: right; + font-size: 13px; + font-weight: 500; + color: #475569; + line-height: 1.4; + white-space: nowrap; +} +.lc-filter-field-control { + flex: 1; + min-width: 0; +} +.lc-filter-field-control .ant-input, +.lc-filter-field-control .ant-select { + width: 100%; +} +.lc-multi-plate-pop { + width: 320px; + padding: 4px 2px; +} +.lc-multi-plate-pop-hint { + font-size: 12px; + color: #64748b; + margin-bottom: 8px; + line-height: 1.5; +} +.lc-multi-plate-pop-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} +.lc-multi-plate-trigger { + cursor: pointer; +} +.lc-multi-plate-trigger .ant-input { + cursor: pointer; +} +.lc-filter-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f1f5f9; +} +.lc-alert-stats-row { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} +@media (max-width: 1200px) { + .lc-alert-stats-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +@media (max-width: 768px) { + .lc-alert-stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +.lc-alert-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 30px 14px 16px; + border-radius: 12px; + border: 1px solid #e2e8f0; + background: #fff; + position: relative; + overflow: hidden; + min-width: 0; +} +.lc-alert-card-main { + flex: 1; + min-width: 0; +} +.lc-alert-card-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; +} +.lc-alert-card-val { + font-size: 26px; + font-weight: 800; + line-height: 1.1; + color: #0f172a; + font-variant-numeric: tabular-nums; +} +.lc-alert-card-title { + font-size: 13px; + font-weight: 600; + color: #334155; + margin-top: 2px; +} +.lc-alert-card-tip-anchor { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + line-height: 0; +} +.lc-alert-card-tip { + width: 18px; + height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: #94a3b8; + background: rgba(255, 255, 255, 0.92); + border: 1px solid #e2e8f0; + cursor: help; + line-height: 0; +} +.lc-alert-card-tip:hover { + color: #64748b; + border-color: #cbd5e1; + background: #fff; +} +.lc-alert-card--total { background: linear-gradient(135deg, #f8fafc 0%, #fff 100%); } +.lc-alert-card--total .lc-alert-card-icon { background: #e2e8f0; color: #475569; } +.lc-alert-card--normal { background: linear-gradient(135deg, #ecfdf5 0%, #fff 55%); border-color: #bbf7d0; } +.lc-alert-card--normal .lc-alert-card-icon { background: #d1fae5; color: #059669; } +.lc-alert-card--normal .lc-alert-card-val { color: #047857; } +.lc-alert-card--warning { background: linear-gradient(135deg, #fff7ed 0%, #fff 55%); border-color: #fed7aa; } +.lc-alert-card--warning .lc-alert-card-icon { background: #ffedd5; color: #ea580c; } +.lc-alert-card--warning .lc-alert-card-val { color: #c2410c; } +.lc-alert-card--expired { background: linear-gradient(135deg, #fef2f2 0%, #fff 55%); border-color: #fecaca; } +.lc-alert-card--expired .lc-alert-card-icon { background: #fee2e2; color: #dc2626; } +.lc-alert-card--expired .lc-alert-card-val { color: #b91c1c; } +.lc-alert-card--unuploaded { background: linear-gradient(135deg, #f8fafc 0%, #fff 55%); } +.lc-alert-card--unuploaded .lc-alert-card-icon { background: #f1f5f9; color: #64748b; } +.lc-alert-card--unuploaded .lc-alert-card-val { color: #64748b; } +.lc-alert-card-clickable { + cursor: pointer; + transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease; +} +.lc-alert-card-clickable:hover { + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08); +} +.lc-alert-card-active { + box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.2) !important; + border-color: #165dff !important; +} +.lc-table-section { margin-bottom: 0; } +.lc-table-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px 16px; + margin-bottom: 8px; + min-height: 32px; +} +.lc-table-cert-legend-outer { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-start; + gap: 10px; + padding: 6px 4px; +} +.lc-table-toolbar-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-left: auto; +} +.lc-cert-legend-items { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + font-size: 12px; + font-weight: 400; + color: #64748b; +} +.lc-cert-legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} +.lc-cert-legend-item--help { cursor: help; } +.lc-plate-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 13px; + height: 25px; + padding: 0 10px; + border-radius: 5px; + letter-spacing: 0.05em; + box-shadow: 0 2px 4px rgba(0,0,0,0.06); + border: 1.5px solid #000000; +} +.lc-plate-blue { + background: linear-gradient(135deg, #1e40af 0%, #1d4ed8 100%); + color: #ffffff; + border-color: #3b82f6; +} +.lc-plate-yellow { + background: linear-gradient(135deg, #eab308 0%, #ca8a04 100%); + color: #0f172a; + border-color: #facc15; +} +.lc-plate-green { + background: linear-gradient(90deg, #34d399 0%, #a7f3d0 50%, #34d399 100%); + color: #0f172a; + border-color: #10b981; +} +.lc-table-card { + background: #ffffff; + border-radius: 16px; + border: 1px solid #e2e8f0; + box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.03); + overflow: hidden; +} +.lc-table-cert-legend-label { + font-size: 12px; + font-weight: 600; + color: #64748b; +} +.lc-table-card .ant-table-thead > tr > th { + background: #f8fafc !important; + color: #475569 !important; + font-weight: 700 !important; + font-size: 13px !important; + border-bottom: 1px solid #e2e8f0 !important; + padding: 12px 16px !important; + vertical-align: middle; +} +.lc-table-card .ant-table-thead > tr > th.lc-th-wrap { + padding: 8px 8px !important; + text-align: center; + vertical-align: middle; +} +.lc-table-th-multiline { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + line-height: 1.3; + white-space: normal; + word-break: keep-all; +} +.lc-table-th-line { + display: block; + font-size: 12px; + font-weight: 700; + color: #475569; +} +.lc-list-table .ant-table-wrapper, +.lc-list-table .ant-table { + width: 100% !important; +} +.lc-list-table .ant-table-content table { + table-layout: fixed; + width: 100% !important; +} +.lc-cell-ellipsis { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lc-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td { + padding: 10px 8px !important; +} +.lc-table-card .ant-table-tbody > tr.ant-table-measure-row, +.lc-table-card .ant-table-tbody > tr.ant-table-measure-row > td { + height: 0 !important; + max-height: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: none !important; + line-height: 0 !important; + font-size: 0 !important; + overflow: hidden !important; + visibility: hidden !important; +} +.lc-table-card .ant-table-tbody > tr:not(.ant-table-measure-row) > td { + padding: 14px 16px !important; + border-bottom: 1px solid #f1f5f9 !important; +} +.lc-table-card .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td { + background: #f8fafc !important; +} +.lc-table-card .ant-table-tbody > tr.lc-row-retired:not(.ant-table-measure-row) > td { + background: #f8fafc !important; + color: #94a3b8; +} +.lc-table-card .ant-table-tbody > tr.lc-row-retired:hover > td { + background: #f1f5f9 !important; +} +.lc-table-card .ant-table-tbody > tr.lc-row-retired .lc-muted-text { + color: #94a3b8 !important; +} +.lc-table-card .ant-table-tbody > tr.lc-row-retired .ant-badge-status-text { + color: #94a3b8 !important; +} +.lc-mini-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; +} +.lc-mini-badge-success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; } +.lc-mini-badge-warning { background: #fff7ed; color: #ea580c; border: 1px solid #ffedd5; } +.lc-mini-badge-error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; } +.lc-mini-badge-default { background: #f1f5f9; color: #64748b; border: 1px solid #e2e8f0; } +.lc-list-cert-status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: auto auto; + gap: 3px 2px; + width: 100%; + max-width: 100%; +} +.lc-list-cert-status-item { + display: inline-flex; + align-items: center; + min-width: 0; + line-height: 1.2; +} +.lc-list-cert-status-item .ant-badge { + display: inline-flex; + align-items: center; + gap: 2px; + max-width: 100%; + font-size: 10px; +} +.lc-list-cert-status-item .ant-badge-status-dot { + width: 5px !important; + height: 5px !important; + top: 0 !important; +} +.lc-list-cert-status-item .ant-badge-status-text { + font-size: 10px !important; + white-space: nowrap; + margin-left: 2px !important; +} +.lc-list-status-badge-wrap { + display: inline-flex; + max-width: 100%; +} +.lc-list-status-badge-wrap .ant-badge { + display: inline-flex; + align-items: center; + max-width: 100%; +} +.lc-list-status-badge-text { + font-size: 10px; + white-space: nowrap; + line-height: 1.2; +} +.lc-list-date-cell-status { + margin-top: 3px; +} + +.lc-dot-indicator { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; +} +.lc-batch-export-types { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; + margin-top: 8px; +} +@media (max-width: 640px) { + .lc-batch-export-types { grid-template-columns: 1fr; } +} +.lc-batch-ocr-confirm { + display: grid; + grid-template-columns: minmax(200px, 340px) 1fr; + gap: 20px; + min-height: 420px; +} +@media (max-width: 900px) { + .lc-batch-ocr-confirm { grid-template-columns: 1fr; } +} +.lc-batch-ocr-photos { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 480px; + overflow-y: auto; +} +.lc-batch-ocr-photo-main { + border-radius: 12px; + border: 1px solid #e2e8f0; + overflow: hidden; + cursor: zoom-in; + background: #f8fafc; +} +.lc-batch-ocr-photo-main img { + width: 100%; + display: block; + max-height: 280px; + object-fit: contain; +} +.lc-batch-ocr-thumb-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.lc-batch-ocr-thumb { + width: 56px; + height: 56px; + border-radius: 8px; + border: 2px solid transparent; + overflow: hidden; + cursor: pointer; + opacity: 0.75; +} +.lc-batch-ocr-thumb.active { + border-color: #10b981; + opacity: 1; +} +.lc-batch-ocr-thumb--fail { + border-color: #ef4444 !important; + opacity: 1; +} +.lc-batch-ocr-thumb img { width: 100%; height: 100%; object-fit: cover; } +.lc-batch-ocr-plate-readonly { + font-size: 15px; + font-weight: 700; + color: #0f172a; + letter-spacing: 0.04em; +} +.lc-batch-ocr-step-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; + padding: 10px 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; +} +.lc-batch-ocr-step-val { + font-size: 18px; + font-weight: 800; + color: #0f172a; + font-variant-numeric: tabular-nums; +} +.lc-batch-ocr-photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); + gap: 8px; + margin-bottom: 10px; +} +`; + +const Component = function () { + const [selectedPlate, setSelectedPlate] = useState('沪A03561F'); + const [activeNav, setActiveTab] = useState('driverLicense'); + + const [allLicenses, setAllLicenses] = useState(() => ( + loadLicensesFromStorage() || JSON.parse(JSON.stringify(INITIAL_LICENSE_DATA)) + )); + + const [licenses, setLicenses] = useState(() => createEmptyLicenseRecord()); + const [savedLicenses, setSavedLicenses] = useState(() => createEmptyLicenseRecord()); + + const [ocrActiveCard, setOcrActiveCard] = useState(null); + const [ocrLoading, setOcrLoading] = useState(false); + const [ocrHighlight, setOcrHighlight] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); + const [previewUrl, setPreviewUrl] = useState(''); + const [prdOpen, setPrdOpen] = useState(false); + + const syncLicensesForPlate = (plate, master) => { + const bundle = loadPlateLicenseBundle(master, plate); + setLicenses(JSON.parse(JSON.stringify(bundle))); + setSavedLicenses(JSON.parse(JSON.stringify(bundle))); + }; + + useEffect(() => { + const master = loadLicensesFromStorage() || JSON.parse(JSON.stringify(INITIAL_LICENSE_DATA)); + setAllLicenses(master); + let plate = MOCK_VEHICLES[0]?.plateNo || '沪A03561F'; + try { + const fromSession = sessionStorage.getItem(LC_EDIT_PLATE_KEY); + if (fromSession) plate = fromSession; + } catch { + /* ignore */ + } + setSelectedPlate(plate); + syncLicensesForPlate(plate, master); + message.loading({ content: `正在调取 [${plate}] 资质档案明细...`, key: 'loading_edit', duration: 0.5 }); + }, []); + + // 切换车辆时,自动填充该车辆的已有证照数据 + const handlePlateChange = (plate) => { + setSelectedPlate(plate); + try { + sessionStorage.setItem(LC_EDIT_PLATE_KEY, plate); + } catch { + /* ignore */ + } + syncLicensesForPlate(plate, allLicenses); + message.success(`已切换车辆:${plate}`); + }; + + // 车辆基本信息 + const currentVehicleInfo = useMemo(() => ( + MOCK_VEHICLES.find((v) => v.plateNo === selectedPlate) || MOCK_VEHICLES[0] + ), [selectedPlate]); + + // 计算上海牌照 + const isShanghaiPlate = useMemo(() => { + return selectedPlate.startsWith('沪'); + }, [selectedPlate]); + + // 行驶证有效期月份自动算最后一天 + const handleDriverLicenseExpireChange = (dateString) => { + if (!dateString) { + setLicenses(prev => { + const copy = { ...prev }; + copy.driverLicense.expireDate = ''; + return copy; + }); + return; + } + const match = dateString.match(/^(\d{4})-(\d{2})/); + if (match) { + const year = parseInt(match[1]); + const month = parseInt(match[2]); + const lastDay = new Date(year, month, 0).getDate(); + const adjustedDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`; + + setLicenses(prev => { + const copy = { ...prev }; + copy.driverLicense.expireDate = adjustedDate; + return copy; + }); + } + }; + + // 模拟 OCR 识别全过程(编辑页联动) + const simulateOcr = (cardType) => { + setOcrActiveCard(cardType); + setOcrLoading(true); + setOcrHighlight(false); + + setTimeout(() => { + const isMismatch = Math.random() < 0.25; + const ocrPlateNo = isMismatch ? '京A88888' : selectedPlate; + + if (ocrPlateNo !== selectedPlate) { + setOcrLoading(false); + setOcrActiveCard(null); + Modal.error({ + title: 'OCR 校验失败', + content: ( +
+

系统检测到上传的证件照片对应的车牌号与当前编辑车辆不符!

+

当前编辑车辆:{selectedPlate}

+

证件识别车牌:{ocrPlateNo}

+

+ 提示:请确认您上传的是否为本车的行驶证或道路运输证。为了保证台账档案的真实严谨,车牌号不一致禁止保存录入。 +

+
+ ), + okText: '重新上传', + onOk() { + setLicenses(prev => { + const copy = { ...prev }; + if (cardType === 'driverLicense') { + copy.driverLicense.photos.pop(); + } else if (cardType === 'transportLicense') { + copy.transportLicense.photos = []; + } + return copy; + }); + } + }); + return; + } + + setOcrLoading(false); + setOcrHighlight(true); + + setLicenses(prev => { + const copy = { ...prev }; + if (cardType === 'driverLicense') { + copy.driverLicense.photos = ['https://picsum.photos/seed/license_ocr/600/400']; + copy.driverLicense.regDate = '2024-06-05'; + copy.driverLicense.issueDate = '2024-06-05'; + copy.driverLicense.scrapDate = '2039-06-04'; + copy.driverLicense.expireDate = '2026-06-30'; + copy.driverLicense.updateType = '直接上传'; + copy.driverLicense.updateTime = new Date().toLocaleString(); + copy.driverLicense.updateUser = '系统智能OCR'; + if (isShanghaiPlate) { + copy.driverLicense.shNextEvaluation = '2026-12-05'; + } + } else if (cardType === 'transportLicense') { + copy.transportLicense.photos = ['https://picsum.photos/seed/trans_ocr/600/400']; + copy.transportLicense.licenseNo = '交字310115582910号'; + copy.transportLicense.issueDate = '2024-08-15'; + copy.transportLicense.expireDate = '2026-08-31'; + copy.transportLicense.inspectValidUntil = '2026-07-31'; + copy.transportLicense.updateTime = new Date().toLocaleString(); + copy.transportLicense.updateUser = '系统智能OCR'; + } + return copy; + }); + + message.success('OCR识别完成,已自动提取关键信息,请进行核对'); + + setTimeout(() => { + setOcrHighlight(false); + setOcrActiveCard(null); + }, 1200); + }, 1800); + }; + + // 手动增删照片(编辑页联动) + const handlePhotoUpload = (cardType) => { + setLicenses(prev => { + const copy = { ...prev }; + const randomSeed = Math.floor(Math.random() * 1000); + const url = `https://picsum.photos/seed/${randomSeed}/600/400`; + + if (cardType === 'driverLicense') { + if (copy.driverLicense.photos.length >= 4) { + message.warning('行驶证最多允许上传4张照片!'); + return prev; + } + copy.driverLicense.photos = [...copy.driverLicense.photos, url]; + setTimeout(() => simulateOcr('driverLicense'), 200); + } else if (cardType === 'transportLicense') { + copy.transportLicense.photos = [url]; + setTimeout(() => simulateOcr('transportLicense'), 200); + } else if (cardType === 'registrationCert') { + copy.registrationCert.photos = [...copy.registrationCert.photos, url]; + } else if (cardType === 'specialEquipCert') { + copy.specialEquipCert.photos = [url]; + } else if (cardType === 'specialEquipDecal') { + copy.specialEquipDecal.photos = [url]; + } else if (cardType === 'safetyValve') { + copy.safetyValve.photos = [url]; + } else if (cardType === 'pressureGauge') { + copy.pressureGauge.photos = [url]; + } + return copy; + }); + }; + + const handlePhotoDelete = (cardType, index) => { + setLicenses(prev => { + const copy = { ...prev }; + if (cardType === 'driverLicense') { + const arr = [...copy.driverLicense.photos]; + arr.splice(index, 1); + copy.driverLicense.photos = arr; + } else if (cardType === 'transportLicense') { + copy.transportLicense.photos = []; + } else if (cardType === 'registrationCert') { + const arr = [...copy.registrationCert.photos]; + arr.splice(index, 1); + copy.registrationCert.photos = arr; + } else if (cardType === 'specialEquipCert') { + copy.specialEquipCert.photos = []; + } else if (cardType === 'specialEquipDecal') { + copy.specialEquipDecal.photos = []; + } else if (cardType === 'safetyValve') { + copy.safetyValve.photos = []; + } else if (cardType === 'pressureGauge') { + copy.pressureGauge.photos = []; + } + return copy; + }); + message.info('照片已移除'); + }; + + const handlePhotoUpdate = (cardType, index) => { + setLicenses(prev => { + const copy = { ...prev }; + const randomSeed = Math.floor(Math.random() * 1000) + 2000; + const url = `https://picsum.photos/seed/${randomSeed}/600/400`; + + const arr = [...copy[cardType].photos]; + arr[index] = url; + copy[cardType].photos = arr; + + if (cardType === 'driverLicense') { + setTimeout(() => simulateOcr('driverLicense'), 200); + } else if (cardType === 'transportLicense') { + setTimeout(() => simulateOcr('transportLicense'), 200); + } + + return copy; + }); + message.success('证件照片已更新!'); + }; + + // 证照完整度(编辑页联动) + const completionStats = useMemo(() => { + let total = 8; + let completed = 0; + + if (licenses.driverLicense.photos.length > 0 && licenses.driverLicense.regDate) completed++; + if (licenses.transportLicense.photos.length > 0 && licenses.transportLicense.licenseNo) completed++; + if (licenses.registrationCert.photos.length > 0) completed++; + if (licenses.specialEquipCert.photos.length > 0) completed++; + if (licenses.specialEquipDecal.photos.length > 0 && licenses.specialEquipDecal.nextInspectDate) completed++; + if (licenses.hydrogenCard.cardNo) completed++; + if (licenses.safetyValve.photos.length > 0 && licenses.safetyValve.inspectDate) completed++; + if (licenses.pressureGauge.photos.length > 0 && licenses.pressureGauge.inspectDate) completed++; + + return { + percent: Math.round((completed / total) * 100), + count: completed, + total: total + }; + }, [licenses]); + + // 提前60天警报计算(编辑页联动) + const transportAlertInfo = useMemo(() => { + if (!licenses.transportLicense.expireDate) return null; + const expDate = new Date(licenses.transportLicense.expireDate); + const today = new Date('2026-06-01'); + expDate.setHours(0, 0, 0, 0); + today.setHours(0, 0, 0, 0); + const diffTime = expDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays > 60) return null; + return { + daysLeft: diffDays, + shouldWarn: diffDays <= 15 + }; + }, [licenses.transportLicense.expireDate]); + + // 提前90天警报计算(编辑页联动) + const driverAlertInfo = useMemo(() => { + if (!licenses.driverLicense.expireDate) return null; + const expDate = new Date(licenses.driverLicense.expireDate); + const today = new Date('2026-06-01'); + expDate.setHours(0, 0, 0, 0); + today.setHours(0, 0, 0, 0); + const diffTime = expDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays > 90) return null; + return { + daysLeft: diffDays, + shouldWarn: diffDays <= 30 + }; + }, [licenses.driverLicense.expireDate]); + + // 特种设备使用标识:提前 60 天超前感知(与列表 KPI 一致) + const specialEquipDecalAlertInfo = useMemo(() => { + if (!licenses.specialEquipDecal.nextInspectDate) return null; + const expDate = new Date(licenses.specialEquipDecal.nextInspectDate); + const today = new Date('2026-06-01'); + expDate.setHours(0, 0, 0, 0); + today.setHours(0, 0, 0, 0); + const diffDays = Math.ceil((expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays > SPECIAL_EQUIP_DECAL_WARN_DAYS) return null; + return { + daysLeft: diffDays, + isExpired: diffDays <= 0, + shouldWarn: diffDays <= SPECIAL_EQUIP_DECAL_CRITICAL_DAYS + }; + }, [licenses.specialEquipDecal.nextInspectDate]); + + // 沪牌评定天数(编辑页联动) + const shEvaluationDaysLeft = useMemo(() => { + if (!licenses.driverLicense.shNextEvaluation) return null; + const targetDate = new Date(licenses.driverLicense.shNextEvaluation); + const today = new Date('2026-06-01'); + targetDate.setHours(0, 0, 0, 0); + today.setHours(0, 0, 0, 0); + const diffTime = targetDate.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return diffDays; + }, [licenses.driverLicense.shNextEvaluation]); + + // 获取单个徽标状态(编辑页联动) + const getBadgeStatus = (key) => { + const item = licenses[key]; + if (!item) return 'default'; + + if (key === 'hydrogenCard') { + if (!item.cardNo) return 'default'; + } else { + if (!item.photos || item.photos.length === 0) return 'default'; + } + + const today = new Date('2026-06-01'); + today.setHours(0, 0, 0, 0); + + if (key === 'driverLicense') { + if (!item.expireDate) return 'success'; + const expDate = new Date(item.expireDate); + expDate.setHours(0, 0, 0, 0); + const diffDays = Math.ceil((expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays <= 0) return 'error'; + if (diffDays <= 90) return 'warning'; + return 'success'; + } + + if (key === 'transportLicense') { + const badgeFromDate = (dateStr) => { + if (!dateStr) return 'success'; + const expDate = new Date(dateStr); + expDate.setHours(0, 0, 0, 0); + const diffDays = Math.ceil((expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays <= 0) return 'error'; + if (diffDays <= 60) return 'warning'; + return 'success'; + }; + return mergeLicenseStatusTypes( + badgeFromDate(item.expireDate), + badgeFromDate(item.inspectValidUntil) + ); + } + + if (key === 'specialEquipDecal' || key === 'safetyValve' || key === 'pressureGauge') { + const dKey = key === 'specialEquipDecal' ? 'nextInspectDate' : 'nextInspectDate'; + if (!item[dKey]) return 'success'; + const expDate = new Date(item[dKey]); + expDate.setHours(0, 0, 0, 0); + const diffDays = Math.ceil((expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays <= 0) return 'error'; + if (diffDays <= 60) return 'warning'; + return 'success'; + } + + return 'success'; + }; + + const isCardDirty = (cardKey) => { + return JSON.stringify(licenses[cardKey]) !== JSON.stringify(savedLicenses[cardKey]); + }; + + const hasUnsavedChanges = useMemo(() => { + return Object.keys(licenses).some(key => isCardDirty(key)); + }, [licenses, savedLicenses]); + + const handlePlateSelectChange = (plate) => { + if (hasUnsavedChanges) { + Modal.confirm({ + title: '确认切换车辆?', + content: '当前页面还有证照未保存,切换车辆将丢失未保存的修改,是否确认切换?', + okText: '确认切换', + cancelText: '取消', + onOk() { + handlePlateChange(plate); + } + }); + } else { + handlePlateChange(plate); + } + }; + + const scrollToAnchor = (id) => { + setActiveTab(id); + const el = document.getElementById(id); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }; + + const handleCardSubmit = (cardKey, cardName) => { + Modal.confirm({ + title: '确认保存', + content: `是否确认保存「${cardName}」的所有更改至 ONE-OS?`, + okText: '确认保存', + cancelText: '取消', + onOk() { + const key = `save_${cardKey}`; + message.loading({ content: `正在保存「${cardName}」数据至 ONE-OS...`, key }); + setTimeout(() => { + // 同步局部已保存状态 + setSavedLicenses(prev => { + const copy = JSON.parse(JSON.stringify(prev)); + copy[cardKey] = JSON.parse(JSON.stringify(licenses[cardKey])); + return copy; + }); + // 核心:回写至共享数据库 master state 保证列表和统计同步更新! + setAllLicenses((prev) => { + const copy = JSON.parse(JSON.stringify(prev)); + if (!copy[selectedPlate]) copy[selectedPlate] = createEmptyLicenseRecord(); + copy[selectedPlate][cardKey] = JSON.parse(JSON.stringify(licenses[cardKey])); + persistLicensesToStorage(copy); + return copy; + }); + message.success({ content: `「${cardName}」数据保存成功!已独立同步回台账。`, key, duration: 2 }); + }, 600); + } + }); + }; + + return ( +
+ + +
+ + {/* 顶栏 */} +
+ + +
+ + {/* 双栏布局 */} +
+ {/* 左侧侧边栏 */} +
+ } + > + + +
+ + + {/* 右侧明细编辑区 */} +
+
+ {/* Card 1: 行驶证 */} + + 行驶证 + + } + extra={ + + {isCardDirty('driverLicense') && ( + + 未保存 + + )} + {licenses.driverLicense.photos.length > 0 && ( + {ICONS.success} 已完善 + )} + + + } + > + + {/* 年审超前任务提醒 */} + {driverAlertInfo && ( + + 车辆年检超前感知系统 + + } + description={ +
+ 根据法规政策,机动车行驶证需在检验有效期前完成年检。系统会提前 90 天自动生成年检任务。 + 本车辆距离年检最终时间还剩余 30 ? '#10b981' : '#ef4444' }}>{driverAlertInfo.daysLeft} 天 +
+ } + type={driverAlertInfo.shouldWarn ? "error" : "warning"} + showIcon + icon={ICONS.warning} + style={{ marginBottom: 20, borderRadius: 12 }} + /> + )} + +
+
+ 证件照片上传 + (支持正副本及细节图,最多4张) +
+ +
+ {licenses.driverLicense.photos.map((url, idx) => ( +
+ {ocrLoading && ocrActiveCard === 'driverLicense' &&
} + 行驶证 +
+ +
{ setPreviewUrl(url); setPreviewOpen(true); }}> + +
+
+ +
handlePhotoUpdate('driverLicense', idx)}> + {ICONS.edit} +
+
+ +
handlePhotoDelete('driverLicense', idx)}> + {ICONS.delete} +
+
+
+
+ ))} + + {licenses.driverLicense.photos.length < 4 && ( +
handlePhotoUpload('driverLicense')}> + {ICONS.camera} + 上传照片 ({licenses.driverLicense.photos.length}/4) +
+ )} +
+
+ + + +
+ 注册日期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.driverLicense.regDate = dateString; + return copy; + })} + placeholder="选择注册日期" + className={ocrHighlight && ocrActiveCard === 'driverLicense' ? 'lc-ocr-flash' : ''} + style={{ width: '100%' }} + /> + + + 发证日期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.driverLicense.issueDate = dateString; + return copy; + })} + placeholder="选择发证日期" + className={ocrHighlight && ocrActiveCard === 'driverLicense' ? 'lc-ocr-flash' : ''} + style={{ width: '100%' }} + /> + + + 强制报废日期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.driverLicense.scrapDate = dateString; + return copy; + })} + placeholder="选择强制报废日期" + className={ocrHighlight && ocrActiveCard === 'driverLicense' ? 'lc-ocr-flash' : ''} + style={{ width: '100%' }} + /> + + + 检验有效期至} + required + > + handleDriverLicenseExpireChange(dateString)} + placeholder="选择年月" + className={ocrHighlight && ocrActiveCard === 'driverLicense' ? 'lc-ocr-flash' : ''} + style={{ width: '100%' }} + /> + +
+ + {/* 沪牌专属字段 */} + {isShanghaiPlate && ( +
+
+ 沪牌车辆等级评定 + + 本市牌照车辆需定期进行等级评定,目前距下次等级评定还有 {shEvaluationDaysLeft !== null ? shEvaluationDaysLeft : '--'} 天 + +
+ + + 下次等级评定时间} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.driverLicense.shNextEvaluation = dateString; + return copy; + })} + style={{ width: '100%', borderRadius: 8 }} + placeholder="选择评定日期" + /> + + + +
+ )} + + + + {/* Card 2: 道路运输证 */} + + 道路运输证 +
+ } + extra={ + + {isCardDirty('transportLicense') && ( + + 未保存 + + )} + {licenses.transportLicense.photos.length > 0 && ( + {ICONS.success} 已完善 + )} + + + } + > + + {/* 年审超前任务提醒 */} + {transportAlertInfo && ( + + 营运证件年审超前感知系统 + + } + description={ +
+ 根据法规政策,道路运输证需在有效期前进行续签,系统会提前 60 天列入年审任务。 + 本车辆距离最终检验时间还剩 15 ? '#10b981' : '#ef4444' }}>{transportAlertInfo.daysLeft} 天 +
+ } + type={transportAlertInfo.shouldWarn ? "error" : "warning"} + showIcon + icon={ICONS.warning} + style={{ marginBottom: 20, borderRadius: 12 }} + /> + )} + +
+
+ 道路运输证照片 +
+
+ {licenses.transportLicense.photos.map((url, idx) => ( +
+ 道路运输证 +
+ +
{ setPreviewUrl(url); setPreviewOpen(true); }}> + +
+
+ +
handlePhotoUpdate('transportLicense', idx)}> + {ICONS.edit} +
+
+ +
handlePhotoDelete('transportLicense', idx)}> + {ICONS.delete} +
+
+
+
+ ))} + {licenses.transportLicense.photos.length === 0 && ( +
handlePhotoUpload('transportLicense')}> + {ICONS.camera} + 上传运输证主图 +
+ )} +
+
+ + + +
+ 经营许可证号} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.transportLicense.licenseNo = e.target.value; + return copy; + })} + placeholder="例如:交字31011..." + className={ocrHighlight && ocrActiveCard === 'transportLicense' ? 'lc-ocr-flash' : ''} + /> + + + 核发时间} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.transportLicense.issueDate = dateString; + return copy; + })} + placeholder="选择核发时间" + className={ocrHighlight && ocrActiveCard === 'transportLicense' ? 'lc-ocr-flash' : ''} + style={{ width: '100%' }} + /> + + + 证件有效期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.transportLicense.expireDate = dateString; + return copy; + })} + placeholder="道路运输证有效期至" + className={ocrHighlight && ocrActiveCard === 'transportLicense' ? 'lc-ocr-flash' : ''} + style={{ width: '100%' }} + /> + + + 审验有效期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.transportLicense.inspectValidUntil = dateString; + return copy; + })} + placeholder="选择审验有效期" + className={ocrHighlight && ocrActiveCard === 'transportLicense' ? 'lc-ocr-flash' : ''} + style={{ width: '100%' }} + /> + +
+
+
+ + {/* Card 3: 登记证 */} + + 登记证 + + } + extra={ + + {isCardDirty('registrationCert') && ( + + 未保存 + + )} + {licenses.registrationCert.photos.length > 0 && ( + {ICONS.success} 已完善 + )} + + + } + > +
+ 登记证高清照片 + (支持上传多张,包含登记栏所有变更页) +
+ +
+ {licenses.registrationCert.photos.map((url, idx) => ( +
+ 登记证 +
+ +
{ setPreviewUrl(url); setPreviewOpen(true); }}> + +
+
+ +
handlePhotoUpdate('registrationCert', idx)}> + {ICONS.edit} +
+
+ +
handlePhotoDelete('registrationCert', idx)}> + {ICONS.delete} +
+
+
+
+ ))} +
handlePhotoUpload('registrationCert')}> + {ICONS.camera} + 追加新页面 +
+
+
+ + {/* Card 4: 特种设备使用登记证 */} + + 特种设备使用登记证 + + } + extra={ + + {isCardDirty('specialEquipCert') && ( + + 未保存 + + )} + {licenses.specialEquipCert.photos.length > 0 && ( + {ICONS.success} 已完善 + )} + + + } + > +
+ 使用登记证照片 +
+ +
+ {licenses.specialEquipCert.photos.map((url, idx) => ( +
+ 特种设备使用登记证 +
+ +
{ setPreviewUrl(url); setPreviewOpen(true); }}> + +
+
+ +
handlePhotoUpdate('specialEquipCert', idx)}> + {ICONS.edit} +
+
+ +
handlePhotoDelete('specialEquipCert', idx)}> + {ICONS.delete} +
+
+
+
+ ))} + {licenses.specialEquipCert.photos.length === 0 && ( +
handlePhotoUpload('specialEquipCert')}> + {ICONS.camera} + 上传登记证 +
+ )} +
+
+ + {/* Card 5: 特种设备使用标识 */} + + 特种设备使用标识 + + } + extra={ + + {isCardDirty('specialEquipDecal') && ( + + 未保存 + + )} + {licenses.specialEquipDecal.photos.length > 0 && ( + {ICONS.success} 已完善 + )} + + + } + > + {specialEquipDecalAlertInfo && ( + + 特种设备使用标识检验超前感知系统 + + } + description={ +
+ {specialEquipDecalAlertInfo.isExpired ? ( + <> + 本车「下次检验日期」已到期,已逾期 {Math.abs(specialEquipDecalAlertInfo.daysLeft)} 天,请尽快安排检验并更新标识信息。 + + ) : ( + <> + 系统提前 {SPECIAL_EQUIP_DECAL_WARN_DAYS} 天感知特种设备使用标识检验临期(与证照台账列表规则一致)。 + 距离下次检验日期还剩 {specialEquipDecalAlertInfo.daysLeft} 天 + {specialEquipDecalAlertInfo.shouldWarn ? ',已进入高危提醒区间,请优先处理。' : ',请关注检验安排。'} + + )} +
+ } + type={specialEquipDecalAlertInfo.isExpired || specialEquipDecalAlertInfo.shouldWarn ? 'error' : 'warning'} + showIcon + icon={ICONS.warning} + style={{ marginBottom: 20, borderRadius: 12 }} + /> + )} + +
+ 使用安全标识照片 +
+ +
+ {licenses.specialEquipDecal.photos.map((url, idx) => ( +
+ 使用安全标识 +
+ +
{ setPreviewUrl(url); setPreviewOpen(true); }}> + +
+
+ +
handlePhotoUpdate('specialEquipDecal', idx)}> + {ICONS.edit} +
+
+ +
handlePhotoDelete('specialEquipDecal', idx)}> + {ICONS.delete} +
+
+
+
+ ))} + {licenses.specialEquipDecal.photos.length === 0 && ( +
handlePhotoUpload('specialEquipDecal')}> + {ICONS.camera} + 上传标识贴 +
+ )} +
+ + + +
+ 下次检验日期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.specialEquipDecal.nextInspectDate = dateString; + return copy; + })} + style={{ width: '100%' }} + placeholder="选择检验截止日期" + /> + +
+
+ + {/* Card 6: 加氢卡 */} + + 加氢卡 + + } + extra={ + + {isCardDirty('hydrogenCard') && ( + + 未保存 + + )} + {licenses.hydrogenCard.cardNo && ( + {ICONS.success} 已完善 + )} + + + } + > +
+
+
+ 加氢卡类型 +
+ +
+ {licenses.hydrogenCard.cardNo || '•••• •••• •••• ••••'} +
+ +
+
Available Balance
+
+ ¥ {licenses.hydrogenCard.balance ? licenses.hydrogenCard.balance.toLocaleString('zh-CN', {minimumFractionDigits: 2}) : '0.00'} +
+
+ +
+
TYPE: {licenses.hydrogenCard.cardType || '中石化加氢卡'}
+
BY: {licenses.hydrogenCard.issueUser ? licenses.hydrogenCard.issueUser.split('-')[1] || licenses.hydrogenCard.issueUser : '能源管理部-张晓'}
+
+
+
+ + + +
+ 加氢卡号} required> + + + + 实时余额} required> + `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={value => value.replace(/\¥\s?|(,*)/g, '')} + /> + + + 配发时间}> + + + + 配发经办人}> + + +
+
+ + {/* Card 7: 安全阀 */} + + 安全阀 + + } + extra={ + + {isCardDirty('safetyValve') && ( + + 未保存 + + )} + {licenses.safetyValve.photos.length > 0 && ( + {ICONS.success} 已完善 + )} + + + } + > +
+ 安全阀校验报告照片 +
+ +
+ {licenses.safetyValve.photos.map((url, idx) => ( +
+ 安全阀 +
+ +
{ setPreviewUrl(url); setPreviewOpen(true); }}> + +
+
+ +
handlePhotoUpdate('safetyValve', idx)}> + {ICONS.edit} +
+
+ +
handlePhotoDelete('safetyValve', idx)}> + {ICONS.delete} +
+
+
+
+ ))} + {licenses.safetyValve.photos.length === 0 && ( +
handlePhotoUpload('safetyValve')}> + {ICONS.camera} + 上传阀体/报告 +
+ )} +
+ + + +
+ 本次检验日期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.safetyValve.inspectDate = dateString; + return copy; + })} + style={{ width: '100%' }} + placeholder="选择检验日期" + /> + + + 下次检测日期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.safetyValve.nextInspectDate = dateString; + return copy; + })} + style={{ width: '100%' }} + placeholder="选择下次检测日期" + /> + +
+
+ + {/* Card 8: 压力表 */} + + 压力表 + + } + extra={ + + {isCardDirty('pressureGauge') && ( + + 未保存 + + )} + {licenses.pressureGauge.photos.length > 0 && ( + {ICONS.success} 已完善 + )} + + + } + > +
+ 压力表校验报告照片 +
+ +
+ {licenses.pressureGauge.photos.map((url, idx) => ( +
+ 压力表 +
+ +
{ setPreviewUrl(url); setPreviewOpen(true); }}> + +
+
+ +
handlePhotoUpdate('pressureGauge', idx)}> + {ICONS.edit} +
+
+ +
handlePhotoDelete('pressureGauge', idx)}> + {ICONS.delete} +
+
+
+
+ ))} + {licenses.pressureGauge.photos.length === 0 && ( +
handlePhotoUpload('pressureGauge')}> + {ICONS.camera} + 上传表盘/报告 +
+ )} +
+ + + +
+ 本次检验日期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.pressureGauge.inspectDate = dateString; + return copy; + })} + style={{ width: '100%' }} + placeholder="选择检验日期" + /> + + + 下次检验日期} required> + setLicenses(prev => { + const copy = { ...prev }; + copy.pressureGauge.nextInspectDate = dateString; + return copy; + })} + style={{ width: '100%' }} + placeholder="选择下次检验日期" + /> + +
+
+
+
+ + + {/* 底部浮动提交栏 */} +
+ + + +
+ + + setPreviewOpen(false)} + width={720} + centered + > + 大图 + + + + 📋 + 证照资质维护 · 产品需求说明 + + } + footer={[ + + ]} + onCancel={() => setPrdOpen(false)} + width={960} + centered + bodyStyle={{ maxHeight: '72vh', overflowY: 'auto', padding: '12px 24px 24px' }} + > +
+
+ 本页:证照资质维护 + 模块路径:运维管理 > 车辆业务 > 证照管理-编辑 + 文档版本:V1.1 + 读者:产品 / 运营 / 合规 / 研发测试 +
+ + 本文档说明「证照管理-编辑」维护页} + description="由台账列表「管理」进入;与「证照管理」列表共用证照库(localStorage + session 车牌)。列表监管、批量作业见列表页 PRD。" + /> + + + +
+
+
产品定位(本页)
+

单车维护工作台:对一台车的八类证照分卡片录入影像与关键日期,不做列表筛选与批量任务。

+

价值:分卡保存降低心智负担;OCR + 车牌校验防串证;超前感知提示驱动检验续办。

+
+ +
端到端主流程
+
    +
  1. 列表点「管理」→ 带入车牌进入本页
  2. +
  3. 左侧看索引状态点 → 点击定位右侧证照卡片
  4. +
  5. 上传/改字段 → 触发「未保存」;行驶证/运输证另触发 OCR
  6. +
  7. 单卡「保存该项」→ 写回证照库 → 列表 KPI/行状态更新
  8. +
  9. 「返回台账列表」→ 回到证照管理列表(已保存数据保留)
  10. +
+ +
【重点】五条必读规则
+
+
+ ① 分卡片保存(非整页提交) + — 每张证照独立保存;未保存切换车辆须二次确认;返回列表不丢已保存数据。 +
+
+ ② 行驶证/运输证 OCR 车牌强校验 + — 上传或更换照片触发 OCR;识别车牌与当前车不一致则弹窗阻断并清空当次无效照片。 +
+
+ ③ 三类超前感知(维护页 Alert) + — 行驶证 ≤90 天;道路运输证 ≤60 天;特种设备使用标识 ≤60 天(≤15 天高危红提示,已到期单独文案)。与列表临期规则一致。 +
+
+ ④ 运营状态展示 + — 主数据「可运营」「待运营」展示为「库存」;与列表、筛选口径一致。 +
+
+ ⑤ 数据回写 + — 保存写入 oneos_lc_licenses_v1;列表通过事件刷新;支持 Axhub 导航往返。 +
+
+ +
超前感知阈值(与列表联动)
+ + + + + + + + + + + + + + +
证照感知窗口维护页提示
行驶证≤90 天≤30 天 Alert 升为 error
道路运输证证件/审验有效期 ≤60 天≤15 天 error(按证件有效期算)
特种设备使用标识下次检验 ≤60 天≤15 天 error;≤0 天「已逾期」文案;索引橙/红与列表一致
安全阀/压力表下次检验 ≤60 天仅索引状态点,卡片内无 Alert 条(可后续迭代)
+
+
+ + +
+ + + + + + + + + + + + + + + +
证照维护要点
行驶证最多 4 张;OCR 回填;检验有效期至(选月取月末);沪牌「下次等级评定」;超前感知 Alert
道路运输证单图 OCR;证号、核发时间、证件有效期审验有效期;超前感知 Alert
登记证 / 特种设备使用登记证影像上传为主,无 OCR 字段表单
特种设备使用标识标识照片 + 下次检验日期60 天超前感知 Alert
加氢卡只读:卡号、类型、余额、配发时间、配发人
安全阀 / 压力表影像;本次/下次检验日期(手填,不联动)
+
+
+ + +
+
+ 车牌 OCR 强校验 +
    +
  • 仅行驶证、道路运输证;原型含 25% 模拟失败便于测试
  • +
  • 失败:Modal 报错、阻断写入、移除当次无效图
  • +
+
+
+ 脏数据守卫 +
    +
  • 任意卡片未保存 → 切换车辆弹窗确认
  • +
  • 照片增删改、字段变更均标「未保存」
  • +
+
+
照片生命周期
+

上传/追加、查看大图、更换(覆盖)、删除;删空后侧边栏索引变灰「未上传」。

+
+
+ + +
+
    +
  • 列表「管理」带入车牌;保存后列表数据一致
  • +
  • 八类分卡保存、未保存提示、切换车辆拦截
  • +
  • 行驶证/运输证 OCR 车牌不一致阻断
  • +
  • 行驶证 90 天、运输证 60 天、特种设备标识 60 天维护页 Alert 与索引颜色正确
  • +
  • 运输证审验有效期字段可维护并回写列表
  • +
  • 沪牌等级评定区块仅沪牌展示
  • +
  • 加氢卡只读
  • +
+
+
+
+
+
+ + ); +}; + +export default Component; diff --git a/web端/运维管理/车辆业务/证照管理.jsx b/web端/运维管理/车辆业务/证照管理.jsx index d101fb3..f9572fa 100644 --- a/web端/运维管理/车辆业务/证照管理.jsx +++ b/web端/运维管理/车辆业务/证照管理.jsx @@ -1,303 +1,3093 @@ // 【重要】必须使用 const Component 作为组件变量名 -// 运维管理 - 车辆管理 - 证照管理 +// 运维管理 - 车辆业务 - 证照管理(台账列表页) -const Component = function () { - var useState = React.useState; - var useMemo = React.useMemo; - var useCallback = React.useCallback; +const { useState, useEffect, useMemo, useRef } = React; +const moment = window.moment || window.dayjs; +const antd = window.antd; +const { + Form, + Input, + Select, + Button, + DatePicker, + Card, + Row, + Col, + Space, + Badge, + Alert, + InputNumber, + Divider, + Switch, + Spin, + Tooltip, + Modal, + Progress, + Popover, + Tabs, + Table, + Tag, + message, + Checkbox, + Upload +} = antd; - var antd = window.antd; - var Breadcrumb = antd.Breadcrumb; - var Card = antd.Card; - var Table = antd.Table; - var Button = antd.Button; - var Select = antd.Select; - var Input = antd.Input; - var DatePicker = antd.DatePicker; - var Modal = antd.Modal; - var message = antd.message; +const CERT_EXPORT_OPTIONS = [ + { key: 'driverLicense', label: '行驶证', folder: '行驶证' }, + { key: 'transportLicense', label: '道路运输证', folder: '道路运输证' }, + { key: 'registrationCert', label: '登记证', folder: '登记证' }, + { key: 'specialEquipCert', label: '特种设备使用登记证', folder: '特种设备使用登记证' }, + { key: 'specialEquipDecal', label: '特种设备使用标识', folder: '特种设备使用标识' }, + { key: 'safetyValve', label: '安全阀', folder: '安全阀' }, + { key: 'pressureGauge', label: '压力表', folder: '压力表' } +]; - function pad2(n) { return n < 10 ? '0' + n : '' + n; } - function fmtDate(d) { return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); } - function todayPlus(days) { var d = new Date(); d.setDate(d.getDate() + days); return fmtDate(d); } +const BATCH_UPLOAD_CERT_OPTIONS = [ + { key: 'driverLicense', label: '行驶证' }, + { key: 'transportLicense', label: '道路运输证' }, + { key: 'registrationCert', label: '登记证', photoOnly: true }, + { key: 'specialEquipCert', label: '特种设备使用登记证', photoOnly: true }, + { key: 'specialEquipDecal', label: '特种设备使用标识' } +]; - var layoutStyle = { padding: '16px 24px', background: '#f5f5f5', minHeight: '100vh' }; - var cardStyle = { marginBottom: 16 }; +const isBatchUploadPhotoOnlyType = (certType) => ( + BATCH_UPLOAD_CERT_OPTIONS.find((o) => o.key === certType)?.photoOnly === true +); - // 筛选 - var filtersState = useState({ - customerName: undefined, - contractCode: undefined, - plateNo: undefined, - vin: undefined, - licenseInspectValid: null, - operationPermitValid: null, - passPermitValid: null - }); - var filters = filtersState[0]; - var setFilters = filtersState[1]; - var moreOpenState = useState(false); +const BATCH_UPLOAD_OPERATOR = '张明辉'; - // mock 列表数据 - var tableDataState = useState(function () { - var rows = []; - for (var i = 1; i <= 20; i++) { - rows.push({ - key: 'row-' + i, - plateNo: i < 10 ? ('粤A1234' + i) : ('京A5432' + i), - vin: 'LJ8ABC' + (100000 + i), - licenseRegDate: todayPlus(-500 - i), - licenseScrapDate: todayPlus(2000 + i), - licenseValidDate: todayPlus(60 + i), - operationCertNo: 'YYZ-' + (10000 + i), - operationRegDate: todayPlus(-480 - i), - operationInspectValid: todayPlus(90 + i), - operationValid: todayPlus(365 + i), - passPermitNo: 'TXZ-' + (20000 + i), - passArea: i % 3 === 0 ? '上海市-浦东新区' : i % 3 === 1 ? '广东省-广州市' : '北京市-朝阳区', - passValid: todayPlus(120 + i), - h2CertCode: 'JQZ-' + (30000 + i), - h2CertInspectDate: todayPlus(-30 - i), - h2CardCode: 'JQK-' + (40000 + i), - safetyValveInspectDate: todayPlus(-20 - i), - safetyValveCycleMonth: 12, - pressureGaugeInspectDate: todayPlus(-10 - i), - pressureGaugeCycleMonth: 12, - h2BottleVendor: i % 2 === 0 ? '亿华' : '中集', - h2BottleInspectDate: todayPlus(-5 - i), - h2BottleCycleMonth: 24 - }); - } - return rows; - }); - var tableData = tableDataState[0]; - var setTableData = tableDataState[1]; +const LC_LICENSES_STORAGE_KEY = 'oneos_lc_licenses_v1'; +const LC_EDIT_PLATE_KEY = 'oneos_lc_edit_plate'; +const LC_NAV_TARGET_KEY = 'oneos_lc_navigate_target'; +const LC_NAV_EVENT = 'oneos-lc-return-list'; - // 下拉选项(来自列表 mock) - var options = useMemo(function () { - var customers = ['嘉兴某某物流有限公司', '上海某某运输公司', '北京某某租赁有限公司']; - var contracts = ['HI-2024-001', 'HI-2024-002', 'HI-2024-003']; - return { - customerName: customers.map(function (v) { return { value: v, label: v }; }), - contractCode: contracts.map(function (v) { return { value: v, label: v }; }), - plateNo: tableData.map(function (r) { return r.plateNo; }).slice(0, 20).map(function (v) { return { value: v, label: v }; }), - vin: tableData.map(function (r) { return r.vin; }).slice(0, 20).map(function (v) { return { value: v, label: v }; }) - }; - }, [tableData]); - - var filteredData = useMemo(function () { - var list = tableData; - if (filters.customerName) { - // mock:按 index 分组匹配 - list = list.filter(function (_, idx) { return (idx % 3) === 0; }); - } - if (filters.contractCode) { - list = list.filter(function (_, idx) { return (idx % 3) === 1; }); - } - if (filters.plateNo) list = list.filter(function (r) { return r.plateNo === filters.plateNo; }); - if (filters.vin) list = list.filter(function (r) { return r.vin === filters.vin; }); - return list; - }, [tableData, filters.customerName, filters.contractCode, filters.plateNo, filters.vin]); - - // 选中 - var selectedRowKeysState = useState([]); - - // 导入弹窗 - var importOpenState = useState(false); - var importFileState = useState(null); - - function resetFilters() { - setFilters({ - customerName: undefined, - contractCode: undefined, - plateNo: undefined, - vin: undefined, - licenseInspectValid: null, - operationPermitValid: null, - passPermitValid: null - }); - message.success('已重置'); - } - - function handleQuery() { - message.success('已查询(原型)'); - } - - function openAdd() { - message.info('进入证照录入页(原型)'); - } - - function confirmDeleteSelected() { - var keys = selectedRowKeysState[0] || []; - if (!keys.length) return; - Modal.confirm({ - title: '确认删除', - content: '确定要删除选中的证照记录吗?删除后将无法恢复。', - okText: '确认删除', - cancelText: '取消', - onOk: function () { - setTableData(function (p) { return p.filter(function (r) { return keys.indexOf(r.key) === -1; }); }); - selectedRowKeysState[1]([]); - message.success('已删除'); - } - }); - } - - function openExport() { - message.info('导出当前筛选结果(原型)'); - } - - function openImport() { - importFileState[1](null); - importOpenState[1](true); - } - - function onPickImportFile(e) { - var f = e && e.target && e.target.files && e.target.files[0]; - if (!f) return; - var name = String(f.name || '').toLowerCase(); - if (!(name.endsWith('.xls') || name.endsWith('.xlsx'))) { - message.error('仅支持 .xls、.xlsx 格式'); - return; - } - importFileState[1](f); - } - - function doImportUpload() { - if (!importFileState[0]) { - message.error('请先选取要上传的文件'); - return; - } - message.success('上传成功(原型)'); - importOpenState[1](false); - } - - // 表格列 - var columns = useMemo(function () { - return [ - { title: '车牌号', dataIndex: 'plateNo', key: 'plateNo', width: 110, fixed: 'left' }, - { - title: 'vin码', - dataIndex: 'vin', - key: 'vin', - width: 150, - render: function (v) { - return React.createElement('span', { style: { color: '#f5222d', fontWeight: 600 } }, v || '-'); - } - }, - { title: '行驶证 注册日期', dataIndex: 'licenseRegDate', key: 'licenseRegDate', width: 140 }, - { title: '行驶证 强制报废日期', dataIndex: 'licenseScrapDate', key: 'licenseScrapDate', width: 160 }, - { title: '行驶证 有效期', dataIndex: 'licenseValidDate', key: 'licenseValidDate', width: 130 }, - { title: '营运证 编号', dataIndex: 'operationCertNo', key: 'operationCertNo', width: 140 }, - { title: '营运证 注册日期', dataIndex: 'operationRegDate', key: 'operationRegDate', width: 140 }, - { title: '营运证 审验有效期', dataIndex: 'operationInspectValid', key: 'operationInspectValid', width: 160 }, - { title: '营运证 有效期', dataIndex: 'operationValid', key: 'operationValid', width: 140 }, - { title: '通行证 编号', dataIndex: 'passPermitNo', key: 'passPermitNo', width: 140 }, - { title: '通行区域', dataIndex: 'passArea', key: 'passArea', width: 160, ellipsis: true }, - { title: '通行证 有效期', dataIndex: 'passValid', key: 'passValid', width: 140 }, - { title: '加氢证 编码', dataIndex: 'h2CertCode', key: 'h2CertCode', width: 140 }, - { title: '加氢证 检验日期', dataIndex: 'h2CertInspectDate', key: 'h2CertInspectDate', width: 150 }, - { title: '加氢卡 编码', dataIndex: 'h2CardCode', key: 'h2CardCode', width: 140 }, - { title: '安全阀 检验日期', dataIndex: 'safetyValveInspectDate', key: 'safetyValveInspectDate', width: 150 }, - { title: '安全阀 检验周期:单位 (月)', dataIndex: 'safetyValveCycleMonth', key: 'safetyValveCycleMonth', width: 200 }, - { title: '压力表 检验日期', dataIndex: 'pressureGaugeInspectDate', key: 'pressureGaugeInspectDate', width: 150 }, - { title: '压力表 检验周期:单位 (月)', dataIndex: 'pressureGaugeCycleMonth', key: 'pressureGaugeCycleMonth', width: 200 }, - { title: '氢气瓶 厂家', dataIndex: 'h2BottleVendor', key: 'h2BottleVendor', width: 120 }, - { title: '氢气瓶 检验日期', dataIndex: 'h2BottleInspectDate', key: 'h2BottleInspectDate', width: 150 }, - { title: '氢气瓶 检验周期:单位 (月)', dataIndex: 'h2BottleCycleMonth', key: 'h2BottleCycleMonth', width: 200 }, - { - title: '操作', - key: 'action', - width: 120, - fixed: 'right', - render: function (_, r) { - return React.createElement('div', { style: { display: 'flex', gap: 8 } }, - React.createElement(Button, { type: 'link', size: 'small', onClick: function () { message.info('编辑:' + (r.plateNo || '')); } }, '编辑'), - React.createElement(Button, { type: 'link', size: 'small', onClick: function () { message.info('查看:' + (r.plateNo || '')); } }, '查看') - ); - } - } - ]; - }, []); - - function filterOption(input, opt) { - return String((opt && opt.label) || '').toLowerCase().indexOf(String(input || '').toLowerCase()) !== -1; - } - - return React.createElement('div', { style: layoutStyle }, - React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 } }, - React.createElement(Breadcrumb, { items: [{ title: '运维管理' }, { title: '车辆管理' }, { title: '证照管理' }] }) - ), - - React.createElement(Card, { title: '筛选与操作区', style: cardStyle }, - React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 } }, - React.createElement('div', { style: { flex: 1 } }, - React.createElement('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 12 } }, - React.createElement(Select, { placeholder: '请选择客户名称', value: filters.customerName, onChange: function (v) { setFilters(function (p) { var n = {}; for (var k in p) n[k] = p[k]; n.customerName = v; return n; }); }, allowClear: true, showSearch: true, filterOption: filterOption, options: options.customerName }), - React.createElement(Select, { placeholder: '请选择合同编码', value: filters.contractCode, onChange: function (v) { setFilters(function (p) { var n = {}; for (var k in p) n[k] = p[k]; n.contractCode = v; return n; }); }, allowClear: true, showSearch: true, filterOption: filterOption, options: options.contractCode }), - React.createElement(Select, { placeholder: '请选择单车车牌号', value: filters.plateNo, onChange: function (v) { setFilters(function (p) { var n = {}; for (var k in p) n[k] = p[k]; n.plateNo = v; return n; }); }, allowClear: true, showSearch: true, filterOption: filterOption, options: options.plateNo }), - React.createElement(Select, { placeholder: '请选择车辆VIN', value: filters.vin, onChange: function (v) { setFilters(function (p) { var n = {}; for (var k in p) n[k] = p[k]; n.vin = v; return n; }); }, allowClear: true, showSearch: true, filterOption: filterOption, options: options.vin }) - ), - moreOpenState[0] - ? React.createElement('div', { style: { marginTop: 12, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 12 } }, - React.createElement(DatePicker, { placeholder: '请选择审验有效期', style: { width: '100%' }, value: filters.licenseInspectValid, onChange: function (v) { setFilters(function (p) { var n = {}; for (var k in p) n[k] = p[k]; n.licenseInspectValid = v; return n; }); } }), - React.createElement(DatePicker, { placeholder: '请选择证件有效期', style: { width: '100%' }, value: filters.operationPermitValid, onChange: function (v) { setFilters(function (p) { var n = {}; for (var k in p) n[k] = p[k]; n.operationPermitValid = v; return n; }); } }), - React.createElement(DatePicker, { placeholder: '请选择有效期', style: { width: '100%' }, value: filters.passPermitValid, onChange: function (v) { setFilters(function (p) { var n = {}; for (var k in p) n[k] = p[k]; n.passPermitValid = v; return n; }); } }), - React.createElement('div', null) - ) - : null, - React.createElement('div', { style: { marginTop: 12, display: 'flex', alignItems: 'center', gap: 8 } }, - React.createElement(Button, { type: 'link', onClick: function () { moreOpenState[1](!moreOpenState[0]); } }, moreOpenState[0] ? '收起条件 ▲' : '更多条件 ▼'), - React.createElement(Button, { onClick: resetFilters }, '重置'), - React.createElement(Button, { type: 'primary', onClick: handleQuery }, '查询') - ) - ), - - React.createElement('div', { style: { width: 360, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10 } }, - React.createElement('div', { style: { color: '#666', fontSize: 13 } }, - '选中 ', (selectedRowKeysState[0] || []).length, '/', (filteredData || []).length, ' 条' - ), - React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' } }, - React.createElement(Button, { type: 'primary', onClick: openAdd }, '+ 新增'), - React.createElement(Button, { danger: true, disabled: !(selectedRowKeysState[0] || []).length, onClick: confirmDeleteSelected }, '删除'), - React.createElement(Button, { onClick: openExport }, '导出'), - React.createElement(Button, { onClick: openImport }, '导入') - ) - ) - ) - ), - - React.createElement(Card, { title: '证照列表', style: cardStyle }, - React.createElement(Table, { - rowKey: 'key', - size: 'middle', - bordered: true, - columns: columns, - dataSource: filteredData, - rowSelection: { - selectedRowKeys: selectedRowKeysState[0], - onChange: function (keys) { selectedRowKeysState[1](keys || []); } - }, - scroll: { x: 2600 }, - pagination: { pageSize: 20, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'], showTotal: function (t) { return '共 ' + t + ' 条'; }, showQuickJumper: true } - }) - ), - - React.createElement(Modal, { - title: '证件导入', - open: importOpenState[0], - onCancel: function () { importOpenState[1](false); }, - okText: '上传', - cancelText: '取消', - onOk: doImportUpload - }, - React.createElement('div', { style: { marginBottom: 12, color: '#666', fontSize: 13 } }, '*支持文件类型 .xls .xlsx'), - React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 } }, - React.createElement(Button, { onClick: function () { message.info('下载模板:证件信息模板.xlsx(原型)'); } }, '模板下载'), - React.createElement('input', { type: 'file', accept: '.xls,.xlsx', onChange: onPickImportFile }) - ), - React.createElement('div', { style: { fontSize: 13, color: '#333' } }, '已选文件:', (importFileState[0] && importFileState[0].name) ? importFileState[0].name : '—') - ) - ); +const loadLicensesFromStorage = () => { + try { + const raw = localStorage.getItem(LC_LICENSES_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } }; +const persistLicensesToStorage = (data) => { + try { + localStorage.setItem(LC_LICENSES_STORAGE_KEY, JSON.stringify(data)); + } catch { + /* ignore */ + } +}; + +const goLicenseEditPage = (plateNo, master) => { + try { + sessionStorage.setItem(LC_EDIT_PLATE_KEY, plateNo); + persistLicensesToStorage(master); + } catch { + /* ignore */ + } + if (typeof window.__axhubNavigate === 'function') { + window.__axhubNavigate('证照管理-编辑'); + message.success(`已进入 [${plateNo}] 资质维护`); + return; + } + message.info(`已带入 [${plateNo}] 车辆信息,请打开「证照管理-编辑」页面继续维护`); +}; + + +/** 列表「证件状态」列:八类证照,双行四列展示 */ +const LIST_CERT_STATUS_ITEMS = [ + { key: 'driverLicense', label: '行驶证', fullLabel: '行驶证' }, + { key: 'transportLicense', label: '运输证', fullLabel: '道路运输证', mergedTransport: true }, + { key: 'registrationCert', label: '登记', fullLabel: '机动车登记证书' }, + { key: 'specialEquipCert', label: '特种', fullLabel: '特种设备使用登记证' }, + { key: 'specialEquipDecal', label: '特设标', fullLabel: '特种设备使用标识' }, + { key: 'hydrogenCard', label: '加氢卡', fullLabel: '加氢卡' }, + { key: 'safetyValve', label: '安全阀', fullLabel: '安全阀检验' }, + { key: 'pressureGauge', label: '压力表', fullLabel: '压力表检验' } +]; + +/** 列表表头:过长标题拆为多行,收窄列宽便于一屏展示 */ +const tableTitleMultiline = (...lines) => ( +
+ {lines.map((line, idx) => ( + {line} + ))} +
+); + +const listColumnHeaderCell = () => ({ className: 'lc-th-wrap' }); + +const BATCH_EXPORT_RULE_LINES = [ + '按当前筛选条件(含 KPI 看板筛选)导出,勾选证照类型各生成一个文件夹,最终打包为 ZIP。', + '文件夹内文件以车牌号命名;仅 1 张影像时为「车牌号.jpg」,多张依次为「车牌号-1」「车牌号-2」……' +]; + +/** 批量导出影像命名:单张=车牌号,多张=车牌号-序号 */ +const buildExportPhotoBaseName = (plateNo, index, total) => { + if (total <= 1) return plateNo; + return `${plateNo}-${index + 1}`; +}; + +/** 列表排序:退出运营车辆置底,组内保持原台账顺序 */ +const sortVehiclesRetiredLast = (vehicles) => { + const active = []; + const retired = []; + vehicles.forEach((v) => { + if (v.status === '退出运营') retired.push(v); + else active.push(v); + }); + return [...active, ...retired]; +}; + +/** 多车牌:优先按行解析,单行内仍支持逗号分隔 */ +const parseMultiPlates = (text) => { + const raw = (text || '').trim(); + if (!raw) return []; + const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const expanded = lines.flatMap((line) => { + if (/[,,、;;]/.test(line)) { + return line.split(/[,,、;;]+/).map((s) => s.trim()).filter(Boolean); + } + return [line]; + }); + return [...new Set(expanded.map((s) => s.toUpperCase()))]; +}; + +const loadJsZip = () => new Promise((resolve, reject) => { + if (typeof window !== 'undefined' && window.JSZip) { + resolve(window.JSZip); + return; + } + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'; + script.async = true; + script.onload = () => (window.JSZip ? resolve(window.JSZip) : reject(new Error('JSZip load failed'))); + script.onerror = () => reject(new Error('JSZip script error')); + document.head.appendChild(script); +}); + +const fetchImageBlob = async (url) => { + try { + const res = await fetch(url, { mode: 'cors' }); + if (!res.ok) return null; + return await res.blob(); + } catch { + return null; + } +}; + +const downloadBlobFile = (blob, filename) => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); +}; + +const formatExportFilename = () => { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`; +}; + +const normalizePlateNo = (plate) => (plate || '').trim().toUpperCase(); + +const findVehicleByPlate = (plate) => { + const key = normalizePlateNo(plate); + return MOCK_VEHICLES.find((v) => normalizePlateNo(v.plateNo) === key) || null; +}; + +/** 批量 OCR:识别车牌须在证照台账中存在,否则视为校验失败 */ +const validateBatchOcrPlate = (ocrPlateNo) => { + const normalized = normalizePlateNo(ocrPlateNo); + if (!normalized) { + return { plateValid: false, ocrPlateNo: '', matchedPlateNo: '', plateError: '未识别到车牌号,请更换清晰照片后重试' }; + } + const vehicle = findVehicleByPlate(normalized); + if (!vehicle) { + return { + plateValid: false, + ocrPlateNo: normalized, + matchedPlateNo: '', + plateError: `识别车牌「${normalized}」与证照台账不一致,请核对照片是否为台账车辆证照` + }; + } + return { plateValid: true, ocrPlateNo: vehicle.plateNo, matchedPlateNo: vehicle.plateNo, plateError: '' }; +}; + +const buildMockBatchOcrItem = (certType, photoIndex) => { + const samplePlate = MOCK_VEHICLES[photoIndex % MOCK_VEHICLES.length]?.plateNo || '沪A00000'; + const isMismatch = Math.random() < 0.2; + const rawOcrPlate = isMismatch ? '京A88888' : samplePlate; + const plateCheck = validateBatchOcrPlate(rawOcrPlate); + + if (certType === 'driverLicense') { + const fields = { + regDate: '2024-06-05', + issueDate: '2024-06-05', + scrapDate: '2039-06-04', + expireDate: '2026-06-30', + updateType: '批量上传' + }; + return { ...plateCheck, fields }; + } + if (certType === 'transportLicense') { + return { + ...plateCheck, + fields: { + licenseNo: '交字310115582910号', + issueDate: '2024-08-15', + expireDate: '2026-08-31', + inspectValidUntil: '2026-07-31' + } + }; + } + if (certType === 'registrationCert' || certType === 'specialEquipCert') { + return { ...plateCheck, fields: {} }; + } + if (certType === 'specialEquipDecal') { + return { + ...plateCheck, + fields: { nextInspectDate: '2027-05-20' } + }; + } + return { ...plateCheck, fields: {} }; +}; + +const countBatchOcrResults = (results) => { + if (!results || !results.length) return { ocrSuccessCount: 0, ocrFailCount: 0 }; + const ocrSuccessCount = results.filter((r) => r.plateValid).length; + return { ocrSuccessCount, ocrFailCount: results.length - ocrSuccessCount }; +}; + +/** 按车牌聚合识别结果,逐张确认时每组对应一个车牌及其全部照片 */ +const buildOcrConfirmGroups = (results) => { + const order = []; + const map = new Map(); + (results || []).forEach((item, sourceIndex) => { + const plateKey = item.plateValid + ? normalizePlateNo(item.matchedPlateNo || item.ocrPlateNo) + : `__invalid__${sourceIndex}`; + if (!map.has(plateKey)) { + map.set(plateKey, { + plateNo: item.matchedPlateNo || item.ocrPlateNo || '', + plateValid: !!item.plateValid, + items: [], + fields: { ...(item.fields || {}) } + }); + order.push(plateKey); + } + const group = map.get(plateKey); + group.items.push({ ...item, sourceIndex }); + if (item.fields) Object.assign(group.fields, item.fields); + }); + return order.map((k) => map.get(k)); +}; + +const createEmptyLicenseRecord = () => ({ + driverLicense: { photos: [], regDate: '', issueDate: '', scrapDate: '', expireDate: '', updateType: '直接上传', updateTime: '', updateUser: '' }, + transportLicense: { photos: [], licenseNo: '', issueDate: '', expireDate: '', inspectValidUntil: '', updateTime: '', updateUser: '' }, + registrationCert: { photos: [] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '', updateTime: '', updateUser: '' }, + hydrogenCard: { cardNo: '', cardType: '中石化加氢卡', balance: 0, issueDate: '', issueUser: '' }, + safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' }, + pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' } +}); + +const applyBatchOcrGroupToCert = (existingCert, certType, group, operator) => { + const photos = group.items.map((it) => it.photoUrl).filter(Boolean); + const f = group.fields || {}; + const now = new Date().toLocaleString('zh-CN', { hour12: false }); + const base = existingCert ? JSON.parse(JSON.stringify(existingCert)) : {}; + + if (certType === 'driverLicense') { + const maxPhotos = photos.slice(0, 4); + return { + ...base, + photos: maxPhotos, + regDate: f.regDate || base.regDate || '', + issueDate: f.issueDate || base.issueDate || '', + scrapDate: f.scrapDate || base.scrapDate || '', + expireDate: f.expireDate || base.expireDate || '', + updateType: f.updateType || '批量上传', + updateTime: now, + updateUser: operator || base.updateUser || '' + }; + } + if (certType === 'transportLicense') { + return { + ...base, + photos: photos.length ? photos.slice(0, 1) : base.photos || [], + licenseNo: f.licenseNo || base.licenseNo || '', + issueDate: f.issueDate || base.issueDate || '', + expireDate: f.expireDate || base.expireDate || '', + inspectValidUntil: f.inspectValidUntil || base.inspectValidUntil || '', + updateTime: now, + updateUser: operator || base.updateUser || '' + }; + } + if (certType === 'specialEquipDecal') { + return { + ...base, + photos: photos.length ? photos : base.photos || [], + nextInspectDate: f.nextInspectDate || base.nextInspectDate || '', + updateTime: now, + updateUser: operator || base.updateUser || '' + }; + } + if (certType === 'registrationCert') { + return { + ...base, + photos: photos.length ? photos : base.photos || [] + }; + } + if (certType === 'specialEquipCert') { + return { + ...base, + photos: photos.length ? photos.slice(0, 1) : base.photos || [] + }; + } + return base; +}; + +// 常用矢量图标,保证 100% 渲染且支持高保真样式 +const ICONS = { + ocr: , + upload: , + camera: , + warning: , + card: , + success: , + delete: , + shield: , + vehicle: , + edit: , + filter: +}; + +// 预设的车辆列表(扩充至 5 辆以完美支持台账数据测试) +const MOCK_VEHICLES = [ + { plateNo: '沪A03561F', brand: '宇通', model: '49吨牵引车头', vin: 'LMRKH9AC0R1004086', status: '自营' }, + { plateNo: '粤B58888F', brand: '福田', model: '奥铃4.5吨冷藏车', vin: 'LGHXCAE28M6789012', status: '租赁' }, + { plateNo: '苏E33333', brand: '陕汽', model: '德龙X3000混动牵引车', vin: 'LSXCH9AE8M1094857', status: '自营' }, + { plateNo: '京A12345', brand: '东风', model: 'DFH1180厢式货车', vin: 'LKLG7C4E4NA774759', status: '退出运营' }, + { plateNo: '浙A88888', brand: '宇通', model: '氢燃料电池大巴', vin: 'LMRKH9AE2P9876543', status: '库存' } +]; + +// 模拟预设的所有车辆证照档案数据库 +const INITIAL_LICENSE_DATA = { + '沪A03561F': { + driverLicense: { + photos: ['https://picsum.photos/seed/license1/600/400', 'https://picsum.photos/seed/license2/600/400'], + regDate: '2024-06-05', + issueDate: '2024-06-05', + scrapDate: '2039-06-04', + expireDate: '2026-06-30', // 行驶证 29 天后到期(临期警告) + updateType: '直接上传', + updateTime: '2026-05-28 14:32:00', + updateUser: '李明辉', + shNextEvaluation: '2026-12-05' + }, + transportLicense: { + photos: ['https://picsum.photos/seed/transport/600/400'], + licenseNo: '交字310115102345号', + issueDate: '2024-07-12', + expireDate: '2026-07-31', // 证件有效期 60 天后到期(临期临界) + inspectValidUntil: '2026-07-20', // 审验有效期 49 天后(临期) + updateTime: '2026-05-10 11:20:00', + updateUser: '陈高伟' + }, + registrationCert: { photos: ['https://picsum.photos/seed/regcert1/600/400'] }, + specialEquipCert: { photos: ['https://picsum.photos/seed/spec1/600/400'] }, + specialEquipDecal: { photos: ['https://picsum.photos/seed/spec2/600/400'], nextInspectDate: '2027-05-20' }, + hydrogenCard: { cardNo: 'H2-9988-7766-5544', cardType: '中石化加氢卡', balance: 12850.50, issueDate: '2025-01-15 14:30', issueUser: '能源管理部-张晓' }, + safetyValve: { photos: ['https://picsum.photos/seed/valve/600/400'], inspectDate: '2025-10-10', nextInspectDate: '2026-10-09' }, + pressureGauge: { photos: ['https://picsum.photos/seed/gauge/600/400'], inspectDate: '2025-12-15', nextInspectDate: '2026-06-14' } + }, + '粤B58888F': { + driverLicense: { photos: [], regDate: '', issueDate: '', scrapDate: '', expireDate: '', updateType: '直接上传', updateTime: '', updateUser: '' }, + transportLicense: { + photos: ['https://picsum.photos/seed/trans_ocr/600/400'], + licenseNo: '粤字440301102947号', + issueDate: '2024-07-20', + expireDate: '2026-07-20', + inspectValidUntil: '2026-08-15', + updateTime: '2026-05-15 09:30:00', + updateUser: '黄志杰' + }, + registrationCert: { photos: [] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '' }, + hydrogenCard: { cardNo: 'H2-5566-4433-2211', cardType: '中石化加氢卡', balance: 5200.00, issueDate: '2025-03-10 10:15', issueUser: '能源管理部-张晓' }, + safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' }, + pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' } + }, + '京A12345': { + driverLicense: { photos: [], regDate: '', issueDate: '', scrapDate: '', expireDate: '', updateType: '直接上传', updateTime: '', updateUser: '' }, + transportLicense: { photos: [], licenseNo: '', issueDate: '', expireDate: '', inspectValidUntil: '', updateTime: '', updateUser: '' }, + registrationCert: { photos: [] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '' }, + hydrogenCard: { cardNo: '', cardType: '中石化加氢卡', balance: 0, issueDate: '', issueUser: '' }, + safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' }, + pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' } + }, + '苏E33333': { + driverLicense: { + photos: ['https://picsum.photos/seed/su_lic/600/400'], + regDate: '2024-05-16', + issueDate: '2024-05-16', + scrapDate: '2039-05-15', + expireDate: '2026-05-15', // 已过期(逾期 17 天) + updateType: '直接上传', + updateTime: '2026-05-01 10:00:00', + updateUser: '王东东' + }, + transportLicense: { + photos: ['https://picsum.photos/seed/su_trans/600/400'], + licenseNo: '苏字320501104829号', + issueDate: '2024-08-10', + expireDate: '2026-08-10', + inspectValidUntil: '2026-08-10', + updateTime: '2026-05-12 15:40:00', + updateUser: '王东东' + }, + registrationCert: { photos: [] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '' }, + hydrogenCard: { cardNo: '', cardType: '中石化加氢卡', balance: 0, issueDate: '', issueUser: '' }, + safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' }, + pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' } + }, + '浙A88888': { + driverLicense: { + photos: ['https://picsum.photos/seed/zhe1/600/400'], + regDate: '2025-01-01', + issueDate: '2025-01-01', + scrapDate: '2040-01-01', + expireDate: '2027-12-31', // 正常 + updateType: '直接上传', + updateTime: '2026-01-10 11:00:00', + updateUser: '张小凡' + }, + transportLicense: { + photos: ['https://picsum.photos/seed/zhe2/600/400'], + licenseNo: '浙字330101582910号', + issueDate: '2025-01-05', + expireDate: '2027-12-31', + inspectValidUntil: '2027-12-31', + updateTime: '2026-01-10 11:00:00', + updateUser: '张小凡' + }, + registrationCert: { photos: ['https://picsum.photos/seed/zhe3/600/400'] }, + specialEquipCert: { photos: [] }, + specialEquipDecal: { photos: [], nextInspectDate: '' }, + hydrogenCard: { cardNo: 'H2-8888-6666-5555', cardType: '中石化加氢卡', balance: 8800.00, issueDate: '2025-05-20 16:30', issueUser: '能源管理部-张晓' }, + safetyValve: { photos: ['https://picsum.photos/seed/zhe_v/600/400'], inspectDate: '2025-12-20', nextInspectDate: '2026-12-19' }, + pressureGauge: { photos: ['https://picsum.photos/seed/zhe_g/600/400'], inspectDate: '2025-12-20', nextInspectDate: '2026-12-19' } + } +}; + +const PAGE_STYLE = ` +.lc-edit-page { + font-family: system-ui, -apple-system, sans-serif; + color: #1e293b; +} +.lc-page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} +.lc-page-title { + font-size: 22px; + font-weight: 700; + color: #0f172a; + margin: 0; +} +.lc-layout-row { + display: flex; + gap: 24px; + flex: 1; + min-height: 0; +} +.lc-sidebar { + width: 320px; + flex-shrink: 0; + height: 100%; + display: flex; + flex-direction: column; +} +.lc-content-area { + flex: 1; + min-width: 0; + height: 100%; + overflow-y: auto; + padding-right: 12px; + padding-bottom: 80px; +} +.lc-content-area::-webkit-scrollbar { + width: 6px; +} +.lc-content-area::-webkit-scrollbar-track { + background: transparent; +} +.lc-content-area::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 3px; +} +.lc-content-area::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} +.lc-sticky-card { + height: 100%; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; +} +.lc-sidebar-card { + border-radius: 16px !important; + border: 1px solid #e2e8f0 !important; + box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.05) !important; + overflow: hidden !important; +} +.lc-sidebar-card-index { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.lc-sidebar-card-index .ant-card-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 12px 16px !important; +} +.lc-sidebar-info-row { + display: flex; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px dashed #f1f5f9; +} +.lc-sidebar-info-row:last-child { + border-bottom: none; +} +.lc-sidebar-label { + font-size: 13px; + color: #64748b; +} +.lc-sidebar-val { + font-size: 13px; + font-weight: 600; + color: #0f172a; +} +.lc-nav-list { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + overflow-y: auto; + padding-right: 4px; +} +.lc-nav-list::-webkit-scrollbar { + width: 4px; +} +.lc-nav-list::-webkit-scrollbar-track { + background: transparent; +} +.lc-nav-list::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 2px; +} +.lc-nav-list::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} +.lc-nav-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: #475569; + background: #f8fafc; + border: 1px solid #f1f5f9; + cursor: pointer; + transition: all .2s ease; +} +.lc-nav-item:hover { + background: #f1f5f9; + border-color: #cbd5e1; + color: #0f172a; +} +.lc-nav-item.active { + background: #ecfdf5; + border-color: #a7f3d0; + color: #065f46; + font-weight: 600; + box-shadow: 0 2px 8px -2px rgba(16, 185, 129, 0.1); +} +.lc-card { + border-radius: 16px !important; + border: 1px solid #e2e8f0 !important; + box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.05) !important; + transition: border-color .3s, box-shadow .3s; + overflow: hidden !important; +} +.lc-card:hover { + border-color: #cbd5e1 !important; + box-shadow: 0 10px 25px -5px rgba(15, 23, 42, 0.08) !important; +} +.lc-card .ant-card-head { + border-bottom: 1px solid #f1f5f9 !important; + background: #fafbfc; + padding: 14px 24px !important; +} +.lc-card-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 700; + color: #0f172a; +} +.lc-card-subtitle { + font-size: 11px; + font-weight: 400; + color: #94a3b8; + margin-top: 2px; +} +.lc-upload-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} +.lc-upload-box { + aspect-ratio: 4/3; + border: 1.5px dashed #cbd5e1; + border-radius: 12px; + background: #f8fafc; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + color: #64748b; + transition: all .2s ease; + position: relative; + overflow: hidden; +} +.lc-upload-box:hover { + border-color: #10b981; + background: #ecfdf5; + color: #10b981; +} +.lc-image-thumb { + width: 100%; + height: 100%; + object-fit: cover; +} +.lc-image-mask { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.6); + opacity: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + transition: opacity .2s ease; + color: #fff; +} +.lc-upload-box:hover .lc-image-mask { + opacity: 1; +} +.lc-image-action-btn { + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(255,255,255,0.15); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background .2s; +} +.lc-image-action-btn:hover { + background: rgba(255,255,255,0.3); +} +.lc-image-action-btn.delete:hover { + background: #ef4444; +} +.lc-ocr-tag { + background: #fef3c7; + color: #d97706; + border: 1px solid #fde68a; + font-size: 11px; + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 4px; +} +.lc-ocr-scanline { + position: absolute; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, transparent, #10b981, transparent); + animation: lc-scan 1.5s infinite linear; + z-index: 10; + box-shadow: 0 0 8px #10b981; +} +@keyframes lc-scan { + 0% { top: 0%; } + 50% { top: 100%; } + 100% { top: 0%; } +} +.lc-sh-badge { + background: #f5f3ff; + color: #7c3aed; + border: 1px solid #ddd6fe; + font-size: 11px; + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; +} +.lc-form-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px 20px; +} +.lc-form-grid .ant-form-item { + margin-bottom: 0 !important; + display: flex !important; + flex-direction: column !important; +} +.lc-form-grid .ant-form-item-label { + padding-bottom: 6px !important; + text-align: left !important; +} +.lc-form-grid .ant-form-item-control { + width: 100% !important; +} +.lc-form-grid .ant-form-item-control-input-content { + display: flex !important; + width: 100% !important; +} +.lc-form-grid .ant-form-item-control-input-content > * { + width: 100% !important; +} +.lc-form-group-title { + font-size: 13px; + font-weight: 600; + color: #475569; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 6px; +} +.lc-h2-refuel-card { + width: 100%; + height: 160px; + border-radius: 16px; + background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); + position: relative; + overflow: hidden; + padding: 20px; + color: #fff; + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.15); +} +.lc-h2-refuel-card::before { + content: ""; + position: absolute; + top: -20%; + right: -20%; + width: 180px; + height: 180px; + border-radius: 50%; + background: radial-gradient(circle, rgba(16, 185, 129, 0.2) 0%, transparent 70%); + filter: blur(20px); +} +.lc-h2-card-logo { + font-size: 16px; + font-weight: 800; + letter-spacing: 0.05em; + background: linear-gradient(90deg, #10b981, #34d399); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + display: flex; + align-items: center; + gap: 6px; +} +.lc-h2-card-number { + font-size: 18px; + font-weight: 700; + font-family: monospace; + letter-spacing: 0.1em; + color: #f1f5f9; +} +.lc-h2-card-balance { + font-size: 24px; + font-weight: 800; + font-family: monospace; + color: #34d399; +} +.lc-h2-card-meta { + display: flex; + justify-content: space-between; + font-size: 11px; + color: #94a3b8; +} +.lc-page-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(12px); + border-top: 1px solid #e2e8f0; + padding: 12px 24px; + display: flex; + justify-content: flex-end; + gap: 12px; + z-index: 100; + box-shadow: 0 -4px 20px rgba(15, 23, 42, 0.03); +} +.lc-ocr-flash { + animation: flash-green .4s ease-out 2; +} +@keyframes flash-green { + 0% { background-color: transparent; } + 50% { background-color: rgba(16, 185, 129, 0.15); } + 100% { background-color: transparent; } +} + +/* ==================== 列表台账页面专属样式 ==================== */ +.lc-filter-card.ant-card { + border-radius: 16px !important; + border: 1px solid #e2e8f0 !important; + box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.03) !important; + margin-bottom: 16px; +} +.lc-filter-card > .ant-card-head { + border-bottom: 1px solid #f1f5f9 !important; + min-height: auto; + padding: 12px 20px !important; +} +.lc-filter-card > .ant-card-head .ant-card-head-title { + font-size: 15px !important; + font-weight: 700 !important; + color: #0f172a !important; + padding: 0 !important; +} +.lc-filter-card > .ant-card-body { + padding: 16px 20px 20px !important; +} +.lc-filter-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px 24px; +} +@media (max-width: 1100px) { + .lc-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 720px) { + .lc-filter-grid { grid-template-columns: 1fr; } +} +.lc-filter-field { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.lc-filter-field-label { + flex: 0 0 72px; + text-align: right; + font-size: 13px; + font-weight: 500; + color: #475569; + line-height: 1.4; + white-space: nowrap; +} +.lc-filter-field-control { + flex: 1; + min-width: 0; +} +.lc-filter-field-control .ant-input, +.lc-filter-field-control .ant-select { + width: 100%; +} +.lc-multi-plate-pop { + width: 320px; + padding: 4px 2px; +} +.lc-multi-plate-pop-hint { + font-size: 12px; + color: #64748b; + margin-bottom: 8px; + line-height: 1.5; +} +.lc-multi-plate-pop-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} +.lc-multi-plate-trigger { + cursor: pointer; +} +.lc-multi-plate-trigger .ant-input { + cursor: pointer; +} +.lc-filter-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f1f5f9; +} +.lc-alert-stats-row { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} +@media (max-width: 1200px) { + .lc-alert-stats-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +@media (max-width: 768px) { + .lc-alert-stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +.lc-alert-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 30px 14px 16px; + border-radius: 12px; + border: 1px solid #e2e8f0; + background: #fff; + position: relative; + overflow: hidden; + min-width: 0; +} +.lc-alert-card-main { + flex: 1; + min-width: 0; +} +.lc-alert-card-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; +} +.lc-alert-card-val { + font-size: 26px; + font-weight: 800; + line-height: 1.1; + color: #0f172a; + font-variant-numeric: tabular-nums; +} +.lc-alert-card-title { + font-size: 13px; + font-weight: 600; + color: #334155; + margin-top: 2px; +} +.lc-alert-card-tip-anchor { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + line-height: 0; +} +.lc-alert-card-tip { + width: 18px; + height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: #94a3b8; + background: rgba(255, 255, 255, 0.92); + border: 1px solid #e2e8f0; + cursor: help; + line-height: 0; +} +.lc-alert-card-tip:hover { + color: #64748b; + border-color: #cbd5e1; + background: #fff; +} +.lc-alert-card--total { background: linear-gradient(135deg, #f8fafc 0%, #fff 100%); } +.lc-alert-card--total .lc-alert-card-icon { background: #e2e8f0; color: #475569; } +.lc-alert-card--normal { background: linear-gradient(135deg, #ecfdf5 0%, #fff 55%); border-color: #bbf7d0; } +.lc-alert-card--normal .lc-alert-card-icon { background: #d1fae5; color: #059669; } +.lc-alert-card--normal .lc-alert-card-val { color: #047857; } +.lc-alert-card--warning { background: linear-gradient(135deg, #fff7ed 0%, #fff 55%); border-color: #fed7aa; } +.lc-alert-card--warning .lc-alert-card-icon { background: #ffedd5; color: #ea580c; } +.lc-alert-card--warning .lc-alert-card-val { color: #c2410c; } +.lc-alert-card--expired { background: linear-gradient(135deg, #fef2f2 0%, #fff 55%); border-color: #fecaca; } +.lc-alert-card--expired .lc-alert-card-icon { background: #fee2e2; color: #dc2626; } +.lc-alert-card--expired .lc-alert-card-val { color: #b91c1c; } +.lc-alert-card--unuploaded { background: linear-gradient(135deg, #f8fafc 0%, #fff 55%); } +.lc-alert-card--unuploaded .lc-alert-card-icon { background: #f1f5f9; color: #64748b; } +.lc-alert-card--unuploaded .lc-alert-card-val { color: #64748b; } +.lc-alert-card-clickable { + cursor: pointer; + transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease; +} +.lc-alert-card-clickable:hover { + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08); +} +.lc-alert-card-active { + box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.2) !important; + border-color: #165dff !important; +} +.lc-table-section { margin-bottom: 0; } +.lc-table-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px 16px; + margin-bottom: 8px; + min-height: 32px; +} +.lc-table-cert-legend-outer { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-start; + gap: 10px; + padding: 6px 4px; +} +.lc-table-toolbar-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-left: auto; +} +.lc-cert-legend-items { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + font-size: 12px; + font-weight: 400; + color: #64748b; +} +.lc-cert-legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} +.lc-cert-legend-item--help { cursor: help; } +.lc-plate-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 13px; + height: 25px; + padding: 0 10px; + border-radius: 5px; + letter-spacing: 0.05em; + box-shadow: 0 2px 4px rgba(0,0,0,0.06); + border: 1.5px solid #000000; +} +.lc-plate-blue { + background: linear-gradient(135deg, #1e40af 0%, #1d4ed8 100%); + color: #ffffff; + border-color: #3b82f6; +} +.lc-plate-yellow { + background: linear-gradient(135deg, #eab308 0%, #ca8a04 100%); + color: #0f172a; + border-color: #facc15; +} +.lc-plate-green { + background: linear-gradient(90deg, #34d399 0%, #a7f3d0 50%, #34d399 100%); + color: #0f172a; + border-color: #10b981; +} +.lc-table-card { + background: #ffffff; + border-radius: 16px; + border: 1px solid #e2e8f0; + box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.03); + overflow: hidden; +} +.lc-table-cert-legend-label { + font-size: 12px; + font-weight: 600; + color: #64748b; +} +.lc-table-card .ant-table-thead > tr > th { + background: #f8fafc !important; + color: #475569 !important; + font-weight: 700 !important; + font-size: 13px !important; + border-bottom: 1px solid #e2e8f0 !important; + padding: 12px 16px !important; + vertical-align: middle; +} +.lc-table-card .ant-table-thead > tr > th.lc-th-wrap { + padding: 8px 8px !important; + text-align: center; + vertical-align: middle; +} +.lc-table-th-multiline { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + line-height: 1.3; + white-space: normal; + word-break: keep-all; +} +.lc-table-th-line { + display: block; + font-size: 12px; + font-weight: 700; + color: #475569; +} +.lc-list-table .ant-table-wrapper, +.lc-list-table .ant-table { + width: 100% !important; +} +.lc-list-table .ant-table-content table { + table-layout: fixed; + width: 100% !important; +} +.lc-cell-ellipsis { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lc-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td { + padding: 10px 8px !important; +} +.lc-table-card .ant-table-tbody > tr.ant-table-measure-row, +.lc-table-card .ant-table-tbody > tr.ant-table-measure-row > td { + height: 0 !important; + max-height: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: none !important; + line-height: 0 !important; + font-size: 0 !important; + overflow: hidden !important; + visibility: hidden !important; +} +.lc-table-card .ant-table-tbody > tr:not(.ant-table-measure-row) > td { + padding: 14px 16px !important; + border-bottom: 1px solid #f1f5f9 !important; +} +.lc-table-card .ant-table-tbody > tr:not(.ant-table-measure-row):hover > td { + background: #f8fafc !important; +} +.lc-table-card .ant-table-tbody > tr.lc-row-retired:not(.ant-table-measure-row) > td { + background: #f8fafc !important; + color: #94a3b8; +} +.lc-table-card .ant-table-tbody > tr.lc-row-retired:hover > td { + background: #f1f5f9 !important; +} +.lc-table-card .ant-table-tbody > tr.lc-row-retired .lc-muted-text { + color: #94a3b8 !important; +} +.lc-table-card .ant-table-tbody > tr.lc-row-retired .ant-badge-status-text { + color: #94a3b8 !important; +} +.lc-mini-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; +} +.lc-mini-badge-success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; } +.lc-mini-badge-warning { background: #fff7ed; color: #ea580c; border: 1px solid #ffedd5; } +.lc-mini-badge-error { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; } +.lc-mini-badge-default { background: #f1f5f9; color: #64748b; border: 1px solid #e2e8f0; } +.lc-list-cert-status-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: auto auto; + gap: 3px 2px; + width: 100%; + max-width: 100%; +} +.lc-list-cert-status-item { + display: inline-flex; + align-items: center; + min-width: 0; + line-height: 1.2; +} +.lc-list-cert-status-item .ant-badge { + display: inline-flex; + align-items: center; + gap: 2px; + max-width: 100%; + font-size: 10px; +} +.lc-list-cert-status-item .ant-badge-status-dot { + width: 5px !important; + height: 5px !important; + top: 0 !important; +} +.lc-list-cert-status-item .ant-badge-status-text { + font-size: 10px !important; + white-space: nowrap; + margin-left: 2px !important; +} +.lc-list-status-badge-wrap { + display: inline-flex; + max-width: 100%; +} +.lc-list-status-badge-wrap .ant-badge { + display: inline-flex; + align-items: center; + max-width: 100%; +} +.lc-list-status-badge-text { + font-size: 10px; + white-space: nowrap; + line-height: 1.2; +} +.lc-list-date-cell-status { + margin-top: 3px; +} + +.lc-dot-indicator { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; +} +.lc-batch-export-types { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; + margin-top: 8px; +} +@media (max-width: 640px) { + .lc-batch-export-types { grid-template-columns: 1fr; } +} +.lc-batch-ocr-confirm { + display: grid; + grid-template-columns: minmax(200px, 340px) 1fr; + gap: 20px; + min-height: 420px; +} +@media (max-width: 900px) { + .lc-batch-ocr-confirm { grid-template-columns: 1fr; } +} +.lc-batch-ocr-photos { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 480px; + overflow-y: auto; +} +.lc-batch-ocr-photo-main { + border-radius: 12px; + border: 1px solid #e2e8f0; + overflow: hidden; + cursor: zoom-in; + background: #f8fafc; +} +.lc-batch-ocr-photo-main img { + width: 100%; + display: block; + max-height: 280px; + object-fit: contain; +} +.lc-batch-ocr-thumb-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.lc-batch-ocr-thumb { + width: 56px; + height: 56px; + border-radius: 8px; + border: 2px solid transparent; + overflow: hidden; + cursor: pointer; + opacity: 0.75; +} +.lc-batch-ocr-thumb.active { + border-color: #10b981; + opacity: 1; +} +.lc-batch-ocr-thumb--fail { + border-color: #ef4444 !important; + opacity: 1; +} +.lc-batch-ocr-thumb img { width: 100%; height: 100%; object-fit: cover; } +.lc-batch-ocr-plate-readonly { + font-size: 15px; + font-weight: 700; + color: #0f172a; + letter-spacing: 0.04em; +} +.lc-batch-ocr-step-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; + padding: 10px 14px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; +} +.lc-batch-ocr-step-val { + font-size: 18px; + font-weight: 800; + color: #0f172a; + font-variant-numeric: tabular-nums; +} +.lc-batch-ocr-photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); + gap: 8px; + margin-bottom: 10px; +} +`; + +const Component = function () { + // 核心共享数据库状态,使编辑修改在列表中实时生效! + const [allLicenses, setAllLicenses] = useState(() => ( + loadLicensesFromStorage() || JSON.parse(JSON.stringify(INITIAL_LICENSE_DATA)) + )); + + const patchAllLicenses = (updater) => { + setAllLicenses((prev) => { + const next = typeof updater === 'function' ? updater(prev) : updater; + persistLicensesToStorage(next); + return next; + }); + }; + + const DEFAULT_LIST_FILTERS = { + plateNo: '', + plateNos: '', + vin: '', + brand: '', + model: '', + operateStatus: '全部' + }; + const [listFilters, setListFilters] = useState(() => ({ ...DEFAULT_LIST_FILTERS })); + const [appliedFilters, setAppliedFilters] = useState(() => ({ ...DEFAULT_LIST_FILTERS })); + const [multiPlateOpen, setMultiPlateOpen] = useState(false); + const [multiPlateDraft, setMultiPlateDraft] = useState(''); + + + const [previewOpen, setPreviewOpen] = useState(false); + const [previewUrl, setPreviewUrl] = useState(''); + const [prdOpen, setPrdOpen] = useState(false); + /** total | normal | warning | expired | unuploaded */ + const [kpiFilter, setKpiFilter] = useState('total'); + + useEffect(() => { + const refreshFromStorage = () => { + const stored = loadLicensesFromStorage(); + if (stored) setAllLicenses(stored); + }; + const onReturn = () => { + try { + if (sessionStorage.getItem(LC_NAV_TARGET_KEY) === 'list') { + sessionStorage.removeItem(LC_NAV_TARGET_KEY); + refreshFromStorage(); + } + } catch { + /* ignore */ + } + }; + window.addEventListener(LC_NAV_EVENT, onReturn); + onReturn(); + return () => window.removeEventListener(LC_NAV_EVENT, onReturn); + }, []); + + const [batchExportOpen, setBatchExportOpen] = useState(false); + const [exportCertTypes, setExportCertTypes] = useState([]); + const [batchOcrOpen, setBatchOcrOpen] = useState(false); + const [batchOcrCertType, setBatchOcrCertType] = useState('driverLicense'); + const [batchOcrFileList, setBatchOcrFileList] = useState([]); + const [ocrTasks, setOcrTasks] = useState([]); + const [ocrConfirmOpen, setOcrConfirmOpen] = useState(false); + const [ocrConfirmTask, setOcrConfirmTask] = useState(null); + const [ocrConfirmGroups, setOcrConfirmGroups] = useState([]); + const [ocrConfirmGroupIdx, setOcrConfirmGroupIdx] = useState(0); + const [ocrConfirmPhotoIdx, setOcrConfirmPhotoIdx] = useState(0); + const ocrTaskTimersRef = useRef({}); + + // 辅助函数:根据车牌前缀获取车牌颜色类别 + const getPlateClass = (plate) => { + if (plate.endsWith('F') || plate.endsWith('D') || plate.length === 8) { + return 'lc-plate-green'; // 绿牌(新能源车) + } + if (plate.startsWith('粤') || plate.startsWith('京')) { + return 'lc-plate-blue'; // 蓝牌(普通货车) + } + return 'lc-plate-yellow'; // 黄牌(中重卡) + }; + + // 辅助函数:获取到期天数 + const getDiffDays = (dateStr) => { + if (!dateStr) return null; + const expDate = new Date(dateStr); + const today = new Date('2026-06-01'); // 锚定今天日期 2026-06-01 + expDate.setHours(0, 0, 0, 0); + today.setHours(0, 0, 0, 0); + const diffTime = expDate.getTime() - today.getTime(); + return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + }; + + const mergeLicenseStatusTypes = (a, b) => { + const rank = { expired: 0, warning: 1, unuploaded: 2, success: 3 }; + return (rank[a] ?? 9) <= (rank[b] ?? 9) ? a : b; + }; + + const getListCertStatus = (plateNo, item) => { + if (item.mergedTransport) { + const cert = getLicenseStatus(plateNo, 'transportLicense', { dateField: 'expireDate' }); + const inspect = getLicenseStatus(plateNo, 'transportLicense', { dateField: 'inspectValidUntil' }); + const type = mergeLicenseStatusTypes(cert.type, inspect.type); + return { + type, + text: `证件有效期:${cert.text};审验有效期:${inspect.text}` + }; + } + const st = getLicenseStatus(plateNo, item.key); + return { type: st.type, text: st.text }; + }; + + // 获取单个车辆各证件的到期状态(道路运输证可通过 dateField 区分证件有效期 / 审验有效期) + const getLicenseStatus = (plate, key, options = {}) => { + const item = allLicenses[plate]?.[key]; + if (!item) return { type: 'unuploaded', text: '未上传', diffDays: null }; + + if (key === 'hydrogenCard') { + return item.cardNo ? { type: 'success', text: '已绑定', diffDays: null } : { type: 'unuploaded', text: '未绑定', diffDays: null }; + } + + if (!item.photos || item.photos.length === 0) { + return { type: 'unuploaded', text: '未上传', diffDays: null }; + } + + // 针对有日期的 + const today = new Date('2026-06-01'); + today.setHours(0,0,0,0); + + let dateValue = ''; + let warnThreshold = 30; // 默认30天警告 + + if (key === 'driverLicense') { + dateValue = item.expireDate; + warnThreshold = 90; // 行驶证 90 天 + } else if (key === 'transportLicense') { + dateValue = options.dateField === 'inspectValidUntil' ? item.inspectValidUntil : item.expireDate; + warnThreshold = 60; // 运输证 60 天 + } else if (key === 'specialEquipDecal') { + dateValue = item.nextInspectDate; + warnThreshold = 60; // 特种设备使用标识 60 天 + } else if (key === 'safetyValve' || key === 'pressureGauge') { + dateValue = item.nextInspectDate; + warnThreshold = 60; // 安全阀 / 压力表 60 天 + } + + if (!dateValue) { + return { type: 'success', text: '正常', diffDays: null }; // 已上传照片无日期的默认为正常 + } + + const expDate = new Date(dateValue); + expDate.setHours(0, 0, 0, 0); + const diffDays = Math.ceil((expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + + if (diffDays <= 0) { + return { type: 'expired', text: `已到期 (逾期 ${Math.abs(diffDays)} 天)`, diffDays }; + } + if (diffDays <= warnThreshold) { + return { type: 'warning', text: `临期 (${diffDays} 天后)`, diffDays }; + } + return { type: 'success', text: '正常', diffDays }; + }; + + const mapLicenseTypeToBadge = (type) => { + if (type === 'success') return 'success'; + if (type === 'warning') return 'warning'; + if (type === 'expired') return 'error'; + return 'default'; + }; + + /** 列表日期列:状态文案单行展示,完整说明放 Tooltip */ + const getListStatusShortText = (status) => { + const { type, text, diffDays } = status || {}; + if (type === 'warning') { + return diffDays != null ? `临期${diffDays}天` : '临期'; + } + if (type === 'expired') { + return diffDays != null ? `逾期${Math.abs(diffDays)}天` : '已到期'; + } + if (type === 'unuploaded') return text === '未绑定' ? '未绑定' : '未上传'; + return text || '正常'; + }; + + const renderListLicenseStatusBadge = (status) => ( + + + {getListStatusShortText(status)}} + /> + + + ); + + const isRetiredVehicle = (record) => record?.status === '退出运营'; + + const CERT_STATUS_LEGEND = ( +
+ 正常 + + 临期 + + + 已到期 + + 未上传 +
+ ); + + const isTransportLicenseWarning = (plateNo) => ( + getLicenseStatus(plateNo, 'transportLicense', { dateField: 'expireDate' }).type === 'warning' + || getLicenseStatus(plateNo, 'transportLicense', { dateField: 'inspectValidUntil' }).type === 'warning' + ); + + const isTransportLicenseExpired = (plateNo) => ( + getLicenseStatus(plateNo, 'transportLicense', { dateField: 'expireDate' }).type === 'expired' + || getLicenseStatus(plateNo, 'transportLicense', { dateField: 'inspectValidUntil' }).type === 'expired' + ); + + const isQualificationNormal = (plateNo) => { + const d = getLicenseStatus(plateNo, 'driverLicense'); + const tCert = getLicenseStatus(plateNo, 'transportLicense', { dateField: 'expireDate' }); + const tInspect = getLicenseStatus(plateNo, 'transportLicense', { dateField: 'inspectValidUntil' }); + return d.type === 'success' && tCert.type === 'success' && tInspect.type === 'success'; + }; + + const isCertNearExpiry = (plateNo) => { + if (isTransportLicenseWarning(plateNo)) return true; + const keys = ['driverLicense', 'specialEquipDecal', 'safetyValve', 'pressureGauge']; + return keys.some((key) => getLicenseStatus(plateNo, key).type === 'warning'); + }; + + const isCoreCertExpired = (plateNo) => { + if (isTransportLicenseExpired(plateNo)) return true; + const keys = ['driverLicense', 'safetyValve', 'pressureGauge']; + return keys.some((key) => getLicenseStatus(plateNo, key).type === 'expired'); + }; + + const isCertPendingUpload = (plateNo) => { + const keys = ['driverLicense', 'transportLicense', 'specialEquipCert', 'specialEquipDecal']; + return keys.some((key) => getLicenseStatus(plateNo, key).type === 'unuploaded'); + }; + + const matchKpiFilter = (plateNo, filterKey) => { + if (filterKey === 'total') return true; + if (filterKey === 'normal') return isQualificationNormal(plateNo); + if (filterKey === 'warning') return isCertNearExpiry(plateNo); + if (filterKey === 'expired') return isCoreCertExpired(plateNo); + if (filterKey === 'unuploaded') return isCertPendingUpload(plateNo); + return true; + }; + + const handleKpiCardClick = (key) => { + setKpiFilter(key); + }; + + // ==================== 统计面板数据动态推算 ==================== + const stats = useMemo(() => { + let normal = 0; + let warning = 0; + let expired = 0; + let unuploaded = 0; + + MOCK_VEHICLES.forEach((v) => { + if (isQualificationNormal(v.plateNo)) normal++; + if (isCertNearExpiry(v.plateNo)) warning++; + if (isCoreCertExpired(v.plateNo)) expired++; + if (isCertPendingUpload(v.plateNo)) unuploaded++; + }); + + return { + total: MOCK_VEHICLES.length, + normal, + warning, + expired, + unuploaded, + }; + }, [allLicenses]); + + const brandOptions = useMemo(() => { + return [...new Set(MOCK_VEHICLES.map(v => v.brand))].map(b => ({ label: b, value: b })); + }, []); + + const modelOptions = useMemo(() => { + return [...new Set(MOCK_VEHICLES.map(v => v.model))].map(m => ({ label: m, value: m })); + }, []); + + useEffect(() => { + return () => { + Object.values(ocrTaskTimersRef.current).forEach((id) => clearInterval(id)); + }; + }, []); + + const runBatchOcrTask = (taskId) => { + if (ocrTaskTimersRef.current[taskId]) clearInterval(ocrTaskTimersRef.current[taskId]); + ocrTaskTimersRef.current[taskId] = setInterval(() => { + setOcrTasks((prev) => { + const task = prev.find((t) => t.id === taskId); + if (!task || task.status === 'done') return prev; + const nextProgress = Math.min(100, task.progress + 12 + Math.floor(Math.random() * 18)); + if (nextProgress < 100) { + return prev.map((t) => (t.id === taskId ? { ...t, progress: nextProgress } : t)); + } + clearInterval(ocrTaskTimersRef.current[taskId]); + delete ocrTaskTimersRef.current[taskId]; + const results = task.photos.map((ph, idx) => ({ + ...buildMockBatchOcrItem(task.certType, idx), + photoUrl: ph.url, + photoName: ph.name + })); + const { ocrSuccessCount, ocrFailCount } = countBatchOcrResults(results); + return prev.map((t) => (t.id === taskId ? { + ...t, + progress: 100, + status: 'done', + results, + ocrSuccessCount, + ocrFailCount + } : t)); + }); + }, 450); + }; + + const handleStartBatchOcr = () => { + if (!batchOcrFileList.length) { + message.warning('请先上传至少一张证照照片'); + return; + } + const photos = batchOcrFileList.map((f) => ({ + url: f.url || (f.originFileObj ? URL.createObjectURL(f.originFileObj) : ''), + name: f.name + })); + const task = { + id: `ocr-${Date.now()}`, + certType: batchOcrCertType, + certLabel: BATCH_UPLOAD_CERT_OPTIONS.find((o) => o.key === batchOcrCertType)?.label || '', + operator: BATCH_UPLOAD_OPERATOR, + operateTime: new Date().toLocaleString('zh-CN', { hour12: false }), + photoCount: photos.length, + progress: 0, + status: 'running', + photos, + results: null + }; + setOcrTasks((prev) => [task, ...prev]); + setBatchOcrFileList([]); + runBatchOcrTask(task.id); + message.success('已创建批量上传任务'); + }; + + const currentOcrConfirmGroup = ocrConfirmGroups[ocrConfirmGroupIdx] || null; + const ocrConfirmTotalSheets = ocrConfirmGroups.length; + + const closeOcrConfirm = () => { + setOcrConfirmOpen(false); + setOcrConfirmTask(null); + setOcrConfirmGroups([]); + setOcrConfirmGroupIdx(0); + setOcrConfirmPhotoIdx(0); + }; + + const finishOcrConfirmFlow = (lastPlate) => { + if (ocrConfirmTask?.id) { + setOcrTasks((prev) => prev.map((t) => ( + t.id === ocrConfirmTask.id ? { ...t, confirmDone: true } : t + ))); + } + const photoOnly = isBatchUploadPhotoOnlyType(ocrConfirmTask?.certType); + message.success( + lastPlate + ? (photoOnly ? `全部确认完成,末次已同步 ${lastPlate} 证照照片` : `全部确认完成,末次已更新 ${lastPlate} 证照`) + : '全部逐张确认完成' + ); + closeOcrConfirm(); + }; + + const goToNextOcrConfirmGroup = (lastUpdatedPlate) => { + if (ocrConfirmGroupIdx >= ocrConfirmGroups.length - 1) { + finishOcrConfirmFlow(lastUpdatedPlate); + return; + } + setOcrConfirmGroupIdx((i) => i + 1); + setOcrConfirmPhotoIdx(0); + }; + + const openOcrConfirm = (task) => { + if (task.progress < 100 || !task.results) return; + const groups = buildOcrConfirmGroups(task.results); + if (!groups.length) { + message.warning('暂无上传结果可确认'); + return; + } + setOcrConfirmTask(task); + setOcrConfirmGroups(JSON.parse(JSON.stringify(groups))); + setOcrConfirmGroupIdx(0); + setOcrConfirmPhotoIdx(0); + setOcrConfirmOpen(true); + }; + + const updateOcrConfirmField = (fieldKey, value) => { + setOcrConfirmGroups((prev) => prev.map((g, i) => ( + i === ocrConfirmGroupIdx ? { ...g, fields: { ...g.fields, [fieldKey]: value } } : g + ))); + }; + + const handleOcrConfirmSkip = () => { + message.info('已跳过本张,进入下一张确认'); + goToNextOcrConfirmGroup(); + }; + + const handleOcrConfirmSubmit = () => { + if (!ocrConfirmTask || !currentOcrConfirmGroup) return; + if (!currentOcrConfirmGroup.plateValid) { + message.error('当前识别车牌未通过校验,请跳过或重新识别'); + return; + } + const plateNo = currentOcrConfirmGroup.plateNo; + const certType = ocrConfirmTask.certType; + const certLabel = ocrConfirmTask.certLabel; + const photoOnly = isBatchUploadPhotoOnlyType(certType); + + patchAllLicenses((prev) => { + const copy = JSON.parse(JSON.stringify(prev)); + if (!copy[plateNo]) copy[plateNo] = createEmptyLicenseRecord(); + const existing = copy[plateNo][certType] || {}; + copy[plateNo][certType] = applyBatchOcrGroupToCert( + existing, + certType, + currentOcrConfirmGroup, + BATCH_UPLOAD_OPERATOR + ); + return copy; + }); + + message.success( + photoOnly + ? `已同步 ${plateNo} 的${certLabel}照片至台账` + : `已更新 ${plateNo} 的${certLabel}` + ); + goToNextOcrConfirmGroup(plateNo); + }; + + const renderOcrPlateValidation = (group) => { + if (!group) return null; + if (group.plateValid) { + return ( + + OCR 识别车牌:{group.plateNo} + (已匹配台账车辆,不可修改) + + } + style={{ marginBottom: 14, borderRadius: 10 }} + /> + ); + } + return ( + + {group.plateNo ? ( +

+ OCR 识别车牌:{group.plateNo} +

+ ) : null} +

+ {group.items[0]?.plateError || '识别车牌与证照台账不一致,请跳过本张后继续'} +

+ + } + style={{ marginBottom: 14, borderRadius: 10 }} + /> + ); + }; + + const handleBatchExport = async () => { + if (!exportCertTypes.length) { + message.warning('请至少勾选一种证照类型'); + return; + } + const hide = message.loading({ content: '正在按筛选结果打包导出...', key: 'batchExport', duration: 0 }); + try { + const JSZipLib = await loadJsZip(); + const zip = new JSZipLib(); + let fileCount = 0; + const selectedOpts = CERT_EXPORT_OPTIONS.filter((o) => exportCertTypes.includes(o.key)); + + for (const opt of selectedOpts) { + const folder = zip.folder(opt.folder); + for (const v of filteredVehicles) { + const lic = allLicenses[v.plateNo]?.[opt.key]; + if (!lic) continue; + + const photos = lic.photos || []; + if (!photos.length) continue; + for (let i = 0; i < photos.length; i += 1) { + const blob = await fetchImageBlob(photos[i]); + const baseName = buildExportPhotoBaseName(v.plateNo, i, photos.length); + if (blob) folder.file(`${baseName}.jpg`, blob); + else folder.file(`${baseName}_链接.txt`, photos[i]); + fileCount += 1; + } + } + } + + if (fileCount === 0) { + hide(); + message.warning('当前筛选条件下,所选证照类型均无可用文件可导出'); + return; + } + + const blob = await zip.generateAsync({ type: 'blob' }); + downloadBlobFile(blob, `证照批量导出_${formatExportFilename()}.zip`); + hide(); + message.success({ content: `导出完成,共 ${fileCount} 个文件,已按证照类型分文件夹打包`, key: 'batchExport' }); + setBatchExportOpen(false); + } catch { + hide(); + message.error({ content: '打包导出失败,请检查网络后重试', key: 'batchExport' }); + } + }; + + const renderOcrConfirmFields = (certType, group) => { + if (isBatchUploadPhotoOnlyType(certType)) return null; + const f = group?.fields || {}; + const fieldDisabled = !group?.plateValid; + if (certType === 'driverLicense') { + return ( +
+ 注册日期} required> + updateOcrConfirmField('regDate', ds)} + style={{ width: '100%' }} + /> + + 发证日期} required> + updateOcrConfirmField('issueDate', ds)} + style={{ width: '100%' }} + /> + + 强制报废日期}> + updateOcrConfirmField('scrapDate', ds)} + style={{ width: '100%' }} + /> + + 检验有效期至} required> + updateOcrConfirmField('expireDate', ds)} + style={{ width: '100%' }} + /> + +
+ ); + } + if (certType === 'transportLicense') { + return ( +
+ 经营许可证号} required> + updateOcrConfirmField('licenseNo', e.target.value)} + placeholder="例如:交字31011..." + /> + + 核发时间} required> + updateOcrConfirmField('issueDate', ds)} + style={{ width: '100%' }} + /> + + 证件有效期} required> + updateOcrConfirmField('expireDate', ds)} + style={{ width: '100%' }} + /> + + 审验有效期} required> + updateOcrConfirmField('inspectValidUntil', ds)} + style={{ width: '100%' }} + /> + +
+ ); + } + return ( +
+ 下次检验日期} required> + updateOcrConfirmField('nextInspectDate', ds)} + style={{ width: '100%' }} + placeholder="选择检验截止日期" + /> + +
+ ); + }; + + const batchOcrTaskColumns = [ + { title: '操作人', dataIndex: 'operator', width: 88 }, + { title: '操作时间', dataIndex: 'operateTime', width: 168 }, + { title: '证照类型', dataIndex: 'certLabel', width: 140 }, + { title: '照片数量', dataIndex: 'photoCount', width: 88, align: 'center' }, + { + title: 'OCR结果', + key: 'ocrStats', + width: 128, + render: (_, record) => { + if (record.status !== 'done') return ; + return ( + + 成功 {record.ocrSuccessCount ?? 0} + 失败 {record.ocrFailCount ?? 0} + + ); + } + }, + { + title: '处理进度', + dataIndex: 'progress', + width: 160, + render: (val, record) => ( + + ) + }, + { + title: '操作', + key: 'action', + width: 88, + render: (_, record) => ( + + ) + } + ]; + + const filterVehiclesByFilters = (vehicles, f, kpi) => { + const plateKey = (f.plateNo || '').trim().toLowerCase(); + const multiPlates = parseMultiPlates(f.plateNos); + const vinKey = (f.vin || '').trim().toLowerCase(); + const brandKey = (f.brand || '').trim().toLowerCase(); + const modelKey = (f.model || '').trim().toLowerCase(); + + return vehicles.filter((v) => { + const plateUpper = v.plateNo.toUpperCase(); + if (multiPlates.length) { + if (!multiPlates.includes(plateUpper)) return false; + } else if (plateKey && !v.plateNo.toLowerCase().includes(plateKey)) return false; + if (vinKey && !v.vin.toLowerCase().includes(vinKey)) return false; + if (brandKey && !v.brand.toLowerCase().includes(brandKey)) return false; + if (modelKey && !v.model.toLowerCase().includes(modelKey)) return false; + if (f.operateStatus !== '全部' && v.status !== f.operateStatus) return false; + if (!matchKpiFilter(v.plateNo, kpi)) return false; + return true; + }); + }; + + const handleListFilterQuery = () => { + const plates = parseMultiPlates(multiPlateDraft); + const next = { + ...listFilters, + plateNos: multiPlateDraft.trim(), + plateNo: plates.length ? '' : listFilters.plateNo, + }; + setListFilters(next); + setAppliedFilters(next); + setMultiPlateOpen(false); + const hitCount = filterVehiclesByFilters(MOCK_VEHICLES, next, kpiFilter).length; + if (plates.length) { + message.success(`已按 ${plates.length} 个车牌筛选,命中 ${hitCount} 条记录`); + } else { + message.success(`已按筛选条件更新列表,共 ${hitCount} 条记录`); + } + }; + + const handleListFilterReset = () => { + const next = { ...DEFAULT_LIST_FILTERS }; + setListFilters(next); + setAppliedFilters(next); + setMultiPlateDraft(''); + setMultiPlateOpen(false); + message.info('已重置筛选条件'); + }; + + const appliedMultiPlates = useMemo( + () => parseMultiPlates(appliedFilters.plateNos), + [appliedFilters.plateNos] + ); + + const multiPlateTriggerText = useMemo(() => { + if (!appliedMultiPlates.length) return ''; + if (appliedMultiPlates.length <= 2) return appliedMultiPlates.join('、'); + return `已选 ${appliedMultiPlates.length} 个车牌`; + }, [appliedMultiPlates]); + + const handleMultiPlateOpenChange = (open) => { + setMultiPlateOpen(open); + if (open) setMultiPlateDraft(listFilters.plateNos || ''); + }; + + const handleMultiPlateDraftClear = () => { + setMultiPlateDraft(''); + setListFilters((prev) => ({ ...prev, plateNos: '' })); + }; + + const renderFilterField = (label, control) => ( +
+ {label} +
{control}
+
+ ); + + // ==================== 车辆列表筛选逻辑 ==================== + const filteredVehicles = useMemo( + () => sortVehiclesRetiredLast(filterVehiclesByFilters(MOCK_VEHICLES, appliedFilters, kpiFilter)), + [appliedFilters, allLicenses, kpiFilter] + ); + + // ==================== 列表台账表格列配置 ==================== + const listColumns = [ + { + title: '车牌号', + dataIndex: 'plateNo', + key: 'plateNo', + width: 96, + onHeaderCell: listColumnHeaderCell, + render: (plate, record) => ( + + {plate} + + ) + }, + { + title: tableTitleMultiline('车辆识别代码', '(VIN码)'), + dataIndex: 'vin', + key: 'vin', + width: 112, + onHeaderCell: listColumnHeaderCell, + render: (vin, record) => ( + + + {vin} + + + ) + }, + { + title: tableTitleMultiline('运营', '状态'), + dataIndex: 'status', + key: 'status', + width: 72, + onHeaderCell: listColumnHeaderCell, + render: (status) => ( + + {status} + + ) + }, + { + title: '品牌', + dataIndex: 'brand', + key: 'brand', + width: 72, + onHeaderCell: listColumnHeaderCell, + render: (brand, record) => ( + + {brand} + + ) + }, + { + title: '型号', + dataIndex: 'model', + key: 'model', + width: 108, + onHeaderCell: listColumnHeaderCell, + render: (model, record) => ( + + + {model} + + + ) + }, + { + title: tableTitleMultiline('行驶证', '到期时间'), + key: 'driverLicense', + width: 92, + onHeaderCell: listColumnHeaderCell, + render: (record) => { + const status = getLicenseStatus(record.plateNo, 'driverLicense'); + const dateVal = allLicenses[record.plateNo]?.driverLicense?.expireDate; + const muted = isRetiredVehicle(record); + + return ( +
+
+ {dateVal || '—'} +
+
+ {renderListLicenseStatusBadge(status)} +
+
+ ); + } + }, + { + title: tableTitleMultiline('道路运输证', '证件有效期'), + key: 'transportLicenseExpire', + width: 92, + onHeaderCell: listColumnHeaderCell, + render: (record) => { + const status = getLicenseStatus(record.plateNo, 'transportLicense', { dateField: 'expireDate' }); + const dateVal = allLicenses[record.plateNo]?.transportLicense?.expireDate; + const muted = isRetiredVehicle(record); + + return ( +
+
+ {dateVal || '—'} +
+
+ {renderListLicenseStatusBadge(status)} +
+
+ ); + } + }, + { + title: tableTitleMultiline('道路运输证', '审验有效期'), + key: 'transportLicenseInspect', + width: 92, + onHeaderCell: listColumnHeaderCell, + render: (record) => { + const status = getLicenseStatus(record.plateNo, 'transportLicense', { dateField: 'inspectValidUntil' }); + const dateVal = allLicenses[record.plateNo]?.transportLicense?.inspectValidUntil; + const muted = isRetiredVehicle(record); + + return ( +
+
+ {dateVal || '—'} +
+
+ {renderListLicenseStatusBadge(status)} +
+
+ ); + } + }, + { + title: tableTitleMultiline('特种设备标识', '到期时间'), + key: 'specialEquipDecal', + width: 92, + onHeaderCell: listColumnHeaderCell, + render: (record) => { + const status = getLicenseStatus(record.plateNo, 'specialEquipDecal'); + const dateVal = allLicenses[record.plateNo]?.specialEquipDecal?.nextInspectDate; + const muted = isRetiredVehicle(record); + + return ( +
+
+ {dateVal || '—'} +
+
+ {renderListLicenseStatusBadge(status)} +
+
+ ); + } + }, + { + title: tableTitleMultiline('证件', '状态'), + key: 'allCertStatus', + width: 272, + onHeaderCell: listColumnHeaderCell, + render: (record) => { + const muted = isRetiredVehicle(record); + const labelColor = muted ? '#94a3b8' : '#64748b'; + + return ( +
+ {LIST_CERT_STATUS_ITEMS.map((item) => { + const st = getListCertStatus(record.plateNo, item); + return ( + + + {item.label}} + /> + + + ); + })} +
+ ); + } + }, + { + title: '操作', + key: 'action', + width: 64, + onHeaderCell: listColumnHeaderCell, + render: (record) => ( + + ) + } + ]; + + return ( +
+ + + {/* ======================================================== */} + {/* ======================= 1. 列表台账视图 =================== */} + {/* ======================================================== */} +
+ + {/* 顶栏 */} +
+ +
+ + {/* 筛选条件 */} + +
+ {renderFilterField('车牌号', ( + 0} + value={listFilters.plateNo} + onChange={e => setListFilters(prev => ({ ...prev, plateNo: e.target.value }))} + onPressEnter={handleListFilterQuery} + style={{ borderRadius: 8 }} + /> + ))} + {renderFilterField('多车牌', ( + +
+ 每行一个车牌号,可从 Excel 等批量复制粘贴;点击「查询」后列表展示全部命中车辆。 +
+ setMultiPlateDraft(e.target.value)} + placeholder={'沪A03561F\n粤B58888F\n苏E33333'} + autoSize={{ minRows: 5, maxRows: 10 }} + style={{ borderRadius: 8, fontFamily: 'monospace', fontSize: 13 }} + /> +
+ + +
+
+ } + > + setMultiPlateOpen(true)} + onClear={(e) => { + e.stopPropagation(); + handleMultiPlateDraftClear(); + setAppliedFilters((prev) => ({ ...prev, plateNos: '' })); + }} + style={{ borderRadius: 8 }} + suffix={ + + + + } + /> + + ))} + {renderFilterField('VIN码', ( + setListFilters(prev => ({ ...prev, vin: e.target.value }))} + onPressEnter={handleListFilterQuery} + style={{ borderRadius: 8 }} + /> + ))} + {renderFilterField('品牌', ( + setListFilters(prev => ({ ...prev, model: val || '' }))} + options={modelOptions} + filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())} + style={{ width: '100%' }} + dropdownStyle={{ borderRadius: 8 }} + /> + ))} + {renderFilterField('运营状态', ( + + ))} +
+
+ + +
+ + + {/* 资质预警看板(筛选与列表之间) */} +
+ {[ + { + key: 'total', + type: 'total', + title: '监管车辆总数', + desc: '纳入证照台账管理的车辆', + val: stats.total, + icon: ICONS.vehicle + }, + { + key: 'normal', + type: 'normal', + title: '资质全部正常', + desc: '行驶证/道路运输证/特种设备登记证/特种设备标志均已上传,并在有效期内', + val: stats.normal, + icon: ICONS.success + }, + { + key: 'warning', + type: 'warning', + title: '证件临期预警', + desc: '行驶证≤90天 / 道路运输证≤60天 / 特种设备标识≤60天 / 安全阀≤60天 / 压力表≤60天', + val: stats.warning, + icon: ICONS.warning + }, + { + key: 'expired', + type: 'expired', + title: '已逾期', + desc: '行驶证检验有效期到期 / 道路运输证有效期/检验时间到期 / 安全阀下次检验到期 / 压力表下次检验到期', + val: stats.expired, + icon: ICONS.warning + }, + { + key: 'unuploaded', + type: 'unuploaded', + title: '证照待补录', + desc: '核心证照:行驶证、道路运输证、特种设备登记证、特种设备标识。任一类未上传影像即计为待补录。', + val: stats.unuploaded, + icon: ICONS.shield + } + ].map(card => ( +
handleKpiCardClick(card.key)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleKpiCardClick(card.key); + } + }} + > +
+ + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + + + + + +
+
{card.icon}
+
+
{card.val}
+
{card.title}
+
+
+ ))} +
+ + {/* 表格台账区域:图示左上,批量操作右上 */} +
+
+
+ 证件状态图示 + {CERT_STATUS_LEGEND} +
+
+ + +
+
+
+ (record.status === '退出运营' ? 'lc-row-retired' : '')} + pagination={false} + locale={{ emptyText:
暂无符合检索条件的证照车辆
}} + /> + + + + + {/* ======================================================== */} + {/* ======================= 2. 批量导出 / 批量上传 ============== */} + {/* ======================================================== */} + + setBatchExportOpen(false)} + onOk={handleBatchExport} + > + + {BATCH_EXPORT_RULE_LINES.map((line) => ( +
  • {line}
  • + ))} + + } + /> + + {CERT_EXPORT_OPTIONS.map((opt) => ( + {opt.label} + ))} + +
    + 当前筛选命中 {filteredVehicles.length} 辆车 +
    +
    + + setBatchOcrOpen(false)} + destroyOnClose={false} + > + +
    新建上传任务
    +
    + 证照类型 +
    + + + + + {currentOcrConfirmGroup && !currentOcrConfirmGroup.plateValid ? ( + + ) : null} + + + } + onCancel={closeOcrConfirm} + > + {currentOcrConfirmGroup && ( +
    +
    +
    +
    逐张确认进度
    +
    + 当前 {ocrConfirmGroupIdx + 1} / {ocrConfirmTotalSheets} 张 +
    +
    + + {currentOcrConfirmGroup.plateValid + ? (isBatchUploadPhotoOnlyType(ocrConfirmTask?.certType) ? '可同步照片' : '可提交更新') + : '车牌校验失败'} + +
    +
    +
    +
    + 本车牌上传照片(共 {currentOcrConfirmGroup.items.length} 张) +
    +
    + {currentOcrConfirmGroup.items.map((it, idx) => ( +
    setOcrConfirmPhotoIdx(idx)} + title={it.photoName || `照片${idx + 1}`} + > + {it.photoName +
    + ))} +
    +
    { + const url = currentOcrConfirmGroup.items[ocrConfirmPhotoIdx]?.photoUrl; + if (url) { setPreviewUrl(url); setPreviewOpen(true); } + }} + > + 证照预览 +
    +
    + 预览第 {ocrConfirmPhotoIdx + 1} 张 · 点击大图可放大 +
    +
    +
    + {isBatchUploadPhotoOnlyType(ocrConfirmTask?.certType) ? ( + <> + {renderOcrPlateValidation(currentOcrConfirmGroup)} + + + ) : ( + <> +
    + 识别字段(可编辑,提交后写入该车辆证照) +
    + {renderOcrPlateValidation(currentOcrConfirmGroup)} +
    + {renderOcrConfirmFields(ocrConfirmTask?.certType, currentOcrConfirmGroup)} + + + )} +
    +
    +
    + )} +
    + + {/* ======================================================== */} + {/* ======================= 4. 公共大图/PRD 弹窗 ============== */} + {/* ======================================================== */} + + {/* 照片预览大图弹窗 */} + setPreviewOpen(false)} + width={720} + centered + > + 大图 + + + {/* 需求说明弹窗(产品经理视角 PRD) */} + + 📋 + 车辆证照管理 · 产品需求说明(PRD) + + } + footer={[ + + ]} + onCancel={() => setPrdOpen(false)} + width={980} + centered + style={{ top: 20 }} + bodyStyle={{ maxHeight: '72vh', overflowY: 'auto', padding: '12px 24px 24px' }} + > +
    +
    + 本页:证照台账列表(监管台) + 模块路径:运维管理 > 车辆业务 > 证照管理 + 文档版本:V1.1 + 读者:产品 / 运营 / 合规 / 研发测试 +
    + + 本文档说明「证照管理」列表页能力} + description="单车证照录入、OCR、分卡保存等在独立页面「证照管理-编辑」;两页通过 session 车牌 + 本地证照库同步。维护页需求见其页面内「查看需求说明」。" + /> + + + +
    +
    +
    产品定位(本页)
    +

    + 监管台:对全量车辆证照进行检索、预警、批量导出/上传、进入单车维护,不承担逐车表单编辑。 +

    +

    + 价值:让合规专员「先看见风险车辆 → 再处理单车」,批量作业与台账维护分工清晰。 +

    +
    + +
    端到端主流程
    +
      +
    1. 进入列表 → 默认展示全量监管车辆(退出运营置底、行置灰)
    2. +
    3. 筛选 / 点击看板 → 缩小待处理范围(看板计数不随筛选变,见规则②)
    4. +
    5. 行内读数 → 到期列 +「证件状态」八类状态点(双行四列)
    6. +
    7. 单车维护 →「管理」进入「证照管理-编辑」,分卡保存后回写列表与看板
    8. +
    9. 批量作业 → 按当前筛选结果导出 ZIP,或批量上传 OCR 任务确认入库
    10. +
    + +
    【重点】五条必读业务规则
    +
    +
    + ① 运营状态归并 + — 主数据「可运营」「待运营」在本模块统一为「库存」;筛选「库存」须命中上述车辆;列表与维护页一致。 +
    +
    + ② 看板 vs 筛选 + — 看板按全量台账统计,不随筛选变化;点击看板仅过滤列表,可与筛选叠加。 +
    +
    + ③ 临期 / 逾期口径 + — 行驶证 ≤90 天临期;运输证证件有效期、审验有效期分列展示、分别算状态;特设标 / 安全阀 / 压力表 ≤60 天临期;≤0 天为已到期。 +
    +
    + ④ 批量上传 + — OCR 车牌须在台账;按车牌聚合确认;登记证 / 特种设备使用登记证仅同步照片 +
    +
    + ⑤ 数据同步 + — 「管理」写 session 车牌;维护页保存、批量确认写回证照库,列表 KPI 与行状态联动更新。 +
    +
    + +
    看板五项指标(点击可筛列表,口径以 ⓘ 为准)
    +
    + + + + + + + + + + + + + +
    指标统计逻辑
    监管车辆总数全部台账车辆;点击恢复全量
    资质全部正常行驶证 + 运输证(证件有效期、审验有效期)均在有效期内
    证件临期预警行驶证 90 天内;运输证/特设标/安全阀/压力表 60 天内(运输证两日期任一即计入)
    已逾期行驶证到期;运输证证件或审验到期;安全阀/压力表下次检验到期
    证照待补录四类核心证照任一未上传:行驶证、道路运输证、特种设备使用登记证、特种设备使用标识
    +
    + + + +
    +
    1. 页面结构(自上而下)
    +
      +
    1. 右上角「查看需求说明」(本文档)
    2. +
    3. 筛选条件:三列网格;「查询」生效、「重置」清空
    4. +
    5. 资质预警看板:五项 KPI,ⓘ 看口径,点击筛列表
    6. +
    7. 证照台账表格:左上图例、右上批量导出/上传
    8. +
    + +
    2. 筛选逻辑
    + + + + + + + + + + + + + + +
    筛选项规则
    车牌号模糊匹配;启用多车牌时禁用
    多车牌点击输入框展开文本域,每行一个车牌(支持 Excel 粘贴);点「查询」后精确匹配并展示全部命中,提示命中条数
    VIN码模糊匹配
    品牌 / 车型可搜索下拉,可清空
    运营状态全部 / 租赁 / 自营 / 库存 / 退出运营;选库存含主数据可运营、待运营
    +

    筛选与看板点击可叠加;修改筛选项后须点「查询」才生效。

    + +
    3. 列表字段与交互
    + + + + + + + + + + + + + + +
    列 / 能力说明
    基础信息车牌、VIN、运营状态、品牌、型号;表头过长字段支持两行展示
    三类到期列行驶证到期;运输证证件有效期审验有效期分列;特种设备标识到期;日期下 Badge 单行展示(临期N天/逾期N天),悬停看全文
    证件状态八类证照双行四列:行驶/运输/登记/特种 · 特设标/加氢/安全阀/压力表;绿正常、橙临期、红到期、灰未上传(加氢:已绑定/未绑定);运输证状态取两日期最严重
    管理跳转「证照管理-编辑」;无删除
    退出运营行置灰,同筛选结果内固定排底部,仍可点管理
    + +
    4. 批量导出证照
    +

    入口:列表右上方。范围:当前筛选命中车辆(含看板筛选)。类型:7 类影像(不含加氢卡)。

    +
      + {BATCH_EXPORT_RULE_LINES.map((line) => ( +
    • {line}
    • + ))} +
    +
    + 证照批量导出_时间戳.zip → 按类型分文件夹 → 沪A03561F.jpg / 粤B58888F-1.jpg … +
    + +
    5. 批量上传证照
    +

    入口:列表右上方。一次任务仅选一种证照类型。

    + + + + + + + + + + + + + + +
    阶段逻辑要点
    建任务多图上传 → 生成任务 → 列表展示 OCR 成功/失败条数、处理进度
    逐张确认车牌聚合;页头「当前 M/总 N 张」为车牌数;同页展示该车牌全部照片
    仅照片类登记证、特种设备使用登记证:车牌通过 →「提交并同步照片」
    字段类行驶证、道路运输证、特种设备使用标识:可编辑识别字段 →「提交并更新」
    异常车牌不在台账 → 失败,可「跳过本张」;全部确认后任务显示「已确认」
    +
    +
    + + +
    + +
      +
    • 进入:列表「管理」→ session 写入车牌 → 打开编辑页(支持 Axhub 导航)
    • +
    • 布局:左车辆信息 + 八类证照索引(状态点);右八类卡片锚点滚动
    • +
    • 保存:分卡片「保存该项」,非整页提交;未保存切换车辆二次确认
    • +
    • 安全:行驶证/运输证 OCR 车牌须与当前车一致,否则阻断并清空无效图
    • +
    • 回写:保存后更新证照库,返回列表后看板与行状态刷新
    • +
    +

    八类证照字段、照片生命周期、沪牌等级评定等详见「证照管理-编辑」页内 PRD。

    +
    +
    + + +
    +
    +
    运营状态归并(接口必读)
    + + + + + + + + + + + +
    主数据本模块展示
    租赁 / 自营 / 退出运营同左
    可运营、待运营库存
    +
    +
    + 到期感知阈值 +
      +
    • 行驶证:≤90 天临期,≤0 天到期
    • +
    • 道路运输证:证件有效期、审验有效期各算,≤60 天临期
    • +
    • 特种设备标识 / 安全阀 / 压力表:下次检验 ≤60 天临期
    • +
    • 登记类、特种设备使用登记证:有图无日期视为正常(列表状态点)
    • +
    +
    +
    + 批量与筛选 +

    批量导出/上传均基于当前列表筛选结果(含 KPI 点击后的列表范围),与看板全量统计独立。

    +
    +
    +
    + + +
    +
    列表页验收清单
    +
      +
    • 可运营/待运营展示为库存;筛库存可命中
    • +
    • 多车牌:每行一车、查询后精确匹配并提示条数
    • +
    • 看板全量统计;点击看板仅筛列表;ⓘ 展示口径
    • +
    • 八类证件状态双行展示;到期 Badge 不换行
    • +
    • 批量导出 7 类 ZIP 命名规则;批量上传 5 类及确认流
    • +
    • 管理跳转编辑页;保存后列表与 KPI 一致
    • +
    • 退出运营置底置灰;无删除
    • +
    +
    本期不做
    +
      +
    • 列表页内嵌单车维护(已拆至编辑页)
    • +
    • 台账车辆删除、批量导入台账
    • +
    • 年审状态作列表筛选项
    • +
    • 加氢卡页面内编辑(能源模块回写)
    • +
    +
    +
    + +
    + +
    + ); +}; + +export default Component;