Files
ONE-OS/web端/业务管理/保险采购.jsx
王冕 d432d51eed feat(web): 同步 web 端目录更新至 Gitea
包含加氢站站点信息、运维交车/故障、台账与数据分析等页面新增与改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 19:57:30 +08:00

5328 lines
230 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 【重要】必须使用 const Component 作为组件变量名
// 业务管理 - 保险采购
// 模块:① 比价单(选车报价 → 保存 → 按最晚付费日临期/超期提醒 → 勾选提交采购工作流)
// ② 保单管理一车一档台账OCR/导入/逐条录入,与比价单不关联)
// 与车辆管理「保险状态」联动:交强险 + 商业险均存在且在有效期内为正常,否则异常(禁止交车)
const { useState, useMemo, useCallback } = React;
const moment = window.moment || window.dayjs;
const antd = window.antd;
const {
Input,
Select,
Button,
Card,
Table,
Badge,
Tooltip,
Modal,
Tag,
message,
Popover,
Alert,
Checkbox,
DatePicker,
InputNumber,
Space,
Radio,
Steps,
Upload,
Progress,
Tabs,
Timeline,
} = antd;
const ANCHOR_TODAY = '2026-06-01';
const IPC_STORAGE_KEY = 'oneos_ipc_insurance_v1';
const IPC_COMPARE_SHEETS_KEY = 'oneos_ipc_compare_sheets_v1';
const IPC_POLICY_RECOGN_TASKS_KEY = 'oneos_ipc_policy_recogn_tasks_v1';
const IPC_INSURANCE_HISTORY_EDITS_KEY = 'oneos_ipc_insurance_history_edits_v1';
const IPC_EDIT_PLATE_KEY = 'oneos_ipc_edit_plate';
const NO_PLATE_LABEL = '暂无车牌';
const PROTO_COMPARE_CREATOR = '张明辉';
const INSURANCE_TYPE_ITEMS = [
{ key: 'compulsory', label: '交强', fullLabel: '交强险' },
{ key: 'commercial', label: '商业', fullLabel: '商业险' },
{ key: 'excess', label: '超赔', fullLabel: '超赔险' },
{ key: 'cargo', label: '货物', fullLabel: '货物险' },
{ key: 'driverAccident', label: '驾意', fullLabel: '驾意险' },
];
const CORE_INSURANCE_KEYS = ['compulsory', 'commercial'];
const INSURANCE_WARN_DAYS = 30;
/** 比价单:最晚付费日期 ≤ 该天数视为临期 */
const LATEST_PAY_WARN_DAYS = 3;
const INSURANCE_LABEL_TO_KEY = {
交强险: 'compulsory',
商业险: 'commercial',
超赔险: 'excess',
货物险: 'cargo',
驾意险: 'driverAccident',
};
const POLICY_OCR_MODES = [
{ key: 'policy', label: '保单录入', desc: '选择险种后上传附件,自动识别保单要素并匹配台账' },
{ key: 'suspend', label: '停保', desc: '上传停保/停驶批单,识别保单号与车牌后停保' },
{ key: 'resume', label: '复驶', desc: '上传复驶批单,识别后更新生效与到期日' },
{ key: 'cancel', label: '退保', desc: '上传退保批单,识别保单号与退费金额' },
];
const POLICY_BIZ_TYPE_OPTIONS = [
{ value: 'policy', label: '保单录入' },
{ value: 'suspend', label: '停租' },
{ value: 'resume', label: '复驶' },
{ value: 'cancel', label: '退保' },
];
const EMPTY_POLICY_DETAIL = {
plateNo: '',
vin: '',
insuranceType: '交强险',
bizType: 'policy',
company: '',
policyNo: '',
endorsementNo: '',
payTime: '',
startDate: '',
endDate: '',
reinstateDate: '',
premium: '',
coverageItems: [],
applicant: '',
insured: '',
signDate: '',
};
/** 保单项目/责任限额:表单为字符串数组,导入/OCR/台账可为分号拼接文本 */
const parseCoverageItemsInput = (raw) => {
if (Array.isArray(raw)) {
return raw.map((s) => String(s ?? '').trim()).filter(Boolean);
}
const text = String(raw ?? '').trim();
if (!text) return [];
const segments = text.split(/[;\n]+/).map((s) => s.trim()).filter(Boolean);
const expanded = [];
segments.forEach((part) => {
const sub = part.split(/、/).map((s) => s.trim()).filter(Boolean);
if (sub.length > 1 && part.length <= 120) {
expanded.push(...sub);
} else {
expanded.push(part);
}
});
return expanded;
};
const serializeCoverageItems = (items) => parseCoverageItemsInput(items).join('');
const getCoverageItemsFormRows = (items) => {
const list = Array.isArray(items) ? items : parseCoverageItemsInput(items);
return list.length ? list : [''];
};
/** 基于用户提供的真实保单/批单样本PDF 解析 + 文件名) */
const REFERENCE_POLICY_OCR_MOCKS = [
{
test: (n) => /沪BDB9161.*交强险/i.test(n),
detail: {
plateNo: '沪BDB9161', vin: 'LC0DF4CD8S0303140', insuranceType: '交强险', bizType: 'policy',
policyNo: 'ASHZ001CTP26B187065J', endorsementNo: 'DZQA26480000279515',
company: '中国太平洋财产保险股份有限公司',
payTime: '2026-06-01 17:42:10', signDate: '2026-05-27', startDate: '2026-06-05', endDate: '2027-06-04',
premium: '1243.00',
coverageItems: '死亡伤残赔偿限额180000元医疗费用赔偿限额18000元财产损失赔偿限额2000元',
applicant: '上海羚牛氢运物联网科技有限公司', insured: '上海羚牛氢运物联网科技有限公司',
},
},
{
test: (n) => /粤AGP9827.*商业险/i.test(n),
detail: {
plateNo: '粤AGP9827', insuranceType: '商业险', bizType: 'policy',
policyNo: '2050AA330400260000GV', company: '紫金财产保险股份有限公司',
payTime: '2026-05-28 10:00:00', startDate: '2026-06-06', endDate: '2027-05-27', premium: '12800.00',
coverageItems: '机动车损失险、第三者责任险、车上人员责任险',
},
},
{
test: (n) => /粤AGP3071.*驾意/i.test(n),
detail: {
plateNo: '粤AGP3071', insuranceType: '驾意险', bizType: 'policy',
policyNo: 'JY2026AGP3071001', company: '中国平安财产保险股份有限公司',
startDate: '2026-06-06', endDate: '2027-05-27', premium: '380.00',
coverageItems: '驾乘意外险,每座身故伤残/医疗限额',
},
},
{
test: (n) => /粤AGR9766.*超赔/i.test(n),
detail: {
plateNo: '粤AGR9766', vin: 'LB9A32A21R0LS1478', insuranceType: '超赔险', bizType: 'policy',
policyNo: '6260828000909X006408', company: '国任财产保险股份有限公司广州市番禺支公司',
payTime: '2026-04-16 14:37:37', signDate: '2026-04-16', startDate: '2026-04-17', endDate: '2027-04-16',
premium: '1500.00',
coverageItems: '公路货物运输定额保险累计赔偿限额10001000元主险货物保险金额1000元',
applicant: '羚牛氢能科技(广东)有限公司', insured: '羚牛氢能科技(广东)有限公司',
},
},
{
test: (n) => /货物险|20208A330400240001QX/i.test(n),
detail: {
plateNo: '浙F05178F', insuranceType: '货物险', bizType: 'policy',
policyNo: '20208A330400240001QX', company: '紫金财产保险股份有限公司',
payTime: '2024-10-17 15:58:05', startDate: '2024-10-18', endDate: '2025-10-17', premium: '1500.00',
coverageItems: '公路货物运输定额保险 CNY500000集装箱货物及其箱体',
applicant: '嘉兴羚牛汽车服务有限公司', insured: '嘉兴羚牛汽车服务有限公司',
},
},
{
test: (n) => /粤A03423F.*停驶/i.test(n),
detail: {
plateNo: '粤A03423F', insuranceType: '商业险', bizType: 'suspend',
policyNo: '2050AA3304002600002EM', endorsementNo: '3050AA3304002600002EM01',
company: '紫金财产保险股份有限公司', payTime: '2026-04-16 16:13:53',
startDate: '2026-04-17', endDate: '2026-04-17', reinstateDate: '2027-03-31',
coverageItems: '停驶批单:保险车辆停驶,停驶期间保险责任中止',
},
},
{
test: (n) => /粤A06290F.*复驶/i.test(n),
detail: {
plateNo: '粤A06290F', insuranceType: '商业险', bizType: 'resume',
policyNo: '2050AA33040026000226', endorsementNo: '3050AA3304002600022602',
company: '紫金财产保险股份有限公司', payTime: '2026-04-30 14:25:43',
startDate: '2026-05-06', endDate: '2027-03-27', reinstateDate: '2027-03-16',
coverageItems: '复驶批单:停驶车辆恢复行驶,保险责任自复驶日起恢复',
},
},
{
test: (n) => /浙F03220F.*复驶|BSHZ001S2024B005477B/i.test(n),
detail: {
plateNo: '浙F03220F', insuranceType: '商业险', bizType: 'resume',
policyNo: 'BSHZ001S2024B005477B', endorsementNo: 'BSHZ001S2024B005477E',
company: '中国太平洋财产保险股份有限公司', startDate: '2026-05-01', endDate: '2027-04-30',
coverageItems: '复驶批单',
},
},
{
test: (n) => /沪A06192F.*停保|BSHZ001S2024B005054V/i.test(n),
detail: {
plateNo: '沪A06192F', insuranceType: '商业险', bizType: 'suspend',
policyNo: 'BSHZ001S2024B005054V', endorsementNo: 'BSHZ001S2024B005054E',
company: '中国太平洋财产保险股份有限公司', startDate: '2025-12-05', endDate: '2026-03-05',
reinstateDate: '2026-06-01',
coverageItems: '停保批单',
},
},
{
test: (n) => /粤A03331F.*退保/i.test(n),
detail: {
plateNo: '粤A03331F', insuranceType: '商业险', bizType: 'cancel',
policyNo: '2050AA330400260000GV', endorsementNo: '3050AA330400260000GV02',
company: '紫金财产保险股份有限公司', payTime: '2026-05-27 17:51:20',
startDate: '2026-05-28', endDate: '2026-05-28', premium: '7853.27',
coverageItems: '商业险退保批单,退还保费',
},
},
{
test: (n) => /粤AGR0772.*停保/i.test(n),
detail: {
plateNo: '粤AGR0772', insuranceType: '商业险', bizType: 'suspend',
policyNo: 'PAIC-SY-AGR0772-2025', company: '中国平安财产保险股份有限公司',
startDate: '2025-12-05', endDate: '2026-03-05', reinstateDate: '2026-06-01',
coverageItems: '商业险停保',
},
},
];
const normalizePolicyDetail = (raw = {}) => ({
...EMPTY_POLICY_DETAIL,
...raw,
plateNo: (raw.plateNo || '').trim(),
vin: (raw.vin || '').trim(),
insuranceType: raw.insuranceType || '交强险',
bizType: raw.bizType || 'policy',
coverageItems: parseCoverageItemsInput(raw.coverageItems),
});
const inferPolicyDetailFromFileName = (fileName) => {
const name = fileName || '';
const plateMatch = name.match(/[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-Z][A-Z0-9]{4,6}[A-Z0-9挂学警港澳]?/i);
const plateNo = plateMatch ? plateMatch[0].toUpperCase() : '';
let insuranceType = '交强险';
if (/商业险|商业/.test(name)) insuranceType = '商业险';
else if (/超赔/.test(name)) insuranceType = '超赔险';
else if (/驾意/.test(name)) insuranceType = '驾意险';
else if (/货物/.test(name)) insuranceType = '货物险';
else if (/交强/.test(name)) insuranceType = '交强险';
let bizType = 'policy';
if (/停驶|停保/.test(name)) bizType = 'suspend';
else if (/复驶/.test(name)) bizType = 'resume';
else if (/退保/.test(name)) bizType = 'cancel';
const policyNoMatch = name.match(/BSHZ\d+[A-Z0-9]+|ASHZ\d+[A-Z0-9]+|202\d{2}A\d+QX|2050AA\d+[A-Z0-9]+|6260\d+X\d+/i);
const rangeMatch = name.match(/(\d{4})[.\-](\d{1,2})[.\-](\d{1,2})/);
let startDate = '';
let endDate = '';
if (rangeMatch) {
const y = rangeMatch[1];
const m = String(rangeMatch[2]).padStart(2, '0');
const d = String(rangeMatch[3]).padStart(2, '0');
startDate = `${y}-${m}-${d}`;
const parts = name.match(/(\d{4})[.\-](\d{1,2})[.\-](\d{1,2}).*?(\d{4})[.\-](\d{1,2})[.\-](\d{1,2})/);
if (parts) {
endDate = `${parts[4]}-${String(parts[5]).padStart(2, '0')}-${String(parts[6]).padStart(2, '0')}`;
}
}
return normalizePolicyDetail({
plateNo,
insuranceType,
bizType,
policyNo: policyNoMatch ? policyNoMatch[0] : '',
startDate,
endDate,
});
};
const resolvePolicyDetailFromFileName = (fileName) => {
const ref = REFERENCE_POLICY_OCR_MOCKS.find((m) => m.test(fileName));
if (ref) return normalizePolicyDetail(ref.detail);
return inferPolicyDetailFromFileName(fileName);
};
const bizTypeToRecognMode = (bizType) => (
bizType === 'suspend' ? 'suspend' : bizType === 'resume' ? 'resume' : bizType === 'cancel' ? 'cancel' : 'policy'
);
const applyPolicyDetailToInsuranceItem = (item, detail, mode) => {
const d = normalizePolicyDetail(detail);
const next = { ...item };
next.company = d.company || next.company;
next.policyNo = d.policyNo || next.policyNo;
next.endorsementNo = d.endorsementNo || next.endorsementNo || '';
next.startDate = d.startDate || next.startDate;
next.endDate = d.endDate || next.endDate;
next.premium = d.premium || next.premium;
next.payTime = d.payTime || next.payTime || '';
next.signDate = d.signDate || next.signDate || '';
next.coverageItems = serializeCoverageItems(d.coverageItems) || next.coverageItems || '';
next.applicant = d.applicant || next.applicant || '';
next.insured = d.insured || next.insured || '';
if (mode === 'policy') {
next.policyTag = '';
next.reinstateDate = '';
} else if (mode === 'suspend') {
next.policyTag = 'suspended';
next.reinstateDate = d.reinstateDate || next.reinstateDate || '2026-09-01';
} else if (mode === 'resume') {
next.policyTag = '';
next.reinstateDate = '';
} else if (mode === 'cancel') {
next.policyTag = 'cancelled';
next.reinstateDate = '';
}
return next;
};
const createPolicyRecognTaskId = () => `TASK-${Date.now().toString().slice(-8)}`;
const isPolicyRecognImageOrPdf = (file) => {
const name = (file?.name || '').toLowerCase();
const type = (file?.type || '').toLowerCase();
return type.includes('pdf') || type.startsWith('image/') || /\.(pdf|png|jpe?g|webp|bmp|gif|tiff?)$/i.test(name);
};
const isPolicyImportExcelFile = (file) => {
const name = (file?.name || '').toLowerCase();
const type = (file?.type || '').toLowerCase();
return /\.(csv|xlsx|xls)$/i.test(name)
|| type.includes('csv')
|| type.includes('spreadsheet')
|| type.includes('excel');
};
const POLICY_IMPORT_TEMPLATE_HEADERS = [
'车牌号',
'VIN码',
'业务类型',
'险种',
'保险公司',
'保单号',
'批单号',
'付款时间',
'生效日期',
'到期日期',
'复驶日期',
'保费(元)',
'保单项目',
'投保人',
'被保险人',
'签单日期',
];
const POLICY_IMPORT_TEMPLATE_SAMPLE_ROW = [
'沪BDB9161',
'LC0DF4CD8S0303140',
'保单录入',
'交强险',
'中国太平洋财产保险股份有限公司',
'ASHZ001CTP26B187065J',
'DZQA26480000279515',
'2026-06-01 17:42:10',
'2026-06-05',
'2027-06-04',
'',
'1243.00',
'死亡伤残180000元医疗18000元财产2000元',
'上海羚牛氢运物联网科技有限公司',
'上海羚牛氢运物联网科技有限公司',
'2026-05-27',
];
const downloadPolicyImportTemplate = () => {
const escapeCsvCell = (val) => {
const s = String(val ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const csv = `\uFEFF${[
POLICY_IMPORT_TEMPLATE_HEADERS.map(escapeCsvCell).join(','),
POLICY_IMPORT_TEMPLATE_SAMPLE_ROW.map(escapeCsvCell).join(','),
].join('\n')}`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '保单批量导入模板.csv';
a.click();
URL.revokeObjectURL(url);
};
const parseCsvLine = (line) => {
const result = [];
let cur = '';
let inQuote = false;
for (let i = 0; i < line.length; i += 1) {
const c = line[i];
if (c === '"') {
inQuote = !inQuote;
continue;
}
if (c === ',' && !inQuote) {
result.push(cur.trim());
cur = '';
continue;
}
cur += c;
}
result.push(cur.trim());
return result;
};
const normalizeImportHeaderKey = (header) => {
const h = String(header || '').trim().replace(/^\uFEFF/, '');
const map = {
车牌号: 'plateNo',
车牌: 'plateNo',
VIN码: 'vin',
VIN: 'vin',
业务类型: 'bizTypeLabel',
险种: 'insuranceType',
保险类型: 'insuranceType',
保险公司: 'company',
保单号: 'policyNo',
批单号: 'endorsementNo',
付款时间: 'payTime',
生效日期: 'startDate',
起保日期: 'startDate',
到期日期: 'endDate',
复驶日期: 'reinstateDate',
'保费(元)': 'premium',
保费: 'premium',
保单项目: 'coverageItems',
投保人: 'applicant',
被保险人: 'insured',
签单日期: 'signDate',
};
return map[h] || null;
};
const parsePolicyImportFileText = (text) => {
const lines = String(text || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (!lines.length) return [];
const headerCells = parseCsvLine(lines[0]);
const colIndex = {};
headerCells.forEach((cell, idx) => {
const key = normalizeImportHeaderKey(cell);
if (key) colIndex[key] = idx;
});
const hasHeader = Object.keys(colIndex).length >= 3;
const dataLines = hasHeader ? lines.slice(1) : lines;
const pick = (cells, key, fallbackIdx) => {
if (hasHeader && colIndex[key] != null) return (cells[colIndex[key]] || '').trim();
return (cells[fallbackIdx] || '').trim();
};
return dataLines.map((line) => {
const cells = parseCsvLine(line);
if (!cells.some((c) => c)) return null;
const bizLabel = pick(cells, 'bizTypeLabel', 2);
const bizMap = { 保单录入: 'policy', 停保: 'suspend', 停租: 'suspend', 复驶: 'resume', 退保: 'cancel' };
return {
plateNo: pick(cells, 'plateNo', 0),
vin: pick(cells, 'vin', 1),
bizType: bizMap[bizLabel] || 'policy',
insuranceType: pick(cells, 'insuranceType', 3),
company: pick(cells, 'company', 4),
policyNo: pick(cells, 'policyNo', 5),
endorsementNo: pick(cells, 'endorsementNo', 6),
payTime: pick(cells, 'payTime', 7),
startDate: pick(cells, 'startDate', 8),
endDate: pick(cells, 'endDate', 9),
reinstateDate: pick(cells, 'reinstateDate', 10),
premium: pick(cells, 'premium', 11),
coverageItems: pick(cells, 'coverageItems', 12),
applicant: pick(cells, 'applicant', 13),
insured: pick(cells, 'insured', 14),
signDate: pick(cells, 'signDate', 15),
};
}).filter(Boolean);
};
const resolveImportRowLedgerKey = (row) => {
const plate = (row?.plateNo || '').trim();
const vin = (row?.vin || '').trim();
if (plate) {
const v = findVehicleByPlate(plate);
if (v) return getVehicleLedgerKey(v);
}
if (vin) {
const v = findVehicleByVin(vin);
if (v) return getVehicleLedgerKey(v);
}
return null;
};
const isImportRowLedgerMatched = (ledgerKey) => (
!!ledgerKey && MOCK_VEHICLES.some((v) => getVehicleLedgerKey(v) === ledgerKey)
);
const buildRecognResultFromDetail = (fileMeta, detail, allInsurance, forcedMode) => {
const d = normalizePolicyDetail(detail);
const mode = forcedMode || bizTypeToRecognMode(d.bizType);
const plate = d.plateNo;
const vin = d.vin;
const vehicle = (plate && findVehicleByPlate(plate))
|| (vin && findVehicleByVin(vin))
|| { plateNo: plate, vin };
const ledgerKey = resolveImportRowLedgerKey({ plateNo: plate, vin })
|| (plate ? getVehicleLedgerKey(vehicle) : '')
|| (vin ? getVehicleLedgerKey(vehicle) : '');
let typeKey = INSURANCE_LABEL_TO_KEY[d.insuranceType];
const record = ledgerKey ? allInsurance[ledgerKey] : null;
let existing = typeKey && record ? record[typeKey] : null;
if (mode !== 'policy' && d.policyNo && ledgerKey) {
const policyMatch = findPolicyMatchInLedger(allInsurance, ledgerKey, d.policyNo);
if (policyMatch) {
typeKey = policyMatch.typeKey;
existing = policyMatch.item;
}
}
const typeLabel = INSURANCE_TYPE_ITEMS.find((it) => it.key === typeKey)?.fullLabel || d.insuranceType || '—';
const ocrPolicyNo = d.policyNo || existing?.policyNo || '';
let ocrEndDate = d.endDate || existing?.endDate || '';
let reinstateDate = d.reinstateDate || '';
if (!ocrEndDate) {
if (mode === 'suspend' || mode === 'cancel') ocrEndDate = ANCHOR_TODAY;
else if (mode === 'resume') ocrEndDate = '2027-06-30';
else ocrEndDate = '2027-12-31';
}
const matched = isImportRowLedgerMatched(ledgerKey) && !!typeKey && !!(ocrPolicyNo || ocrEndDate);
const bizLabel = POLICY_BIZ_TYPE_OPTIONS.find((o) => o.value === d.bizType)?.label || '保单录入';
return {
id: fileMeta.id || `ocr-r-${Date.now()}`,
fileUid: fileMeta.fileUid || fileMeta.uid || `f-${Date.now()}`,
fileName: fileMeta.fileName || fileMeta.name || '导入记录',
fileType: fileMeta.fileType || '',
policyDetail: d,
ocrPlateNo: (vehicle.plateNo || plate || '').trim(),
ocrVin: vehicle.vin || vin || '',
displayPlate: formatVehiclePlateDisplay(vehicle.plateNo || plate),
ocrPolicyNo,
ocrEndDate,
ocrStartDate: d.startDate || '',
ocrPremium: d.premium || '',
ocrPayTime: d.payTime || '',
ocrEndorsementNo: d.endorsementNo || '',
ocrCoverageItems: serializeCoverageItems(d.coverageItems),
ocrBizType: d.bizType,
ocrBizTypeLabel: bizLabel,
ocrCompany: d.company || existing?.company || INSURANCE_MGMT_COMPANIES[0],
reinstateDate,
ledgerKey: ledgerKey || '',
typeKey: typeKey || '',
insuranceTypeLabel: typeLabel,
matched,
matchTip: matched
? '已与台账车辆、险种匹配,可核对后确认'
: !ledgerKey
? '未匹配到台账车辆,请检查车牌或 VIN'
: !typeKey
? '险种填写有误'
: '请填写保单号或到期日期',
confirmed: false,
recognMode: mode,
};
};
const buildImportResultsFromRows = (rows, allInsurance) => (
(rows || []).map((row, idx) => buildRecognResultFromDetail(
{ id: `import-r-${idx}-${Date.now()}`, fileUid: `import-row-${idx}`, fileName: `导入_${row.plateNo || row.vin || `${idx + 1}`}.csv`, fileType: 'text/csv' },
normalizePolicyDetail(row),
allInsurance
))
);
const readPolicyImportFileAsText = (file) => new Promise((resolve, reject) => {
const name = (file?.name || '').toLowerCase();
if (/\.(xlsx|xls)$/i.test(name)) {
message.warning('当前原型请使用 CSV 模板(在 Excel 中打开模板后另存为 CSV UTF-8 再上传)');
resolve('');
return;
}
const reader = new FileReader();
reader.onload = (e) => resolve(String(e?.target?.result || ''));
reader.onerror = () => reject(new Error('read failed'));
reader.readAsText(file, 'UTF-8');
});
const findPolicyMatchInLedger = (allInsurance, ledgerKey, policyNo) => {
const record = allInsurance?.[ledgerKey];
if (!record || !policyNo) return null;
for (let i = 0; i < INSURANCE_TYPE_ITEMS.length; i += 1) {
const item = INSURANCE_TYPE_ITEMS[i];
if (record[item.key]?.policyNo === policyNo) {
return { typeKey: item.key, item: record[item.key], label: item.fullLabel };
}
}
return null;
};
const buildMockOcrResults = (files, mode, insuranceTypeLabel, allInsurance) => (
(files || []).filter((f) => f.status === 'done').map((file, idx) => {
const fromFile = resolvePolicyDetailFromFileName(file.name);
const detail = normalizePolicyDetail({
...fromFile,
insuranceType: mode === 'policy' ? (insuranceTypeLabel || fromFile.insuranceType) : fromFile.insuranceType,
bizType: mode !== 'policy' ? mode : (fromFile.bizType || 'policy'),
});
if (!detail.plateNo && !detail.vin) {
const vehicle = MOCK_VEHICLES[idx % MOCK_VEHICLES.length] || {};
detail.plateNo = vehicle.plateNo || '';
detail.vin = vehicle.vin || '';
}
if (!detail.policyNo) {
detail.policyNo = `PDZA${String(20260000 + idx)}`;
}
return buildRecognResultFromDetail(
{ id: `ocr-r-${file.uid}`, fileUid: file.uid, fileName: file.name, fileType: file.type || '' },
detail,
allInsurance,
mode
);
})
);
const recognResultToPolicyDetail = (result) => normalizePolicyDetail({
plateNo: result.ocrPlateNo,
vin: result.ocrVin,
insuranceType: result.insuranceTypeLabel,
bizType: result.ocrBizType || result.policyDetail?.bizType || 'policy',
company: result.ocrCompany,
policyNo: result.ocrPolicyNo,
endorsementNo: result.ocrEndorsementNo,
payTime: result.ocrPayTime,
startDate: result.ocrStartDate,
endDate: result.ocrEndDate,
reinstateDate: result.reinstateDate,
premium: result.ocrPremium,
coverageItems: parseCoverageItemsInput(result.policyDetail?.coverageItems ?? result.ocrCoverageItems),
...(result.policyDetail || {}),
});
const applyPolicyOcrResultToLedger = (result, mode) => {
const { ledgerKey, typeKey } = result;
if (!ledgerKey || !typeKey) return false;
const effectiveMode = result.recognMode || mode;
const detail = result.policyDetail ? normalizePolicyDetail(result.policyDetail) : recognResultToPolicyDetail(result);
const nowStr = formatCompareSheetNow();
return (prev) => {
const record = ensureInsuranceRecordShape(prev[ledgerKey] || createEmptyInsuranceRecord());
const item = applyPolicyDetailToInsuranceItem({ ...record[typeKey] }, detail, effectiveMode);
item.updateTime = nowStr;
item.updateUser = PROTO_COMPARE_CREATOR;
return { ...prev, [ledgerKey]: { ...record, [typeKey]: item } };
};
};
/** 保险公司管理模块 — 保险公司名称枚举(原型 mock */
const INSURANCE_MGMT_COMPANIES = [
'中国人民财产保险股份有限公司',
'中国平安财产保险股份有限公司',
'中国太平洋财产保险股份有限公司',
'中国人寿财产保险股份有限公司',
'阳光财产保险股份有限公司',
'中华联合财产保险股份有限公司',
'太平财产保险有限公司',
'大地财产保险股份有限公司',
'上海某某保险公司',
];
const QUOTE_INSURANCE_TYPES = ['交强险', '商业险', '超赔险', '货物险', '驾意险'];
const sanitizePremiumInput = (raw) => {
let s = String(raw || '').replace(/[^\d.]/g, '');
const dotIdx = s.indexOf('.');
if (dotIdx >= 0) {
s = s.slice(0, dotIdx + 1) + s.slice(dotIdx + 1).replace(/\./g, '').slice(0, 2);
}
return s;
};
const isValidPremium = (s) => {
const v = (s || '').trim();
if (!v) return false;
if (!/^\d+(\.\d{1,2})?$/.test(v)) return false;
return parseFloat(v) > 0;
};
const formatPremiumDisplay = (s) => {
if (!isValidPremium(s)) return s || '';
return parseFloat(s).toFixed(2);
};
const createEmptyQuoteDraft = () => ({ company: undefined, premium: '' });
const shortInsuranceCompanyName = (name) => (
(name || '').replace(/股份有限公司/g, '').replace(/有限公司/g, '').trim()
);
/** 比价单:汇总各行已确认报价金额 */
const calcCompareSheetConfirmedTotal = (rows) => {
let total = 0;
let count = 0;
(rows || []).forEach((row) => {
if (!row.confirmedQuoteId) return;
const quote = (row.quotes || []).find((q) => q.id === row.confirmedQuoteId);
if (!quote?.premium) return;
const amount = parseFloat(quote.premium);
if (!Number.isNaN(amount) && amount > 0) {
total += amount;
count += 1;
}
});
return { total, count };
};
const VEHICLE_PROFILES = {
'沪A03561F': { customer: '上海迅杰物流有限公司', ownerCompany: '浙江羚牛氢能科技有限公司', color: '白色', regDate: '2024-06-05', inspectExpire: '2026-06-30' },
'粤B58888F': { customer: '深圳冷链运输有限公司', ownerCompany: '羚牛运营(广东)', color: '蓝色', regDate: '2024-07-20', inspectExpire: '2026-07-20' },
'苏E33333': { customer: '苏州港务集团', ownerCompany: '浙江羚牛氢能科技有限公司', color: '红色', regDate: '2024-05-16', inspectExpire: '2026-05-15' },
'京A12345': { customer: '—', ownerCompany: '某某科技有限公司', color: '灰色', regDate: '2020-10-01', inspectExpire: '2024-10-01' },
'浙A88888': { customer: '—', ownerCompany: '浙江羚牛氢能科技有限公司', color: '绿色', regDate: '2025-01-01', inspectExpire: '2027-12-31' },
'沪D66666': { customer: '客户C', ownerCompany: '羚牛运营(上海)', color: '白色', regDate: '2021-06-15', inspectExpire: '2025-01-31' },
'粤A12345': { customer: '客户A', ownerCompany: '羚牛运营(广东)', color: '白色', regDate: '2023-07-01', inspectExpire: '2026-02-28' },
'苏A55678': { customer: '—', ownerCompany: '羚牛运营(嘉兴)', color: '黄色', regDate: '2025-05-01', inspectExpire: '2026-04-30' },
LZYTBACR2M9999001: { customer: '嘉兴某某物流有限公司', ownerCompany: '浙江羚牛氢能科技有限公司', color: '白色', regDate: '2025-11-01', inspectExpire: '2026-10-31' },
};
const createCompareRowId = () => `cr-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const createCompareSheetId = () => `cs-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const createCompareAttachmentId = () => `att-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const createQuoteId = () => `qt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const formatFileSize = (bytes) => {
if (bytes == null || Number.isNaN(bytes)) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
};
const attachmentsToUploadFileList = (attachments) => (attachments || []).map((a) => ({
uid: a.uid || a.id || createCompareAttachmentId(),
name: a.name,
size: a.size,
type: a.type,
status: 'done',
uploadedAt: a.uploadedAt,
}));
const uploadFileListToAttachments = (fileList) => (fileList || []).map((f) => ({
id: f.uid || createCompareAttachmentId(),
uid: f.uid,
name: f.name,
size: f.size,
type: f.type || '',
uploadedAt: f.uploadedAt || formatCompareSheetNow(),
}));
const formatCompareSheetNow = () => {
if (moment) return moment(ANCHOR_TODAY).hour(10).minute(30).second(0).format('YYYY-MM-DD HH:mm:ss');
return `${ANCHOR_TODAY} 10:30:00`;
};
const calcCompareSheetStats = (rows) => {
const vehicleKeys = new Set();
(rows || []).forEach((row) => {
const plate = (row.plateNo || '').trim();
const vin = (row.vin || '').trim();
if (plate) vehicleKeys.add(`plate:${plate.toUpperCase()}`);
else if (vin) vehicleKeys.add(`vin:${vin.toUpperCase()}`);
});
return {
totalVehicles: vehicleKeys.size,
insuranceCount: (rows || []).length,
};
};
const countCompareRowsWithConfirmedQuote = (rows) => (
(rows || []).filter((r) => r.confirmedQuoteId).length
);
const getLatestPayDateDiffDays = (dateStr) => {
if (!dateStr || !moment) return null;
const today = moment(ANCHOR_TODAY).startOf('day');
const pay = moment(dateStr).startOf('day');
if (!pay.isValid()) return null;
return pay.diff(today, 'days');
};
const getLatestPayDateStatus = (dateStr) => {
const diff = getLatestPayDateDiffDays(dateStr);
if (diff === null) return { type: 'none', text: '未填写最晚付费日期' };
if (diff < 0) return { type: 'overdue', text: `最晚付费已超期 ${Math.abs(diff)}`, diffDays: diff };
if (diff <= LATEST_PAY_WARN_DAYS) return { type: 'warning', text: `最晚付费临期,剩余 ${diff}`, diffDays: diff };
return { type: 'normal', text: `距离最晚付费 ${diff}`, diffDays: diff };
};
const calcCompareSheetPayAlerts = (sheet) => {
let warning = 0;
let overdue = 0;
(sheet?.rows || []).forEach((row) => {
const st = getLatestPayDateStatus(row.latestPayDate);
if (st.type === 'warning') warning += 1;
if (st.type === 'overdue') overdue += 1;
});
return { warning, overdue };
};
const syncCompareSheetProcurementCounts = (sheet) => {
const rows = sheet?.rows || [];
const submittedProcurementCount = rows.filter((r) => r.procurementStatus === 'submitted' || r.procurementStatus === 'completed').length;
const completedCount = rows.filter((r) => r.procurementStatus === 'completed').length;
return { submittedProcurementCount, completedCount };
};
const normalizeCompareRows = (rows) => (rows || []).map((row) => ({
...row,
procurementStatus: row.procurementStatus || 'none',
}));
const normalizeCompareSheet = (sheet) => {
const rows = normalizeCompareRows(sheet.rows);
const attachments = Array.isArray(sheet.attachments) ? sheet.attachments : [];
return {
...sheet,
rows,
attachments,
...calcCompareSheetStats(rows),
...syncCompareSheetProcurementCounts({ rows }),
};
};
const createEmptyInsuranceItem = () => ({
company: '',
policyNo: '',
endorsementNo: '',
startDate: '',
endDate: '',
premium: '',
payTime: '',
signDate: '',
coverageItems: '',
applicant: '',
insured: '',
updateTime: '',
updateUser: '',
policyTag: '',
reinstateDate: '',
});
const createEmptyCompareRow = () => ({
id: createCompareRowId(),
plateNo: '',
vin: '',
customer: '',
ownerCompany: '',
brand: '',
model: '',
bodyColor: '',
regDate: '',
inspectExpire: '',
insureMode: '续保',
insuranceType: '交强险',
jqValidUntil: '',
syValidUntil: '',
latestPayDate: '',
quotes: [],
confirmedQuoteId: '',
procurementStatus: 'none',
procurementSubmittedAt: '',
});
const buildCompareRowFromVehicle = (v, insuranceData) => ({
id: createCompareRowId(),
...buildVehicleComparePatch(v, insuranceData),
latestPayDate: '',
quotes: [],
confirmedQuoteId: '',
procurementStatus: 'none',
procurementSubmittedAt: '',
});
const cloneCompareRow = (row) => ({
...JSON.parse(JSON.stringify(row)),
id: createCompareRowId(),
quotes: (row.quotes || []).map((q) => ({ ...q, id: createQuoteId() })),
confirmedQuoteId: '',
});
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: '库存' },
{ plateNo: '沪D66666', brand: '比亚迪', model: 'T5轻卡', vin: 'LSVAU2BR3NS567890', status: '租赁' },
{ plateNo: '粤A12345', brand: '比亚迪', model: '汉EV', vin: 'LGWEF4A59NS123456', status: '租赁' },
{ plateNo: '苏A55678', brand: '福田', model: '欧马可4.2米', vin: 'LVBV3JBB8NY123456', status: '库存' },
{ plateNo: '', brand: '东风', model: '氢燃料电池牵引车(待上牌)', vin: 'LZYTBACR2M9999001', status: '库存' },
{ plateNo: '沪BDB9161', brand: '腾势', model: 'QCJ6520MBEV1纯电动', vin: 'LC0DF4CD8S0303140', status: '自营' },
{ plateNo: '粤AGR9766', brand: '帕力安', model: '燃料电池翼开启厢式车', vin: 'LB9A32A21R0LS1478', status: '自营' },
{ plateNo: '粤AGP9827', brand: '比亚迪', model: '轻卡', vin: 'LGXAGP98270000001', status: '租赁' },
{ plateNo: '粤AGP3071', brand: '福田', model: '欧马可', vin: 'LGXAGP30710000001', status: '租赁' },
{ plateNo: '粤A03423F', brand: '宇通', model: '49吨牵引', vin: 'LZYTBACR2A03423F01', status: '自营' },
{ plateNo: '粤A06290F', brand: '陕汽', model: '牵引车', vin: 'LZYTBACR2A06290F01', status: '自营' },
{ plateNo: '粤A03331F', brand: '东风', model: '厢式货车', vin: 'LZYTBACR2A03331F01', status: '自营' },
{ plateNo: '浙F03220F', brand: '福田', model: '冷藏车', vin: 'LZYTBACR2F03220F01', status: '租赁' },
{ plateNo: '沪A06192F', brand: '比亚迪', model: 'T5', vin: 'LZYTBACR2A06192F01', status: '自营' },
{ plateNo: '浙F05178F', brand: '福田', model: '牵引车', vin: 'LZYTBACR2F05178F01', status: '自营' },
];
const hasVehiclePlate = (vehicle) => !!(vehicle?.plateNo || '').trim();
const getVehicleLedgerKey = (vehicleOrKey) => {
if (!vehicleOrKey) return '';
if (typeof vehicleOrKey === 'object') {
const plate = (vehicleOrKey.plateNo || '').trim();
if (plate) return plate;
return (vehicleOrKey.vin || '').trim();
}
return String(vehicleOrKey).trim();
};
const formatVehiclePlateDisplay = (plateNo) => {
const p = (plateNo || '').trim();
return p || NO_PLATE_LABEL;
};
const isCompareRowVehicleLinked = (row) => !!(row?.plateNo || '').trim() || !!(row?.vin || '').trim();
const getVehicleProfile = (vehicle) => {
if (!vehicle) return {};
const plate = (vehicle.plateNo || '').trim();
if (plate && VEHICLE_PROFILES[plate]) return VEHICLE_PROFILES[plate];
const vin = (vehicle.vin || '').trim();
return VEHICLE_PROFILES[vin] || {};
};
const getInitialInsuranceSeed = (vehicle) => {
const plate = (vehicle.plateNo || '').trim();
if (plate && INITIAL_INSURANCE_DATA[plate]) return INITIAL_INSURANCE_DATA[plate];
const vin = (vehicle.vin || '').trim();
return INITIAL_INSURANCE_DATA[vin] || null;
};
const findVehicleByPlate = (plate) => {
const key = (plate || '').trim().toUpperCase();
if (!key) return null;
return MOCK_VEHICLES.find((v) => (v.plateNo || '').trim().toUpperCase() === key) || null;
};
const findVehicleByVin = (vin) => {
const key = (vin || '').trim().toUpperCase();
return MOCK_VEHICLES.find((v) => v.vin.toUpperCase() === key) || null;
};
const PLATE_SELECT_OPTIONS = MOCK_VEHICLES
.filter((v) => hasVehiclePlate(v))
.map((v) => ({ label: v.plateNo, value: v.plateNo }));
const VIN_SELECT_OPTIONS = MOCK_VEHICLES.map((v) => ({ label: v.vin, value: v.vin }));
const buildVehicleComparePatch = (vehicle, insuranceData) => {
if (!vehicle) return {};
const profile = getVehicleProfile(vehicle);
const ins = insuranceData[getVehicleLedgerKey(vehicle)] || {};
const jqEnd = ins.compulsory?.endDate || '';
const syEnd = ins.commercial?.endDate || '';
return {
plateNo: vehicle.plateNo,
vin: vehicle.vin,
customer: profile.customer || '',
ownerCompany: profile.ownerCompany || '',
brand: vehicle.brand,
model: vehicle.model,
bodyColor: profile.color || '',
regDate: profile.regDate || '',
inspectExpire: profile.inspectExpire || '',
insureMode: jqEnd || syEnd ? '续保' : '新保',
insuranceType: '交强险',
jqValidUntil: jqEnd,
syValidUntil: syEnd,
};
};
const clearVehicleComparePatch = () => ({
plateNo: '',
vin: '',
customer: '',
ownerCompany: '',
brand: '',
model: '',
bodyColor: '',
regDate: '',
inspectExpire: '',
insureMode: '续保',
insuranceType: '交强险',
jqValidUntil: '',
syValidUntil: '',
});
const createEmptyInsuranceRecord = () => ({
compulsory: createEmptyInsuranceItem(),
commercial: createEmptyInsuranceItem(),
excess: createEmptyInsuranceItem(),
cargo: createEmptyInsuranceItem(),
driverAccident: createEmptyInsuranceItem(),
});
const ensureInsuranceRecordShape = (record) => {
const base = createEmptyInsuranceRecord();
const next = { ...base };
INSURANCE_TYPE_ITEMS.forEach(({ key }) => {
next[key] = { ...base[key], ...(record?.[key] || {}) };
});
return next;
};
const VEHICLE_INSURANCE_MGMT_TABS = [
{ key: 'timeline', label: '保险采购全周期记录' },
{ key: 'compulsory', label: '交强险' },
{ key: 'commercial', label: '商业险' },
{ key: 'excess', label: '超赔险' },
{ key: 'driverAccident', label: '驾意险' },
{ key: 'cargo', label: '货物险' },
];
/** 管理页记录类型:新保 / 续保 / 停租 / 复驶 / 退保 */
const POLICY_PURCHASE_TYPE_META = {
new: { label: '新保', color: 'success', timelineColor: 'green', chipClass: 'lc-purchase-type--new' },
renew: { label: '续保', color: 'processing', timelineColor: 'blue', chipClass: 'lc-purchase-type--renew' },
rentStop: { label: '停租', color: 'warning', timelineColor: 'orange', chipClass: 'lc-purchase-type--rent-stop' },
resume: { label: '复驶', color: 'cyan', timelineColor: 'cyan', chipClass: 'lc-purchase-type--resume' },
cancel: { label: '退保', color: 'default', timelineColor: 'gray', chipClass: 'lc-purchase-type--cancel' },
};
const POLICY_PURCHASE_TYPE_LEGEND = ['new', 'renew', 'rentStop', 'resume', 'cancel'];
const POLICY_LIKE_EVENT_TYPES = new Set(['purchase', 'renew', 'procurement', 'recognize']);
const eventTypeToDefaultPurchaseType = (eventType) => {
if (eventType === 'suspend') return 'rentStop';
if (eventType === 'resume') return 'resume';
if (eventType === 'cancel') return 'cancel';
if (eventType === 'renew') return 'renew';
return 'new';
};
const assignPurchaseTypesToRecords = (records) => {
const counters = {};
const chronological = [...records].sort((a, b) => String(a.time).localeCompare(String(b.time)));
chronological.forEach((rec) => {
const typeKey = rec.typeKey || '_';
if (!counters[typeKey]) counters[typeKey] = 0;
let purchaseType = eventTypeToDefaultPurchaseType(rec.eventType);
if (POLICY_LIKE_EVENT_TYPES.has(rec.eventType)) {
purchaseType = counters[typeKey] > 0 ? 'renew' : 'new';
counters[typeKey] += 1;
}
rec.purchaseType = purchaseType;
});
return records;
};
const subtractInsuranceYears = (dateStr, years) => {
if (!moment || !dateStr) return '';
const d = moment(dateStr, 'YYYY-MM-DD', true);
if (!d.isValid()) return '';
return d.subtract(years, 'year').format('YYYY-MM-DD');
};
const vehicleMatchesCompareRow = (vehicle, row) => {
if (!vehicle || !row) return false;
const ledgerKey = getVehicleLedgerKey(vehicle);
const rowKey = getVehicleLedgerKey({ plateNo: row.plateNo, vin: row.vin });
return !!ledgerKey && ledgerKey === rowKey;
};
const createInsuranceHistoryRecord = (payload) => ({
id: payload.id || `ih-${payload.typeKey}-${payload.eventType}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
typeKey: payload.typeKey,
typeLabel: payload.typeLabel,
eventType: payload.eventType,
purchaseType: payload.purchaseType || eventTypeToDefaultPurchaseType(payload.eventType),
time: payload.time || '',
payTime: payload.payTime || '',
purchaseTime: payload.time || '',
policyNo: payload.policyNo || '',
company: payload.company || '',
premium: payload.premium || '',
startDate: payload.startDate || '',
endDate: payload.endDate || '',
source: payload.source || 'ledger',
sourceLabel: payload.sourceLabel || '',
policyTag: payload.policyTag || '',
reinstateDate: payload.reinstateDate || '',
policyDetail: payload.policyDetail || null,
fileName: payload.fileName || (payload.policyNo ? `${payload.policyNo}_${payload.typeLabel}.pdf` : '保单附件.pdf'),
});
const purchaseTypeToBizType = (purchaseType, eventType) => {
if (purchaseType === 'rentStop' || eventType === 'suspend') return 'suspend';
if (purchaseType === 'resume' || eventType === 'resume') return 'resume';
if (purchaseType === 'cancel' || eventType === 'cancel') return 'cancel';
return 'policy';
};
const historyRecordToPolicyDetail = (record, vehicle) => {
if (record?.policyDetail) {
return normalizePolicyDetail({
...record.policyDetail,
plateNo: vehicle?.plateNo || record.policyDetail.plateNo,
vin: vehicle?.vin || record.policyDetail.vin,
});
}
return normalizePolicyDetail({
plateNo: vehicle?.plateNo || '',
vin: vehicle?.vin || '',
insuranceType: record?.typeLabel || '交强险',
bizType: purchaseTypeToBizType(record?.purchaseType, record?.eventType),
company: record?.company || '',
policyNo: record?.policyNo || '',
payTime: record?.payTime || '',
startDate: record?.startDate || '',
endDate: record?.endDate || '',
reinstateDate: record?.reinstateDate || '',
premium: record?.premium || '',
coverageItems: parseCoverageItemsInput(record?.policyDetail?.coverageItems),
applicant: '',
insured: '',
signDate: '',
});
};
const applyPolicyDetailToHistoryRecord = (record, detail) => {
const d = normalizePolicyDetail(detail);
const time = d.startDate || record.time;
const typeLabel = d.insuranceType || record.typeLabel;
const next = {
...record,
policyDetail: d,
typeLabel,
policyNo: d.policyNo || record.policyNo,
company: d.company,
payTime: d.payTime,
startDate: d.startDate,
endDate: d.endDate,
premium: d.premium,
reinstateDate: d.reinstateDate,
time,
purchaseTime: time,
fileName: d.policyNo ? `${d.policyNo}_${typeLabel}.pdf` : record.fileName,
};
next.summary = getInsuranceEventSummary(next);
return next;
};
const applyHistoryEditsToVehicleHistory = (history, edits) => {
if (!history || !edits || typeof edits !== 'object') return history;
const patch = (r) => {
const detail = edits[r.id];
if (!detail) return r;
return applyPolicyDetailToHistoryRecord(r, detail);
};
const byType = {};
Object.keys(history.byType || {}).forEach((k) => {
byType[k] = (history.byType[k] || []).map(patch);
});
return {
...history,
timeline: (history.timeline || []).map(patch),
byType,
};
};
const buildPolicyDetailFromLedgerItem = (vehicle, typeLabel, item, bizType = 'policy') => normalizePolicyDetail({
plateNo: vehicle?.plateNo || '',
vin: vehicle?.vin || '',
insuranceType: typeLabel,
bizType,
company: item?.company || '',
policyNo: item?.policyNo || '',
endorsementNo: item?.endorsementNo || '',
payTime: item?.payTime || '',
signDate: item?.signDate || '',
startDate: item?.startDate || '',
endDate: item?.endDate || '',
reinstateDate: item?.reinstateDate || '',
premium: item?.premium || '',
coverageItems: parseCoverageItemsInput(item?.coverageItems),
applicant: item?.applicant || '',
insured: item?.insured || '',
});
const getInsuranceEventSummary = (record) => {
const premiumText = record.premium ? `,金额 ¥${record.premium}` : '';
const typeLabel = POLICY_PURCHASE_TYPE_META[record.purchaseType]?.label || '记录';
const period = record.startDate && record.endDate
? `,期间 ${record.startDate}${record.endDate}`
: (record.endDate ? `,到期 ${record.endDate}` : '');
switch (record.purchaseType) {
case 'new':
return `${typeLabel} ${record.typeLabel},保单号 ${record.policyNo || '—'}${period}${premiumText}`;
case 'renew':
return `${typeLabel} ${record.typeLabel},保单号 ${record.policyNo || '—'}${period}${premiumText}`;
case 'rentStop':
return `${typeLabel} · ${record.typeLabel},保单号 ${record.policyNo || '—'}${record.reinstateDate ? `,预计复驶 ${record.reinstateDate}` : ''}`;
case 'resume':
return `${typeLabel} · ${record.typeLabel},保单号 ${record.policyNo || '—'}${period}`;
case 'cancel':
return `${typeLabel} · ${record.typeLabel},保单号 ${record.policyNo || '—'}${premiumText}`;
default:
return `${record.typeLabel} · ${record.policyNo || '—'}`;
}
};
const buildVehicleInsuranceHistory = (vehicle, allInsurance, compareSheets, policyRecognTasks) => {
const ledgerKey = getVehicleLedgerKey(vehicle);
const record = ensureInsuranceRecordShape(allInsurance[ledgerKey] || createEmptyInsuranceRecord());
const records = [];
INSURANCE_TYPE_ITEMS.forEach(({ key: typeKey, fullLabel: typeLabel }) => {
const item = record[typeKey];
if (!item?.policyNo) return;
const purchaseTime = item.startDate || item.updateTime || item.endDate;
records.push(createInsuranceHistoryRecord({
id: `ih-${ledgerKey}-${typeKey}-ledger-current`,
typeKey,
typeLabel,
eventType: 'purchase',
time: purchaseTime,
payTime: item.payTime || '',
policyNo: item.policyNo,
company: item.company,
premium: item.premium,
startDate: item.startDate,
endDate: item.endDate,
policyDetail: buildPolicyDetailFromLedgerItem(vehicle, typeLabel, item, 'policy'),
source: 'ledger',
sourceLabel: '台账当前保单',
fileName: `${item.policyNo}_${typeLabel}.pdf`,
}));
if (item.startDate) {
const prevStart = subtractInsuranceYears(item.startDate, 1);
const prevEnd = item.endDate ? subtractInsuranceYears(item.endDate, 1) : '';
if (prevStart) {
records.push(createInsuranceHistoryRecord({
id: `ih-${ledgerKey}-${typeKey}-history-${prevStart}`,
typeKey,
typeLabel,
eventType: 'renew',
time: prevStart,
policyNo: `${String(item.policyNo).slice(0, 12)}-H1`,
company: item.company,
premium: item.premium,
startDate: prevStart,
endDate: prevEnd,
source: 'history',
sourceLabel: '历史续保',
fileName: `${item.policyNo}_续保_${typeLabel}.pdf`,
}));
}
}
if (item.policyTag === 'suspended') {
records.push(createInsuranceHistoryRecord({
id: `ih-${ledgerKey}-${typeKey}-ledger-suspend`,
typeKey,
typeLabel,
eventType: 'suspend',
time: item.updateTime || item.endDate || purchaseTime,
policyNo: item.policyNo,
company: item.company,
premium: item.premium,
startDate: item.startDate,
endDate: item.endDate,
policyTag: 'suspended',
reinstateDate: item.reinstateDate,
policyDetail: buildPolicyDetailFromLedgerItem(vehicle, typeLabel, item, 'suspend'),
source: 'ledger',
sourceLabel: '停租',
fileName: `${item.policyNo}_停租_${typeLabel}.pdf`,
}));
}
if (item.policyTag === 'cancelled') {
records.push(createInsuranceHistoryRecord({
id: `ih-${ledgerKey}-${typeKey}-ledger-cancel`,
typeKey,
typeLabel,
eventType: 'cancel',
time: item.updateTime || item.endDate || purchaseTime,
policyNo: item.policyNo,
company: item.company,
premium: item.premium,
startDate: item.startDate,
endDate: item.endDate,
policyTag: 'cancelled',
policyDetail: buildPolicyDetailFromLedgerItem(vehicle, typeLabel, item, 'cancel'),
source: 'ledger',
sourceLabel: '退保',
fileName: `${item.policyNo}_退保_${typeLabel}.pdf`,
}));
}
});
(compareSheets || []).forEach((sheet) => {
(sheet.rows || []).forEach((row) => {
if (!vehicleMatchesCompareRow(vehicle, row)) return;
const typeKey = INSURANCE_LABEL_TO_KEY[row.insuranceType] || 'compulsory';
const typeLabel = row.insuranceType || INSURANCE_TYPE_ITEMS.find((it) => it.key === typeKey)?.fullLabel || '—';
const confirmed = (row.quotes || []).find((q) => q.id === row.confirmedQuoteId);
if (!confirmed) return;
const eventType = row.procurementStatus === 'completed' ? 'procurement' : 'procurement';
records.push(createInsuranceHistoryRecord({
id: `ih-compare-${sheet.id}-${row.id}`,
typeKey,
typeLabel,
eventType,
time: row.procurementSubmittedAt || sheet.createdAt,
policyNo: confirmed.policyNo || `CG-${String(row.id).slice(-8)}`,
company: confirmed.company,
premium: confirmed.premium,
source: 'compare',
sourceLabel: sheet.periodLabel ? `比价单 · ${sheet.periodLabel}` : '比价单采购',
fileName: `${sheet.id || 'sheet'}_${typeLabel}_采购单.pdf`,
}));
});
});
(policyRecognTasks || []).forEach((task) => {
(task.results || []).forEach((r) => {
if (!r.confirmed || r.ledgerKey !== ledgerKey) return;
const typeKey = r.typeKey || INSURANCE_LABEL_TO_KEY[r.insuranceTypeLabel];
if (!typeKey) return;
let eventType = 'recognize';
if (task.mode === 'suspend') eventType = 'suspend';
else if (task.mode === 'cancel') eventType = 'cancel';
else if (task.mode === 'resume') eventType = 'resume';
records.push(createInsuranceHistoryRecord({
id: `ih-recognize-${task.id}-${r.id}`,
typeKey,
typeLabel: r.insuranceTypeLabel || INSURANCE_TYPE_ITEMS.find((it) => it.key === typeKey)?.fullLabel,
eventType,
time: task.completedAt || task.createdAt,
policyNo: r.ocrPolicyNo,
company: r.ocrCompany,
premium: r.ocrPremium || '',
payTime: r.ocrPayTime || '',
startDate: r.ocrStartDate || '',
endDate: r.ocrEndDate,
reinstateDate: r.reinstateDate,
policyTag: eventType === 'suspend' ? 'suspended' : eventType === 'cancel' ? 'cancelled' : '',
policyDetail: r.policyDetail ? normalizePolicyDetail(r.policyDetail) : recognResultToPolicyDetail(r),
source: 'recognize',
sourceLabel: task.entryLabel || '批量识别',
recognizeTaskId: task.id,
recognizeResultId: r.id,
fileName: r.fileName || `${r.ocrPolicyNo}_${r.insuranceTypeLabel}.pdf`,
}));
});
});
assignPurchaseTypesToRecords(records);
records.sort((a, b) => String(b.time).localeCompare(String(a.time)));
const timeline = records.map((r) => ({
...r,
summary: getInsuranceEventSummary(r),
}));
const byType = {};
INSURANCE_TYPE_ITEMS.forEach(({ key }) => { byType[key] = []; });
records.forEach((r) => {
if (byType[r.typeKey]) byType[r.typeKey].push(r);
});
Object.keys(byType).forEach((k) => {
byType[k].sort((a, b) => String(b.time).localeCompare(String(a.time)));
});
return { timeline, byType, ledgerKey };
};
const INITIAL_INSURANCE_DATA = {
'沪A03561F': {
compulsory: { company: '中国人民财产保险', policyNo: 'PDZA202533048200000123', startDate: '2025-01-01', endDate: '2026-12-31', premium: '950.00', updateTime: '2025-01-05 10:00', updateUser: '张明辉' },
commercial: { company: '中国人民财产保险', policyNo: 'PDAA202533048200000456', startDate: '2025-01-01', endDate: '2026-12-31', premium: '12800.50', updateTime: '2025-01-05 10:00', updateUser: '张明辉' },
excess: { company: '中国平安财产保险', policyNo: 'PAIC-CP-2025-8899', startDate: '2025-07-01', endDate: '2026-06-30', premium: '3200.00', updateTime: '2025-06-28 14:20', updateUser: '李专员' },
cargo: { company: '中国太平洋财产保险', policyNo: 'CPIC-HW-2025-1122', startDate: '2025-03-15', endDate: '2026-03-14', premium: '1800.00', updateTime: '2025-03-10 09:15', updateUser: '李专员' },
driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
},
'粤B58888F': {
compulsory: { company: '阳光财产保险', policyNo: 'YGCI-JQ-2025-3301', startDate: '2025-09-01', endDate: '2026-08-31', premium: '950.00', updateTime: '2025-08-28 11:00', updateUser: '王专员' },
commercial: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
cargo: { company: '中国人寿财产保险', policyNo: 'GPIC-HW-2025-7788', startDate: '2025-04-01', endDate: '2026-03-31', premium: '1600.00', updateTime: '2025-03-28 16:40', updateUser: '王专员' },
driverAccident: { company: '中华联合财产保险', policyNo: 'CIC-JY-2025-001', startDate: '2025-05-01', endDate: '2026-04-30', premium: '560.00', updateTime: '2025-04-28 10:00', updateUser: '王专员' },
},
'苏E33333': {
compulsory: { company: '中国太平洋财产保险', policyNo: 'CPIC-JQ-2024-7788', startDate: '2024-06-01', endDate: '2025-05-31', premium: '880.00', updateTime: '2024-05-28 09:00', updateUser: '陈高伟' },
commercial: { company: '中国人寿财产保险', policyNo: 'GPIC-SY-2025-1122', startDate: '2025-07-01', endDate: '2026-06-30', premium: '9850.00', updateTime: '2025-06-25 15:30', updateUser: '陈高伟' },
excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
},
'京A12345': {
compulsory: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
commercial: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
},
'浙A88888': {
compulsory: { company: '中国人民财产保险', policyNo: 'PDZA202533048200000789', startDate: '2025-07-01', endDate: '2026-06-30', premium: '950.00', updateTime: '2025-06-28 10:00', updateUser: '张小凡' },
commercial: { company: '中国人民财产保险', policyNo: 'PDAA202533048200000790', startDate: '2025-01-01', endDate: '2027-12-31', premium: '15600.00', updateTime: '2025-01-05 10:00', updateUser: '张小凡' },
excess: { company: '中国平安财产保险', policyNo: 'PAIC-CP-2025-9900', startDate: '2025-01-01', endDate: '2026-12-31', premium: '2800.00', updateTime: '2025-01-03 11:00', updateUser: '张小凡' },
cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
driverAccident: { company: '阳光财产保险', policyNo: 'YGCI-JY-2025-2200', startDate: '2025-09-01', endDate: '2026-08-31', premium: '480.00', updateTime: '2025-08-30 09:00', updateUser: '张小凡' },
},
'沪D66666': {
compulsory: { company: '中国人民财产保险', policyNo: 'PDZA202533048200000321', startDate: '2025-02-01', endDate: '2026-01-31', premium: '950.00', updateTime: '2025-01-28 14:00', updateUser: '赵六' },
commercial: { company: '中国人民财产保险', policyNo: 'PDAA202533048200000322', startDate: '2025-02-01', endDate: '2026-01-31', premium: '11200.00', updateTime: '2025-01-28 14:00', updateUser: '赵六' },
excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
},
'粤A12345': {
compulsory: { company: '中国平安财产保险', policyNo: 'PAIC-JQ-2025-4455', startDate: '2025-03-01', endDate: '2026-02-28', premium: '950.00', updateTime: '2025-02-25 10:00', updateUser: '张三' },
commercial: { company: '中国平安财产保险', policyNo: 'PAIC-SY-2025-4456', startDate: '2025-03-01', endDate: '2026-02-28', premium: '10500.00', updateTime: '2025-02-25 10:00', updateUser: '张三' },
excess: { company: '中国太平洋财产保险', policyNo: 'CPIC-CP-2025-5566', startDate: '2025-03-01', endDate: '2026-02-28', premium: '2400.00', updateTime: '2025-02-25 10:00', updateUser: '张三' },
cargo: { company: '中华联合财产保险', policyNo: 'CIC-HW-2025-7788', startDate: '2025-03-01', endDate: '2026-02-28', premium: '1500.00', updateTime: '2025-02-25 10:00', updateUser: '张三' },
driverAccident: { company: '阳光财产保险', policyNo: 'YGCI-JY-2025-9900', startDate: '2025-03-01', endDate: '2026-02-28', premium: '520.00', updateTime: '2025-02-25 10:00', updateUser: '张三' },
},
'苏A55678': {
compulsory: { company: '中国人寿财产保险', policyNo: 'GPIC-JQ-2025-0011', startDate: '2025-05-01', endDate: '2026-04-30', premium: '880.00', updateTime: '2025-04-28 09:00', updateUser: '孙七' },
commercial: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
excess: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
},
LZYTBACR2M9999001: {
compulsory: { company: '中国人民财产保险', policyNo: 'PDZA202533048200000901', startDate: '2025-10-01', endDate: '2026-09-30', premium: '950.00', updateTime: '2025-09-28 10:00', updateUser: '李专员' },
commercial: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
excess: { company: '中国平安财产保险', policyNo: 'PAIC-CP-2025-7701', startDate: '2025-10-01', endDate: '2026-09-30', premium: '2600.00', updateTime: '2025-09-28 10:00', updateUser: '李专员' },
cargo: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
driverAccident: { company: '', policyNo: '', startDate: '', endDate: '', premium: '', updateTime: '', updateUser: '' },
},
};
const loadInsuranceFromStorage = () => {
try {
const raw = localStorage.getItem(IPC_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
};
const persistInsuranceToStorage = (data) => {
try {
localStorage.setItem(IPC_STORAGE_KEY, JSON.stringify(data));
} catch {
/* ignore */
}
};
const loadCompareSheetsFromStorage = () => {
try {
const raw = localStorage.getItem(IPC_COMPARE_SHEETS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
};
const persistCompareSheetsToStorage = (sheets) => {
try {
localStorage.setItem(IPC_COMPARE_SHEETS_KEY, JSON.stringify(sheets));
} catch {
/* ignore */
}
};
const POLICY_RECOGN_ENTRY_LABEL = { ocr: '保单批量识别', import: '批量导入' };
const POLICY_RECOGN_MODE_LABEL = Object.fromEntries(POLICY_OCR_MODES.map((m) => [m.key, m.label]));
const POLICY_RECOGN_STATUS_META = {
pending_confirm: { label: '待确认', color: 'warning' },
partial: { label: '部分确认', color: 'processing' },
completed: { label: '已完成', color: 'success' },
};
const derivePolicyRecognTaskStatus = (results) => {
const list = results || [];
const matched = list.filter((r) => r.matched);
const confirmedMatched = matched.filter((r) => r.confirmed);
if (matched.length > 0 && confirmedMatched.length >= matched.length) return 'completed';
if (list.some((r) => r.confirmed)) return 'partial';
return 'pending_confirm';
};
const summarizePolicyRecognTask = (results) => {
const list = results || [];
return {
fileCount: list.length,
matchedCount: list.filter((r) => r.matched).length,
confirmedCount: list.filter((r) => r.confirmed).length,
};
};
const buildPolicyRecognTaskRecord = ({
id,
entry,
mode,
insuranceType,
results,
createdAt,
creator,
status,
completedAt,
phase,
}) => {
const stats = summarizePolicyRecognTask(results);
return {
id,
createdAt: createdAt || formatCompareSheetNow(),
completedAt: completedAt || '',
entry,
entryLabel: POLICY_RECOGN_ENTRY_LABEL[entry] || entry,
mode,
modeLabel: POLICY_RECOGN_MODE_LABEL[mode] || mode,
insuranceType: insuranceType || '',
creator: creator || PROTO_COMPARE_CREATOR,
status: status || derivePolicyRecognTaskStatus(results),
phase: phase || 'results',
...stats,
results: (results || []).map((r) => ({ ...r })),
fileNames: (results || []).map((r) => r.fileName).filter(Boolean),
};
};
const loadPolicyRecognTasksFromStorage = () => {
try {
const raw = localStorage.getItem(IPC_POLICY_RECOGN_TASKS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
};
const persistPolicyRecognTasksToStorage = (tasks) => {
try {
localStorage.setItem(IPC_POLICY_RECOGN_TASKS_KEY, JSON.stringify(tasks));
} catch {
/* ignore */
}
};
const loadInsuranceHistoryEditsFromStorage = () => {
try {
const raw = localStorage.getItem(IPC_INSURANCE_HISTORY_EDITS_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
};
const persistInsuranceHistoryEditsToStorage = (edits) => {
try {
localStorage.setItem(IPC_INSURANCE_HISTORY_EDITS_KEY, JSON.stringify(edits));
} catch {
/* ignore */
}
};
const createMockPolicyRecognTasks = () => {
const insMap = buildMockInsuranceMap();
const files1 = [
{ uid: 'demo-f1', name: '粤BDG9701_交强险.pdf', status: 'done' },
{ uid: 'demo-f2', name: '粤AGR9766_超赔险.pdf', status: 'done' },
];
const results1 = buildMockOcrResults(files1, 'policy', '交强险', insMap);
results1.forEach((r) => { r.confirmed = true; });
const files2 = [
{ uid: 'demo-f3', name: '沪A03561F_商业险.jpg', status: 'done' },
{ uid: 'demo-f4', name: '粤B88888_交强险.pdf', status: 'done' },
];
const results2 = buildMockOcrResults(files2, 'policy', '商业险', insMap);
if (results2[0]) results2[0].confirmed = true;
return [
buildPolicyRecognTaskRecord({
id: 'TASK-83892906',
entry: 'ocr',
mode: 'policy',
insuranceType: '交强险',
results: results1,
createdAt: '2026-05-28 15:20:10',
completedAt: '2026-05-28 15:32:00',
phase: 'results',
}),
buildPolicyRecognTaskRecord({
id: 'TASK-84120155',
entry: 'import',
mode: 'policy',
insuranceType: '商业险',
results: results2,
createdAt: '2026-05-30 09:15:00',
phase: 'results',
}),
];
};
const buildMockInsuranceMap = () => {
const map = {};
MOCK_VEHICLES.forEach((v) => {
const key = getVehicleLedgerKey(v);
const seed = getInitialInsuranceSeed(v);
map[key] = seed ? JSON.parse(JSON.stringify(seed)) : createEmptyInsuranceRecord();
});
return map;
};
const createMockCompareRowWithQuote = (vehicle, insMap, insuranceType, premium, extra = {}) => {
const row = buildCompareRowFromVehicle(vehicle, insMap);
const quoteId = createQuoteId();
row.insuranceType = insuranceType;
row.quotes = [{ id: quoteId, company: INSURANCE_MGMT_COMPANIES[0], premium }];
row.confirmedQuoteId = quoteId;
row.latestPayDate = extra.latestPayDate || '2026-06-15';
row.procurementStatus = extra.procurementStatus || 'none';
row.procurementSubmittedAt = extra.procurementSubmittedAt || '';
return row;
};
const createMockCompareSheets = () => {
const insMap = buildMockInsuranceMap();
const sheet1Rows = normalizeCompareRows([
createMockCompareRowWithQuote(MOCK_VEHICLES[0], insMap, '交强险', '950.00', { latestPayDate: '2026-06-03', procurementStatus: 'completed' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[0], insMap, '商业险', '12800.50', { latestPayDate: '2026-06-04', procurementStatus: 'submitted' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[1], insMap, '交强险', '950.00', { latestPayDate: '2026-05-28' }),
]);
const sheet2Rows = normalizeCompareRows([
createMockCompareRowWithQuote(MOCK_VEHICLES[2], insMap, '交强险', '880.00', { latestPayDate: '2026-06-02', procurementStatus: 'completed' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[2], insMap, '商业险', '9850.00', { latestPayDate: '2026-06-02', procurementStatus: 'completed' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[7], insMap, '交强险', '950.00', { latestPayDate: '2026-06-01', procurementStatus: 'submitted' }),
createMockCompareRowWithQuote(MOCK_VEHICLES[7], insMap, '超赔险', '2600.00', { latestPayDate: '2026-07-01', procurementStatus: 'submitted' }),
]);
const sheet3Rows = normalizeCompareRows([
createMockCompareRowWithQuote(MOCK_VEHICLES[4], insMap, '商业险', '15600.00', { latestPayDate: '2026-05-20' }),
]);
return [
normalizeCompareSheet({
id: 'cs-mock-20260528',
createdAt: '2026-05-28 14:20:00',
createdBy: '张明辉',
periodLabel: '2026年5-6月',
remark: '华东区二季度集中采购',
attachments: [
{ id: 'att-demo-1', uid: 'att-demo-1', name: '6月比价询价单.pdf', size: 245760, type: 'application/pdf', uploadedAt: '2026-05-28 14:18:00' },
{ id: 'att-demo-2', uid: 'att-demo-2', name: '保险公司报价截图.zip', size: 1048576, type: 'application/zip', uploadedAt: '2026-05-28 14:19:00' },
],
rows: sheet1Rows,
}),
normalizeCompareSheet({
id: 'cs-mock-20260520',
createdAt: '2026-05-20 09:15:00',
createdBy: '李专员',
periodLabel: '2026年5月',
remark: '苏粤车辆续保比价',
rows: sheet2Rows,
}),
normalizeCompareSheet({
id: 'cs-mock-20260510',
createdAt: '2026-05-10 16:40:00',
createdBy: '王专员',
periodLabel: '2026年5月',
remark: '',
rows: sheet3Rows,
}),
];
};
const compareSheetMatchesPlateFilter = (sheet, plateKey) => {
if (!plateKey) return true;
const key = plateKey.trim().toLowerCase();
return (sheet.rows || []).some((row) => {
const plate = (row.plateNo || '').trim().toLowerCase();
const vin = (row.vin || '').trim().toLowerCase();
if (plate && plate.includes(key)) return true;
if (!plate && (NO_PLATE_LABEL.toLowerCase().includes(key) || key.includes('暂无'))) return true;
if (vin && vin.includes(key)) return true;
return false;
});
};
const compareSheetMatchesCreatedRange = (createdAt, range) => {
if (!range || !range[0] || !range[1]) return true;
if (!createdAt || !moment) return true;
const day = moment(String(createdAt).slice(0, 10), 'YYYY-MM-DD', true);
if (!day.isValid()) return true;
const start = range[0].clone().startOf('day');
const end = range[1].clone().endOf('day');
return day.isSameOrAfter(start) && day.isSameOrBefore(end);
};
const DEFAULT_COMPARE_MGMT_FILTERS = { plateNo: '', createdRange: null };
const DEFAULT_POLICY_RECOGN_TASK_FILTERS = { taskId: '', entry: '全部', status: '全部', createdRange: null };
const tableTitleMultiline = (...lines) => (
<div className="lc-table-th-multiline">
{lines.map((line, idx) => (
<span key={idx} className="lc-table-th-line">{line}</span>
))}
</div>
);
const listColumnHeaderCell = () => ({ className: 'lc-th-wrap' });
const mapInsuranceStatusToBadge = (type) => {
if (type === 'success') return 'success';
if (type === 'warning') return 'warning';
if (type === 'expired') return 'error';
return 'default';
};
/** 到期时间列:剩余 / 过期天数文案 */
const getInsuranceRemainShortText = (status) => {
const { type, diffDays } = status || {};
if (type === 'unuploaded') return '未购买';
if (type === 'expired') return diffDays != null ? `过期${Math.abs(diffDays)}` : '已过期';
if (diffDays != null) return `剩余${diffDays}`;
return '—';
};
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 ICONS = {
vehicle: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>,
success: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>,
warning: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>,
shield: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>,
policy: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>,
};
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-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,.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 .2s ease, border-color .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-card { background: #fff; border-radius: 16px; border: 1px solid #e2e8f0; box-shadow: 0 4px 20px -4px rgba(15, 23, 42, 0.03); overflow: hidden; }
.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: 10px 8px !important; text-align: center; vertical-align: middle; white-space: nowrap; }
.lc-table-th-multiline { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; line-height: 1.3; white-space: normal; }
.lc-table-th-line { display: block; font-size: 12px; font-weight: 700; color: #475569; }
.lc-list-expire-cell { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.lc-list-expire-date { font-size: 12px; font-weight: 600; line-height: 1.35; }
.lc-list-expire-meta { line-height: 1.2; }
.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: 11px; font-weight: 600; white-space: nowrap; }
.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-table-card .ant-table-tbody > tr:not(.ant-table-measure-row) > td { padding: 10px 8px !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-list-plate-cell { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.lc-list-plate-sub { display: block; font-size: 11px; font-weight: 500; line-height: 1.35; }
.lc-list-plate-empty { color: #94a3b8 !important; font-style: italic; font-weight: 500 !important; }
.lc-table-toolbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-left: auto; }
.lc-compare-modal .ant-modal-content { border-radius: 16px; overflow: hidden; }
.lc-compare-modal .ant-modal-header { background: linear-gradient(135deg, #f8fafc 0%, #fff 100%); border-bottom: 1px solid #e2e8f0; padding: 16px 20px; }
.lc-compare-modal .ant-modal-title { font-size: 16px; font-weight: 700; color: #0f172a; }
.lc-compare-modal .ant-modal-body { padding: 16px 20px 12px; max-height: calc(100vh - 180px); overflow: auto; }
.lc-compare-modal .ant-modal-footer { border-top: 1px solid #f1f5f9; padding: 12px 20px 16px; }
.lc-compare-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
.lc-compare-table-wrap { border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; background: #fff; box-shadow: 0 4px 16px -4px rgba(15, 23, 42, 0.06); }
.lc-compare-table .ant-table-thead > tr > th { background: #f1f5f9 !important; color: #334155 !important; font-size: 12px !important; font-weight: 700 !important; padding: 10px 8px !important; white-space: nowrap; border-bottom: 1px solid #e2e8f0 !important; }
.lc-compare-table .ant-table-thead > tr > th.lc-compare-th-key { background: #ecfdf5 !important; color: #065f46 !important; }
.lc-compare-table .ant-table-thead > tr > th.lc-compare-th-auto { background: #f8fafc !important; }
.lc-compare-table .ant-table-thead > tr > th.lc-compare-th-edit { background: #fffbeb !important; color: #92400e !important; }
.lc-compare-table .ant-table-tbody > tr > td { padding: 8px 6px !important; vertical-align: middle !important; font-size: 12px; border-bottom: 1px solid #f1f5f9 !important; }
.lc-compare-table .ant-table-tbody > tr:nth-child(even) > td { background: #fafbfc; }
.lc-compare-table .ant-table-tbody > tr:hover > td { background: #f0fdf4 !important; }
.lc-compare-table .ant-table-tbody > tr.ant-table-row-selected > td { background: #ecfdf5 !important; }
.lc-compare-table .ant-table-thead > tr > th.ant-table-cell-fix-left,
.lc-compare-table .ant-table-thead > tr > th.ant-table-cell-fix-right { background: #f1f5f9 !important; }
.lc-compare-table .ant-table-thead > tr > th.ant-table-cell-fix-left.lc-compare-th-key { background: #ecfdf5 !important; }
.lc-compare-table .ant-table-tbody > tr > td.ant-table-cell-fix-left,
.lc-compare-table .ant-table-tbody > tr > td.ant-table-cell-fix-right { background: #fff !important; }
.lc-compare-table .ant-table-tbody > tr:nth-child(even) > td.ant-table-cell-fix-left,
.lc-compare-table .ant-table-tbody > tr:nth-child(even) > td.ant-table-cell-fix-right { background: #fafbfc !important; }
.lc-compare-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
.lc-compare-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-right { background: #f0fdf4 !important; }
.lc-compare-table .ant-table-tbody > tr.ant-table-row-selected > td.ant-table-cell-fix-left,
.lc-compare-table .ant-table-tbody > tr.ant-table-row-selected > td.ant-table-cell-fix-right { background: #ecfdf5 !important; }
.lc-compare-table .ant-table-cell-fix-left-last::after,
.lc-compare-table .ant-table-cell-fix-right-first::after { box-shadow: inset 10px 0 8px -8px rgba(15, 23, 42, 0.12); }
.lc-compare-cell-select { width: 100%; min-width: 100px; }
.lc-compare-cell-select .ant-select-selector { border-radius: 6px !important; font-size: 12px !important; min-height: 28px !important; }
.lc-compare-cell-input { width: 100%; min-width: 88px; border-radius: 6px; }
.lc-compare-cell-input.ant-input-sm, .lc-compare-cell-input.ant-picker-small { font-size: 12px; }
.lc-compare-readonly { display: block; padding: 4px 8px; min-height: 28px; line-height: 20px; border-radius: 6px; background: #f8fafc; border: 1px solid #f1f5f9; color: #334155; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lc-compare-readonly--wrap { white-space: normal; word-break: break-all; line-height: 1.4; }
.lc-compare-readonly.is-empty { color: #94a3b8; }
.lc-compare-readonly.is-linked { background: #f0fdf4; border-color: #bbf7d0; color: #065f46; font-weight: 500; }
.lc-compare-action-cell { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
.lc-compare-action-cell .ant-btn-link { font-size: 12px; font-weight: 600; padding: 0 4px; height: auto; }
.lc-compare-quote-btn { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; background: #ecfdf5; border: 1px solid #a7f3d0; color: #059669 !important; font-weight: 600 !important; font-size: 12px !important; }
.lc-compare-quote-btn:hover { background: #d1fae5 !important; }
.lc-quote-popover-overlay .ant-popover-inner { padding: 0 !important; border-radius: 14px !important; overflow: hidden; box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.22) !important; border: 1px solid #e2e8f0; }
.lc-quote-popover-overlay .ant-popover-arrow { display: none; }
.lc-quote-card { width: 400px; max-width: 92vw; }
.lc-quote-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 16px; background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 55%, #fff 100%); border-bottom: 1px solid #e2e8f0; }
.lc-quote-card-title { font-size: 15px; font-weight: 700; color: #0f172a; }
.lc-quote-card-plate { font-size: 11px; font-weight: 600; color: #059669; background: #d1fae5; border: 1px solid #a7f3d0; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
.lc-quote-card-body { padding: 14px 16px 16px; max-height: 420px; overflow-y: auto; }
.lc-quote-card-body::-webkit-scrollbar { width: 4px; }
.lc-quote-card-body::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 2px; }
.lc-quote-list-label { font-size: 11px; font-weight: 700; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 8px; }
.lc-quote-empty { text-align: center; padding: 20px 12px; border-radius: 10px; background: #f8fafc; border: 1px dashed #e2e8f0; color: #94a3b8; font-size: 12px; margin-bottom: 14px; }
.lc-quote-item-card { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 8px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff; transition: border-color .2s, box-shadow .2s; }
.lc-quote-item-card:hover { border-color: #cbd5e1; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); }
.lc-quote-item-card.is-selected { border-color: #10b981; background: linear-gradient(135deg, #f0fdf4 0%, #fff 100%); box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.15); }
.lc-quote-item-card .ant-radio { margin-top: 2px; }
.lc-quote-item-main { flex: 1; min-width: 0; }
.lc-quote-item-company { font-size: 13px; font-weight: 600; color: #0f172a; line-height: 1.4; margin-bottom: 4px; }
.lc-quote-item-row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; font-size: 12px; color: #64748b; }
.lc-quote-type-tag { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 4px; background: #eff6ff; border: 1px solid #bfdbfe; color: #1d4ed8; font-size: 11px; font-weight: 600; }
.lc-quote-price { font-size: 14px; font-weight: 700; color: #059669; font-variant-numeric: tabular-nums; }
.lc-quote-price-unit { font-size: 11px; font-weight: 500; color: #64748b; margin-left: 2px; }
.lc-quote-item-del { flex-shrink: 0; padding: 0 4px !important; height: auto !important; font-size: 12px !important; }
.lc-quote-form-wrap { margin-top: 14px; padding-top: 14px; border-top: 1px solid #f1f5f9; }
.lc-quote-form-title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
.lc-quote-form-title::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #10b981; }
.lc-quote-form-field { margin-bottom: 10px; }
.lc-quote-form-label { display: block; font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 5px; }
.lc-quote-form-label-required::after { content: ' *'; color: #ef4444; }
.lc-quote-form-actions { display: flex; justify-content: flex-end; margin-top: 4px; }
.lc-quote-form-actions .ant-btn-primary { border-radius: 8px; font-weight: 600; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; box-shadow: 0 2px 8px rgba(16, 185, 129, 0.25); }
.lc-compare-quote-cell { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.lc-compare-quote-inline-list { display: flex; flex-direction: column; gap: 4px; width: 100%; }
.lc-compare-quote-inline-item { display: flex; align-items: center; gap: 4px; padding: 4px 6px; border-radius: 6px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer; transition: border-color .15s, background .15s; margin: 0 !important; }
.lc-compare-quote-inline-item:hover { border-color: #cbd5e1; background: #f8fafc; }
.lc-compare-quote-inline-item.is-selected { border-color: #10b981; background: #f0fdf4; box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.12); }
.lc-compare-quote-inline-item .ant-radio { margin-right: 0; top: 0; }
.lc-compare-quote-inline-company { flex: 1; min-width: 0; font-size: 11px; font-weight: 600; color: #334155; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lc-compare-quote-inline-price { flex-shrink: 0; font-size: 12px; font-weight: 700; color: #059669; font-variant-numeric: tabular-nums; }
.lc-compare-quote-inline-del { flex-shrink: 0; padding: 0 2px !important; height: 18px !important; min-width: 18px !important; font-size: 14px !important; line-height: 1 !important; color: #94a3b8 !important; }
.lc-compare-quote-inline-del:hover { color: #ef4444 !important; }
.lc-compare-quote-add { padding: 0 !important; height: auto !important; font-size: 12px !important; font-weight: 600 !important; color: #059669 !important; align-self: flex-start; }
.lc-compare-quote-empty-hint { font-size: 11px; color: #94a3b8; line-height: 1.4; }
.lc-quote-card-type-badge { font-size: 11px; font-weight: 600; color: #1d4ed8; background: #eff6ff; border: 1px solid #bfdbfe; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
.lc-compare-footer { margin-top: 14px; padding-top: 14px; border-top: 1px solid #e2e8f0; display: flex; flex-direction: column; gap: 12px; }
.lc-compare-remark-field { display: flex; flex-direction: column; gap: 6px; }
.lc-compare-remark-label { font-size: 13px; font-weight: 600; color: #334155; }
.lc-compare-total-bar { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 16px; padding: 12px 16px; border-radius: 10px; background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 100%); border: 1px solid #bbf7d0; }
.lc-compare-total-label { font-size: 13px; font-weight: 600; color: #334155; }
.lc-compare-total-amount { font-size: 22px; font-weight: 800; color: #059669; font-variant-numeric: tabular-nums; line-height: 1.2; }
.lc-compare-total-unit { font-size: 13px; font-weight: 600; color: #059669; margin-left: 2px; }
.lc-compare-total-hint { font-size: 12px; color: #64748b; margin-left: auto; }
.lc-copy-pop { width: 200px; }
.lc-copy-pop-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
.lc-compare-mgmt-modal .ant-modal-body { padding: 16px 20px 20px; }
.lc-compare-mgmt-filter { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 24px; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid #f1f5f9; }
@media (max-width: 720px) { .lc-compare-mgmt-filter { grid-template-columns: 1fr; } }
.lc-compare-mgmt-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 12px; }
.lc-compare-mgmt-table .ant-table-thead > tr > th { background: #f8fafc !important; font-weight: 700 !important; font-size: 13px !important; }
.lc-compare-mgmt-table .ant-table-tbody > tr > td { font-size: 13px; }
.lc-compare-mgmt-count { font-variant-numeric: tabular-nums; font-weight: 700; color: #0f172a; }
.lc-compare-pay-alert-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; }
.lc-compare-pay-alert { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff; }
.lc-compare-pay-alert--warning { border-color: #fed7aa; background: linear-gradient(135deg, #fff7ed 0%, #fff 80%); }
.lc-compare-pay-alert--overdue { border-color: #fecaca; background: linear-gradient(135deg, #fef2f2 0%, #fff 80%); }
.lc-compare-pay-alert-val { font-size: 22px; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1; }
.lc-compare-pay-alert--warning .lc-compare-pay-alert-val { color: #c2410c; }
.lc-compare-pay-alert--overdue .lc-compare-pay-alert-val { color: #b91c1c; }
.lc-compare-editor-meta { display: flex; flex-wrap: wrap; gap: 12px 20px; margin-bottom: 12px; padding: 10px 14px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; }
.lc-compare-editor-meta-field { display: flex; align-items: center; gap: 8px; min-width: 200px; flex: 1; }
.lc-compare-editor-meta-label { font-size: 12px; font-weight: 600; color: #64748b; white-space: nowrap; }
.lc-compare-procurement-hint { font-size: 12px; color: #64748b; }
.lc-compare-total-bar--procurement { margin-top: 8px; border-color: #bfdbfe; background: linear-gradient(135deg, #eff6ff 0%, #f8fafc 100%); }
.lc-compare-total-bar--procurement .lc-compare-total-amount { color: #1d4ed8; }
.lc-module-tabs.ant-tabs > .ant-tabs-nav { margin-bottom: 12px; }
.lc-module-tabs .ant-tabs-tab { font-weight: 600; font-size: 14px; }
.lc-policy-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.lc-policy-ocr-upload { margin: 12px 0; }
.lc-policy-import-template-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 14px; margin-bottom: 12px; border-radius: 10px; background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 100%); border: 1px solid #a7f3d0; }
.lc-policy-import-template-bar-text { font-size: 12px; color: #047857; line-height: 1.55; flex: 1; min-width: 200px; }
.lc-policy-import-excel-upload { margin: 0; }
.lc-policy-recogn-modal .ant-modal-body { padding: 16px 20px 20px; max-height: calc(100vh - 160px); overflow-y: auto; }
.lc-policy-recogn-task { padding: 10px 14px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; margin-bottom: 14px; font-size: 12px; color: #64748b; }
.lc-policy-recogn-task strong { color: #0f172a; }
.lc-policy-recogn-progress { margin: 16px 0; }
.lc-policy-recogn-file-list { margin-top: 10px; max-height: 140px; overflow-y: auto; }
.lc-policy-recogn-file-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f1f5f9; font-size: 12px; }
.lc-policy-recogn-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.lc-policy-recogn-tasks-filter { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 16px; margin-bottom: 12px; }
@media (max-width: 900px) { .lc-policy-recogn-tasks-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.lc-policy-recogn-tasks-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
.lc-policy-recogn-task-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #334155; }
.lc-policy-recogn-preview { width: 100%; min-height: 360px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; display: flex; align-items: center; justify-content: center; }
.lc-policy-recogn-preview img { max-width: 100%; max-height: 70vh; object-fit: contain; }
.lc-policy-recogn-preview iframe { width: 100%; height: 70vh; border: none; border-radius: 8px; }
.lc-vehicle-ins-mgmt-modal .ant-modal-content { border-radius: 16px; overflow: hidden; box-shadow: 0 24px 64px -16px rgba(15, 23, 42, 0.28); }
.lc-vehicle-ins-mgmt-modal .ant-modal-header { display: none; }
.lc-vehicle-ins-mgmt-modal .ant-modal-body { padding: 0; max-height: calc(100vh - 96px); overflow: hidden; display: flex; flex-direction: column; }
.lc-vehicle-ins-mgmt-modal .ant-modal-footer { border-top: 1px solid #e2e8f0; padding: 12px 20px; background: #f8fafc; }
.lc-vehicle-ins-mgmt-shell { display: flex; flex-direction: column; min-height: 0; flex: 1; }
.lc-vehicle-ins-mgmt-hero { padding: 20px 24px 18px; background: linear-gradient(135deg, #ecfdf5 0%, #f8fafc 42%, #fff 100%); border-bottom: 1px solid #e2e8f0; }
.lc-vehicle-ins-mgmt-hero-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.lc-vehicle-ins-mgmt-hero-title { font-size: 11px; font-weight: 700; color: #059669; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 6px; }
.lc-vehicle-ins-mgmt-plate { font-size: 26px; font-weight: 800; color: #0f172a; letter-spacing: 0.02em; line-height: 1.2; }
.lc-vehicle-ins-mgmt-subtitle { font-size: 13px; color: #64748b; margin-top: 6px; }
.lc-vehicle-ins-mgmt-status-pill { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 999px; font-size: 13px; font-weight: 700; border: 1px solid transparent; }
.lc-vehicle-ins-mgmt-status-pill--success { background: #ecfdf5; border-color: #a7f3d0; color: #047857; }
.lc-vehicle-ins-mgmt-status-pill--warning { background: #fffbeb; border-color: #fde68a; color: #b45309; }
.lc-vehicle-ins-mgmt-status-pill--error { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
.lc-vehicle-ins-mgmt-meta-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px 14px; }
@media (max-width: 768px) { .lc-vehicle-ins-mgmt-meta-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.lc-vehicle-ins-mgmt-meta-card { padding: 10px 12px; border-radius: 10px; background: rgba(255,255,255,.85); border: 1px solid #e2e8f0; min-width: 0; }
.lc-vehicle-ins-mgmt-meta-label { font-size: 11px; color: #94a3b8; font-weight: 600; margin-bottom: 4px; }
.lc-vehicle-ins-mgmt-meta-val { font-size: 13px; font-weight: 600; color: #0f172a; word-break: break-all; line-height: 1.35; }
.lc-vehicle-ins-mgmt-body { padding: 16px 20px 20px; overflow-y: auto; flex: 1; min-height: 0; }
.lc-vehicle-ins-mgmt-legend { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; padding: 10px 12px; border-radius: 10px; background: #f8fafc; border: 1px solid #e2e8f0; }
.lc-vehicle-ins-mgmt-legend-hint { font-size: 12px; color: #64748b; margin-right: 8px; align-self: center; }
.lc-purchase-type-chip { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; border: 1px solid transparent; line-height: 1.5; }
.lc-purchase-type--new { background: #ecfdf5; border-color: #a7f3d0; color: #047857; }
.lc-purchase-type--renew { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
.lc-purchase-type--rent-stop { background: #fff7ed; border-color: #fed7aa; color: #c2410c; }
.lc-purchase-type--resume { background: #ecfeff; border-color: #a5f3fc; color: #0e7490; }
.lc-purchase-type--cancel { background: #f1f5f9; border-color: #cbd5e1; color: #475569; }
.lc-vehicle-ins-mgmt-tabs .ant-tabs-nav { margin-bottom: 0 !important; padding: 0 4px; }
.lc-vehicle-ins-mgmt-tabs .ant-tabs-tab { font-weight: 600; font-size: 13px; padding: 10px 14px !important; transition: color 0.15s; }
.lc-vehicle-ins-mgmt-tabs .ant-tabs-tab-active .ant-tabs-tab-btn { color: #059669 !important; }
.lc-vehicle-ins-mgmt-tabs .ant-tabs-ink-bar { background: #10b981 !important; height: 3px !important; border-radius: 3px 3px 0 0; }
.lc-vehicle-ins-mgmt-tabs .ant-tabs-content-holder { padding-top: 14px; }
.lc-ins-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; margin-left: 6px; border-radius: 999px; background: #e2e8f0; color: #475569; font-size: 11px; font-weight: 700; }
.lc-vehicle-ins-mgmt-tabs .ant-tabs-tab-active .lc-ins-tab-badge { background: #d1fae5; color: #047857; }
.lc-vehicle-ins-timeline { padding: 4px 8px 12px; max-height: 440px; overflow-y: auto; }
.lc-vehicle-ins-timeline-item { cursor: pointer; padding: 12px 14px; margin: 0 0 10px; border-radius: 12px; border: 1px solid #e2e8f0; background: #fff; transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; }
.lc-vehicle-ins-timeline-item:hover { border-color: #a7f3d0; box-shadow: 0 4px 14px -6px rgba(16, 185, 129, 0.35); transform: translateY(-1px); }
.lc-vehicle-ins-timeline-item:focus-visible { outline: 2px solid #10b981; outline-offset: 2px; }
.lc-vehicle-ins-timeline-title { font-size: 13px; font-weight: 700; color: #0f172a; margin-bottom: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.lc-vehicle-ins-timeline-desc { font-size: 12px; color: #475569; line-height: 1.55; }
.lc-vehicle-ins-timeline-meta { font-size: 11px; color: #94a3b8; margin-top: 6px; }
.lc-vehicle-ins-mgmt-table-card { border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; background: #fff; }
.lc-vehicle-ins-mgmt-table-card .ant-table-thead > tr > th { background: #f8fafc !important; font-weight: 700 !important; font-size: 12px !important; color: #475569 !important; }
.lc-vehicle-ins-mgmt-empty { padding: 48px 24px; text-align: center; border-radius: 12px; border: 1px dashed #cbd5e1; background: #f8fafc; }
.lc-ins-history-row--active > td { background: #ecfdf5 !important; }
.lc-ins-history-row--active > td:first-child { box-shadow: inset 3px 0 0 #10b981; }
.lc-policy-detail-form { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
@media (max-width: 720px) { .lc-policy-detail-form { grid-template-columns: 1fr; } }
.lc-policy-detail-form-full { grid-column: 1 / -1; }
.lc-policy-detail-section-title { font-size: 13px; font-weight: 700; color: #334155; margin: 4px 0 8px; grid-column: 1 / -1; }
.lc-coverage-items-editor { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.lc-coverage-items-row { display: flex; align-items: center; gap: 8px; }
.lc-coverage-items-row .ant-input { flex: 1; min-width: 0; }
.lc-coverage-items-index { flex-shrink: 0; width: 22px; font-size: 12px; font-weight: 700; color: #94a3b8; text-align: right; font-variant-numeric: tabular-nums; }
.lc-coverage-items-add { margin-top: 2px; border-radius: 8px !important; font-weight: 600; color: #059669 !important; border-color: #a7f3d0 !important; background: #f0fdf4 !important; }
.lc-coverage-items-add:hover { border-color: #6ee7b7 !important; color: #047857 !important; }
.lc-list-policy-tag { margin-top: 4px; }
.lc-compare-attach-field { display: flex; flex-direction: column; gap: 8px; }
.lc-compare-attach-label { font-size: 13px; font-weight: 600; color: #334155; }
.lc-compare-attach-hint { font-size: 12px; color: #94a3b8; line-height: 1.5; }
.lc-compare-attach-upload .ant-upload-list { max-height: 160px; overflow-y: auto; margin-top: 8px; }
.lc-compare-attach-upload .ant-upload-list-item { border-radius: 8px; }
.lc-compare-attach-upload .ant-upload-select { display: block; }
`;
const goInsuranceEditPage = (ledgerKey, master, vehicle) => {
const label = vehicle
? (hasVehiclePlate(vehicle) ? vehicle.plateNo : `${NO_PLATE_LABEL}${vehicle.vin}`)
: ledgerKey;
try {
sessionStorage.setItem(IPC_EDIT_PLATE_KEY, ledgerKey);
persistInsuranceToStorage(master);
} catch {
/* ignore */
}
if (typeof window.__axhubNavigate === 'function') {
window.__axhubNavigate('保险采购-编辑');
message.success(`已进入 [${label}] 保险维护`);
return;
}
message.info(`已带入 [${label}] 车辆信息,请打开「保险采购-编辑」页面继续维护`);
};
const Component = function () {
const [allInsurance, setAllInsurance] = useState(() => {
const stored = loadInsuranceFromStorage();
if (stored) {
const normalized = {};
Object.keys(stored).forEach((k) => {
normalized[k] = ensureInsuranceRecordShape(stored[k]);
});
return normalized;
}
const merged = {};
MOCK_VEHICLES.forEach((v) => {
const key = getVehicleLedgerKey(v);
const seed = getInitialInsuranceSeed(v);
merged[key] = ensureInsuranceRecordShape(
seed ? JSON.parse(JSON.stringify(seed)) : createEmptyInsuranceRecord()
);
});
return merged;
});
const updateAllInsurance = useCallback((updater) => {
setAllInsurance((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater;
persistInsuranceToStorage(next);
return next;
});
}, []);
const DEFAULT_LIST_FILTERS = {
plateNo: '',
plateNos: '',
vin: '',
brand: '',
model: '',
operateStatus: '全部',
insuranceStatus: '全部',
};
const [listFilters, setListFilters] = useState(() => ({ ...DEFAULT_LIST_FILTERS }));
const [appliedFilters, setAppliedFilters] = useState(() => ({ ...DEFAULT_LIST_FILTERS }));
const [multiPlateOpen, setMultiPlateOpen] = useState(false);
const [multiPlateDraft, setMultiPlateDraft] = useState('');
const [kpiFilter, setKpiFilter] = useState('total');
const [prdOpen, setPrdOpen] = useState(false);
const [compareMgmtOpen, setCompareMgmtOpen] = useState(false);
const [compareSheets, setCompareSheets] = useState(() => {
const stored = loadCompareSheetsFromStorage();
const list = stored && stored.length ? stored : createMockCompareSheets();
return list.map(normalizeCompareSheet);
});
const [compareMgmtFilters, setCompareMgmtFilters] = useState(() => ({ ...DEFAULT_COMPARE_MGMT_FILTERS }));
const [appliedCompareMgmtFilters, setAppliedCompareMgmtFilters] = useState(() => ({ ...DEFAULT_COMPARE_MGMT_FILTERS }));
const [compareModalOpen, setCompareModalOpen] = useState(false);
const [editingCompareSheetId, setEditingCompareSheetId] = useState(null);
const [compareRows, setCompareRows] = useState([]);
const [selectedCompareKeys, setSelectedCompareKeys] = useState([]);
const [copyPopoverRowId, setCopyPopoverRowId] = useState(null);
const [copyCountDraft, setCopyCountDraft] = useState(1);
const [quoteDraft, setQuoteDraft] = useState(createEmptyQuoteDraft);
const [quoteEditRowId, setQuoteEditRowId] = useState(null);
const [compareRemark, setCompareRemark] = useState('');
const [compareSheetPeriod, setCompareSheetPeriod] = useState('');
const [compareAttachmentFileList, setCompareAttachmentFileList] = useState([]);
const [procurementFlowOpen, setProcurementFlowOpen] = useState(false);
const [procurementFlowStep, setProcurementFlowStep] = useState(0);
const [procurementFlowLoading, setProcurementFlowLoading] = useState(false);
const [policyRecognOpen, setPolicyRecognOpen] = useState(false);
const [policyRecognEntry, setPolicyRecognEntry] = useState('ocr');
const [policyRecognMode, setPolicyRecognMode] = useState('policy');
const [policyRecognInsuranceType, setPolicyRecognInsuranceType] = useState('交强险');
const [policyRecognPhase, setPolicyRecognPhase] = useState('upload');
const [policyRecognFiles, setPolicyRecognFiles] = useState([]);
const [policyRecognProgress, setPolicyRecognProgress] = useState(0);
const [policyRecognTaskId, setPolicyRecognTaskId] = useState('');
const [policyRecognResults, setPolicyRecognResults] = useState([]);
const [policyRecognViewOnly, setPolicyRecognViewOnly] = useState(false);
const [policyPreview, setPolicyPreview] = useState(null);
const [policyRecognTasks, setPolicyRecognTasks] = useState(() => {
const stored = loadPolicyRecognTasksFromStorage();
return stored && stored.length ? stored : createMockPolicyRecognTasks();
});
const [policyRecognTasksOpen, setPolicyRecognTasksOpen] = useState(false);
const [policyRecognTasksFilters, setPolicyRecognTasksFilters] = useState(() => ({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS }));
const [appliedPolicyRecognTasksFilters, setAppliedPolicyRecognTasksFilters] = useState(() => ({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS }));
const [policyAddOpen, setPolicyAddOpen] = useState(false);
const [vehicleInsMgmtOpen, setVehicleInsMgmtOpen] = useState(false);
const [vehicleInsMgmtVehicle, setVehicleInsMgmtVehicle] = useState(null);
const [vehicleInsMgmtActiveTab, setVehicleInsMgmtActiveTab] = useState('timeline');
const [vehicleInsMgmtHighlightId, setVehicleInsMgmtHighlightId] = useState('');
const [policyAddDraft, setPolicyAddDraft] = useState(() => ({ ...EMPTY_POLICY_DETAIL }));
const [policyRecognEditOpen, setPolicyRecognEditOpen] = useState(false);
const [policyRecognEditId, setPolicyRecognEditId] = useState('');
const [policyRecognEditDraft, setPolicyRecognEditDraft] = useState(() => ({ ...EMPTY_POLICY_DETAIL }));
const [insuranceHistoryEdits, setInsuranceHistoryEdits] = useState(() => loadInsuranceHistoryEditsFromStorage());
const [vehicleInsHistoryEditOpen, setVehicleInsHistoryEditOpen] = useState(false);
const [vehicleInsHistoryEditRecord, setVehicleInsHistoryEditRecord] = useState(null);
const [vehicleInsHistoryEditDraft, setVehicleInsHistoryEditDraft] = useState(() => ({ ...EMPTY_POLICY_DETAIL }));
const compareSheetSummary = useMemo(
() => calcCompareSheetConfirmedTotal(compareRows),
[compareRows]
);
const selectedProcurementSummary = useMemo(() => {
const selected = compareRows.filter((r) => selectedCompareKeys.includes(r.id));
return calcCompareSheetConfirmedTotal(selected);
}, [compareRows, selectedCompareKeys]);
const compareMgmtPayAlerts = useMemo(() => {
let warning = 0;
let overdue = 0;
compareSheets.forEach((sheet) => {
const alert = calcCompareSheetPayAlerts(sheet);
warning += alert.warning;
overdue += alert.overdue;
});
return { warning, overdue };
}, [compareSheets]);
const saveCompareSheets = useCallback((nextSheets) => {
setCompareSheets(nextSheets);
persistCompareSheetsToStorage(nextSheets);
}, []);
const filteredCompareSheets = useMemo(() => {
const plateKey = (appliedCompareMgmtFilters.plateNo || '').trim();
const range = appliedCompareMgmtFilters.createdRange;
return compareSheets
.filter((sheet) => compareSheetMatchesCreatedRange(sheet.createdAt, range))
.filter((sheet) => compareSheetMatchesPlateFilter(sheet, plateKey))
.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
}, [compareSheets, appliedCompareMgmtFilters]);
const updateCompareRow = useCallback((rowId, patch) => {
setCompareRows((prev) => prev.map((r) => (r.id === rowId ? { ...r, ...patch } : r)));
}, []);
const fillCompareRowFromVehicle = useCallback((rowId, vehicle) => {
if (!vehicle) return;
updateCompareRow(rowId, buildVehicleComparePatch(vehicle, allInsurance));
}, [allInsurance, updateCompareRow]);
const fillCompareRowFromPlate = useCallback((rowId, plateNo) => {
const vehicle = findVehicleByPlate(plateNo);
if (!vehicle) return;
fillCompareRowFromVehicle(rowId, vehicle);
}, [fillCompareRowFromVehicle]);
const handleComparePlateChange = useCallback((rowId, plateNo) => {
if (!plateNo) {
updateCompareRow(rowId, clearVehicleComparePatch());
return;
}
fillCompareRowFromPlate(rowId, plateNo);
}, [fillCompareRowFromPlate, updateCompareRow]);
const handleCompareVinChange = useCallback((rowId, vin) => {
if (!vin) {
updateCompareRow(rowId, clearVehicleComparePatch());
return;
}
const vehicle = findVehicleByVin(vin);
if (!vehicle) {
message.warning('未找到该 VIN 对应车辆');
return;
}
fillCompareRowFromVehicle(rowId, vehicle);
}, [fillCompareRowFromVehicle, updateCompareRow]);
const renderReadonlyField = (val, linked) => (
<span
className={`lc-compare-readonly lc-compare-readonly--wrap${val ? (linked ? ' is-linked' : '') : ' is-empty'}`}
title={val || ''}
>
{val || '—'}
</span>
);
const renderReadonlyDate = (val, linked) => (
<span className={`lc-compare-readonly${val ? (linked ? ' is-linked' : '') : ' is-empty'}`}>
{val || '—'}
</span>
);
const openCompareMgmtModal = () => {
setCompareMgmtFilters({ ...DEFAULT_COMPARE_MGMT_FILTERS });
setAppliedCompareMgmtFilters({ ...DEFAULT_COMPARE_MGMT_FILTERS });
setCompareMgmtOpen(true);
};
const openCompareEditor = (sheet) => {
if (sheet) {
setEditingCompareSheetId(sheet.id);
setCompareRows(normalizeCompareRows(JSON.parse(JSON.stringify(sheet.rows || []))));
setCompareRemark(sheet.remark || '');
setCompareSheetPeriod(sheet.periodLabel || '');
setCompareAttachmentFileList(attachmentsToUploadFileList(sheet.attachments));
} else {
setEditingCompareSheetId(null);
setCompareRows([createEmptyCompareRow()]);
setCompareRemark('');
setCompareSheetPeriod(moment ? moment(ANCHOR_TODAY).format('YYYY年M月') : '2026年6月');
setCompareAttachmentFileList([]);
}
setSelectedCompareKeys([]);
setQuoteDraft(createEmptyQuoteDraft());
setQuoteEditRowId(null);
setCompareModalOpen(true);
};
const buildSheetPayloadFromEditor = (rowsSnapshot) => normalizeCompareSheet({
id: editingCompareSheetId || createCompareSheetId(),
createdAt: editingCompareSheetId
? (compareSheets.find((s) => s.id === editingCompareSheetId)?.createdAt || formatCompareSheetNow())
: formatCompareSheetNow(),
createdBy: editingCompareSheetId
? (compareSheets.find((s) => s.id === editingCompareSheetId)?.createdBy || PROTO_COMPARE_CREATOR)
: PROTO_COMPARE_CREATOR,
periodLabel: compareSheetPeriod,
remark: compareRemark,
attachments: uploadFileListToAttachments(compareAttachmentFileList),
rows: rowsSnapshot,
});
const handleCompareAttachmentChange = ({ fileList }) => {
setCompareAttachmentFileList(
fileList.map((f) => ({
...f,
status: 'done',
uploadedAt: f.uploadedAt || formatCompareSheetNow(),
}))
);
};
const handleCompareMgmtQuery = () => {
setAppliedCompareMgmtFilters({ ...compareMgmtFilters });
message.success('查询完成');
};
const handleCompareMgmtReset = () => {
setCompareMgmtFilters({ ...DEFAULT_COMPARE_MGMT_FILTERS });
setAppliedCompareMgmtFilters({ ...DEFAULT_COMPARE_MGMT_FILTERS });
message.info('筛选条件已重置');
};
const handleDeleteCompareSheet = (sheet) => {
Modal.confirm({
title: '删除比价单',
content: `确定删除 ${sheet.createdAt || ''}${sheet.createdBy || '—'} 创建的比价单吗?删除后不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
centered: true,
onOk: () => {
const next = compareSheets.filter((s) => s.id !== sheet.id);
saveCompareSheets(next);
message.success('比价单已删除');
},
});
};
const handleAddCompareRow = () => {
setCompareRows((prev) => [...prev, createEmptyCompareRow()]);
};
const handleDeleteCompareRow = (rowId) => {
setCompareRows((prev) => prev.filter((r) => r.id !== rowId));
setSelectedCompareKeys((prev) => prev.filter((k) => k !== rowId));
};
const handleCopyCompareRow = (row, count) => {
const n = Math.max(1, Math.min(50, Number(count) || 1));
const clones = Array.from({ length: n }, () => cloneCompareRow(row));
setCompareRows((prev) => {
const idx = prev.findIndex((r) => r.id === row.id);
if (idx < 0) return [...prev, ...clones];
const next = [...prev];
next.splice(idx + 1, 0, ...clones);
return next;
});
setCopyPopoverRowId(null);
setCopyCountDraft(1);
message.success(`已复制 ${n} 条记录`);
};
const handleAddQuote = (rowId, rowInsuranceType) => {
if (!quoteDraft.company) {
message.warning('请选择保险公司');
return;
}
if (!isValidPremium(quoteDraft.premium)) {
message.warning('请输入大于 0 的报价,最多两位小数');
return;
}
const premium = formatPremiumDisplay(quoteDraft.premium);
const newQuote = {
id: createQuoteId(),
company: quoteDraft.company,
premium,
};
let added = false;
setCompareRows((prev) => prev.map((r) => {
if (r.id !== rowId) return r;
const exists = (r.quotes || []).some((q) => q.company === quoteDraft.company);
if (exists) {
message.warning('该保险公司报价已存在');
return r;
}
added = true;
return { ...r, quotes: [...(r.quotes || []), newQuote] };
}));
if (added) {
setQuoteDraft(createEmptyQuoteDraft());
message.success(`已添加${rowInsuranceType || ''}报价`);
}
};
const handleRemoveQuote = (rowId, quoteId) => {
setCompareRows((prev) => prev.map((r) => {
if (r.id !== rowId) return r;
const quotes = (r.quotes || []).filter((q) => q.id !== quoteId);
const confirmedQuoteId = r.confirmedQuoteId === quoteId ? '' : r.confirmedQuoteId;
return { ...r, quotes, confirmedQuoteId };
}));
};
const handleSubmitCompareSheet = (options = {}) => {
const { closeModal = true } = options;
if (!compareRows.length) {
message.warning('请至少添加一条购买记录');
return null;
}
const missingVehicle = compareRows.find((r) => !(r.plateNo || '').trim() && !(r.vin || '').trim());
if (missingVehicle) {
message.warning('存在未选择车辆的记录,请填写车牌或 VIN');
return null;
}
const rowsSnapshot = normalizeCompareRows(JSON.parse(JSON.stringify(compareRows)));
const payload = buildSheetPayloadFromEditor(rowsSnapshot);
let nextSheets;
let savedId = payload.id;
if (editingCompareSheetId) {
nextSheets = compareSheets.map((s) => (s.id === editingCompareSheetId ? payload : s));
message.success(`比价单已保存,共 ${rowsSnapshot.length} 条购买记录`);
} else {
nextSheets = [payload, ...compareSheets];
message.success(`比价单已创建,共 ${rowsSnapshot.length} 条购买记录`);
savedId = payload.id;
}
saveCompareSheets(nextSheets);
setEditingCompareSheetId(savedId);
if (closeModal) {
setCompareModalOpen(false);
}
return savedId;
};
const handleSubmitProcurement = () => {
if (!selectedCompareKeys.length) {
message.warning('请勾选需要提交采购的购买记录');
return;
}
const selectedRows = compareRows.filter((r) => selectedCompareKeys.includes(r.id));
const invalid = selectedRows.find((r) => !r.confirmedQuoteId || !r.latestPayDate);
if (invalid) {
message.warning('勾选记录须已选定确认报价并填写最晚付费日期');
return;
}
const alreadySubmitted = selectedRows.find((r) => r.procurementStatus === 'submitted' || r.procurementStatus === 'completed');
if (alreadySubmitted) {
message.warning('勾选记录中包含已提交采购项,请重新选择');
return;
}
if (!editingCompareSheetId) {
Modal.confirm({
title: '保存并提交采购',
content: '提交采购前将先保存当前比价单,是否继续?',
okText: '继续',
cancelText: '取消',
centered: true,
onOk: () => {
const savedId = handleSubmitCompareSheet({ closeModal: false });
if (savedId) {
setProcurementFlowStep(0);
setProcurementFlowLoading(false);
setProcurementFlowOpen(true);
}
},
});
return;
}
setProcurementFlowStep(0);
setProcurementFlowLoading(false);
setProcurementFlowOpen(true);
};
const runProcurementWorkflow = () => {
setProcurementFlowLoading(true);
setProcurementFlowStep(1);
window.setTimeout(() => setProcurementFlowStep(2), 650);
window.setTimeout(() => {
const submittedAt = formatCompareSheetNow();
const rowsSnapshot = compareRows.map((r) => (
selectedCompareKeys.includes(r.id)
? { ...r, procurementStatus: 'submitted', procurementSubmittedAt: submittedAt }
: r
));
const payload = buildSheetPayloadFromEditor(rowsSnapshot);
const nextSheets = compareSheets.map((s) => (s.id === payload.id ? payload : s));
saveCompareSheets(nextSheets);
setCompareRows(rowsSnapshot);
setProcurementFlowStep(3);
setProcurementFlowLoading(false);
message.success(`采购申请已发起(原型),已提交 ${selectedCompareKeys.length} 条,工作流单号 WF-INS-${Date.now().toString().slice(-6)}`);
setSelectedCompareKeys([]);
}, 1300);
};
const resolvePolicyVehicleKey = (plateOrVin) => {
const key = (plateOrVin || '').trim();
if (!key) return null;
const byPlate = findVehicleByPlate(key);
if (byPlate) return getVehicleLedgerKey(byPlate);
const byVin = findVehicleByVin(key);
if (byVin) return getVehicleLedgerKey(byVin);
return null;
};
const savePolicyRecognTaskSnapshot = useCallback((snapshot) => {
const {
taskId,
entry,
mode,
insuranceType,
results,
phase,
completedAt,
} = snapshot;
if (!taskId || !results?.length) return;
setPolicyRecognTasks((prev) => {
const existing = prev.find((t) => t.id === taskId);
const record = buildPolicyRecognTaskRecord({
id: taskId,
entry: entry ?? existing?.entry ?? 'ocr',
mode: mode ?? existing?.mode ?? 'policy',
insuranceType: insuranceType ?? existing?.insuranceType ?? '',
results,
createdAt: existing?.createdAt,
creator: existing?.creator,
completedAt: completedAt ?? existing?.completedAt ?? '',
phase: phase ?? existing?.phase ?? 'results',
});
const idx = prev.findIndex((t) => t.id === taskId);
const next = idx >= 0
? prev.map((t, i) => (i === idx ? record : t))
: [record, ...prev];
persistPolicyRecognTasksToStorage(next);
return next;
});
}, []);
const vehicleInsuranceHistory = useMemo(() => {
if (!vehicleInsMgmtVehicle) return { timeline: [], byType: {}, ledgerKey: '' };
const built = buildVehicleInsuranceHistory(
vehicleInsMgmtVehicle,
allInsurance,
compareSheets,
policyRecognTasks
);
return applyHistoryEditsToVehicleHistory(built, insuranceHistoryEdits);
}, [vehicleInsMgmtVehicle, allInsurance, compareSheets, policyRecognTasks, insuranceHistoryEdits]);
const openVehicleInsuranceMgmt = (vehicle) => {
setVehicleInsMgmtVehicle(vehicle);
setVehicleInsMgmtActiveTab('timeline');
setVehicleInsMgmtHighlightId('');
setVehicleInsMgmtOpen(true);
};
const jumpToVehicleInsuranceRecord = (typeKey, recordId) => {
setVehicleInsMgmtActiveTab(typeKey);
setVehicleInsMgmtHighlightId(recordId);
window.setTimeout(() => setVehicleInsMgmtHighlightId(''), 3200);
};
const handleInsuranceRecordPreview = (record) => {
Modal.info({
title: `预览 · ${record.fileName}`,
width: 520,
centered: true,
content: (
<div style={{ padding: '12px 0', color: '#64748b', fontSize: 13, lineHeight: 1.6 }}>
<div><strong style={{ color: '#334155' }}>险种</strong>{record.typeLabel}</div>
<div><strong style={{ color: '#334155' }}>保单号</strong>{record.policyNo || ''}</div>
<div><strong style={{ color: '#334155' }}>保险公司</strong>{record.company || ''}</div>
<div style={{ marginTop: 12 }}>正式环境将内嵌 PDF / 图片预览原型仅展示附件名称</div>
</div>
),
okText: '关闭',
});
};
const handleInsuranceRecordDownload = (record) => {
message.success(`已开始下载:${record.fileName || '保单附件'}(原型)`);
};
const syncVehicleInsHistoryEditToLedger = (record, detail) => {
if (!vehicleInsMgmtVehicle || record.source !== 'ledger') return;
const ledgerKey = getVehicleLedgerKey(vehicleInsMgmtVehicle);
if (!ledgerKey) return;
const ledgerEvents = new Set(['purchase', 'suspend', 'cancel']);
if (!ledgerEvents.has(record.eventType)) return;
const mode = bizTypeToRecognMode(detail.bizType);
updateAllInsurance((prev) => {
const rec = ensureInsuranceRecordShape(prev[ledgerKey] || createEmptyInsuranceRecord());
const item = rec[record.typeKey];
if (!item?.policyNo) return prev;
if (record.eventType === 'purchase' && item.policyNo !== record.policyNo) return prev;
const nextItem = applyPolicyDetailToInsuranceItem({ ...item }, detail, mode);
nextItem.updateTime = formatCompareSheetNow();
nextItem.updateUser = PROTO_COMPARE_CREATOR;
return { ...prev, [ledgerKey]: { ...rec, [record.typeKey]: nextItem } };
});
};
const syncVehicleInsHistoryEditToRecognTask = (record, detail) => {
if (record.source !== 'recognize' || !record.recognizeTaskId || !record.recognizeResultId) return;
setPolicyRecognTasks((prev) => {
const next = prev.map((task) => {
if (task.id !== record.recognizeTaskId) return task;
const results = (task.results || []).map((r) => (
r.id === record.recognizeResultId
? mergeRecognResultWithDetail(r, detail)
: r
));
return { ...task, results };
});
persistPolicyRecognTasksToStorage(next);
return next;
});
};
const openVehicleInsHistoryEdit = (record) => {
if (!vehicleInsMgmtVehicle) return;
setVehicleInsHistoryEditRecord(record);
setVehicleInsHistoryEditDraft(historyRecordToPolicyDetail(record, vehicleInsMgmtVehicle));
setVehicleInsHistoryEditOpen(true);
};
const saveVehicleInsHistoryEdit = () => {
if (!vehicleInsHistoryEditRecord) return;
const detail = normalizePolicyDetail(vehicleInsHistoryEditDraft);
if (!detail.policyNo && !detail.endDate) {
message.warning('请至少填写保单号或到期日期');
return;
}
const record = vehicleInsHistoryEditRecord;
setInsuranceHistoryEdits((prev) => {
const next = { ...prev, [record.id]: detail };
persistInsuranceHistoryEditsToStorage(next);
return next;
});
syncVehicleInsHistoryEditToLedger(record, detail);
syncVehicleInsHistoryEditToRecognTask(record, detail);
setVehicleInsHistoryEditOpen(false);
setVehicleInsHistoryEditRecord(null);
message.success('已保存保单要素');
};
const renderPurchaseTypeChip = (purchaseType) => {
const meta = POLICY_PURCHASE_TYPE_META[purchaseType] || { label: purchaseType, chipClass: '' };
return (
<span className={`lc-purchase-type-chip ${meta.chipClass || ''}`}>{meta.label}</span>
);
};
const vehicleInsMgmtTabCounts = useMemo(() => {
const counts = { timeline: vehicleInsuranceHistory.timeline.length };
VEHICLE_INSURANCE_MGMT_TABS.filter((t) => t.key !== 'timeline').forEach((tab) => {
counts[tab.key] = (vehicleInsuranceHistory.byType[tab.key] || []).length;
});
return counts;
}, [vehicleInsuranceHistory]);
const vehicleInsuranceHistoryColumns = [
{
title: '业务时间',
dataIndex: 'purchaseTime',
width: 104,
render: (val) => <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>{val || '—'}</span>,
},
{
title: '类型',
dataIndex: 'purchaseType',
width: 76,
render: (val) => renderPurchaseTypeChip(val),
},
{
title: '保单号',
dataIndex: 'policyNo',
width: 140,
ellipsis: true,
},
{
title: '保险公司',
dataIndex: 'company',
width: 168,
ellipsis: true,
render: (val) => val || '—',
},
{
title: '付款时间',
dataIndex: 'payTime',
width: 108,
ellipsis: true,
render: (val) => val || '—',
},
{
title: '生效日',
dataIndex: 'startDate',
width: 96,
render: (val) => val || '—',
},
{
title: '到期日',
dataIndex: 'endDate',
width: 96,
render: (val) => val || '—',
},
{
title: '金额',
dataIndex: 'premium',
width: 88,
align: 'right',
render: (val, record) => (
val ? (
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: record.purchaseType === 'cancel' ? '#b45309' : '#047857' }}>
{record.purchaseType === 'cancel' ? '-' : ''}¥{val}
</span>
) : '—'
),
},
{
title: '操作',
key: 'action',
width: 148,
fixed: 'right',
render: (_, record) => (
<Space size={4}>
<Button type="link" size="small" style={{ padding: 0, fontWeight: 600 }} onClick={() => openVehicleInsHistoryEdit(record)}>编辑</Button>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => handleInsuranceRecordPreview(record)}>预览</Button>
<Button type="link" size="small" style={{ padding: 0, fontWeight: 600, color: '#059669' }} onClick={() => handleInsuranceRecordDownload(record)}>下载</Button>
</Space>
),
},
];
const renderVehicleInsuranceTypeTab = (typeKey) => {
const rows = vehicleInsuranceHistory.byType[typeKey] || [];
const tabLabel = VEHICLE_INSURANCE_MGMT_TABS.find((t) => t.key === typeKey)?.label || '';
if (!rows.length) {
return (
<div className="lc-vehicle-ins-mgmt-empty">
<div style={{ fontSize: 15, fontWeight: 700, color: '#334155', marginBottom: 8 }}>{tabLabel}暂无记录</div>
<div style={{ fontSize: 13, color: '#64748b' }}>该险种首次购买为新保此前已购后再投保记为续保</div>
</div>
);
}
return (
<div className="lc-vehicle-ins-mgmt-table-card">
<Table
className="lc-vehicle-ins-mgmt-table"
size="small"
rowKey="id"
dataSource={rows}
columns={vehicleInsuranceHistoryColumns}
pagination={rows.length > 8 ? { pageSize: 8, showSizeChanger: false, size: 'small' } : false}
scroll={{ x: 1020 }}
rowClassName={(record) => (record.id === vehicleInsMgmtHighlightId ? 'lc-ins-history-row--active' : '')}
/>
</div>
);
};
const renderVehicleInsMgmtTabLabel = (tab) => {
const count = vehicleInsMgmtTabCounts[tab.key] || 0;
return (
<span>
{tab.label}
{count > 0 ? <span className="lc-ins-tab-badge">{count}</span> : null}
</span>
);
};
const filteredPolicyRecognTasks = useMemo(() => {
const taskKey = (appliedPolicyRecognTasksFilters.taskId || '').trim().toUpperCase();
const entry = appliedPolicyRecognTasksFilters.entry;
const status = appliedPolicyRecognTasksFilters.status;
const range = appliedPolicyRecognTasksFilters.createdRange;
return [...policyRecognTasks]
.filter((task) => {
if (taskKey && !(task.id || '').toUpperCase().includes(taskKey)) return false;
if (entry && entry !== '全部' && task.entryLabel !== entry) return false;
if (status && status !== '全部') {
const meta = POLICY_RECOGN_STATUS_META[task.status];
if ((meta?.label || task.status) !== status) return false;
}
return compareSheetMatchesCreatedRange(task.createdAt, range);
})
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
}, [policyRecognTasks, appliedPolicyRecognTasksFilters]);
const openPolicyRecognTasksModal = () => {
setPolicyRecognTasksFilters({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS });
setAppliedPolicyRecognTasksFilters({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS });
setPolicyRecognTasksOpen(true);
};
const handlePolicyRecognTasksQuery = () => {
setAppliedPolicyRecognTasksFilters({ ...policyRecognTasksFilters });
message.success('已刷新任务列表');
};
const handlePolicyRecognTasksReset = () => {
setPolicyRecognTasksFilters({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS });
setAppliedPolicyRecognTasksFilters({ ...DEFAULT_POLICY_RECOGN_TASK_FILTERS });
};
const openPolicyRecogn = (entry, initialMode = 'policy') => {
setPolicyRecognEntry(entry);
setPolicyRecognMode(entry === 'import' ? 'policy' : initialMode);
setPolicyRecognInsuranceType('交强险');
setPolicyRecognPhase('upload');
setPolicyRecognFiles([]);
setPolicyRecognProgress(0);
setPolicyRecognTaskId('');
setPolicyRecognResults([]);
setPolicyRecognViewOnly(false);
setPolicyPreview(null);
setPolicyRecognOpen(true);
};
const openPolicyRecognTaskRecord = (task) => {
if (!task?.id || !task.results?.length) {
message.warning('任务记录无效');
return;
}
setPolicyRecognEntry(task.entry || 'ocr');
setPolicyRecognMode(task.mode || 'policy');
setPolicyRecognInsuranceType(task.insuranceType || '交强险');
setPolicyRecognTaskId(task.id);
setPolicyRecognResults(task.results.map((r) => ({ ...r })));
setPolicyRecognFiles([]);
setPolicyRecognProgress(100);
setPolicyRecognViewOnly(task.status === 'completed');
const phase = task.status === 'completed'
? 'results'
: (task.phase === 'recognized' ? 'recognized' : 'results');
setPolicyRecognPhase(phase);
setPolicyPreview(null);
setPolicyRecognOpen(true);
setPolicyRecognTasksOpen(false);
};
const closePolicyRecogn = () => {
if (policyRecognTaskId && policyRecognResults.length) {
const status = derivePolicyRecognTaskStatus(policyRecognResults);
savePolicyRecognTaskSnapshot({
taskId: policyRecognTaskId,
entry: policyRecognEntry,
mode: policyRecognMode,
insuranceType: policyRecognInsuranceType,
results: policyRecognResults,
phase: policyRecognPhase,
completedAt: status === 'completed' ? formatCompareSheetNow() : undefined,
});
}
if (policyPreview?.url) URL.revokeObjectURL(policyPreview.url);
setPolicyPreview(null);
setPolicyRecognOpen(false);
};
const policyRecognAllUploaded = policyRecognFiles.length > 0
&& policyRecognFiles.every((f) => f.status === 'done');
const handlePolicyRecognUploadChange = ({ fileList }) => {
const incoming = fileList.filter((f) => f.status !== 'removed');
const valid = incoming.filter((f) => isPolicyRecognImageOrPdf(f));
if (valid.length < incoming.length) {
message.warning('已忽略不支持格式,仅支持 PDF / 图片');
}
const next = valid.map((f) => {
if (f.status === 'done') return f;
return { ...f, status: 'uploading', percent: f.percent || 0 };
});
setPolicyRecognFiles(next);
next.forEach((f, i) => {
if (f.status === 'uploading') {
window.setTimeout(() => {
setPolicyRecognFiles((prev) => prev.map((p) => (
p.uid === f.uid ? { ...p, status: 'done', percent: 100 } : p
)));
}, 500 + i * 280);
}
});
};
const handlePolicyImportUploadChange = ({ fileList }) => {
const incoming = fileList.filter((f) => f.status !== 'removed').slice(-1);
const valid = incoming.filter((f) => isPolicyImportExcelFile(f));
if (incoming.length && !valid.length) {
message.warning('请上传 Excel 模板文件(.csv、.xlsx、.xls');
return;
}
const next = valid.map((f) => (
f.status === 'done' ? f : { ...f, status: 'done', percent: 100 }
));
setPolicyRecognFiles(next);
};
const startPolicyExcelImportTask = async () => {
const fileItem = policyRecognFiles.find((f) => f.status === 'done');
const file = fileItem?.originFileObj;
if (!file) {
message.warning('请先上传 Excel 导入文件');
return;
}
const taskId = createPolicyRecognTaskId();
setPolicyRecognPhase('recognizing');
setPolicyRecognTaskId(taskId);
setPolicyRecognProgress(0);
let progress = 0;
const progTimer = window.setInterval(() => {
progress = Math.min(92, progress + 14);
setPolicyRecognProgress(progress);
}, 220);
try {
const text = await readPolicyImportFileAsText(file);
window.clearInterval(progTimer);
if (!String(text).trim()) {
setPolicyRecognPhase('upload');
return;
}
const rows = parsePolicyImportFileText(text);
if (!rows.length) {
message.error('未解析到有效数据,请按模板填写车牌/VIN、险种、保单号等字段');
setPolicyRecognPhase('upload');
return;
}
const results = buildImportResultsFromRows(rows, allInsurance);
setPolicyRecognProgress(100);
setPolicyRecognResults(results);
setPolicyRecognPhase('recognized');
savePolicyRecognTaskSnapshot({
taskId,
entry: 'import',
mode: 'policy',
insuranceType: '',
results,
phase: 'recognized',
});
const matchedN = results.filter((r) => r.matched).length;
message.success(`已解析 ${results.length} 条,${matchedN} 条已匹配台账,请点击「处理」核对`);
} catch {
window.clearInterval(progTimer);
message.error('导入文件读取失败,请重试');
setPolicyRecognPhase('upload');
}
};
const startPolicyRecognTask = () => {
if (!policyRecognAllUploaded) {
message.warning(policyRecognEntry === 'import' ? '请先上传 Excel 文件' : '请等待全部文件上传完成');
return;
}
if (policyRecognEntry === 'import') {
startPolicyExcelImportTask();
return;
}
if (policyRecognMode === 'policy' && !policyRecognInsuranceType) {
message.warning('请选择保险类型');
return;
}
const taskId = createPolicyRecognTaskId();
const entrySnap = policyRecognEntry;
const modeSnap = policyRecognMode;
const insuranceSnap = policyRecognInsuranceType;
const filesSnap = policyRecognFiles;
setPolicyRecognPhase('recognizing');
setPolicyRecognTaskId(taskId);
setPolicyRecognProgress(0);
let progress = 0;
const timer = window.setInterval(() => {
progress += 10 + Math.floor(Math.random() * 8);
if (progress >= 100) {
window.clearInterval(timer);
setPolicyRecognProgress(100);
const results = buildMockOcrResults(
filesSnap,
modeSnap,
insuranceSnap,
allInsurance
);
setPolicyRecognResults(results);
setPolicyRecognPhase('recognized');
savePolicyRecognTaskSnapshot({
taskId,
entry: entrySnap,
mode: modeSnap,
insuranceType: insuranceSnap,
results,
phase: 'recognized',
});
message.success('识别完成,已写入任务记录,请点击「处理」查看结果');
} else {
setPolicyRecognProgress(Math.min(99, progress));
}
}, 380);
};
const openPolicyRecognResults = () => {
if (!policyRecognResults.length) {
message.warning('暂无识别结果');
return;
}
setPolicyRecognPhase('results');
if (policyRecognTaskId) {
savePolicyRecognTaskSnapshot({
taskId: policyRecognTaskId,
entry: policyRecognEntry,
mode: policyRecognMode,
insuranceType: policyRecognInsuranceType,
results: policyRecognResults,
phase: 'results',
});
}
};
const handlePreviewPolicyResult = (result) => {
const file = policyRecognFiles.find((f) => f.uid === result.fileUid);
if (policyPreview?.url) URL.revokeObjectURL(policyPreview.url);
if (file?.originFileObj && (file.type || '').startsWith('image/')) {
const url = URL.createObjectURL(file.originFileObj);
setPolicyPreview({ url, fileName: result.fileName, isImage: true });
return;
}
setPolicyPreview({
url: null,
fileName: result.fileName,
isImage: false,
hint: policyRecognFiles.length
? 'PDF 预览(原型):正式环境将内嵌预览识别原件'
: '任务记录未保存原件,正式环境可从附件库查看',
});
};
const mergeRecognResultWithDetail = (result, detail) => {
const rebuilt = buildRecognResultFromDetail(
{
id: result.id,
fileUid: result.fileUid,
fileName: result.fileName,
fileType: result.fileType,
},
detail,
allInsurance,
bizTypeToRecognMode(detail.bizType)
);
return { ...result, ...rebuilt, id: result.id, confirmed: result.confirmed };
};
const renderPolicyDetailForm = (draft, setDraft, options = {}) => {
const { showBizType = true } = options;
const coverageRows = getCoverageItemsFormRows(draft.coverageItems);
const updateCoverageRow = (idx, value) => {
const next = [...coverageRows];
next[idx] = value;
setDraft((p) => ({ ...p, coverageItems: next }));
};
const addCoverageRow = () => {
setDraft((p) => ({
...p,
coverageItems: [...getCoverageItemsFormRows(p.coverageItems), ''],
}));
};
const removeCoverageRow = (idx) => {
const next = coverageRows.filter((_, i) => i !== idx);
setDraft((p) => ({ ...p, coverageItems: next.length ? next : [''] }));
};
return (
<div className="lc-policy-detail-form">
<div className="lc-policy-detail-section-title">车辆与险种</div>
{renderFilterField('车牌号', (
<Input
value={draft.plateNo}
onChange={(e) => setDraft((p) => ({ ...p, plateNo: e.target.value }))}
placeholder="与 VIN 至少填一项"
/>
))}
{renderFilterField('VIN码', (
<Input value={draft.vin} onChange={(e) => setDraft((p) => ({ ...p, vin: e.target.value }))} />
))}
{showBizType ? renderFilterField('业务类型', (
<Select
value={draft.bizType}
onChange={(v) => setDraft((p) => ({ ...p, bizType: v }))}
style={{ width: '100%' }}
options={POLICY_BIZ_TYPE_OPTIONS}
/>
)) : null}
{renderFilterField('险种', (
<Select
value={draft.insuranceType}
onChange={(v) => setDraft((p) => ({ ...p, insuranceType: v }))}
style={{ width: '100%' }}
options={QUOTE_INSURANCE_TYPES.map((t) => ({ label: t, value: t }))}
/>
))}
<div className="lc-policy-detail-section-title">保单要素</div>
{renderFilterField('保险公司', (
<Select
allowClear
showSearch
value={draft.company || undefined}
onChange={(v) => setDraft((p) => ({ ...p, company: v || '' }))}
style={{ width: '100%' }}
options={INSURANCE_MGMT_COMPANIES.map((c) => ({ label: c, value: c }))}
/>
))}
{renderFilterField('保单号', (
<Input value={draft.policyNo} onChange={(e) => setDraft((p) => ({ ...p, policyNo: e.target.value }))} />
))}
{renderFilterField('批单号', (
<Input value={draft.endorsementNo} onChange={(e) => setDraft((p) => ({ ...p, endorsementNo: e.target.value }))} placeholder="停保/复驶/退保批单号" />
))}
{renderFilterField('付款时间', (
<Input value={draft.payTime} onChange={(e) => setDraft((p) => ({ ...p, payTime: e.target.value }))} placeholder="如 2026-06-01 17:42:10" />
))}
{renderFilterField('签单日期', (
<DatePicker
style={{ width: '100%' }}
value={draft.signDate && moment ? moment(draft.signDate) : null}
onChange={(_, ds) => setDraft((p) => ({ ...p, signDate: ds || '' }))}
/>
))}
{renderFilterField('生效日期', (
<DatePicker
style={{ width: '100%' }}
value={draft.startDate && moment ? moment(draft.startDate) : null}
onChange={(_, ds) => setDraft((p) => ({ ...p, startDate: ds || '' }))}
/>
))}
{renderFilterField('到期日期', (
<DatePicker
style={{ width: '100%' }}
value={draft.endDate && moment ? moment(draft.endDate) : null}
onChange={(_, ds) => setDraft((p) => ({ ...p, endDate: ds || '' }))}
/>
))}
{(draft.bizType === 'suspend' || draft.bizType === 'resume') ? renderFilterField('复驶日期', (
<DatePicker
style={{ width: '100%' }}
value={draft.reinstateDate && moment ? moment(draft.reinstateDate) : null}
onChange={(_, ds) => setDraft((p) => ({ ...p, reinstateDate: ds || '' }))}
/>
)) : null}
{renderFilterField(draft.bizType === 'cancel' ? '退费金额(元)' : '保费(元)', (
<Input value={draft.premium} onChange={(e) => setDraft((p) => ({ ...p, premium: e.target.value }))} placeholder="元" />
))}
<div className="lc-policy-detail-form-full">
{renderFilterField('保单项目/责任限额', (
<div className="lc-coverage-items-editor">
{coverageRows.map((row, idx) => (
<div key={`cov-${idx}`} className="lc-coverage-items-row">
<span className="lc-coverage-items-index">{idx + 1}</span>
<Input
value={row}
onChange={(e) => updateCoverageRow(idx, e.target.value)}
placeholder="如死亡伤残赔偿限额180000元、机动车损失险"
/>
<Button
type="link"
size="small"
danger
style={{ padding: '0 4px', flexShrink: 0 }}
disabled={coverageRows.length <= 1}
onClick={() => removeCoverageRow(idx)}
>
删除
</Button>
</div>
))}
<Button type="dashed" block className="lc-coverage-items-add" onClick={addCoverageRow}>
+ 新增项目
</Button>
<div style={{ fontSize: 12, color: '#94a3b8', lineHeight: 1.5 }}>
一条保单可维护多项责任/险种批量导入时同一单元格可用分隔多项
</div>
</div>
))}
</div>
{renderFilterField('投保人', (
<Input value={draft.applicant} onChange={(e) => setDraft((p) => ({ ...p, applicant: e.target.value }))} />
))}
{renderFilterField('被保险人', (
<Input value={draft.insured} onChange={(e) => setDraft((p) => ({ ...p, insured: e.target.value }))} />
))}
</div>
);
};
const openPolicyRecognResultEdit = (result) => {
setPolicyRecognEditId(result.id);
setPolicyRecognEditDraft(recognResultToPolicyDetail(result));
setPolicyRecognEditOpen(true);
};
const savePolicyRecognResultEdit = () => {
const result = policyRecognResults.find((r) => r.id === policyRecognEditId);
if (!result) return;
const merged = mergeRecognResultWithDetail(result, policyRecognEditDraft);
setPolicyRecognResults((prev) => prev.map((r) => (r.id === policyRecognEditId ? merged : r)));
setPolicyRecognEditOpen(false);
message.success('已更新识别结果,请确认后写入台账');
};
const confirmPolicyRecognResult = (resultId) => {
const result = policyRecognResults.find((r) => r.id === resultId);
if (!result) return;
if (!result.matched) {
message.warning('该条未匹配台账,无法确认');
return;
}
if (result.confirmed) {
message.info('该条已确认');
return;
}
const mode = result.recognMode || policyRecognMode;
updateAllInsurance(applyPolicyOcrResultToLedger(result, mode));
const nextResults = policyRecognResults.map((r) => (
r.id === resultId ? { ...r, confirmed: true } : r
));
setPolicyRecognResults(nextResults);
if (derivePolicyRecognTaskStatus(nextResults) === 'completed') {
setPolicyRecognViewOnly(true);
}
if (policyRecognTaskId) {
savePolicyRecognTaskSnapshot({
taskId: policyRecognTaskId,
entry: policyRecognEntry,
mode: policyRecognMode,
insuranceType: policyRecognInsuranceType,
results: nextResults,
phase: 'results',
completedAt: derivePolicyRecognTaskStatus(nextResults) === 'completed' ? formatCompareSheetNow() : undefined,
});
}
message.success(`已更新 ${result.displayPlate || result.ocrVin} 的到期时间`);
};
const confirmAllPolicyRecognResults = () => {
const pending = policyRecognResults.filter((r) => r.matched && !r.confirmed);
if (!pending.length) {
message.info('没有可批量确认的记录');
return;
}
let nextInsurance = { ...allInsurance };
pending.forEach((result) => {
const mode = result.recognMode || policyRecognMode;
nextInsurance = applyPolicyOcrResultToLedger(result, mode)(nextInsurance);
});
updateAllInsurance(() => nextInsurance);
const nextResults = policyRecognResults.map((r) => (
r.matched ? { ...r, confirmed: true } : r
));
setPolicyRecognResults(nextResults);
const allDone = derivePolicyRecognTaskStatus(nextResults) === 'completed';
if (allDone) setPolicyRecognViewOnly(true);
if (policyRecognTaskId) {
savePolicyRecognTaskSnapshot({
taskId: policyRecognTaskId,
entry: policyRecognEntry,
mode: policyRecognMode,
insuranceType: policyRecognInsuranceType,
results: nextResults,
phase: 'results',
completedAt: allDone ? formatCompareSheetNow() : undefined,
});
}
message.success(`已批量确认 ${pending.length} 条,台账到期时间已更新`);
};
const policyRecognResultColumns = useMemo(() => {
const canEdit = !policyRecognViewOnly;
return [
{
title: '文件',
dataIndex: 'fileName',
width: 140,
ellipsis: true,
},
{
title: '类型',
key: 'biz',
width: 72,
render: (_, r) => (
<Tag style={{ margin: 0, fontSize: 11 }}>{r.ocrBizTypeLabel || '保单录入'}</Tag>
),
},
{
title: '车牌号',
key: 'plate',
width: 96,
render: (_, r) => r.displayPlate || r.ocrPlateNo || r.ocrVin || '—',
},
{
title: '保单号',
dataIndex: 'ocrPolicyNo',
width: 130,
ellipsis: true,
},
{
title: '险种',
dataIndex: 'insuranceTypeLabel',
width: 72,
},
{
title: '付款时间',
dataIndex: 'ocrPayTime',
width: 108,
ellipsis: true,
render: (v) => v || '—',
},
{
title: '生效日',
dataIndex: 'ocrStartDate',
width: 96,
render: (v) => v || '—',
},
{
title: '到期日',
dataIndex: 'ocrEndDate',
width: 96,
},
{
title: '金额',
dataIndex: 'ocrPremium',
width: 80,
align: 'right',
render: (v) => (v ? `¥${v}` : '—'),
},
{
title: '匹配',
key: 'matched',
width: 88,
render: (_, r) => (
<Tooltip title={r.matchTip}>
<Tag color={r.matched ? 'success' : 'error'} style={{ margin: 0 }}>{r.matched ? '已匹配' : '未匹配'}</Tag>
</Tooltip>
),
},
{
title: '状态',
key: 'confirmed',
width: 80,
render: (_, r) => (
r.confirmed ? <Tag color="blue">已确认</Tag> : <Tag></Tag>
),
},
{
title: '操作',
key: 'action',
width: 168,
fixed: 'right',
render: (_, r) => (
<Space size={4} wrap>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => handlePreviewPolicyResult(r)}>预览</Button>
{canEdit ? (
<>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => openPolicyRecognResultEdit(r)}>编辑</Button>
<Button
type="link"
size="small"
style={{ padding: 0, fontWeight: 600, color: '#10b981' }}
disabled={!r.matched || r.confirmed}
onClick={() => confirmPolicyRecognResult(r.id)}
>
确认
</Button>
</>
) : null}
</Space>
),
},
];
}, [policyRecognViewOnly, policyRecognResults, policyRecognFiles, allInsurance]);
const handlePolicyAddSubmit = () => {
const detail = normalizePolicyDetail(policyAddDraft);
const ledgerKey = resolvePolicyVehicleKey(detail.plateNo || detail.vin);
if (!ledgerKey) {
message.warning('请填写台账中存在的车牌或 VIN');
return;
}
const typeKey = INSURANCE_LABEL_TO_KEY[detail.insuranceType];
if (!typeKey || !detail.endDate) {
message.warning('请填写险种与到期日期');
return;
}
const mode = bizTypeToRecognMode(detail.bizType);
updateAllInsurance((prev) => {
const record = ensureInsuranceRecordShape(prev[ledgerKey] || createEmptyInsuranceRecord());
const item = applyPolicyDetailToInsuranceItem(
{ ...record[typeKey] },
{ ...detail, policyNo: detail.policyNo || `MAN-${Date.now().toString().slice(-6)}` },
mode
);
item.updateTime = formatCompareSheetNow();
item.updateUser = PROTO_COMPARE_CREATOR;
return { ...prev, [ledgerKey]: { ...record, [typeKey]: item } };
});
message.success('保单已录入台账');
setPolicyAddOpen(false);
setPolicyAddDraft({ ...EMPTY_POLICY_DETAIL });
};
const renderPolicyStatusTag = (item) => {
if (!item?.policyTag) return null;
if (item.policyTag === 'suspended') {
return (
<Tooltip title={item.reinstateDate ? `复保日期:${item.reinstateDate}` : '复保日期待定'}>
<Tag color="warning" className="lc-list-policy-tag" style={{ margin: 0, fontSize: 10, fontWeight: 600 }}>已停保</Tag>
</Tooltip>
);
}
if (item.policyTag === 'cancelled') {
return (
<Tag color="default" className="lc-list-policy-tag" style={{ margin: 0, fontSize: 10, fontWeight: 600 }}>已退保</Tag>
);
}
return null;
};
const renderDateCell = (rowId, field, value) => (
<DatePicker
size="small"
className="lc-compare-cell-input"
value={value && moment ? moment(value) : null}
onChange={(_, ds) => updateCompareRow(rowId, { [field]: ds || '' })}
placeholder="选择日期"
style={{ width: '100%' }}
/>
);
const renderQuoteAddPopover = (row) => (
<Popover
trigger="click"
placement="leftTop"
overlayClassName="lc-quote-popover-overlay"
open={quoteEditRowId === row.id}
onOpenChange={(open) => {
setQuoteEditRowId(open ? row.id : null);
if (!open) setQuoteDraft(createEmptyQuoteDraft());
}}
content={(
<div className="lc-quote-card">
<div className="lc-quote-card-head">
<span className="lc-quote-card-title">添加报价</span>
<span className="lc-quote-card-type-badge">{row.insuranceType || '交强险'}</span>
</div>
<div className="lc-quote-card-body" style={{ maxHeight: 'none' }}>
<div className="lc-quote-form-field">
<label className="lc-quote-form-label lc-quote-form-label-required">保险公司</label>
<Select
size="small"
placeholder="请选择保险公司"
showSearch
allowClear
value={quoteDraft.company}
onChange={(val) => setQuoteDraft((d) => ({ ...d, company: val }))}
options={INSURANCE_MGMT_COMPANIES.map((c) => ({ label: c, value: c }))}
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
style={{ width: '100%' }}
/>
</div>
<div className="lc-quote-form-field">
<label className="lc-quote-form-label lc-quote-form-label-required">报价</label>
<Input
size="small"
placeholder="0.00"
value={quoteDraft.premium}
onChange={(e) => setQuoteDraft((d) => ({ ...d, premium: sanitizePremiumInput(e.target.value) }))}
onBlur={() => {
if (quoteDraft.premium && isValidPremium(quoteDraft.premium)) {
setQuoteDraft((d) => ({ ...d, premium: formatPremiumDisplay(d.premium) }));
}
}}
/>
</div>
<div className="lc-quote-form-actions">
<Button size="small" type="primary" onClick={() => handleAddQuote(row.id, row.insuranceType)}>确认添加</Button>
</div>
</div>
</div>
)}
>
<Button type="link" size="small" className="lc-compare-quote-add">+ 添加报价</Button>
</Popover>
);
const renderQuoteCell = (row) => {
const quotes = row.quotes || [];
return (
<div className="lc-compare-quote-cell">
{quotes.length > 0 ? (
<Radio.Group
value={row.confirmedQuoteId || undefined}
onChange={(e) => updateCompareRow(row.id, { confirmedQuoteId: e.target.value })}
className="lc-compare-quote-inline-list"
>
{quotes.map((q) => {
const selected = row.confirmedQuoteId === q.id;
return (
<label
key={q.id}
className={`lc-compare-quote-inline-item${selected ? ' is-selected' : ''}`}
title={`${q.company} · ${q.premium}`}
>
<Radio value={q.id} />
<span className="lc-compare-quote-inline-company">{shortInsuranceCompanyName(q.company)}</span>
<span className="lc-compare-quote-inline-price">{q.premium}</span>
<Button
type="link"
size="small"
className="lc-compare-quote-inline-del"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveQuote(row.id, q.id);
}}
>
×
</Button>
</label>
);
})}
</Radio.Group>
) : (
<span className="lc-compare-quote-empty-hint">暂无报价</span>
)}
{renderQuoteAddPopover(row)}
</div>
);
};
const compareColumns = [
{
title: '车牌号',
dataIndex: 'plateNo',
width: 118,
fixed: 'left',
onHeaderCell: () => ({ className: 'lc-compare-th-key' }),
render: (val, row) => (
<Select
size="small"
className="lc-compare-cell-select"
placeholder="选择车牌"
allowClear
showSearch
value={val || undefined}
options={PLATE_SELECT_OPTIONS}
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
onChange={(v) => handleComparePlateChange(row.id, v)}
/>
),
},
{
title: '车辆识别代码',
dataIndex: 'vin',
width: 168,
fixed: 'left',
onHeaderCell: () => ({ className: 'lc-compare-th-key' }),
render: (val, row) => (
<Tooltip title={row.plateNo ? '已选车牌VIN 自动带出' : '未选车牌时可通过 VIN 定位车辆'}>
<Select
size="small"
className="lc-compare-cell-select"
placeholder={row.plateNo ? '已关联' : '选择 VIN'}
allowClear={!row.plateNo}
showSearch
disabled={!!row.plateNo}
value={val || undefined}
options={VIN_SELECT_OPTIONS}
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
onChange={(v) => handleCompareVinChange(row.id, v)}
/>
</Tooltip>
),
},
{
title: '客户',
dataIndex: 'customer',
width: 128,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '归属公司',
dataIndex: 'ownerCompany',
width: 148,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '品牌',
dataIndex: 'brand',
width: 80,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '型号',
dataIndex: 'model',
width: 120,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '车身颜色',
dataIndex: 'bodyColor',
width: 80,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyField(val, isCompareRowVehicleLinked(row)),
},
{
title: '行驶证注册日期',
dataIndex: 'regDate',
width: 118,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)),
},
{
title: '年检有效期',
dataIndex: 'inspectExpire',
width: 108,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)),
},
{
title: '投保方式',
dataIndex: 'insureMode',
width: 96,
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
render: (val, row) => (
<Select
size="small"
className="lc-compare-cell-select"
value={val}
onChange={(v) => updateCompareRow(row.id, { insureMode: v })}
options={[{ label: '新保', value: '新保' }, { label: '续保', value: '续保' }]}
/>
),
},
{
title: '保险类型',
dataIndex: 'insuranceType',
width: 100,
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
render: (val, row) => (
<Select
size="small"
className="lc-compare-cell-select"
value={val || '交强险'}
onChange={(v) => updateCompareRow(row.id, {
insuranceType: v,
quotes: [],
confirmedQuoteId: '',
})}
options={QUOTE_INSURANCE_TYPES.map((t) => ({ label: t, value: t }))}
/>
),
},
{
title: '交强险有效期',
dataIndex: 'jqValidUntil',
width: 108,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)),
},
{
title: '商业险有效期',
dataIndex: 'syValidUntil',
width: 108,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (val, row) => renderReadonlyDate(val, isCompareRowVehicleLinked(row)),
},
{
title: '最晚付费日期',
dataIndex: 'latestPayDate',
width: 128,
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
render: (val, row) => {
const paySt = getLatestPayDateStatus(val);
return (
<div>
{renderDateCell(row.id, 'latestPayDate', val)}
{val ? (
<div style={{ marginTop: 4 }}>
<Tag
color={paySt.type === 'overdue' ? 'error' : paySt.type === 'warning' ? 'warning' : 'success'}
style={{ margin: 0, fontSize: 10, fontWeight: 600 }}
>
{paySt.type === 'overdue' ? `超期${Math.abs(paySt.diffDays)}` : paySt.type === 'warning' ? `临期${paySt.diffDays}` : `剩余${paySt.diffDays}`}
</Tag>
</div>
) : null}
</div>
);
},
},
{
title: '采购状态',
dataIndex: 'procurementStatus',
width: 92,
onHeaderCell: () => ({ className: 'lc-compare-th-auto' }),
render: (st) => {
if (st === 'completed') return <Tag color="success" style={{ margin: 0, fontWeight: 600 }}>已完结</Tag>;
if (st === 'submitted') return <Tag color="processing" style={{ margin: 0, fontWeight: 600 }}>已提交</Tag>;
return <Tag style={{ margin: 0, fontWeight: 600 }}>未提交</Tag>;
},
},
{
title: '报价情况',
key: 'quotes',
width: 220,
onHeaderCell: () => ({ className: 'lc-compare-th-edit' }),
render: (_, row) => renderQuoteCell(row),
},
{
title: '操作',
key: 'action',
width: 96,
fixed: 'right',
render: (_, row) => (
<div className="lc-compare-action-cell">
<Popover
trigger="click"
open={copyPopoverRowId === row.id}
onOpenChange={(open) => {
setCopyPopoverRowId(open ? row.id : null);
if (!open) setCopyCountDraft(1);
}}
content={(
<div className="lc-copy-pop">
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 8 }}>复制条数</div>
<InputNumber min={1} max={50} value={copyCountDraft} onChange={(v) => setCopyCountDraft(v || 1)} style={{ width: '100%' }} />
<div className="lc-copy-pop-actions">
<Button size="small" onClick={() => setCopyPopoverRowId(null)}>取消</Button>
<Button size="small" type="primary" onClick={() => handleCopyCompareRow(row, copyCountDraft)}>确认</Button>
</div>
</div>
)}
>
<Button type="link" size="small">复制</Button>
</Popover>
<Button type="link" size="small" danger onClick={() => handleDeleteCompareRow(row.id)}>删除</Button>
</div>
),
},
];
const getInsuranceItemStatus = (ledgerKey, typeKey) => {
const item = allInsurance[ledgerKey]?.[typeKey];
if (!item || !item.endDate || !item.policyNo) {
return { type: 'unuploaded', text: '未购买', diffDays: null };
}
const today = new Date(ANCHOR_TODAY);
today.setHours(0, 0, 0, 0);
const expDate = new Date(item.endDate);
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 <= INSURANCE_WARN_DAYS) {
return { type: 'warning', text: `临期 (${diffDays} 天后)`, diffDays };
}
return { type: 'success', text: '正常', diffDays };
};
/** 与车辆管理「保险状态」一致:交强险 + 商业险均存在且在有效期内(临期仍算有效) */
const getVehicleInsuranceStatus = (ledgerKey) => {
const jq = getInsuranceItemStatus(ledgerKey, 'compulsory');
const sy = getInsuranceItemStatus(ledgerKey, 'commercial');
const isValid = (st) => st.type === 'success' || st.type === 'warning';
const isNormal = isValid(jq) && isValid(sy);
if (isNormal) {
const hasWarning = jq.type === 'warning' || sy.type === 'warning';
return {
label: hasWarning ? '临期' : '正常',
color: hasWarning ? 'warning' : 'success',
abnormal: false,
tip: hasWarning
? '交强险或商业险临期,仍在有效期内,可交车但需尽快续保'
: '交强险、商业险均在有效期内',
};
}
const reasons = [];
if (jq.type === 'unuploaded') reasons.push('交强险未购买');
else if (jq.type === 'expired') reasons.push('交强险已到期');
if (sy.type === 'unuploaded') reasons.push('商业险未购买');
else if (sy.type === 'expired') reasons.push('商业险已到期');
return {
label: '异常',
color: 'error',
abnormal: true,
tip: `${reasons.join('')}。保险状态异常的车辆禁止交车`,
};
};
const isCoreInsuranceNormal = (ledgerKey) => !getVehicleInsuranceStatus(ledgerKey).abnormal;
const isAnyInsuranceWarning = (ledgerKey) => (
INSURANCE_TYPE_ITEMS.some((item) => getInsuranceItemStatus(ledgerKey, item.key).type === 'warning')
);
const isCoreInsuranceExpired = (ledgerKey) => (
CORE_INSURANCE_KEYS.some((key) => getInsuranceItemStatus(ledgerKey, key).type === 'expired')
);
const isCoreInsuranceMissing = (ledgerKey) => (
CORE_INSURANCE_KEYS.some((key) => getInsuranceItemStatus(ledgerKey, key).type === 'unuploaded')
);
const matchKpiFilter = (ledgerKey, filterKey) => {
if (filterKey === 'total') return true;
if (filterKey === 'normal') return isCoreInsuranceNormal(ledgerKey);
if (filterKey === 'warning') return isAnyInsuranceWarning(ledgerKey);
if (filterKey === 'expired') return isCoreInsuranceExpired(ledgerKey);
if (filterKey === 'unuploaded') return isCoreInsuranceMissing(ledgerKey);
return true;
};
const stats = useMemo(() => {
let normal = 0;
let warning = 0;
let expired = 0;
let unuploaded = 0;
MOCK_VEHICLES.forEach((v) => {
const ledgerKey = getVehicleLedgerKey(v);
if (isCoreInsuranceNormal(ledgerKey)) normal += 1;
if (isAnyInsuranceWarning(ledgerKey)) warning += 1;
if (isCoreInsuranceExpired(ledgerKey)) expired += 1;
if (isCoreInsuranceMissing(ledgerKey)) unuploaded += 1;
});
return { total: MOCK_VEHICLES.length, normal, warning, expired, unuploaded };
}, [allInsurance]);
const brandOptions = useMemo(
() => [...new Set(MOCK_VEHICLES.map((v) => v.brand))].map((b) => ({ label: b, value: b })),
[]
);
const modelOptions = useMemo(
() => [...new Set(MOCK_VEHICLES.map((v) => v.model))].map((m) => ({ label: m, value: m })),
[]
);
const appliedMultiPlates = useMemo(() => parseMultiPlates(appliedFilters.plateNos), [appliedFilters.plateNos]);
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 ledgerKey = getVehicleLedgerKey(v);
const plateText = (v.plateNo || '').trim();
if (multiPlates.length) {
if (!plateText) return false;
if (!multiPlates.includes(plateText.toUpperCase())) return false;
} else if (plateKey) {
if (!plateText) {
if (!NO_PLATE_LABEL.toLowerCase().includes(plateKey) && !plateKey.includes('暂无')) return false;
} else if (!plateText.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 (f.insuranceStatus === '正常' && getVehicleInsuranceStatus(ledgerKey).abnormal) return false;
if (f.insuranceStatus === '异常' && !getVehicleInsuranceStatus(ledgerKey).abnormal) return false;
if (!matchKpiFilter(ledgerKey, kpi)) return false;
return true;
});
};
const filteredVehicles = useMemo(
() => sortVehiclesRetiredLast(filterVehiclesByFilters(MOCK_VEHICLES, appliedFilters, kpiFilter)),
[appliedFilters, allInsurance, kpiFilter]
);
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 = () => {
setListFilters({ ...DEFAULT_LIST_FILTERS });
setAppliedFilters({ ...DEFAULT_LIST_FILTERS });
setMultiPlateDraft('');
setKpiFilter('total');
message.info('筛选条件已重置');
};
const handleMultiPlateOpenChange = (open) => {
setMultiPlateOpen(open);
if (open) setMultiPlateDraft(listFilters.plateNos || '');
};
const renderFilterField = (label, control) => (
<div className="lc-filter-field">
<span className="lc-filter-field-label">{label}</span>
<div className="lc-filter-field-control">{control}</div>
</div>
);
const isRetiredVehicle = (record) => record?.status === '退出运营';
const renderInsuranceDateCell = (record, typeKey) => {
const ledgerKey = getVehicleLedgerKey(record);
const item = allInsurance[ledgerKey]?.[typeKey];
const status = getInsuranceItemStatus(ledgerKey, typeKey);
const dateVal = item?.endDate;
const muted = isRetiredVehicle(record);
const policyTagEl = renderPolicyStatusTag(item);
return (
<div className="lc-list-expire-cell">
<div
className="lc-list-expire-date"
style={{ color: muted ? '#94a3b8' : (dateVal ? '#334155' : '#94a3b8') }}
>
{dateVal || '—'}
</div>
{!muted ? (
<div className="lc-list-expire-meta">
{policyTagEl || (
<Tooltip title={status.text}>
<span className="lc-list-status-badge-wrap">
<Badge
status={mapInsuranceStatusToBadge(status.type)}
text={(
<span className="lc-list-status-badge-text">
{getInsuranceRemainShortText(status)}
</span>
)}
/>
</span>
</Tooltip>
)}
</div>
) : null}
</div>
);
};
const listColumns = [
{
title: '车牌号',
dataIndex: 'plateNo',
key: 'plateNo',
width: 148,
onHeaderCell: listColumnHeaderCell,
align: 'left',
render: (plate, record) => {
const muted = isRetiredVehicle(record);
const noPlate = !hasVehiclePlate(record);
const displayPlate = formatVehiclePlateDisplay(plate);
const sub = [record.brand, record.model].filter(Boolean).join(' · ');
return (
<div className="lc-list-plate-cell">
<span
className={noPlate && !muted ? 'lc-list-plate-empty' : ''}
style={{ fontWeight: 600, fontSize: 13, color: muted ? '#94a3b8' : (noPlate ? '#94a3b8' : '#0f172a') }}
>
{displayPlate}
</span>
{sub ? (
<Tooltip title={sub}>
<span className="lc-list-plate-sub lc-cell-ellipsis" style={{ color: muted ? '#94a3b8' : '#64748b' }}>
{sub}
</span>
</Tooltip>
) : null}
</div>
);
},
},
{
title: 'VIN码',
dataIndex: 'vin',
key: 'vin',
width: 112,
onHeaderCell: listColumnHeaderCell,
render: (vin, record) => (
<Tooltip title={vin}>
<span className="lc-cell-ellipsis" style={{ fontFamily: 'monospace', fontSize: 11, color: isRetiredVehicle(record) ? '#94a3b8' : '#475569' }}>
{vin}
</span>
</Tooltip>
),
},
{
title: '运营状态',
dataIndex: 'status',
key: 'status',
width: 80,
onHeaderCell: listColumnHeaderCell,
render: (status) => (
<span style={{ fontSize: 13, fontWeight: 600, color: status === '退出运营' ? '#94a3b8' : '#334155' }}>
{status}
</span>
),
},
{
title: '保险状态',
key: 'insuranceStatus',
width: 80,
onHeaderCell: listColumnHeaderCell,
render: (record) => {
const st = getVehicleInsuranceStatus(getVehicleLedgerKey(record));
return (
<Tooltip title={st.tip}>
<Tag color={st.color} style={{ margin: 0, fontWeight: 600, fontSize: 12 }}>
{st.label}
</Tag>
</Tooltip>
);
},
},
{
title: '交强险到期时间',
key: 'compulsory',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'compulsory'),
},
{
title: '商业险到期时间',
key: 'commercial',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'commercial'),
},
{
title: '超赔险到期时间',
key: 'excess',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'excess'),
},
{
title: '货物险到期时间',
key: 'cargo',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'cargo'),
},
{
title: '驾意险到期时间',
key: 'driverAccident',
width: 118,
onHeaderCell: listColumnHeaderCell,
render: (record) => renderInsuranceDateCell(record, 'driverAccident'),
},
{
title: '操作',
key: 'action',
width: 64,
onHeaderCell: listColumnHeaderCell,
render: (record) => (
<Button
type="link"
size="small"
style={{ fontWeight: 600, color: '#10b981', padding: 0 }}
onClick={() => openVehicleInsuranceMgmt(record)}
>
管理
</Button>
),
},
];
return (
<div
className="lc-edit-page"
style={{
padding: '24px 24px 80px',
height: '100vh',
display: 'flex',
flexDirection: 'column',
background: 'linear-gradient(165deg, #f1f5f9 0%, #f8fafc 50%, #f1f5f9 100%)',
overflow: 'hidden',
boxSizing: 'border-box',
}}
>
<style>{PAGE_STYLE}</style>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div className="lc-page-header" style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div>
<div style={{ fontSize: 18, fontWeight: 800, color: '#0f172a', lineHeight: 1.3 }}>保险采购</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>保单管理一车一档· 比价单独立管理互不关联</div>
</div>
<Button
type="default"
icon={ICONS.policy}
style={{ borderRadius: 8, border: '1px solid #cbd5e1', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 6, color: '#475569' }}
onClick={() => setPrdOpen(true)}
>
查看需求说明
</Button>
</div>
<Card className="lc-filter-card" title="保单管理 · 筛选条件" bordered={false}>
<div className="lc-filter-grid">
{renderFilterField('车牌号', (
<Input
placeholder={appliedMultiPlates.length ? '已启用多车牌筛选' : '请输入车牌号'}
allowClear
disabled={appliedMultiPlates.length > 0}
value={listFilters.plateNo}
onChange={(e) => setListFilters((prev) => ({ ...prev, plateNo: e.target.value }))}
onPressEnter={handleListFilterQuery}
style={{ borderRadius: 8 }}
/>
))}
{renderFilterField('多车牌', (
<Popover
open={multiPlateOpen}
onOpenChange={handleMultiPlateOpenChange}
trigger="click"
placement="bottomLeft"
content={(
<div className="lc-multi-plate-pop">
<div className="lc-multi-plate-pop-hint">每行一个车牌或同一行内用逗号分隔</div>
<Input.TextArea
rows={5}
value={multiPlateDraft}
onChange={(e) => setMultiPlateDraft(e.target.value)}
placeholder={'沪A03561F\n粤B58888F'}
style={{ borderRadius: 8 }}
/>
<div className="lc-multi-plate-pop-actions">
<Button size="small" onClick={() => setMultiPlateOpen(false)}>取消</Button>
<Button size="small" type="primary" onClick={handleListFilterQuery}>应用</Button>
</div>
</div>
)}
>
<Input
className="lc-multi-plate-trigger"
readOnly
placeholder="点击输入多个车牌"
value={appliedMultiPlates.length ? `已选 ${appliedMultiPlates.length} 个车牌` : ''}
style={{ borderRadius: 8 }}
/>
</Popover>
))}
{renderFilterField('VIN码', (
<Input
placeholder="请输入车辆识别代码"
allowClear
value={listFilters.vin}
onChange={(e) => setListFilters((prev) => ({ ...prev, vin: e.target.value }))}
onPressEnter={handleListFilterQuery}
style={{ borderRadius: 8 }}
/>
))}
{renderFilterField('品牌', (
<Select
placeholder="请选择或输入品牌"
allowClear
showSearch
value={listFilters.brand || undefined}
onChange={(val) => setListFilters((prev) => ({ ...prev, brand: val || '' }))}
options={brandOptions}
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
style={{ width: '100%' }}
/>
))}
{renderFilterField('型号', (
<Select
placeholder="请选择或输入型号"
allowClear
showSearch
value={listFilters.model || undefined}
onChange={(val) => setListFilters((prev) => ({ ...prev, model: val || '' }))}
options={modelOptions}
filterOption={(input, option) => (option?.label || '').toLowerCase().includes(input.toLowerCase())}
style={{ width: '100%' }}
/>
))}
{renderFilterField('运营状态', (
<Select
value={listFilters.operateStatus}
onChange={(val) => setListFilters((prev) => ({ ...prev, operateStatus: val }))}
style={{ width: '100%' }}
>
<Select.Option value="全部">全部</Select.Option>
<Select.Option value="租赁">租赁</Select.Option>
<Select.Option value="自营">自营</Select.Option>
<Select.Option value="库存">库存</Select.Option>
<Select.Option value="退出运营">退出运营</Select.Option>
</Select>
))}
{renderFilterField('保险状态', (
<Select
value={listFilters.insuranceStatus}
onChange={(val) => setListFilters((prev) => ({ ...prev, insuranceStatus: val }))}
style={{ width: '100%' }}
>
<Select.Option value="全部">全部</Select.Option>
<Select.Option value="正常">正常/临期</Select.Option>
<Select.Option value="异常">异常</Select.Option>
</Select>
))}
</div>
<div className="lc-filter-actions">
<Button onClick={handleListFilterReset}>重置</Button>
<Button type="primary" onClick={handleListFilterQuery}>查询</Button>
</div>
</Card>
<div className="lc-alert-stats-row">
{[
{ 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: `任一类险种止期 ≤ ${INSURANCE_WARN_DAYS} 天(含交强险、商业险、超赔险、货物险、驾意险)`, 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) => (
<div
key={card.key}
role="button"
tabIndex={0}
className={`lc-alert-card lc-alert-card--${card.type} lc-alert-card-clickable${kpiFilter === card.key ? ' lc-alert-card-active' : ''}`}
onClick={() => setKpiFilter(card.key)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setKpiFilter(card.key);
}
}}
>
<div className="lc-alert-card-tip-anchor">
<Tooltip title={card.desc} placement="topRight" overlayStyle={{ maxWidth: 340 }}>
<span
className="lc-alert-card-tip"
role="img"
aria-label={`${card.title}说明`}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</span>
</Tooltip>
</div>
<div className="lc-alert-card-icon">{card.icon}</div>
<div className="lc-alert-card-main">
<div className="lc-alert-card-val">{card.val}</div>
<div className="lc-alert-card-title">{card.title}</div>
</div>
</div>
))}
</div>
<div className="lc-table-section" style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div className="lc-table-toolbar">
<span style={{ fontSize: 13, fontWeight: 600, color: '#475569' }}>保单录入</span>
<div className="lc-table-toolbar-actions lc-policy-toolbar">
<Button
type="primary"
style={{ borderRadius: 8, fontWeight: 600, background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', border: 'none' }}
onClick={openCompareMgmtModal}
>
比价单管理
</Button>
<Button
style={{ borderRadius: 8, fontWeight: 600 }}
onClick={openPolicyRecognTasksModal}
>
识别任务记录
</Button>
<Button
type="default"
style={{ borderRadius: 8, fontWeight: 600, borderColor: '#10b981', color: '#059669' }}
onClick={() => openPolicyRecogn('ocr', 'policy')}
>
保单批量识别
</Button>
<Button
style={{ borderRadius: 8, fontWeight: 600, borderColor: '#10b981', color: '#059669' }}
onClick={() => openPolicyRecogn('import', 'policy')}
>
批量导入
</Button>
<Button
style={{ borderRadius: 8, fontWeight: 600 }}
onClick={() => {
setPolicyAddDraft({ ...EMPTY_POLICY_DETAIL });
setPolicyAddOpen(true);
}}
>
新增
</Button>
</div>
</div>
<div className="lc-table-card" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<Table
className="lc-list-table"
columns={listColumns}
dataSource={filteredVehicles}
rowKey={(record) => getVehicleLedgerKey(record)}
rowClassName={(record) => (record.status === '退出运营' ? 'lc-row-retired' : '')}
pagination={false}
scroll={{ x: 1040 }}
locale={{
emptyText: (
<div style={{ padding: '40px 0' }}>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="1.5" style={{ marginBottom: 12 }}><circle cx="12" cy="12" r="10" /><line x1="8" y1="12" x2="16" y2="12" /></svg>
<div style={{ color: '#94a3b8' }}>暂无符合检索条件的保险台账车辆</div>
</div>
),
}}
/>
</div>
</div>
</div>
<Modal
className="lc-vehicle-ins-mgmt-modal"
open={vehicleInsMgmtOpen}
title={null}
width={1120}
centered
destroyOnClose
footer={(
<Button type="primary" style={{ fontWeight: 600, borderRadius: 8 }} onClick={() => setVehicleInsMgmtOpen(false)}>
关闭
</Button>
)}
onCancel={() => setVehicleInsMgmtOpen(false)}
>
{vehicleInsMgmtVehicle ? (() => {
const profile = getVehicleProfile(vehicleInsMgmtVehicle);
const ledgerKey = getVehicleLedgerKey(vehicleInsMgmtVehicle);
const insStatus = getVehicleInsuranceStatus(ledgerKey);
const statusPillClass = insStatus.color === 'success' || insStatus.color === 'warning'
? `lc-vehicle-ins-mgmt-status-pill--${insStatus.color}`
: 'lc-vehicle-ins-mgmt-status-pill--error';
return (
<div className="lc-vehicle-ins-mgmt-shell">
<div className="lc-vehicle-ins-mgmt-hero">
<div className="lc-vehicle-ins-mgmt-hero-top">
<div>
<div className="lc-vehicle-ins-mgmt-hero-title">车辆保险档案</div>
<div className="lc-vehicle-ins-mgmt-plate">{formatVehiclePlateDisplay(vehicleInsMgmtVehicle.plateNo)}</div>
<div className="lc-vehicle-ins-mgmt-subtitle">
{vehicleInsMgmtVehicle.brand} {vehicleInsMgmtVehicle.model}
</div>
</div>
<Tooltip title={insStatus.tip}>
<span className={`lc-vehicle-ins-mgmt-status-pill ${statusPillClass}`}>
保险状态 · {insStatus.label}
</span>
</Tooltip>
</div>
<div className="lc-vehicle-ins-mgmt-meta-grid">
<div className="lc-vehicle-ins-mgmt-meta-card">
<div className="lc-vehicle-ins-mgmt-meta-label">VIN码</div>
<div className="lc-vehicle-ins-mgmt-meta-val">{vehicleInsMgmtVehicle.vin || '—'}</div>
</div>
<div className="lc-vehicle-ins-mgmt-meta-card">
<div className="lc-vehicle-ins-mgmt-meta-label">运营状态</div>
<div className="lc-vehicle-ins-mgmt-meta-val">{vehicleInsMgmtVehicle.status || '—'}</div>
</div>
<div className="lc-vehicle-ins-mgmt-meta-card">
<div className="lc-vehicle-ins-mgmt-meta-label">客户</div>
<div className="lc-vehicle-ins-mgmt-meta-val">{profile.customer || '—'}</div>
</div>
<div className="lc-vehicle-ins-mgmt-meta-card">
<div className="lc-vehicle-ins-mgmt-meta-label">产权方</div>
<div className="lc-vehicle-ins-mgmt-meta-val">{profile.ownerCompany || '—'}</div>
</div>
<div className="lc-vehicle-ins-mgmt-meta-card">
<div className="lc-vehicle-ins-mgmt-meta-label">注册日期</div>
<div className="lc-vehicle-ins-mgmt-meta-val">{profile.regDate || '—'}</div>
</div>
<div className="lc-vehicle-ins-mgmt-meta-card">
<div className="lc-vehicle-ins-mgmt-meta-label">年审到期</div>
<div className="lc-vehicle-ins-mgmt-meta-val">{profile.inspectExpire || '—'}</div>
</div>
</div>
</div>
<div className="lc-vehicle-ins-mgmt-body">
<div className="lc-vehicle-ins-mgmt-legend">
<span className="lc-vehicle-ins-mgmt-legend-hint">记录类型</span>
{POLICY_PURCHASE_TYPE_LEGEND.map((key) => renderPurchaseTypeChip(key))}
<span style={{ fontSize: 11, color: '#94a3b8', marginLeft: 'auto', alignSelf: 'center' }}>
新保该险种首次购买续保此前已购后再投保
</span>
</div>
<Tabs
className="lc-vehicle-ins-mgmt-tabs"
activeKey={vehicleInsMgmtActiveTab}
onChange={setVehicleInsMgmtActiveTab}
type="card"
size="small"
>
<Tabs.TabPane tab={renderVehicleInsMgmtTabLabel({ key: 'timeline', label: '全周期记录' })} key="timeline">
{vehicleInsuranceHistory.timeline.length ? (
<Timeline mode="left" className="lc-vehicle-ins-timeline">
{vehicleInsuranceHistory.timeline.map((item) => {
const meta = POLICY_PURCHASE_TYPE_META[item.purchaseType] || {};
return (
<Timeline.Item
key={item.id}
color={meta.timelineColor || 'gray'}
label={(
<span style={{ fontSize: 12, color: '#64748b', fontVariantNumeric: 'tabular-nums' }}>
{item.time || '—'}
</span>
)}
>
<div
className="lc-vehicle-ins-timeline-item"
role="button"
tabIndex={0}
onClick={() => jumpToVehicleInsuranceRecord(item.typeKey, item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') jumpToVehicleInsuranceRecord(item.typeKey, item.id);
}}
>
<div className="lc-vehicle-ins-timeline-title">
{renderPurchaseTypeChip(item.purchaseType)}
<span>{item.typeLabel}</span>
{item.policyNo ? (
<span style={{ fontSize: 12, color: '#64748b', fontWeight: 500 }}>{item.policyNo}</span>
) : null}
</div>
<div className="lc-vehicle-ins-timeline-desc">{item.summary}</div>
<div className="lc-vehicle-ins-timeline-meta">
{item.sourceLabel ? `${item.sourceLabel} · ` : ''}
点击查看 {item.typeLabel} 明细
</div>
</div>
</Timeline.Item>
);
})}
</Timeline>
) : (
<div className="lc-vehicle-ins-mgmt-empty">
<div style={{ fontSize: 15, fontWeight: 700, color: '#334155', marginBottom: 8 }}>暂无全周期记录</div>
<div style={{ fontSize: 13, color: '#64748b' }}>可通过批量识别Excel 导入或比价单采购产生记录</div>
</div>
)}
</Tabs.TabPane>
{VEHICLE_INSURANCE_MGMT_TABS.filter((t) => t.key !== 'timeline').map((tab) => (
<Tabs.TabPane tab={renderVehicleInsMgmtTabLabel(tab)} key={tab.key}>
{renderVehicleInsuranceTypeTab(tab.key)}
</Tabs.TabPane>
))}
</Tabs>
</div>
</div>
);
})() : null}
</Modal>
<Modal
open={prdOpen}
title="保险采购 — 需求说明"
width={720}
centered
footer={null}
onCancel={() => setPrdOpen(false)}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16, borderRadius: 10 }}
message="模块定位"
description="保险采购用于维护车辆各险种保单信息,采用一车一档(每个车牌号一条记录),与车辆管理「保险状态」字段联动。"
/>
<div style={{ fontSize: 13, color: '#334155', lineHeight: 1.7 }}>
<p><strong>1. 面包屑</strong> </p>
<p><strong>2. 保险状态规则交车拦截</strong></p>
<ul style={{ paddingLeft: 20, margin: '8px 0' }}>
<li>核验<strong>交强险</strong><strong></strong></li>
<li>两项均满足含临期未到期 车辆管理保险状态 = 正常/临期</li>
<li>任一项缺失或已到期 保险状态 = 异常<strong>禁止交车</strong></li>
</ul>
<p><strong>3. 管理险种</strong></p>
<p><strong>4. 列表字段</strong>VIN + Tab/</p>
<p><strong>5. 筛选</strong>VINKPI </p>
<p><strong>6. 交互参照</strong>KPI </p>
<p><strong>7. 比价单</strong> VIN /</p>
<p><strong>8. 保单管理</strong>///</p>
</div>
</Modal>
<Modal
className="lc-compare-mgmt-modal"
open={compareMgmtOpen}
title="比价单管理"
width={1080}
centered
footer={null}
onCancel={() => setCompareMgmtOpen(false)}
>
<div className="lc-compare-mgmt-filter">
{renderFilterField('创建时间', (
<DatePicker.RangePicker
style={{ width: '100%' }}
value={compareMgmtFilters.createdRange}
onChange={(range) => setCompareMgmtFilters((prev) => ({ ...prev, createdRange: range }))}
placeholder={['开始日期', '结束日期']}
allowClear
/>
))}
{renderFilterField('车牌号', (
<Input
placeholder="支持模糊匹配,含暂无车牌车辆 VIN"
allowClear
value={compareMgmtFilters.plateNo}
onChange={(e) => setCompareMgmtFilters((prev) => ({ ...prev, plateNo: e.target.value }))}
onPressEnter={handleCompareMgmtQuery}
style={{ borderRadius: 8 }}
/>
))}
</div>
<div className="lc-filter-actions" style={{ marginTop: 0, paddingTop: 0, borderTop: 'none', marginBottom: 14 }}>
<Button onClick={handleCompareMgmtReset}>重置</Button>
<Button type="primary" onClick={handleCompareMgmtQuery}>查询</Button>
</div>
<div className="lc-compare-pay-alert-row">
<div className="lc-compare-pay-alert lc-compare-pay-alert--warning">
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#9a3412' }}>最晚付费临期</div>
<div style={{ fontSize: 11, color: '#c2410c', marginTop: 2 }}>距最晚付费日 {LATEST_PAY_WARN_DAYS} </div>
</div>
<span className="lc-compare-pay-alert-val">{compareMgmtPayAlerts.warning}</span>
</div>
<div className="lc-compare-pay-alert lc-compare-pay-alert--overdue">
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#991b1b' }}>最晚付费超期</div>
<div style={{ fontSize: 11, color: '#b91c1c', marginTop: 2 }}>已超过最晚付费日</div>
</div>
<span className="lc-compare-pay-alert-val">{compareMgmtPayAlerts.overdue}</span>
</div>
</div>
<div className="lc-compare-mgmt-toolbar">
<span style={{ fontSize: 13, color: '#64748b' }}>
<strong style={{ color: '#0f172a' }}>{filteredCompareSheets.length}</strong>
</span>
<Button
type="primary"
style={{ fontWeight: 600, background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', border: 'none', borderRadius: 8 }}
onClick={() => openCompareEditor(null)}
>
新建比价单
</Button>
</div>
<Table
className="lc-compare-mgmt-table"
size="middle"
rowKey="id"
dataSource={filteredCompareSheets}
pagination={{ pageSize: 8, showSizeChanger: false, showTotal: (t) => `${t}` }}
scroll={{ x: 1040 }}
locale={{ emptyText: '暂无比价单,请点击「新建比价单」' }}
columns={[
{
title: '创建日期',
dataIndex: 'createdAt',
key: 'createdAt',
width: 168,
render: (val) => <span style={{ fontVariantNumeric: 'tabular-nums' }}>{val || '—'}</span>,
},
{
title: '创建人',
dataIndex: 'createdBy',
key: 'createdBy',
width: 96,
render: (val) => val || '—',
},
{
title: '采购周期',
dataIndex: 'periodLabel',
key: 'periodLabel',
width: 108,
render: (val) => val || '—',
},
{
title: '总车辆',
dataIndex: 'totalVehicles',
key: 'totalVehicles',
width: 88,
align: 'center',
render: (val) => <span className="lc-compare-mgmt-count">{val ?? 0}</span>,
},
{
title: '保险数量',
dataIndex: 'insuranceCount',
key: 'insuranceCount',
width: 96,
align: 'center',
render: (val) => <span className="lc-compare-mgmt-count">{val ?? 0}</span>,
},
{
title: '附件',
key: 'attachments',
width: 72,
align: 'center',
render: (_, record) => {
const n = record.attachments?.length || 0;
return n > 0 ? (
<Tooltip title={record.attachments.map((a) => a.name).join('、')}>
<Tag color="blue" style={{ margin: 0, fontWeight: 600 }}>{n}</Tag>
</Tooltip>
) : (
<span style={{ color: '#94a3b8' }}></span>
);
},
},
{
title: '已提交采购数量',
dataIndex: 'submittedProcurementCount',
key: 'submittedProcurementCount',
width: 128,
align: 'center',
render: (val) => (
<Tag color={val > 0 ? 'processing' : 'default'} style={{ margin: 0, fontWeight: 600 }}>
{val ?? 0}
</Tag>
),
},
{
title: '流程完结数量',
dataIndex: 'completedCount',
key: 'completedCount',
width: 120,
align: 'center',
render: (val) => (
<Tag color={val > 0 ? 'success' : 'default'} style={{ margin: 0, fontWeight: 600 }}>
{val ?? 0}
</Tag>
),
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right',
render: (_, record) => (
<Space size={4}>
<Button type="link" size="small" style={{ padding: 0, fontWeight: 600, color: '#10b981' }} onClick={() => openCompareEditor(record)}>
编辑
</Button>
<Button type="link" size="small" danger style={{ padding: 0, fontWeight: 600 }} onClick={() => handleDeleteCompareSheet(record)}>
删除
</Button>
</Space>
),
},
]}
/>
</Modal>
<Modal
className="lc-compare-modal"
open={compareModalOpen}
title={editingCompareSheetId ? '编辑比价单' : '新建比价单'}
width="96vw"
centered
destroyOnClose
onCancel={() => {
setCompareModalOpen(false);
setEditingCompareSheetId(null);
}}
footer={(
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<span className="lc-compare-procurement-hint">
勾选购买记录后可提交采购申请须已确认报价并填写最晚付费日期
</span>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={() => {
setCompareModalOpen(false);
setEditingCompareSheetId(null);
}}
>
取消
</Button>
<Button style={{ fontWeight: 600 }} onClick={() => handleSubmitCompareSheet({ closeModal: false })}>
保存比价单
</Button>
<Button
type="primary"
style={{ fontWeight: 600, background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', border: 'none' }}
onClick={handleSubmitProcurement}
disabled={!selectedCompareKeys.length}
>
提交采购申请
</Button>
</div>
</div>
)}
>
<div className="lc-compare-editor-meta">
<div className="lc-compare-editor-meta-field">
<span className="lc-compare-editor-meta-label">采购周期</span>
<Input
size="small"
placeholder="如 2026年5-6月"
value={compareSheetPeriod}
onChange={(e) => setCompareSheetPeriod(e.target.value)}
style={{ flex: 1, borderRadius: 8 }}
/>
</div>
<Alert
type="info"
showIcon
style={{ flex: 2, minWidth: 280, margin: 0, borderRadius: 8, fontSize: 12 }}
message="比价单与保单台账独立维护;保存后可按最晚付费日筛选临期/超期记录并提交采购流程。"
/>
</div>
<div className="lc-compare-toolbar">
<Space wrap>
<Button type="primary" size="small" style={{ fontWeight: 600 }} onClick={handleAddCompareRow}>新增一行</Button>
{selectedCompareKeys.length ? (
<Button
size="small"
danger
onClick={() => {
setCompareRows((prev) => prev.filter((r) => !selectedCompareKeys.includes(r.id)));
setSelectedCompareKeys([]);
message.success('已删除所选记录');
}}
>
删除所选{selectedCompareKeys.length}
</Button>
) : null}
</Space>
<span style={{ fontSize: 12, color: '#64748b' }}> {compareRows.length} 条购买记录</span>
</div>
<div className="lc-compare-table-wrap">
<Table
className="lc-compare-table"
size="small"
bordered={false}
rowKey="id"
columns={compareColumns}
dataSource={compareRows}
pagination={false}
scroll={{ x: 2420, y: 'calc(100vh - 480px)' }}
rowSelection={{
selectedRowKeys: selectedCompareKeys,
onChange: setSelectedCompareKeys,
columnWidth: 40,
getCheckboxProps: (record) => ({
disabled: record.procurementStatus === 'completed',
}),
}}
locale={{ emptyText: '暂无购买记录,请点击「新增一行」' }}
/>
</div>
<div className="lc-compare-footer">
<div className="lc-compare-remark-field">
<label className="lc-compare-remark-label" htmlFor="lc-compare-remark">备注</label>
<Input.TextArea
id="lc-compare-remark"
rows={2}
value={compareRemark}
onChange={(e) => setCompareRemark(e.target.value)}
placeholder="选填,可填写比价说明、采购要求等"
maxLength={500}
showCount
style={{ borderRadius: 8 }}
/>
</div>
<div className="lc-compare-attach-field">
<span className="lc-compare-attach-label">附件</span>
<span className="lc-compare-attach-hint">不限制文件格式与上传数量保存比价单时一并存储附件信息原型仅存元数据</span>
<Upload
className="lc-compare-attach-upload"
multiple
fileList={compareAttachmentFileList}
beforeUpload={() => false}
onChange={handleCompareAttachmentChange}
itemRender={(originNode, file) => (
<Tooltip title={`${file.name}${file.size != null ? ` · ${formatFileSize(file.size)}` : ''}`}>
{originNode}
</Tooltip>
)}
>
<Button type="dashed" style={{ borderRadius: 8, fontWeight: 600, width: '100%' }}>
点击或拖拽上传附件
</Button>
</Upload>
</div>
<div className="lc-compare-total-bar">
<span className="lc-compare-total-label">整单确认金额</span>
<span>
<span className="lc-compare-total-amount">{compareSheetSummary.total.toFixed(2)}</span>
<span className="lc-compare-total-unit"></span>
</span>
<span className="lc-compare-total-hint">
全单已确认 {compareSheetSummary.count}
</span>
</div>
<div className="lc-compare-total-bar lc-compare-total-bar--procurement">
<span className="lc-compare-total-label">勾选提交采购金额</span>
<span>
<span className="lc-compare-total-amount">{selectedProcurementSummary.total.toFixed(2)}</span>
<span className="lc-compare-total-unit"></span>
</span>
<span className="lc-compare-total-hint">
已勾选 {selectedCompareKeys.length} · 可提交 {selectedProcurementSummary.count} 项确认报价
</span>
</div>
</div>
</Modal>
<Modal
title="提交采购申请"
open={procurementFlowOpen}
centered
footer={(
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button disabled={procurementFlowLoading} onClick={() => setProcurementFlowOpen(false)}>取消</Button>
<Button
type="primary"
disabled={procurementFlowStep < 3}
onClick={() => setProcurementFlowOpen(false)}
>
完成
</Button>
</div>
)}
onCancel={() => !procurementFlowLoading && setProcurementFlowOpen(false)}
>
<Steps
current={procurementFlowStep}
items={[
{ title: '校验比价结果', description: '确认报价与最晚付费日' },
{ title: '生成采购申请', description: `合计 ${selectedProcurementSummary.total.toFixed(2)}` },
{ title: '发起审批工作流', description: '推送至采购审批(原型)' },
]}
style={{ marginBottom: 20 }}
/>
{procurementFlowStep >= 3 ? (
<Alert type="success" showIcon message="工作流已发起" description="比价单列表已更新「已提交采购数量」;审批完结后将更新「流程完结数量」(原型)。" />
) : (
<Button type="primary" block loading={procurementFlowLoading} onClick={runProcurementWorkflow}>
确认发起工作流
</Button>
)}
</Modal>
<Modal
className="lc-policy-recogn-tasks-modal"
open={policyRecognTasksOpen}
title="识别任务记录"
width={1000}
centered
footer={null}
onCancel={() => setPolicyRecognTasksOpen(false)}
>
<div className="lc-policy-recogn-tasks-filter">
{renderFilterField('创建时间', (
<DatePicker.RangePicker
style={{ width: '100%' }}
value={policyRecognTasksFilters.createdRange}
onChange={(range) => setPolicyRecognTasksFilters((prev) => ({ ...prev, createdRange: range }))}
placeholder={['开始日期', '结束日期']}
allowClear
/>
))}
{renderFilterField('任务编号', (
<Input
allowClear
placeholder="支持模糊匹配"
value={policyRecognTasksFilters.taskId}
onChange={(e) => setPolicyRecognTasksFilters((prev) => ({ ...prev, taskId: e.target.value }))}
onPressEnter={handlePolicyRecognTasksQuery}
style={{ borderRadius: 8 }}
/>
))}
{renderFilterField('业务类型', (
<Select
value={policyRecognTasksFilters.entry}
onChange={(v) => setPolicyRecognTasksFilters((prev) => ({ ...prev, entry: v }))}
style={{ width: '100%' }}
options={[
{ label: '全部', value: '全部' },
{ label: '保单批量识别', value: '保单批量识别' },
{ label: '批量导入', value: '批量导入' },
]}
/>
))}
{renderFilterField('状态', (
<Select
value={policyRecognTasksFilters.status}
onChange={(v) => setPolicyRecognTasksFilters((prev) => ({ ...prev, status: v }))}
style={{ width: '100%' }}
options={[
{ label: '全部', value: '全部' },
{ label: '待确认', value: '待确认' },
{ label: '部分确认', value: '部分确认' },
{ label: '已完成', value: '已完成' },
]}
/>
))}
</div>
<div className="lc-filter-actions" style={{ marginTop: 0, paddingTop: 0, borderTop: 'none', marginBottom: 14 }}>
<Button onClick={handlePolicyRecognTasksReset}>重置</Button>
<Button type="primary" onClick={handlePolicyRecognTasksQuery}>查询</Button>
</div>
<div className="lc-policy-recogn-tasks-toolbar">
<span style={{ fontSize: 13, color: '#64748b' }}>
<strong style={{ color: '#0f172a' }}>{filteredPolicyRecognTasks.length}</strong>
</span>
<Button
type="primary"
style={{ fontWeight: 600, background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', border: 'none', borderRadius: 8 }}
onClick={() => {
setPolicyRecognTasksOpen(false);
openPolicyRecogn('ocr', 'policy');
}}
>
新建识别任务
</Button>
</div>
<Table
size="middle"
rowKey="id"
dataSource={filteredPolicyRecognTasks}
pagination={{ pageSize: 8, showSizeChanger: false, showTotal: (t) => `${t}` }}
scroll={{ x: 960 }}
locale={{ emptyText: '暂无识别任务,请发起「保单批量识别」或「批量导入」' }}
columns={[
{
title: '创建时间',
dataIndex: 'createdAt',
width: 168,
render: (val) => <span style={{ fontVariantNumeric: 'tabular-nums' }}>{val || '—'}</span>,
},
{
title: '任务编号',
dataIndex: 'id',
width: 148,
render: (val) => <span className="lc-policy-recogn-task-id">{val}</span>,
},
{
title: '业务类型',
dataIndex: 'entryLabel',
width: 120,
},
{
title: '业务模式',
dataIndex: 'modeLabel',
width: 96,
render: (val, record) => (
<span>
{val || '—'}
{record.insuranceType ? (
<span style={{ display: 'block', fontSize: 11, color: '#94a3b8' }}>{record.insuranceType}</span>
) : null}
</span>
),
},
{
title: '文件数',
dataIndex: 'fileCount',
width: 72,
align: 'center',
},
{
title: '确认进度',
key: 'progress',
width: 100,
align: 'center',
render: (_, record) => (
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{record.confirmedCount}/{record.matchedCount || record.fileCount}
</span>
),
},
{
title: '状态',
dataIndex: 'status',
width: 96,
render: (status) => {
const meta = POLICY_RECOGN_STATUS_META[status] || { label: status, color: 'default' };
return <Tag color={meta.color} style={{ margin: 0 }}>{meta.label}</Tag>;
},
},
{
title: '操作',
key: 'action',
width: 88,
fixed: 'right',
render: (_, record) => (
<Button
type="link"
size="small"
style={{ padding: 0, fontWeight: 600 }}
onClick={() => openPolicyRecognTaskRecord(record)}
>
查看
</Button>
),
},
]}
/>
</Modal>
<Modal
className="lc-policy-recogn-modal"
title={
policyRecognPhase === 'results'
? (policyRecognEntry === 'import' ? '批量导入 · 确认' : '保单批量识别 · 确认')
: (policyRecognEntry === 'import' ? '批量导入' : '保单批量识别')
}
open={policyRecognOpen}
width={policyRecognPhase === 'results' ? 1280 : 760}
centered
footer={null}
onCancel={closePolicyRecogn}
destroyOnClose
>
{policyRecognTaskId ? (
<div className="lc-policy-recogn-task">
任务编号<strong>{policyRecognTaskId}</strong>
{policyRecognPhase === 'recognizing' ? (policyRecognEntry === 'import' ? ' · 导入中…' : ' · 识别中…') : null}
{policyRecognPhase === 'recognized' ? ' · 识别完成' : null}
{policyRecognPhase === 'results' ? ' · 核对确认' : null}
</div>
) : null}
{policyRecognPhase === 'upload' ? (
<>
{policyRecognEntry === 'ocr' ? (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 14, borderRadius: 8 }}
message="自动识别车牌号、保单号并与台账匹配,无需手填车牌 / VIN / 险种(保单录入需先选择保险类型)"
/>
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#64748b', marginBottom: 6 }}>业务类型</div>
<Radio.Group
value={policyRecognMode}
onChange={(e) => setPolicyRecognMode(e.target.value)}
style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
>
{POLICY_OCR_MODES.map((m) => (
<Radio key={m.key} value={m.key}>
<span style={{ fontWeight: 600 }}>{m.label}</span>
<span style={{ color: '#64748b', marginLeft: 6, fontSize: 12 }}>{m.desc}</span>
</Radio>
))}
</Radio.Group>
</div>
{policyRecognMode === 'policy' ? (
<div style={{ marginBottom: 12 }}>
{renderFilterField('保险类型', (
<Select
value={policyRecognInsuranceType}
onChange={setPolicyRecognInsuranceType}
style={{ width: '100%' }}
options={QUOTE_INSURANCE_TYPES.map((t) => ({ label: t, value: t }))}
/>
))}
</div>
) : null}
<Upload.Dragger
className="lc-policy-ocr-upload"
multiple
accept=".pdf,.png,.jpg,.jpeg,.webp,.bmp,.gif,.tiff,.tif"
beforeUpload={() => false}
fileList={policyRecognFiles}
onChange={handlePolicyRecognUploadChange}
disabled={policyRecognPhase !== 'upload'}
>
<p style={{ fontWeight: 600, color: '#334155' }}>拖拽或点击上传 PDF / 图片</p>
<p style={{ fontSize: 12, color: '#94a3b8' }}>不限制上传数量上传完成后可开始识别</p>
</Upload.Dragger>
</>
) : (
<>
<div className="lc-policy-import-template-bar">
<div className="lc-policy-import-template-bar-text">
<div style={{ fontWeight: 700, marginBottom: 4 }}>第一步下载 Excel 导入模板</div>
模板含车牌VIN业务类型险种保单号批单号付款/生效/到期时间保费保单项目等与新增页字段一致
</div>
<Button
type="primary"
ghost
style={{ borderColor: '#10b981', color: '#059669', fontWeight: 600, flexShrink: 0 }}
onClick={downloadPolicyImportTemplate}
>
下载导入模板
</Button>
</div>
<Alert
type="success"
showIcon
style={{ marginBottom: 12, borderRadius: 8 }}
message="第二步:填写模板后上传 Excel 文件批量导入"
description="支持 .csv推荐Excel 可直接打开编辑)、.xlsx、.xls上传后系统校验并匹配台账再进入核对确认。"
/>
<Upload.Dragger
className="lc-policy-import-excel-upload"
maxCount={1}
accept=".csv,.xlsx,.xls"
beforeUpload={() => false}
fileList={policyRecognFiles}
onChange={handlePolicyImportUploadChange}
disabled={policyRecognPhase !== 'upload'}
>
<p style={{ fontWeight: 600, color: '#334155' }}>拖拽或点击上传 Excel / CSV</p>
<p style={{ fontSize: 12, color: '#94a3b8' }}>单次上传一个文件.xlsx 原型请另存为 CSV UTF-8</p>
</Upload.Dragger>
</>
)}
{policyRecognEntry === 'ocr' && policyRecognFiles.length ? (
<div className="lc-policy-recogn-file-list">
{policyRecognFiles.map((f) => (
<div key={f.uid} className="lc-policy-recogn-file-item">
<span className="lc-cell-ellipsis" style={{ flex: 1 }} title={f.name}>{f.name}</span>
{f.status === 'uploading' ? (
<Tag color="processing">上传中</Tag>
) : (
<Tag color="success">已上传</Tag>
)}
</div>
))}
</div>
) : null}
<div className="lc-policy-recogn-actions">
<Button onClick={closePolicyRecogn}>取消</Button>
<Button
type="primary"
disabled={!policyRecognAllUploaded}
onClick={startPolicyRecognTask}
>
{policyRecognEntry === 'import' ? '开始导入' : '开始识别'}
</Button>
</div>
</>
) : null}
{policyRecognPhase === 'recognizing' ? (
<>
<Alert
showIcon
type="info"
message={policyRecognEntry === 'import' ? '正在解析 Excel 导入数据,请稍候…' : '正在执行识别任务,请稍候…'}
style={{ marginBottom: 12, borderRadius: 8 }}
/>
<Progress percent={policyRecognProgress} status="active" className="lc-policy-recogn-progress" />
<div className="lc-policy-recogn-actions">
<Button onClick={closePolicyRecogn} disabled>请等待识别完成</Button>
</div>
</>
) : null}
{policyRecognPhase === 'recognized' ? (
<>
<Alert
showIcon
type="success"
message={policyRecognEntry === 'import' ? '导入解析完成' : '识别完成'}
description={
policyRecognEntry === 'import'
? `共解析 ${policyRecognResults.length} 条 Excel 记录,已校验车牌/VIN 与险种。请点击「处理」核对并确认写入台账。`
: `共识别 ${policyRecognResults.length} 个文件,已自动匹配车牌号与保单号。请点击「处理」核对并确认结果。`
}
style={{ marginBottom: 14, borderRadius: 8 }}
/>
<div className="lc-policy-recogn-actions">
<Button onClick={() => setPolicyRecognPhase('upload')}>
{policyRecognEntry === 'import' ? '重新上传' : '返回上传'}
</Button>
<Button type="primary" onClick={openPolicyRecognResults}>处理</Button>
</div>
</>
) : null}
{policyRecognPhase === 'results' ? (
<>
{policyRecognViewOnly ? (
<Alert
showIcon
type="info"
message="该任务已全部确认"
description="当前为只读查看;如需修改台账请使用「新增」或重新发起识别任务。"
style={{ marginBottom: 12, borderRadius: 8 }}
/>
) : null}
<Table
size="small"
rowKey="id"
dataSource={policyRecognResults}
pagination={{ pageSize: 6, showSizeChanger: false }}
scroll={{ x: 1180 }}
columns={policyRecognResultColumns}
/>
<div className="lc-policy-recogn-actions">
{!policyRecognViewOnly ? (
<Button onClick={() => {
setPolicyRecognPhase('recognized');
if (policyRecognTaskId) {
savePolicyRecognTaskSnapshot({
taskId: policyRecognTaskId,
entry: policyRecognEntry,
mode: policyRecognMode,
insuranceType: policyRecognInsuranceType,
results: policyRecognResults,
phase: 'recognized',
});
}
}}
>
返回
</Button>
) : null}
{!policyRecognViewOnly ? (
<Button onClick={confirmAllPolicyRecognResults}>批量确认</Button>
) : null}
<Button type="primary" onClick={closePolicyRecogn}>
{policyRecognViewOnly ? '关闭' : '完成'}
</Button>
</div>
</>
) : null}
</Modal>
<Modal
title={`预览 · ${policyPreview?.fileName || ''}`}
open={!!policyPreview}
width={800}
centered
footer={<Button onClick={() => { if (policyPreview?.url) URL.revokeObjectURL(policyPreview.url); setPolicyPreview(null); }}>关闭</Button>}
onCancel={() => { if (policyPreview?.url) URL.revokeObjectURL(policyPreview.url); setPolicyPreview(null); }}
>
<div className="lc-policy-recogn-preview">
{policyPreview?.isImage && policyPreview.url ? (
<img src={policyPreview.url} alt={policyPreview.fileName} />
) : (
<div style={{ padding: 24, textAlign: 'center', color: '#64748b' }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>PDF</div>
<div style={{ fontWeight: 600, color: '#334155' }}>{policyPreview?.fileName}</div>
<div style={{ fontSize: 13, marginTop: 8 }}>{policyPreview?.hint}</div>
</div>
)}
</div>
</Modal>
<Modal
title="编辑识别结果"
open={policyRecognEditOpen}
width={920}
centered
destroyOnClose
onCancel={() => setPolicyRecognEditOpen(false)}
onOk={savePolicyRecognResultEdit}
okText="保存"
cancelText="取消"
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12, borderRadius: 8 }}
message="可修正 OCR / 导入识别字段,保存后需再次点击「确认」写入台账"
/>
{renderPolicyDetailForm(policyRecognEditDraft, setPolicyRecognEditDraft)}
</Modal>
<Modal
title={vehicleInsHistoryEditRecord ? `编辑保单 · ${vehicleInsHistoryEditRecord.typeLabel}` : '编辑保单'}
open={vehicleInsHistoryEditOpen}
width={920}
centered
destroyOnClose
onCancel={() => {
setVehicleInsHistoryEditOpen(false);
setVehicleInsHistoryEditRecord(null);
}}
onOk={saveVehicleInsHistoryEdit}
okText="保存"
cancelText="取消"
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12, borderRadius: 8 }}
message="可编辑保单识别/台账关键要素;保存后同步更新本车档案记录,来源为台账或识别任务时一并回写"
/>
{renderPolicyDetailForm(vehicleInsHistoryEditDraft, setVehicleInsHistoryEditDraft)}
</Modal>
<Modal
title="新增保单"
open={policyAddOpen}
width={920}
centered
destroyOnClose
onCancel={() => setPolicyAddOpen(false)}
onOk={handlePolicyAddSubmit}
okText="保存"
cancelText="取消"
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12, borderRadius: 8 }}
message="支持五类险种保单及停保、复驶、退保批单要素维护;上传 PDF 识别后将自动填入下列字段"
/>
{renderPolicyDetailForm(policyAddDraft, setPolicyAddDraft)}
</Modal>
</div>
);
};
export default Component;