// 【重要】必须使用 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,
Table,
Tag,
message,
Checkbox,
Upload
} = antd;
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: '压力表' }
];
const BATCH_UPLOAD_CERT_OPTIONS = [
{ key: 'driverLicense', label: '行驶证' },
{ key: 'transportLicense', label: '道路运输证' },
{ key: 'registrationCert', label: '登记证', photoOnly: true },
{ key: 'specialEquipCert', label: '特种设备使用登记证', photoOnly: true },
{ key: 'specialEquipDecal', label: '特种设备使用标识' }
];
const isBatchUploadPhotoOnlyType = (certType) => (
BATCH_UPLOAD_CERT_OPTIONS.find((o) => o.key === certType)?.photoOnly === true
);
const BATCH_UPLOAD_OPERATOR = '张明辉';
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 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) => (
);
// ==================== 车辆列表筛选逻辑 ====================
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('品牌', (
{/* 资质预警看板(筛选与列表之间) */}
{[
{
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}
))}
{/* 表格台账区域:图示左上,批量操作右上 */}
证件状态图示
{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}
>
新建上传任务
证照类型
({ label: o.label, value: o.key }))}
/>
登记证、特种设备使用登记证:识别车牌通过后仅需确认同步照片;行驶证、道路运输证、特种设备使用标识需核对识别字段。
false}
onChange={({ fileList }) => setBatchOcrFileList(fileList)}
>
{batchOcrFileList.length < 20 && (
)}
上传任务列表
{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}`}
>
))}
{
const url = currentOcrConfirmGroup.items[ocrConfirmPhotoIdx]?.photoUrl;
if (url) { setPreviewUrl(url); setPreviewOpen(true); }
}}
>
预览第 {ocrConfirmPhotoIdx + 1} 张 · 点击大图可放大
{isBatchUploadPhotoOnlyType(ocrConfirmTask?.certType) ? (
<>
{renderOcrPlateValidation(currentOcrConfirmGroup)}
>
) : (
<>
识别字段(可编辑,提交后写入该车辆证照)
{renderOcrPlateValidation(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 车牌 + 本地证照库同步。维护页需求见其页面内「查看需求说明」。"
/>
产品定位(本页)
监管台:对全量车辆证照进行检索、预警、批量导出/上传、进入单车维护,不承担逐车表单编辑。
价值:让合规专员「先看见风险车辆 → 再处理单车」,批量作业与台账维护分工清晰。
端到端主流程
- 进入列表 → 默认展示全量监管车辆(退出运营置底、行置灰)
- 筛选 / 点击看板 → 缩小待处理范围(看板计数不随筛选变,见规则②)
- 行内读数 → 到期列 +「证件状态」八类状态点(双行四列)
- 单车维护 →「管理」进入「证照管理-编辑」,分卡保存后回写列表与看板
- 批量作业 → 按当前筛选结果导出 ZIP,或批量上传 OCR 任务确认入库
【重点】五条必读业务规则
① 运营状态归并
— 主数据「可运营」「待运营」在本模块统一为「库存」;筛选「库存」须命中上述车辆;列表与维护页一致。
② 看板 vs 筛选
— 看板按全量台账统计,不随筛选变化;点击看板仅过滤列表,可与筛选叠加。
③ 临期 / 逾期口径
— 行驶证 ≤90 天临期;运输证证件有效期、审验有效期分列展示、分别算状态;特设标 / 安全阀 / 压力表 ≤60 天临期;≤0 天为已到期。
④ 批量上传
— OCR 车牌须在台账;按车牌聚合确认;登记证 / 特种设备使用登记证仅同步照片。
⑤ 数据同步
— 「管理」写 session 车牌;维护页保存、批量确认写回证照库,列表 KPI 与行状态联动更新。
看板五项指标(点击可筛列表,口径以 ⓘ 为准)
| 指标 |
统计逻辑 |
| 监管车辆总数 | 全部台账车辆;点击恢复全量 |
| 资质全部正常 | 行驶证 + 运输证(证件有效期、审验有效期)均在有效期内 |
| 证件临期预警 | 行驶证 90 天内;运输证/特设标/安全阀/压力表 60 天内(运输证两日期任一即计入) |
| 已逾期 | 行驶证到期;运输证证件或审验到期;安全阀/压力表下次检验到期 |
| 证照待补录 | 四类核心证照任一未上传:行驶证、道路运输证、特种设备使用登记证、特种设备使用标识 |
1. 页面结构(自上而下)
- 右上角「查看需求说明」(本文档)
- 筛选条件:三列网格;「查询」生效、「重置」清空
- 资质预警看板:五项 KPI,ⓘ 看口径,点击筛列表
- 证照台账表格:左上图例、右上批量导出/上传
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;