Files
ONE-OS/ONE-OS小程序/小羚羚.jsx

8892 lines
514 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 【重要】必须使用 const Component 作为组件变量名
// ONE-OS 小程序 - 小羚羚(待办 / 业务 / 地图 / 我的)
const { useState, useMemo, useCallback, useEffect, useRef } = React;
const XLL_GREEN = '#7AB929';
const XLL_GREEN_DEEP = '#6AA322';
const XLL_GREEN_SOFT = 'rgba(122, 185, 41, 0.14)';
const COLOR_TEXT = '#1D2129';
const COLOR_TEXT_SEC = '#4E5969';
const COLOR_MUTED = '#86909C';
const COLOR_LINE = '#E5E6EB';
const COLOR_BG = '#FFFFFF';
const COLOR_PAGE = '#F2F3F5';
const COLOR_DANGER = '#F53F3F';
const COLOR_WARN = '#FF7D00';
const COLOR_SUCCESS = '#00B42A';
const FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", STHeiti, sans-serif';
const antd = (() => {
const raw = window.antd;
if (!raw) return {};
return raw.default && typeof raw.default === 'object' ? { ...raw, ...raw.default } : raw;
})();
const message = antd.message || { info: () => {}, success: () => {}, warning: () => {} };
const Modal = antd.Modal;
const Button = antd.Button;
const Drawer = antd.Drawer;
const Input = antd.Input;
const MOCK_USER = '张明辉';
const formatMoneySymbol = (val) => {
const n = parseFloat(val);
if (Number.isNaN(n)) return '—';
return `${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatMoney = formatMoneySymbol;
const formatYuan = formatMoneySymbol;
const MAIN_TABS = [
{ key: 'todo', label: '工作台', navLabel: '待办' },
{ key: 'business', label: '业务', navLabel: '业务' },
{ key: 'map', label: '地图', navLabel: '地图' },
{ key: 'mine', label: '我的', navLabel: '我的' },
];
const TASK_THEME = {
delivery: { accent: XLL_GREEN, soft: XLL_GREEN_SOFT, label: '交车' },
return: { accent: '#2563EB', soft: 'rgba(37, 99, 235, 0.12)', label: '还车' },
inspection: { accent: COLOR_WARN, soft: 'rgba(255, 125, 0, 0.12)', label: '年审' },
transfer: { accent: '#8B5CF6', soft: 'rgba(139, 92, 246, 0.12)', label: '调拨' },
move: { accent: '#14B8A6', soft: 'rgba(20, 184, 166, 0.12)', label: '异动' },
};
const TODO_TASKS = [
{
id: 't1', type: 'delivery', badge: null,
title: '交车任务3辆',
fields: [
{ label: '项目名称', value: '嘉兴氢能示范项目' },
{ label: '客户名称', value: '嘉兴某某物流有限公司' },
{ label: '交车地点', value: '浙江省嘉兴市南湖区xx路xx号' },
{ label: '交车时间', value: '2026-06-05 09:30' },
],
},
{
id: 't2', type: 'return', badge: null,
title: '还车任务粤AGP5368',
fields: [
{ label: '项目名称', value: '嘉兴腾4.5T租赁' },
{ label: '客户名称', value: '嘉兴某某物流有限公司' },
{ label: '还车时间', value: '2026-06-03 16:20' },
],
},
{
id: 't3', type: 'inspection', badge: 4,
title: '年审任务',
fields: [
{ label: '车牌号', value: '粤B58888F' },
{ label: '年审/等评时间', value: '2026-05-28已到期', warn: true },
],
},
{
id: 't4', type: 'transfer', badge: 2,
title: '张三发起的调拨申请',
fields: [
{ label: '调拨日期', value: '2026-06-01' },
{ label: '出发区域', value: '广东省深圳市' },
{ label: '接收区域', value: '浙江省杭州市' },
{ label: '车辆数', value: '40辆' },
],
},
{
id: 't5', type: 'move', badge: 3,
title: '异动申请粤A08875F',
fields: [
{ label: '异动类型', value: '保养' },
{ label: '目的地', value: '嘉兴xx检测站' },
{ label: '计划时间', value: '2026-06-02 08:00' },
],
},
];
const BUSINESS_SECTIONS = [
{
title: '运维管理',
items: [
{ key: 'vehicle', label: '车辆管理', badge: 0 },
{ key: 'prepare', label: '备车', badge: 0 },
{ key: 'delivery', label: '交车', badge: 99 },
{ key: 'return', label: '还车', badge: 99 },
{ key: 'replace', label: '替换车', badge: 99 },
{ key: 'move', label: '异动', badge: 0 },
{ key: 'transfer', label: '调拨', badge: 99 },
{ key: 'inspection', label: '年审', badge: 99 },
{ key: 'fault', label: '故障', badge: 0 },
{ key: 'training', label: '司机安全培训', badge: 0 },
],
},
{
title: '审批管理',
items: [{ key: 'audit', label: '审批中心', badge: 99 }],
},
{
title: '数据可视化',
items: [
{ key: 'stat-vehicle', label: '车辆统计', badge: 0 },
{ key: 'stat-h2-fee', label: '氢费统计', badge: 0 },
{ key: 'stat-h2-qty', label: '氢量汇总', badge: 0 },
{ key: 'stat-electric', label: '电量汇总', badge: 0 },
{ key: 'mileage-query', label: '里程查询', badge: 0 },
{ key: 'mileage-assess', label: '里程考核', badge: 0 },
],
},
];
const PAGE_STYLE = `
.xll-root { height:100dvh; max-height:100dvh; overflow:hidden; background:linear-gradient(165deg,#e8ebef 0%,${COLOR_PAGE} 40%); display:flex; justify-content:center; align-items:center; padding:16px 12px; box-sizing:border-box; font-family:${FONT_FAMILY}; -webkit-font-smoothing:antialiased; }
.xll-phone { width:100%; max-width:390px; height:min(844px, calc(100dvh - 32px)); max-height:calc(100dvh - 32px); background:${COLOR_PAGE}; border-radius:28px; overflow:hidden; box-shadow:0 24px 48px rgba(15,23,42,.14), 0 0 0 1px rgba(15,23,42,.05); display:flex; flex-direction:column; position:relative; }
.xll-chrome { flex-shrink:0; background:${COLOR_BG}; }
.xll-status { height:44px; padding:14px 20px 0; display:flex; align-items:center; justify-content:space-between; box-sizing:border-box; }
.xll-status-time { font-size:15px; font-weight:600; color:${COLOR_TEXT}; letter-spacing:-0.02em; }
.xll-status-icons { display:flex; align-items:center; gap:6px; color:${COLOR_TEXT}; }
.xll-navbar { height:48px; display:flex; align-items:center; padding:0 4px 0 8px; border-bottom:1px solid rgba(0,0,0,.05); position:relative; background:${COLOR_BG}; }
.xll-nav-left { display:flex; align-items:center; gap:4px; min-width:72px; z-index:2; }
.xll-nav-bell { position:relative; width:44px; height:44px; border:none; background:transparent; cursor:pointer; display:flex; align-items:center; justify-content:center; color:${COLOR_TEXT}; border-radius:10px; touch-action:manipulation; transition:background 0.15s ease; }
.xll-nav-bell:active { background:rgba(0,0,0,.05); }
.xll-nav-bell:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:2px; }
.xll-nav-badge { position:absolute; top:6px; right:4px; min-width:18px; height:18px; padding:0 5px; border-radius:999px; background:${XLL_GREEN}; color:#fff; font-size:10px; font-weight:700; display:flex; align-items:center; justify-content:center; line-height:1; font-variant-numeric:tabular-nums; }
.xll-nav-title { position:absolute; left:50%; transform:translateX(-50%); font-size:17px; font-weight:700; color:${COLOR_TEXT}; max-width:42%; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.xll-nav-right { margin-left:auto; display:flex; align-items:center; gap:6px; z-index:2; }
.xll-prd-link { border:none; background:transparent; color:${XLL_GREEN_DEEP}; font-size:13px; font-weight:600; padding:8px 4px; min-height:44px; cursor:pointer; white-space:nowrap; touch-action:manipulation; border-radius:8px; transition:background 0.15s ease; }
.xll-prd-link:active { background:${XLL_GREEN_SOFT}; }
.xll-prd-link:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:2px; }
.xll-back { width:44px; height:44px; border:none; background:transparent; cursor:pointer; display:flex; align-items:center; justify-content:center; color:${COLOR_TEXT}; border-radius:10px; touch-action:manipulation; transition:background 0.15s ease; }
.xll-back:active { background:rgba(0,0,0,.05); }
.xll-back:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:2px; }
.xll-capsule { display:flex; align-items:center; height:32px; border-radius:16px; border:.5px solid rgba(0,0,0,.12); background:rgba(255,255,255,.92); overflow:hidden; }
.xll-capsule-btn { width:44px; height:32px; border:none; background:transparent; font-size:16px; cursor:pointer; color:${COLOR_TEXT}; touch-action:manipulation; }
.xll-capsule-divider { width:1px; height:18px; background:rgba(0,0,0,.12); flex-shrink:0; }
.xll-body { flex:1; min-height:0; overflow-y:auto; overflow-x:hidden; -webkit-overflow-scrolling:touch; overscroll-behavior:contain; padding-bottom:16px; }
.xll-body--login { overflow:hidden; display:flex; flex-direction:column; padding-bottom:0; }
.xll-body--map { padding-bottom:12px; }
.xll-body--module { padding:0; overflow:hidden; display:flex; flex-direction:column; flex:1; min-height:0; }
.xll-mod-root { flex:1; min-height:0; display:flex; flex-direction:column; overflow:hidden; background:${COLOR_PAGE}; }
.xll-mod-tabs { display:flex; gap:6px; padding:10px 14px 8px; background:${COLOR_BG}; flex-shrink:0; overflow-x:auto; scrollbar-width:none; }
.xll-mod-tabs::-webkit-scrollbar { display:none; }
.xll-mod-tab { flex:1 0 auto; min-width:72px; min-height:44px; padding:8px 10px; border:none; border-radius:10px; background:${COLOR_PAGE}; color:${COLOR_MUTED}; font-size:13px; font-weight:500; cursor:pointer; touch-action:manipulation; }
.xll-mod-tab.active { background:${COLOR_BG}; color:${XLL_GREEN_DEEP}; font-weight:700; box-shadow:0 2px 8px rgba(15,23,42,.08); }
.xll-mod-tab-count { display:block; font-size:11px; font-weight:600; margin-top:2px; font-variant-numeric:tabular-nums; }
.xll-mod-toolbar { padding:0 14px 10px; flex-shrink:0; background:${COLOR_BG}; border-bottom:1px solid ${COLOR_LINE}; }
.xll-mod-search { display:flex; align-items:center; gap:8px; min-height:44px; padding:0 12px; background:${COLOR_PAGE}; border-radius:12px; border:1px solid transparent; }
.xll-mod-search:focus-within { border-color:rgba(122,185,41,.45); box-shadow:0 0 0 3px ${XLL_GREEN_SOFT}; background:${COLOR_BG}; }
.xll-mod-search input { flex:1; border:none; background:transparent; font-size:15px; outline:none; min-width:0; color:${COLOR_TEXT}; }
.xll-mod-chips { display:flex; gap:8px; margin-top:10px; overflow-x:auto; padding-bottom:2px; scrollbar-width:none; }
.xll-mod-chips::-webkit-scrollbar { display:none; }
.xll-mod-chip { flex-shrink:0; min-height:36px; padding:0 14px; border:1px solid ${COLOR_LINE}; background:${COLOR_BG}; border-radius:999px; font-size:13px; color:${COLOR_TEXT_SEC}; cursor:pointer; white-space:nowrap; touch-action:manipulation; }
.xll-mod-chip.active { border-color:${XLL_GREEN}; color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; font-weight:600; }
.xll-mod-list-head { display:flex; justify-content:space-between; padding:12px 16px 4px; font-size:12px; color:${COLOR_MUTED}; flex-shrink:0; }
.xll-mod-list { flex:1; min-height:0; overflow-y:auto; padding:4px 14px 20px; -webkit-overflow-scrolling:touch; }
.xll-mod-card { position:relative; background:${COLOR_BG}; border-radius:14px; padding:14px 14px 12px 16px; margin-bottom:12px; box-shadow:0 2px 8px rgba(15,23,42,.05); border:1px solid rgba(0,0,0,.04); cursor:pointer; touch-action:manipulation; }
.xll-mod-card::before { content:''; position:absolute; left:0; top:12px; bottom:12px; width:3px; border-radius:0 3px 3px 0; background:var(--mod-accent, ${XLL_GREEN}); }
.xll-mod-card-head { display:flex; justify-content:space-between; align-items:center; gap:10px; margin-bottom:8px; }
.xll-mod-card-type { font-size:11px; font-weight:600; color:var(--mod-accent, ${XLL_GREEN}); background:var(--mod-soft, ${XLL_GREEN_SOFT}); padding:2px 8px; border-radius:999px; }
.xll-mod-card-status { display:inline-flex; align-items:center; justify-content:center; font-size:11px; font-weight:600; line-height:1; padding:4px 8px; border-radius:999px; box-sizing:border-box; flex-shrink:0; }
.xll-mod-card-status.pending { color:${COLOR_WARN}; background:rgba(255,125,0,.1); }
.xll-mod-card-status.with-approvers { max-width:58%; text-align:right; line-height:1.35; white-space:normal; }
.xll-mod-card-status.ok { color:${COLOR_SUCCESS}; background:rgba(0,180,42,.1); }
.xll-mod-card-status.reject { color:${COLOR_DANGER}; background:rgba(245,63,63,.1); }
.xll-mod-card-status.info { color:#2563EB; background:rgba(37,99,235,.1); }
.xll-mod-card-title { font-size:15px; font-weight:700; color:${COLOR_TEXT}; margin-bottom:4px; }
.xll-mod-card-sub { font-size:13px; color:${COLOR_TEXT_SEC}; line-height:1.5; margin-bottom:8px; }
.xll-mod-card-period { font-size:12px; color:${COLOR_TEXT_SEC}; line-height:1.45; margin-bottom:8px; display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
.xll-mod-card-period-tag { font-size:11px; font-weight:700; color:#0EA5E9; background:rgba(14,165,233,.12); padding:2px 8px; border-radius:999px; flex-shrink:0; }
.xll-mod-card-vehicles { margin-bottom:8px; }
.xll-mod-card-vehicle-line { font-size:13px; color:${COLOR_TEXT_SEC}; line-height:1.5; }
.xll-mod-meta { display:grid; grid-template-columns:1fr 1fr; gap:6px 10px; font-size:12px; }
.xll-mod-meta-label { color:${COLOR_MUTED}; }
.xll-mod-meta-val { color:${COLOR_TEXT_SEC}; font-weight:500; }
.xll-mod-card-foot { display:flex; justify-content:space-between; align-items:center; margin-top:10px; padding-top:10px; border-top:1px solid ${COLOR_LINE}; }
.xll-mod-card-btn { min-height:36px; padding:0 14px; border-radius:10px; border:1px solid rgba(122,185,41,.35); background:${XLL_GREEN_SOFT}; color:${XLL_GREEN}; font-size:13px; font-weight:600; cursor:pointer; }
.xll-mod-empty { text-align:center; padding:48px 24px; color:${COLOR_MUTED}; font-size:14px; line-height:1.6; }
.xll-mod-scroll { flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; padding-bottom:88px; }
.xll-mod-hero { margin:12px 14px 0; border-radius:14px; padding:18px 16px; color:#fff; }
.xll-mod-hero.orange { background:linear-gradient(135deg,#F97316,#EA580C); }
.xll-mod-hero.purple { background:linear-gradient(135deg,#8B5CF6,#7C3AED); }
.xll-mod-hero.green { background:linear-gradient(135deg,${XLL_GREEN},${XLL_GREEN_DEEP}); }
.xll-mod-hero-label { font-size:13px; opacity:.9; margin-bottom:6px; }
.xll-mod-hero-amt { font-size:28px; font-weight:800; font-variant-numeric:tabular-nums; margin-bottom:8px; }
.xll-mod-hero-meta { font-size:13px; opacity:.92; line-height:1.5; }
.xll-mod-section { margin:12px 14px 0; background:${COLOR_BG}; border-radius:14px; padding:14px 16px; box-shadow:0 2px 8px rgba(15,23,42,.04); border:1px solid rgba(0,0,0,.03); }
.xll-mod-section-title { font-size:14px; font-weight:700; color:${COLOR_TEXT}; margin-bottom:12px; }
.xll-mod-action-bar { position:absolute; left:0; right:0; bottom:0; display:flex; gap:10px; padding:10px 14px calc(10px + env(safe-area-inset-bottom,0)); background:${COLOR_BG}; border-top:1px solid ${COLOR_LINE}; z-index:20; }
.xll-mod-action-bar button { flex:1; min-height:44px; border-radius:12px; font-size:14px; font-weight:600; cursor:pointer; border:none; touch-action:manipulation; }
.xll-mod-btn-ghost { background:${COLOR_PAGE}; color:${COLOR_TEXT_SEC}; border:1px solid ${COLOR_LINE}; }
.xll-mod-btn-danger { background:rgba(245,63,63,.08); color:${COLOR_DANGER}; border:1px solid rgba(245,63,63,.25); }
.xll-mod-btn-primary { background:${XLL_GREEN}; color:#fff; box-shadow:0 4px 12px rgba(122,185,41,.3); }
.xll-mod-ar-plate { font-size:18px; font-weight:800; color:${COLOR_TEXT}; }
.xll-mod-ar-tag { display:inline-flex; font-size:11px; font-weight:600; padding:2px 8px; border-radius:999px; margin-left:8px; }
.xll-mod-ar-tag.warn { color:${COLOR_WARN}; background:rgba(255,125,0,.12); }
.xll-mod-ar-tag.danger { color:${COLOR_DANGER}; background:rgba(245,63,63,.1); }
.xll-mod-form-row { display:flex; justify-content:space-between; align-items:center; padding:12px 0; border-bottom:1px solid ${COLOR_LINE}; gap:12px; font-size:14px; }
.xll-mod-form-row:last-child { border-bottom:none; }
.xll-mod-form-label { color:${COLOR_MUTED}; flex-shrink:0; }
.xll-mod-form-value { color:${COLOR_TEXT}; text-align:right; flex:1; word-break:break-all; }
.xll-mod-form-input { flex:1; min-height:40px; border:1px solid ${COLOR_LINE}; border-radius:8px; padding:0 10px; font-size:14px; text-align:right; outline:none; }
.xll-mod-form-input:focus { border-color:${XLL_GREEN}; box-shadow:0 0 0 2px ${XLL_GREEN_SOFT}; }
.xll-mod-form-page select.xll-mod-form-input,
.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input,
.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input[type="date"],
.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input[type="datetime-local"],
.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input.xll-mod-form-picker,
.xll-mod-form-page .tc-section-form select.xll-mod-form-input,
.xll-mod-form-page select.xll-dv-metric-input,
.xll-mod-form-page input.xll-dv-metric-input[type="datetime-local"] { border:none; background:transparent; border-radius:0; box-shadow:none; padding-right:0; }
.xll-mod-form-page select.xll-mod-form-input:focus,
.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input:focus,
.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input[type="date"]:focus,
.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input[type="datetime-local"]:focus,
.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input.xll-mod-form-picker:focus,
.xll-mod-form-page .tc-section-form select.xll-mod-form-input:focus,
.xll-mod-form-page select.xll-dv-metric-input:focus,
.xll-mod-form-page input.xll-dv-metric-input[type="datetime-local"]:focus { border:none; box-shadow:none; }
.xll-vr-module.xll-mod-form-page select.xll-mod-form-input:focus,
.xll-vr-module.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input[type="date"]:focus,
.xll-vr-module.xll-mod-form-page .xll-mod-form-row input.xll-mod-form-input[type="datetime-local"]:focus,
.xll-vr-module.xll-mod-form-page .tc-section-form select.xll-mod-form-input:focus { border:none; box-shadow:none; }
.xll-mod-foot-btns { display:flex; gap:10px; padding:14px; flex-shrink:0; background:${COLOR_BG}; border-top:1px solid ${COLOR_LINE}; }
.xll-mod-foot-btns button { flex:1; min-height:48px; border-radius:12px; font-size:15px; font-weight:600; cursor:pointer; border:none; touch-action:manipulation; }
.xll-mod-detail-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; overflow:hidden; }
.xll-mod-drawer-types { display:flex; flex-wrap:wrap; gap:8px; }
.xll-mod-drawer-type-btn { min-height:40px; padding:0 14px; border:1px solid ${COLOR_LINE}; border-radius:10px; background:${COLOR_BG}; font-size:13px; color:${COLOR_TEXT_SEC}; cursor:pointer; touch-action:manipulation; }
.xll-mod-drawer-type-btn.active { border-color:${XLL_GREEN}; color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; font-weight:600; }
.xll-mod-drawer-section { margin-bottom:18px; }
.xll-mod-drawer-section-title { font-size:13px; font-weight:600; color:${COLOR_TEXT}; margin-bottom:10px; }
.xll-mod-drawer-form-card { background:${COLOR_PAGE}; border-radius:12px; padding:0 14px; border:1px solid ${COLOR_LINE}; }
.xll-mod-drawer-form-card .xll-mod-form-row { padding:12px 0; margin:0; }
.xll-mod-drawer-form-card .xll-mod-form-row input.xll-mod-form-input { min-height:36px; border:none; background:transparent; border-radius:0; box-shadow:none; text-align:right; padding:0; }
.xll-mod-drawer-form-card .xll-mod-form-row input.xll-mod-form-input:focus { box-shadow:none; }
.xll-mod-drawer-form-card .xll-mod-form-row input.xll-mod-form-input::placeholder { color:${COLOR_MUTED}; }
.xll-mod-drawer-date-row { display:flex; align-items:center; gap:8px; padding:12px 0; }
.xll-mod-drawer-date-row input { flex:1; min-width:0; min-height:36px; border:none; background:transparent; font-size:14px; text-align:center; outline:none; color:${COLOR_TEXT}; font-family:inherit; }
.xll-mod-drawer-date-sep { font-size:13px; color:${COLOR_MUTED}; flex-shrink:0; }
.xll-mod-drawer-hint { font-size:12px; color:${COLOR_MUTED}; line-height:1.55; margin-top:8px; }
.xll-mod-drawer-actions { display:flex; gap:10px; margin-top:20px; }
.xll-mod-sheet-overlay { position:fixed; inset:0; z-index:200; display:flex; flex-direction:column; justify-content:flex-end; }
.xll-mod-sheet-mask { position:absolute; inset:0; border:none; padding:0; background:rgba(0,0,0,.45); cursor:pointer; }
.xll-mod-sheet-panel { position:relative; z-index:1; width:100%; max-height:min(72vh, 460px); background:${COLOR_BG}; border-radius:16px 16px 0 0; display:flex; flex-direction:column; box-shadow:0 -8px 32px rgba(15,23,42,.12); animation:xll-mod-sheet-up .24s ease; padding-bottom:env(safe-area-inset-bottom,0px); }
@keyframes xll-mod-sheet-up { from { transform:translateY(100%); } to { transform:translateY(0); } }
.xll-mod-sheet-handle { width:36px; height:4px; border-radius:999px; background:${COLOR_LINE}; margin:8px auto 0; flex-shrink:0; }
.xll-mod-sheet-header { position:relative; display:flex; align-items:center; justify-content:center; min-height:48px; padding:8px 48px 10px; border-bottom:1px solid ${COLOR_LINE}; flex-shrink:0; }
.xll-mod-sheet-title { font-size:16px; font-weight:700; color:${COLOR_TEXT}; text-align:center; }
.xll-mod-sheet-close { position:absolute; right:8px; top:50%; transform:translateY(-50%); width:36px; height:36px; border:none; border-radius:999px; background:transparent; color:${COLOR_MUTED}; font-size:22px; line-height:1; cursor:pointer; touch-action:manipulation; }
.xll-mod-sheet-close:active { background:${COLOR_PAGE}; }
.xll-mod-sheet-body { flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:4px 0 8px; }
.xll-mod-sheet-option { width:100%; display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:52px; padding:12px 16px; border:none; border-bottom:1px solid ${COLOR_LINE}; background:transparent; text-align:left; cursor:pointer; touch-action:manipulation; box-sizing:border-box; }
.xll-mod-sheet-option:last-child { border-bottom:none; }
.xll-mod-sheet-option:active { background:${COLOR_PAGE}; }
.xll-mod-sheet-option.active { background:${XLL_GREEN_SOFT}; }
.xll-mod-sheet-option-text { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
.xll-mod-sheet-option-main { font-size:15px; font-weight:600; color:${COLOR_TEXT}; line-height:1.35; }
.xll-mod-sheet-option-sub { font-size:12px; color:${COLOR_MUTED}; line-height:1.4; }
.xll-mod-sheet-option-check { flex-shrink:0; width:22px; height:22px; border-radius:999px; background:${XLL_GREEN}; color:#fff; font-size:13px; font-weight:700; display:inline-flex; align-items:center; justify-content:center; }
.xll-mod-sheet-panel--filter { max-height:min(88vh, 580px); padding-bottom:0; }
.xll-mod-sheet-scroll { flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:12px 16px 8px; }
.xll-mod-sheet-footer { flex-shrink:0; display:flex; gap:10px; padding:12px 16px calc(12px + env(safe-area-inset-bottom,0px)); border-top:1px solid ${COLOR_LINE}; background:${COLOR_BG}; }
.xll-mod-sheet-footer button { flex:1; min-height:44px; border-radius:12px; font-size:15px; font-weight:600; touch-action:manipulation; }
.xll-mod-timeline { padding-left:4px; }
.xll-mod-step { display:flex; gap:12px; margin-bottom:14px; }
.xll-mod-step-dot { width:22px; height:22px; border-radius:50%; background:${COLOR_LINE}; color:#fff; font-size:12px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.xll-mod-step-dot.done { background:${COLOR_SUCCESS}; }
.xll-mod-step-title { font-size:14px; font-weight:600; color:${COLOR_TEXT}; margin-bottom:4px; }
.xll-mod-step-meta { font-size:12px; color:${COLOR_MUTED}; line-height:1.55; }
.xll-mod-upload { border:1px dashed ${COLOR_LINE}; border-radius:10px; padding:20px; text-align:center; color:${COLOR_MUTED}; font-size:13px; cursor:pointer; background:${COLOR_PAGE}; }
.xll-mod-upload:active { background:${XLL_GREEN_SOFT}; border-color:${XLL_GREEN}; }
.xll-dv-steps-wrap { flex-shrink:0; background:${COLOR_BG}; border-bottom:1px solid ${COLOR_LINE}; padding:10px 14px; }
.xll-dv-steps { display:flex; gap:6px; overflow-x:auto; scrollbar-width:none; -webkit-overflow-scrolling:touch; }
.xll-dv-steps::-webkit-scrollbar { display:none; }
.xll-dv-step { flex-shrink:0; font-size:11px; padding:5px 10px; border-radius:999px; background:${COLOR_PAGE}; color:${COLOR_MUTED}; border:1px solid transparent; }
.xll-dv-step.active { background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; border-color:rgba(122,185,41,.35); font-weight:600; }
.xll-dv-step.done { color:${COLOR_SUCCESS}; background:rgba(0,180,42,.08); }
.xll-dv-step-nav { display:flex; align-items:center; gap:0; }
.xll-dv-step-nav-item { flex:1; min-width:0; display:flex; flex-direction:column; align-items:center; gap:4px; padding:4px 2px; border:none; background:transparent; cursor:pointer; touch-action:manipulation; }
.xll-dv-step-nav-item:active { opacity:.78; }
.xll-dv-step-nav-index { width:22px; height:22px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; color:${COLOR_MUTED}; background:${COLOR_PAGE}; border:1px solid ${COLOR_LINE}; }
.xll-dv-step-nav-item.active .xll-dv-step-nav-index { color:#fff; background:${XLL_GREEN}; border-color:${XLL_GREEN}; }
.xll-dv-step-nav-item.done .xll-dv-step-nav-index { color:${COLOR_SUCCESS}; background:rgba(0,180,42,.1); border-color:rgba(0,180,42,.35); }
.xll-dv-step-nav-label { font-size:11px; color:${COLOR_MUTED}; line-height:1.3; text-align:center; white-space:nowrap; }
.xll-dv-step-nav-item.active .xll-dv-step-nav-label { color:${XLL_GREEN_DEEP}; font-weight:600; }
.xll-dv-step-nav-connector { flex:0 0 24px; height:2px; background:${COLOR_LINE}; margin-top:-14px; }
.xll-dv-step-nav-item.done + .xll-dv-step-nav-connector, .xll-dv-step-nav-connector.done { background:rgba(0,180,42,.35); }
.xll-dv-metric-unit-wrap { display:flex; align-items:stretch; gap:0; }
.xll-dv-metric-unit-wrap .xll-dv-metric-input { flex:1; border-top-right-radius:0; border-bottom-right-radius:0; }
.xll-dv-metric-unit-suffix { flex-shrink:0; min-width:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid ${COLOR_LINE}; border-left:none; border-radius:0 10px 10px 0; background:${COLOR_PAGE}; font-size:13px; font-weight:600; color:${COLOR_TEXT_SEC}; }
.xll-dv-metric-input[type="number"] { -moz-appearance:textfield; appearance:textfield; }
.xll-dv-metric-input[type="number"]::-webkit-outer-spin-button, .xll-dv-metric-input[type="number"]::-webkit-inner-spin-button { -webkit-appearance:none; margin:0; }
.xll-dv-inspection-block { margin:0 14px 12px; border:1px solid ${COLOR_LINE}; border-radius:12px; overflow:hidden; background:${COLOR_BG}; }
.xll-dv-inspection-cat { padding:8px 12px; font-size:12px; font-weight:700; color:${COLOR_TEXT}; background:${COLOR_PAGE}; border-bottom:1px solid ${COLOR_LINE}; }
.xll-dv-inspection-row { display:flex; align-items:center; gap:10px; min-height:44px; padding:8px 12px; border-bottom:1px solid ${COLOR_LINE}; font-size:13px; }
.xll-dv-inspection-row:last-child { border-bottom:none; }
.xll-dv-inspection-item { flex:1; min-width:0; color:${COLOR_TEXT}; line-height:1.35; }
.xll-dv-inspection-status { flex-shrink:0; font-size:12px; font-weight:600; color:${COLOR_SUCCESS}; }
.xll-dv-inspection-status.off { color:${COLOR_DANGER}; }
.xll-dv-inspection-tread { width:64px; min-height:32px; border:1px solid ${COLOR_LINE}; border-radius:8px; padding:0 8px; font-size:13px; text-align:right; outline:none; background:#fff; }
.xll-dv-inspection-switch { width:44px; height:26px; border-radius:999px; border:none; background:${COLOR_LINE}; position:relative; cursor:pointer; flex-shrink:0; touch-action:manipulation; }
.xll-dv-inspection-switch.on { background:${XLL_GREEN}; }
.xll-dv-inspection-switch::after { content:''; position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(0,0,0,.15); transition:transform .15s ease; }
.xll-dv-inspection-switch.on::after { transform:translateX(18px); }
.xll-dv-inspection-controls { display:flex; align-items:center; gap:8px; flex-shrink:0; }
.xll-dv-inspection-remark { width:76px; min-height:32px; border:1px solid ${COLOR_LINE}; border-radius:8px; padding:0 8px; font-size:12px; text-align:right; outline:none; background:#fff; color:${COLOR_TEXT}; }
.xll-dv-inspection-remark::placeholder { color:${COLOR_MUTED}; }
.xll-dv-inspection-remark:focus { border-color:${XLL_GREEN}; box-shadow:0 0 0 2px ${XLL_GREEN_SOFT}; }
.xll-dv-inspection-remark-read { flex-shrink:0; max-width:88px; font-size:12px; color:${COLOR_TEXT_SEC}; text-align:right; word-break:break-all; }
.xll-dv-photo-capture-scroll { background:linear-gradient(180deg, ${XLL_GREEN_SOFT} 0%, ${COLOR_PAGE} 42%, ${COLOR_BG} 100%); }
.xll-dv-photo-capture-page { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:28px 20px 32px; min-height:min(58vh,420px); text-align:center; }
.xll-dv-photo-capture-card { width:100%; max-width:340px; padding:28px 22px 24px; border-radius:20px; background:${COLOR_BG}; border:1px solid rgba(122,185,41,.18); box-shadow:0 12px 40px rgba(29,33,41,.08), 0 2px 8px rgba(122,185,41,.08); }
.xll-dv-photo-capture-icon { width:72px; height:72px; margin:0 auto 18px; border-radius:50%; display:flex; align-items:center; justify-content:center; background:linear-gradient(145deg, ${XLL_GREEN_SOFT}, rgba(122,185,41,.22)); color:${XLL_GREEN_DEEP}; box-shadow:inset 0 0 0 1px rgba(122,185,41,.2); }
.xll-dv-photo-capture-icon svg { width:34px; height:34px; display:block; }
.xll-dv-photo-capture-title { margin:0 0 8px; font-size:20px; font-weight:700; color:${COLOR_TEXT}; line-height:1.4; letter-spacing:.02em; }
.xll-dv-photo-capture-sub { margin:0 0 20px; font-size:13px; color:${COLOR_TEXT_SEC}; line-height:1.6; }
.xll-dv-photo-capture-resume { margin-bottom:18px; padding:12px 14px; border-radius:12px; background:${COLOR_PAGE}; border:1px solid ${COLOR_LINE}; text-align:left; }
.xll-dv-photo-capture-progress-bar { height:6px; border-radius:999px; background:${COLOR_LINE}; overflow:hidden; margin-bottom:8px; }
.xll-dv-photo-capture-progress-bar > span { display:block; height:100%; border-radius:999px; background:linear-gradient(90deg, ${XLL_GREEN}, ${XLL_GREEN_DEEP}); transition:width .35s ease; }
.xll-dv-photo-capture-resume-text { font-size:12px; color:${COLOR_TEXT_SEC}; line-height:1.5; }
.xll-dv-photo-capture-resume-text strong { color:${XLL_GREEN_DEEP}; font-weight:700; }
.xll-dv-photo-capture-tips { margin:0; padding:0; list-style:none; text-align:left; }
.xll-dv-photo-capture-tips li { position:relative; padding-left:18px; font-size:12px; color:${COLOR_MUTED}; line-height:1.65; }
.xll-dv-photo-capture-tips li + li { margin-top:6px; }
.xll-dv-photo-capture-tips li::before { content:''; position:absolute; left:0; top:8px; width:6px; height:6px; border-radius:50%; background:${XLL_GREEN}; opacity:.75; }
.xll-dv-photo-capture-badge { display:inline-flex; align-items:center; min-height:24px; padding:0 10px; margin-bottom:14px; border-radius:999px; font-size:11px; font-weight:700; letter-spacing:.04em; }
.xll-dv-photo-capture-badge--body { color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; border:1px solid rgba(122,185,41,.28); }
.xll-dv-photo-capture-badge--chassis { color:#0E7490; background:rgba(14,116,144,.1); border:1px solid rgba(14,116,144,.22); }
.xll-dv-photo-capture-badge--tire { color:#C2410C; background:rgba(194,65,12,.1); border:1px solid rgba(194,65,12,.2); }
.xll-dv-photo-capture-soon { margin:0 0 16px; font-size:14px; font-weight:600; color:${COLOR_TEXT_SEC}; letter-spacing:.12em; }
.xll-dv-photo-capture-countdown-ring { position:relative; width:108px; height:108px; margin:0 auto 18px; display:flex; align-items:center; justify-content:center; }
.xll-dv-photo-capture-countdown-ring::before { content:''; position:absolute; inset:0; border-radius:50%; background:conic-gradient(${XLL_GREEN} 0deg, ${XLL_GREEN_SOFT} 280deg, ${COLOR_LINE} 280deg); animation:xllPhotoRingSpin 3s linear infinite; }
.xll-dv-photo-capture-countdown-ring::after { content:''; position:absolute; inset:6px; border-radius:50%; background:${COLOR_BG}; box-shadow:inset 0 2px 8px rgba(29,33,41,.06); }
.xll-dv-photo-capture-countdown-num { position:relative; z-index:1; font-size:44px; font-weight:800; color:${XLL_GREEN_DEEP}; line-height:1; font-variant-numeric:tabular-nums; animation:xllPhotoCountPop .55s ease; }
.xll-dv-photo-capture-target { margin:0 0 10px; font-size:22px; font-weight:700; color:${COLOR_TEXT}; line-height:1.35; }
.xll-dv-photo-capture-countdown-tip { margin:0 0 16px; font-size:12px; color:${COLOR_MUTED}; line-height:1.5; }
.xll-dv-photo-capture-step-pill { display:inline-flex; align-items:center; min-height:30px; padding:0 14px; border-radius:999px; background:${COLOR_PAGE}; border:1px solid ${COLOR_LINE}; font-size:12px; font-weight:600; color:${COLOR_TEXT_SEC}; }
.xll-dv-photo-capture-step-pill em { font-style:normal; color:${XLL_GREEN_DEEP}; font-weight:700; }
@keyframes xllPhotoCountPop { 0% { transform:scale(.72); opacity:.35; } 55% { transform:scale(1.08); } 100% { transform:scale(1); opacity:1; } }
@keyframes xllPhotoRingSpin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
.xll-dv-photo-done-list { margin:0 14px 12px; border:1px solid ${COLOR_LINE}; border-radius:12px; overflow:hidden; background:${COLOR_BG}; }
.xll-dv-photo-done-row { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:44px; padding:10px 12px; border-bottom:1px solid ${COLOR_LINE}; font-size:13px; }
.xll-dv-photo-done-row:last-child { border-bottom:none; }
.xll-dv-photo-done-ok { font-size:12px; font-weight:600; color:${COLOR_SUCCESS}; flex-shrink:0; }
.xll-mod-action-bar.xll-dv-photo-action-bar { padding-left:20px; padding-right:20px; }
.xll-mod-action-bar.xll-dv-photo-action-bar .xll-dv-photo-action-btn { flex:1; min-height:48px; border-radius:24px; font-size:16px; font-weight:600; }
.xll-dv-photo-reshoot-bar .tc-section-head { display:none; }
.xll-dv-photo-thumb { position:relative; aspect-ratio:1; border-radius:10px; overflow:hidden; border:1px solid ${COLOR_LINE}; background:${COLOR_PAGE}; cursor:pointer; touch-action:manipulation; }
.xll-dv-photo-thumb--empty { cursor:default; }
.xll-dv-photo-thumb:active:not(.xll-dv-photo-thumb--empty) { opacity:.92; }
.xll-dv-photo-thumb img { width:100%; height:100%; object-fit:cover; display:block; }
.xll-dv-photo-thumb-placeholder { width:100%; height:100%; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:600; color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; }
.xll-dv-photo-thumb-placeholder--reshoot { color:${COLOR_WARN}; background:rgba(255,125,0,.08); box-shadow:inset 0 0 0 1px rgba(255,125,0,.35); }
.xll-dv-photo-thumb-del { position:absolute; top:6px; right:6px; width:22px; height:22px; border-radius:50%; border:1.5px solid rgba(255,255,255,.9); background:rgba(245,63,63,.94); color:#fff; font-size:13px; font-weight:700; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center; box-shadow:0 2px 8px rgba(15,23,42,.22); z-index:2; touch-action:manipulation; padding:0; }
.xll-dv-photo-thumb-del:active { transform:scale(.94); background:rgba(220,38,38,.98); }
.xll-dv-photo-thumb-label { margin-top:8px; font-size:11px; color:${COLOR_TEXT_SEC}; line-height:1.35; text-align:center; }
.xll-dv-photo-thumb-tread { margin-top:3px; font-size:10px; color:${XLL_GREEN_DEEP}; text-align:center; font-weight:600; }
.xll-dv-photo-viewer { position:absolute; inset:0; z-index:80; display:flex; flex-direction:column; background:#0f1419; color:#fff; }
.xll-dv-photo-viewer-top { flex-shrink:0; display:flex; align-items:flex-start; justify-content:space-between; gap:10px; padding:12px 14px calc(10px + env(safe-area-inset-top,0px)); background:linear-gradient(180deg,rgba(0,0,0,.72),rgba(0,0,0,.2)); }
.xll-dv-photo-viewer-close { flex-shrink:0; min-height:32px; padding:0 12px; border-radius:999px; border:1px solid rgba(255,255,255,.22); background:rgba(255,255,255,.1); color:#fff; font-size:13px; font-weight:600; cursor:pointer; }
.xll-dv-photo-viewer-meta { flex:1; min-width:0; text-align:center; }
.xll-dv-photo-viewer-cat { font-size:11px; color:rgba(255,255,255,.65); margin-bottom:2px; }
.xll-dv-photo-viewer-title { font-size:15px; font-weight:700; line-height:1.35; }
.xll-dv-photo-viewer-counter { flex-shrink:0; min-height:28px; padding:0 10px; border-radius:999px; background:rgba(255,255,255,.12); font-size:12px; font-weight:700; display:flex; align-items:center; }
.xll-dv-photo-viewer-stage { flex:1; min-height:0; display:flex; align-items:center; justify-content:center; gap:8px; padding:0 8px; touch-action:pan-y; }
.xll-dv-photo-viewer-img-wrap { flex:1; min-width:0; height:100%; display:flex; align-items:center; justify-content:center; }
.xll-dv-photo-viewer-img-wrap img { max-width:100%; max-height:100%; object-fit:contain; display:block; border-radius:8px; }
.xll-dv-photo-viewer-nav { flex-shrink:0; width:40px; height:40px; border-radius:50%; border:1px solid rgba(255,255,255,.22); background:rgba(255,255,255,.1); color:#fff; font-size:24px; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center; touch-action:manipulation; }
.xll-dv-photo-viewer-nav:disabled { opacity:.28; cursor:not-allowed; }
.xll-dv-photo-viewer-nav:not(:disabled):active { background:rgba(255,255,255,.2); }
.xll-dv-photo-viewer-foot { flex-shrink:0; padding:12px 16px calc(14px + env(safe-area-inset-bottom,0px)); text-align:center; background:rgba(0,0,0,.45); font-size:13px; color:rgba(255,255,255,.85); }
.xll-dv-photo-viewer-tread { font-weight:700; color:#86efac; }
.xll-dv-photo-viewer-hint { margin-top:4px; font-size:11px; color:rgba(255,255,255,.45); }
.xll-dv-photo-camera-overlay { position:absolute; inset:0; z-index:70; display:flex; flex-direction:column; background:#1a1f2e; color:#fff; }
.xll-dv-photo-camera-top { flex-shrink:0; padding:12px 16px; text-align:center; font-size:14px; font-weight:600; background:rgba(0,0,0,.35); }
.xll-dv-photo-camera-view { flex:1; min-height:0; position:relative; overflow:hidden; background:#2d3748; }
.xll-dv-photo-camera-view img { width:100%; height:100%; object-fit:cover; display:block; }
.xll-dv-photo-camera-placeholder { font-size:16px; color:rgba(255,255,255,.55); }
.xll-dv-camera-viewfinder { position:relative; width:100%; height:100%; overflow:hidden; background:#2d3748; touch-action:none; cursor:crosshair; }
.xll-dv-camera-preview { width:100%; height:100%; display:flex; align-items:center; justify-content:center; transition:transform .22s ease; will-change:transform; }
.xll-dv-camera-preview img { width:100%; height:100%; object-fit:cover; display:block; pointer-events:none; user-select:none; }
.xll-dv-camera-focus-ring { position:absolute; z-index:2; width:56px; height:56px; margin:-28px 0 0 -28px; border:2px solid #fff; border-radius:4px; box-shadow:0 0 0 1px rgba(0,0,0,.35); pointer-events:none; animation:xll-dv-focus-pulse .35s ease; }
@keyframes xll-dv-focus-pulse { from { transform:scale(1.12); opacity:.55; } to { transform:scale(1); opacity:1; } }
.xll-dv-camera-zoom { position:absolute; z-index:3; right:12px; top:50%; transform:translateY(-50%); display:flex; flex-direction:column; align-items:center; gap:6px; padding:8px 6px; border-radius:12px; background:rgba(0,0,0,.45); }
.xll-dv-camera-zoom-btn { width:32px; height:32px; border:none; border-radius:8px; background:rgba(255,255,255,.16); color:#fff; font-size:18px; font-weight:700; line-height:1; cursor:pointer; touch-action:manipulation; }
.xll-dv-camera-zoom-btn:active { background:rgba(255,255,255,.28); }
.xll-dv-camera-zoom-val { font-size:11px; font-weight:700; color:#fff; min-width:32px; text-align:center; font-variant-numeric:tabular-nums; }
.xll-dv-camera-focus-tip { position:absolute; z-index:3; left:50%; bottom:14px; transform:translateX(-50%); padding:4px 10px; border-radius:999px; background:rgba(0,0,0,.45); color:rgba(255,255,255,.88); font-size:11px; pointer-events:none; white-space:nowrap; }
.xll-dv-photo-camera-tread { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:52px; padding:0 16px; background:${COLOR_BG}; color:${COLOR_TEXT}; border-top:1px solid ${COLOR_LINE}; }
.xll-dv-photo-camera-tread-label { font-size:15px; color:${COLOR_MUTED}; flex-shrink:0; }
.xll-dv-photo-camera-actions { display:flex; gap:10px; padding:12px 16px calc(12px + env(safe-area-inset-bottom,0px)); background:${COLOR_BG}; border-top:1px solid ${COLOR_LINE}; }
.xll-dv-photo-album-btn { flex:0 0 auto; min-width:88px; min-height:44px; padding:0 12px; border:1px solid ${COLOR_LINE}; border-radius:10px; background:#fff; color:${COLOR_TEXT_SEC}; font-size:14px; font-weight:600; cursor:pointer; touch-action:manipulation; }
.xll-dv-photo-album-btn:active { opacity:.88; }
.xll-dv-photo-camera-skip { flex:0 0 auto; min-width:88px; min-height:44px; padding:0 12px; border:1px solid ${COLOR_LINE}; border-radius:10px; background:#fff; color:${COLOR_TEXT_SEC}; font-size:14px; font-weight:600; cursor:pointer; }
.xll-dv-status { display:inline-flex; font-size:11px; font-weight:600; padding:2px 8px; border-radius:999px; margin-left:8px; vertical-align:middle; }
.xll-dv-status.neutral { color:${COLOR_TEXT_SEC}; background:${COLOR_PAGE}; }
.xll-dv-status.warn { color:${COLOR_WARN}; background:rgba(255,125,0,.12); }
.xll-dv-status.ok { color:${COLOR_SUCCESS}; background:rgba(0,180,42,.1); }
.xll-dv-status.info { color:#2563EB; background:rgba(37,99,235,.1); }
.xll-dv-plate-pending { color:${COLOR_WARN}; font-weight:700; }
.xll-dv-plate-row { display:flex; align-items:center; flex-wrap:wrap; gap:6px; flex:1; min-width:0; }
.xll-dv-replace-tag { display:inline-flex; align-items:center; justify-content:center; font-size:11px; font-weight:600; line-height:1; padding:3px 8px; border-radius:999px; color:#E11D48; background:rgba(244,63,94,.12); flex-shrink:0; }
.xll-dv-hint { font-size:12px; color:${COLOR_MUTED}; line-height:1.55; margin-bottom:10px; padding:8px 10px; background:${COLOR_PAGE}; border-radius:8px; }
.xll-dv-photo-block { margin-bottom:14px; }
.xll-dv-photo-title { font-size:13px; font-weight:600; color:${COLOR_TEXT}; margin-bottom:8px; }
.xll-dv-photo-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
.xll-dv-photo-slot { aspect-ratio:1; border-radius:10px; border:1px dashed ${COLOR_LINE}; background:${COLOR_PAGE}; display:flex; align-items:center; justify-content:center; font-size:11px; color:${COLOR_MUTED}; text-align:center; padding:4px; cursor:pointer; touch-action:manipulation; }
.xll-dv-photo-slot:active { background:${XLL_GREEN_SOFT}; border-color:${XLL_GREEN}; }
.xll-dv-view-val { color:#000 !important; }
.xll-dv-module .tc-section-form { padding:0 14px 14px; }
.xll-dv-module .tc-section-form .xll-mod-form-row { padding:10px 0; }
.xll-dv-module .tc-section-form .xll-mod-form-row:last-child { border-bottom:none; }
.xll-dv-module .tc-section-hint { padding:0 14px 12px; font-size:12px; color:${COLOR_MUTED}; line-height:1.55; }
.xll-dv-module .xll-dv-photo-block { margin-bottom:0; padding:12px 14px 14px; }
.xll-dv-step-label { font-size:13px; font-weight:700; color:${COLOR_TEXT}; margin-bottom:10px; }
.xll-dv-vehicle-picker { width:100%; min-height:44px; display:flex; align-items:center; justify-content:space-between; gap:10px; padding:0; border:none; border-radius:0; background:transparent; font-size:15px; color:${COLOR_TEXT}; cursor:pointer; touch-action:manipulation; text-align:left; }
.xll-dv-vehicle-picker:active { opacity:.72; }
.xll-dv-vehicle-picker.placeholder { color:${COLOR_MUTED}; }
.xll-dv-vehicle-picker-chevron { flex-shrink:0; color:${COLOR_MUTED}; font-size:18px; line-height:1; }
.xll-dv-pick-toolbar { flex-shrink:0; padding:10px 14px 0; background:${COLOR_BG}; }
.xll-dv-pick-parking { display:flex; align-items:center; gap:8px; margin-top:10px; width:100%; padding:10px 12px; border-radius:10px; background:${COLOR_PAGE}; border:1px solid ${COLOR_LINE}; font-size:13px; color:${COLOR_TEXT_SEC}; cursor:pointer; touch-action:manipulation; box-sizing:border-box; }
.xll-dv-pick-parking:active { background:${XLL_GREEN_SOFT}; border-color:${XLL_GREEN}; }
.xll-dv-pick-parking-label { flex-shrink:0; font-size:12px; color:${COLOR_MUTED}; }
.xll-dv-pick-parking strong { flex:1; min-width:0; color:${COLOR_TEXT}; font-weight:600; font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.xll-dv-pick-parking-arrow { flex-shrink:0; color:${COLOR_MUTED}; font-size:12px; }
.xll-dv-pick-list { flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:12px 14px calc(16px + env(safe-area-inset-bottom,0px)); }
.xll-dv-pick-card { margin-bottom:12px; opacity:1; }
.xll-dv-pick-card.blocked { opacity:.72; }
.xll-dv-pick-card.blocked .xll-mod-card { border-color:#FECACA; background:linear-gradient(180deg,#fff 0%,#FFF5F5 100%); }
.xll-dv-readiness-bar { display:flex; align-items:center; min-height:32px; padding:6px 12px; margin:8px 0 0; border-radius:8px; font-size:12px; font-weight:600; line-height:1.4; }
.xll-dv-readiness-bar.ready { color:#047857; background:rgba(0,180,42,.1); }
.xll-dv-readiness-bar.blocked { color:#DC2626; background:rgba(220,38,38,.08); }
.xll-dv-section-badge { display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:8px; background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; font-size:12px; font-weight:800; flex-shrink:0; margin-right:8px; }
.xll-dv-required-tag { display:inline-flex; align-items:center; margin-left:6px; padding:1px 6px; border-radius:4px; font-size:10px; font-weight:600; color:#E11D48; background:rgba(244,63,94,.1); vertical-align:middle; line-height:1.4; }
.xll-dv-section-head-title { display:flex; align-items:center; font-size:15px; font-weight:700; color:${COLOR_TEXT}; }
.xll-dv-section-actions { display:flex; align-items:center; gap:6px; flex-shrink:0; }
.xll-dv-section-action-btn { min-height:28px; padding:0 10px; border-radius:999px; font-size:12px; font-weight:600; cursor:pointer; touch-action:manipulation; white-space:nowrap; }
.xll-dv-section-action-btn--primary { border:none; background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; }
.xll-dv-section-action-btn--primary:active { opacity:.82; }
.xll-dv-section-action-btn--ghost { border:1px solid ${COLOR_LINE}; background:${COLOR_PAGE}; color:${COLOR_TEXT_SEC}; }
.xll-dv-section-action-btn--ghost:active { opacity:.82; }
.xll-dv-validate-overlay { position:absolute; inset:0; z-index:50; display:flex; align-items:center; justify-content:center; padding:24px; box-sizing:border-box; }
.xll-dv-validate-mask { position:absolute; inset:0; background:rgba(0,0,0,.45); border:none; padding:0; cursor:pointer; }
.xll-dv-validate-card { position:relative; z-index:1; width:100%; max-width:320px; background:${COLOR_BG}; border-radius:14px; padding:18px 16px 14px; box-shadow:0 12px 40px rgba(15,23,42,.2); animation:xll-dv-validate-in .22s ease; }
@keyframes xll-dv-validate-in { from { opacity:0; transform:scale(.96); } to { opacity:1; transform:scale(1); } }
.xll-dv-validate-title { font-size:16px; font-weight:700; color:${COLOR_TEXT}; margin-bottom:6px; }
.xll-dv-validate-plate { font-size:13px; color:${COLOR_MUTED}; margin-bottom:12px; }
.xll-dv-validate-list { margin:0; padding:0 0 0 18px; font-size:14px; color:${COLOR_TEXT_SEC}; line-height:1.65; }
.xll-dv-validate-list li { margin-bottom:8px; }
.xll-dv-validate-list li:last-child { margin-bottom:0; }
.xll-dv-validate-ok { width:100%; min-height:44px; margin-top:16px; border:none; border-radius:12px; background:${XLL_GREEN}; color:#fff; font-size:15px; font-weight:600; cursor:pointer; touch-action:manipulation; }
.xll-dv-validate-ok:active { opacity:.88; }
.xll-dv-confirm-actions { display:flex; gap:10px; margin-top:16px; }
.xll-dv-confirm-cancel, .xll-dv-confirm-ok { flex:1; min-height:44px; border-radius:12px; font-size:15px; font-weight:600; cursor:pointer; touch-action:manipulation; }
.xll-dv-confirm-cancel { border:1px solid ${COLOR_LINE}; background:${COLOR_PAGE}; color:${COLOR_TEXT_SEC}; }
.xll-dv-confirm-cancel:active { opacity:.82; }
.xll-dv-confirm-ok { border:none; background:${XLL_GREEN}; color:#fff; }
.xll-dv-confirm-ok:active { opacity:.88; }
.xll-dv-equip-block { padding:0 14px 12px; }
.xll-dv-equip-row { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px 0; border-bottom:1px solid ${COLOR_LINE}; font-size:14px; }
.xll-dv-equip-row:last-child { border-bottom:none; }
.xll-dv-equip-label { color:${COLOR_MUTED}; flex-shrink:0; min-width:108px; line-height:1.45; }
.xll-dv-equip-val { flex:1; text-align:right; color:${COLOR_TEXT}; line-height:1.45; }
.xll-dv-equip-val strong { font-weight:700; }
.xll-dv-equip-switch-wrap { display:flex; align-items:center; justify-content:flex-end; gap:8px; flex:1; min-width:0; }
.xll-dv-equip-switch-label { font-size:13px; color:${COLOR_TEXT_SEC}; font-weight:600; min-width:16px; text-align:right; }
.xll-dv-equip-switch { position:relative; width:44px; height:26px; border-radius:999px; border:none; background:${COLOR_LINE}; cursor:pointer; padding:0; flex-shrink:0; transition:background .2s; touch-action:manipulation; }
.xll-dv-equip-switch.on { background:${XLL_GREEN}; }
.xll-dv-equip-switch:disabled { opacity:.55; cursor:default; }
.xll-dv-equip-switch::after { content:''; position:absolute; top:3px; left:3px; width:20px; height:20px; border-radius:50%; background:#fff; transition:transform .2s; box-shadow:0 1px 3px rgba(0,0,0,.2); }
.xll-dv-equip-switch.on::after { transform:translateX(18px); }
.xll-dv-equip-photo-status { display:block; font-size:12px; color:${COLOR_MUTED}; margin-top:4px; }
.xll-dv-equip-photo-status.done { color:${COLOR_SUCCESS}; }
.xll-dv-equip-photo-status.pending { color:${COLOR_WARN}; }
.xll-dv-equip-photo-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
.xll-dv-equip-photo-item { min-width:0; }
.xll-dv-equip-photo-item-label { font-size:11px; color:${COLOR_MUTED}; margin-bottom:6px; }
.xll-dv-equip-photo-slot { aspect-ratio:4/3; border-radius:8px; border:1px dashed ${COLOR_LINE}; background:${COLOR_PAGE}; display:flex; align-items:center; justify-content:center; font-size:11px; color:${COLOR_MUTED}; text-align:center; padding:4px; cursor:pointer; touch-action:manipulation; }
.xll-dv-equip-photo-slot.done { border-style:solid; border-color:rgba(122,185,41,.35); color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; }
.xll-dv-equip-photo-slot:active { opacity:.82; }
.xll-dv-spare-tire-block { margin-top:10px; }
.xll-dv-spare-tire-hint { font-size:11px; color:${COLOR_MUTED}; line-height:1.55; margin-bottom:8px; }
.xll-dv-spare-tread-field { margin-top:10px; }
.xll-dv-spare-tread-label { font-size:11px; color:${COLOR_MUTED}; margin-bottom:6px; display:flex; align-items:center; justify-content:space-between; gap:8px; }
.xll-dv-spare-tread-ocr-tag { font-size:10px; color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; padding:2px 6px; border-radius:4px; font-weight:600; white-space:nowrap; }
.xll-dv-spare-tread-input { width:100%; min-height:40px; border:1px solid ${COLOR_LINE}; border-radius:8px; padding:0 12px; font-size:15px; color:${COLOR_TEXT}; background:#fff; outline:none; box-sizing:border-box; }
.xll-dv-spare-tread-input:focus { border-color:${XLL_GREEN}; box-shadow:0 0 0 2px rgba(122,185,41,.15); }
.xll-dv-spare-tread-input:disabled { background:${COLOR_PAGE}; color:${COLOR_TEXT_SEC}; }
.xll-dv-spare-photo-result { margin-top:10px; border-radius:10px; overflow:hidden; border:1px solid ${COLOR_LINE}; cursor:pointer; touch-action:manipulation; }
.xll-dv-spare-photo-preview { aspect-ratio:4/3; background:#2d3748; display:flex; align-items:center; justify-content:center; overflow:hidden; }
.xll-dv-spare-photo-preview img { width:100%; height:100%; object-fit:cover; display:block; }
.xll-dv-spare-photo-preview-placeholder { font-size:15px; color:rgba(255,255,255,.72); font-weight:500; }
.xll-dv-spare-tread-row { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:44px; padding:0 14px; background:${COLOR_BG}; border-top:1px solid ${COLOR_LINE}; }
.xll-dv-spare-tread-row-label { font-size:14px; color:${COLOR_MUTED}; flex-shrink:0; }
.xll-dv-spare-tread-row-val { font-size:15px; color:${COLOR_TEXT}; font-weight:600; font-variant-numeric:tabular-nums; }
.xll-dv-spare-capture { position:absolute; inset:0; z-index:60; display:flex; flex-direction:column; background:#1a1f2e; color:#fff; }
.xll-dv-spare-capture-photo { flex:1; min-height:0; display:flex; align-items:center; justify-content:center; background:#2d3748; margin:0; overflow:hidden; }
.xll-dv-spare-capture-photo img { width:100%; height:100%; object-fit:cover; display:block; }
.xll-dv-spare-capture-photo-placeholder { font-size:18px; color:rgba(255,255,255,.55); font-weight:500; }
.xll-dv-spare-capture-tread { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:52px; padding:0 16px; background:${COLOR_BG}; border-top:1px solid ${COLOR_LINE}; }
.xll-dv-spare-capture-tread-label { font-size:15px; color:${COLOR_MUTED}; flex-shrink:0; }
.xll-dv-spare-capture-tread-input-wrap { display:flex; align-items:center; gap:6px; flex:1; justify-content:flex-end; min-width:0; }
.xll-dv-spare-capture-tread-input { width:72px; min-height:36px; border:none; background:transparent; font-size:18px; font-weight:700; color:${COLOR_TEXT}; text-align:right; outline:none; font-variant-numeric:tabular-nums; }
.xll-dv-spare-capture-tread-unit { font-size:15px; color:${COLOR_MUTED}; flex-shrink:0; }
.xll-dv-spare-capture-actions { display:flex; gap:10px; padding:12px 16px calc(12px + env(safe-area-inset-bottom,0px)); background:${COLOR_BG}; border-top:1px solid ${COLOR_LINE}; }
.xll-dv-spare-capture-retake { flex:0 0 auto; min-width:108px; min-height:44px; padding:0 14px; border:1px solid ${XLL_GREEN}; border-radius:10px; background:#fff; color:${XLL_GREEN_DEEP}; font-size:15px; font-weight:600; cursor:pointer; touch-action:manipulation; }
.xll-dv-spare-capture-done { flex:1; min-height:44px; border:none; border-radius:10px; background:${XLL_GREEN}; color:#fff; font-size:15px; font-weight:600; cursor:pointer; touch-action:manipulation; }
.xll-dv-spare-capture-done:active, .xll-dv-spare-capture-retake:active { opacity:.88; }
.xll-dv-training-done-tag { display:inline-flex; align-items:center; min-height:28px; padding:0 10px; border-radius:999px; font-size:12px; font-weight:600; color:${COLOR_SUCCESS}; background:rgba(0,180,42,.1); white-space:nowrap; }
.xll-dv-training-pending-tag { display:inline-flex; align-items:center; min-height:28px; padding:0 10px; border-radius:999px; font-size:12px; font-weight:600; color:${COLOR_WARN}; background:rgba(255,125,0,.12); white-space:nowrap; }
.xll-dv-training-pending-panel { margin:0 14px 14px; padding:14px; border-radius:12px; background:rgba(255,125,0,.06); border:1px solid rgba(255,125,0,.2); }
.xll-dv-training-pending-hint { font-size:13px; color:${COLOR_TEXT_SEC}; line-height:1.55; margin-bottom:12px; }
.xll-dv-training-bound-kv { margin:0 14px 12px; padding:12px; border-radius:10px; background:${COLOR_PAGE}; border:1px solid ${COLOR_LINE}; }
.xll-dv-training-bound-row { display:flex; justify-content:space-between; gap:12px; font-size:13px; line-height:1.5; padding:4px 0; }
.xll-dv-training-bound-label { color:${COLOR_MUTED}; flex-shrink:0; }
.xll-dv-training-bound-val { color:${COLOR_TEXT}; font-weight:600; text-align:right; word-break:break-all; }
.xll-dv-training-cells { margin:0 14px 14px; border-radius:12px; overflow:hidden; background:${COLOR_BG}; border:1px solid ${COLOR_LINE}; }
.xll-dv-training-cell { width:100%; display:flex; align-items:center; gap:12px; min-height:56px; padding:12px 14px; border:none; border-bottom:1px solid ${COLOR_LINE}; background:${COLOR_BG}; cursor:pointer; touch-action:manipulation; text-align:left; box-sizing:border-box; }
.xll-dv-training-cell:last-child { border-bottom:none; }
.xll-dv-training-cell:active { background:${COLOR_PAGE}; }
.xll-dv-training-cell-icon { width:36px; height:36px; border-radius:10px; display:flex; align-items:center; justify-content:center; font-size:18px; flex-shrink:0; }
.xll-dv-training-cell-icon--scan { background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; }
.xll-dv-training-cell-icon--edit { background:rgba(22,93,255,.1); color:#165DFF; }
.xll-dv-training-cell-main { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
.xll-dv-training-cell-title { font-size:15px; font-weight:600; color:${COLOR_TEXT}; line-height:1.35; }
.xll-dv-training-cell-desc { font-size:12px; color:${COLOR_MUTED}; line-height:1.4; }
.xll-dv-training-cell-arrow { flex-shrink:0; color:${COLOR_MUTED}; font-size:20px; line-height:1; font-weight:300; }
.xll-dv-training-qr-wrap { padding:0 14px 14px; }
.xll-dv-training-qr-card { padding:20px 16px 18px; border-radius:12px; background:${COLOR_BG}; border:1px solid ${COLOR_LINE}; text-align:center; }
.xll-dv-training-qr-img { width:200px; height:200px; margin:0 auto 14px; display:block; border-radius:8px; border:1px solid ${COLOR_LINE}; background:#fff; object-fit:contain; }
.xll-dv-training-qr-title { font-size:15px; font-weight:600; color:${COLOR_TEXT}; margin-bottom:6px; }
.xll-dv-training-qr-hint { font-size:13px; color:${COLOR_TEXT_SEC}; line-height:1.55; }
.xll-dv-training-qr-wechat { display:inline-flex; align-items:center; gap:4px; margin-top:8px; font-size:12px; color:${COLOR_MUTED}; }
.xll-dv-driver-manual-page .tc-scroll { padding-bottom:calc(88px + env(safe-area-inset-bottom,0px)); }
.xll-dv-driver-manual-photos { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; padding:0 14px 14px; }
.xll-dv-driver-panel { margin:0 14px 14px; padding:12px 14px; border-radius:12px; background:${COLOR_PAGE}; border:1px solid ${COLOR_LINE}; }
.xll-dv-driver-kv { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px 14px; margin-bottom:12px; }
.xll-dv-driver-kv-item.full { grid-column:1 / -1; }
.xll-dv-driver-licenses { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.xll-dv-driver-licenses.cols-3 { grid-template-columns:repeat(3,minmax(0,1fr)); }
.xll-dv-driver-license-item { min-width:0; }
.xll-dv-driver-license-label { font-size:11px; color:${COLOR_MUTED}; margin-bottom:6px; }
.xll-dv-driver-license-thumb { aspect-ratio:4/3; border-radius:8px; border:1px solid ${COLOR_LINE}; background:linear-gradient(135deg,#f8fafc 0%,#eef2f7 100%); display:flex; align-items:center; justify-content:center; font-size:11px; color:${COLOR_TEXT_SEC}; text-align:center; padding:4px; overflow:hidden; }
.xll-dv-driver-license-thumb img { width:100%; height:100%; object-fit:cover; display:block; }
.xll-dv-driver-manual-photo-item { min-width:0; }
.xll-dv-driver-manual-photo-label { font-size:12px; color:${COLOR_MUTED}; margin-bottom:6px; display:flex; align-items:center; gap:6px; }
.xll-dv-driver-manual-photo-slot { aspect-ratio:4/3; border-radius:10px; border:1px dashed ${COLOR_LINE}; background:${COLOR_PAGE}; display:flex; align-items:center; justify-content:center; font-size:12px; color:${COLOR_TEXT_SEC}; cursor:pointer; touch-action:manipulation; overflow:hidden; }
.xll-dv-driver-manual-photo-slot.done { border-style:solid; border-color:${XLL_GREEN}; color:${XLL_GREEN_DEEP}; background:#fff; }
.xll-dv-driver-manual-photo-slot img { width:100%; height:100%; object-fit:cover; display:block; }
.xll-dv-kv-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px 14px; padding:12px 14px 14px; }
.xll-dv-kv-item { min-width:0; }
.xll-dv-kv-label { font-size:11px; color:${COLOR_MUTED}; margin-bottom:3px; }
.xll-dv-kv-val { font-size:13px; color:${COLOR_TEXT}; font-weight:500; line-height:1.4; word-break:break-all; }
.xll-dv-kv-item.full { grid-column:1 / -1; }
.xll-dv-selected-vehicle { margin:0 14px 12px; padding:12px 14px; border-radius:12px; border:1px solid rgba(122,185,41,.35); background:linear-gradient(135deg,#f0fdf4 0%,#fff 100%); }
.xll-dv-vehicle-pick-section .tc-section-head { border-bottom:none; padding:12px 14px 0; }
.xll-dv-vehicle-pick-section:not(.has-vehicle) .tc-section-head { padding-bottom:12px; }
.xll-dv-vehicle-pick-section .xll-dv-selected-vehicle { margin:12px 14px 12px; }
.xll-dv-selected-vehicle-plate { font-size:18px; font-weight:800; color:${COLOR_TEXT}; margin-bottom:4px; }
.xll-dv-selected-vehicle-sub { font-size:12px; color:${COLOR_TEXT_SEC}; line-height:1.45; }
.xll-dv-selected-vehicle-actions { display:flex; gap:8px; margin-top:10px; }
.xll-dv-selected-vehicle-actions button { flex:1; min-height:36px; border-radius:10px; font-size:13px; font-weight:600; cursor:pointer; touch-action:manipulation; }
.xll-dv-chip-group { display:flex; flex-wrap:wrap; gap:8px; padding:0 14px 14px; }
.xll-dv-chip-opt { min-height:36px; padding:0 14px; border:1px solid ${COLOR_LINE}; border-radius:999px; background:${COLOR_BG}; font-size:13px; color:${COLOR_TEXT_SEC}; cursor:pointer; touch-action:manipulation; }
.xll-dv-chip-opt.active { border-color:${XLL_GREEN}; color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; font-weight:600; }
.xll-dv-metrics-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; padding:12px 14px 14px; }
.xll-dv-metric-field { display:flex; flex-direction:column; gap:6px; }
.xll-dv-metric-field.full { grid-column:1 / -1; }
.xll-dv-metric-label { font-size:12px; font-weight:600; color:${COLOR_MUTED}; }
.xll-dv-metric-input { min-height:44px; border:1px solid ${COLOR_LINE}; border-radius:10px; padding:0 12px; font-size:15px; font-weight:600; color:${COLOR_TEXT}; outline:none; background:${COLOR_BG}; width:100%; box-sizing:border-box; }
.xll-dv-metric-input:focus { border-color:${XLL_GREEN}; box-shadow:0 0 0 2px ${XLL_GREEN_SOFT}; }
.xll-dv-metric-remark { width:100%; min-height:72px; border:1px solid ${COLOR_LINE}; border-radius:10px; padding:10px 12px; font-size:14px; color:${COLOR_TEXT}; resize:vertical; box-sizing:border-box; font-family:inherit; outline:none; background:${COLOR_BG}; }
.xll-dv-metric-remark:focus { border-color:${XLL_GREEN}; box-shadow:0 0 0 2px ${XLL_GREEN_SOFT}; }
.xll-dv-delivery-location { padding:0 14px 14px; }
.xll-dv-delivery-location-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
.xll-dv-delivery-location-label { font-size:12px; font-weight:600; color:${COLOR_MUTED}; }
.xll-dv-delivery-location-plate { font-size:12px; font-weight:600; color:${XLL_GREEN_DEEP}; }
.xll-dv-delivery-map { position:relative; height:220px; border-radius:12px; overflow:hidden; border:1px solid ${COLOR_LINE}; background:#e8f0e4; box-shadow:inset 0 1px 4px rgba(0,0,0,.04); }
.xll-dv-delivery-map-canvas { position:absolute; inset:0; background:linear-gradient(160deg,#dce8d4 0%,#e8f2e0 28%,#d0e0c8 52%,#c5d8bc 78%,#dbe8d2 100%); }
.xll-dv-delivery-map-water { position:absolute; top:8%; right:-6%; width:46%; height:34%; border-radius:42% 58% 60% 40%; background:linear-gradient(135deg,rgba(147,197,253,.55) 0%,rgba(96,165,250,.42) 100%); transform:rotate(-8deg); }
.xll-dv-delivery-map-road { position:absolute; background:rgba(255,255,255,.82); box-shadow:0 0 0 1px rgba(148,163,184,.25); }
.xll-dv-delivery-map-road--h { height:5px; left:-4%; right:-4%; top:46%; transform:rotate(-4deg); }
.xll-dv-delivery-map-road--v { width:5px; top:-6%; bottom:-6%; left:54%; transform:rotate(6deg); }
.xll-dv-delivery-map-road--d { height:4px; width:68%; left:12%; top:28%; transform:rotate(18deg); }
.xll-dv-delivery-map-marker { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:3; display:flex; align-items:center; justify-content:center; }
.xll-dv-delivery-map-marker-pin { width:38px; height:38px; border-radius:50%; background:#2563EB; border:3px solid #fff; box-shadow:0 4px 14px rgba(37,99,235,.38); display:flex; align-items:center; justify-content:center; }
.xll-dv-delivery-map-marker-pin::after { content:''; width:10px; height:10px; border-radius:50%; background:#fff; }
.xll-dv-delivery-map-foot { position:absolute; left:0; right:0; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:8px 10px; background:linear-gradient(180deg,rgba(255,255,255,0) 0%,rgba(255,255,255,.94) 35%,rgba(255,255,255,.98) 100%); font-size:12px; color:${COLOR_TEXT_SEC}; }
.xll-dv-delivery-map-foot strong { color:${COLOR_TEXT}; font-weight:600; }
.xll-dv-delivery-map .xll-map-brand { z-index:2; }
.xll-dv-delivery-map--pending .xll-dv-delivery-map-canvas { opacity:.72; }
.xll-dv-section-action-btn:disabled { opacity:.55; cursor:not-allowed; }
.xll-dv-summary-card { margin:14px 14px 14px; padding:14px; border-radius:12px; background:${COLOR_PAGE}; border:1px dashed ${COLOR_LINE}; font-size:13px; color:${COLOR_TEXT_SEC}; line-height:1.6; }
.xll-dv-borderless-picker { flex:1; min-height:40px; border:none; background:transparent; padding:0; font-size:14px; color:${COLOR_TEXT}; text-align:right; cursor:pointer; touch-action:manipulation; }
.xll-dv-borderless-picker.placeholder { color:${COLOR_MUTED}; }
.xll-dv-borderless-picker:active { opacity:.72; }
.xll-dv-sign-pending-hint { margin:0; padding:10px 12px; border-radius:10px; background:rgba(37,99,235,.08); color:#2563EB; font-size:12px; line-height:1.55; }
.xll-dv-sign-pending-foot { padding:12px 0 0; }
.xll-dv-sign-success-page { display:flex; flex-direction:column; height:100%; min-height:0; background:${COLOR_BG}; }
.xll-dv-sign-success-body { flex:1; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:24px 20px; text-align:center; }
.xll-dv-sign-success-icon { width:72px; height:72px; border-radius:50%; background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; display:flex; align-items:center; justify-content:center; font-size:36px; font-weight:700; margin-bottom:18px; }
.xll-dv-sign-success-title { font-size:20px; font-weight:700; color:${COLOR_TEXT}; line-height:1.4; margin-bottom:8px; }
.xll-dv-sign-success-desc { font-size:14px; color:${COLOR_TEXT_SEC}; line-height:1.6; max-width:280px; }
.xll-dv-sign-success-countdown { margin-top:14px; font-size:13px; color:${COLOR_MUTED}; }
.xll-dv-authorized-section .tc-section-head { border-bottom:none; padding:12px 14px 0; }
.xll-dv-authorized-panel { padding:12px 14px 14px; }
.xll-dv-authorized-section .xll-dv-summary-card { margin:0 0 12px; }
.xll-dv-authorized-hint { margin:0 0 10px; font-size:12px; color:${COLOR_TEXT_SEC}; line-height:1.55; }
.xll-dv-authorized-subtitle { margin:0 0 8px; font-size:12px; font-weight:600; color:${COLOR_MUTED}; }
.xll-dv-authorized-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
.xll-dv-authorized-card { position:relative; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:96px; padding:14px 10px 12px; border-radius:12px; border:1.5px solid ${COLOR_LINE}; background:${COLOR_BG}; text-align:center; cursor:pointer; touch-action:manipulation; transition:border-color .15s ease, background .15s ease, box-shadow .15s ease; -webkit-appearance:none; appearance:none; font:inherit; color:inherit; }
.xll-dv-authorized-card:active:not(:disabled):not(.xll-dv-authorized-card--readonly) { opacity:.9; }
.xll-dv-authorized-card.active { border-color:${XLL_GREEN}; background:${XLL_GREEN_SOFT}; box-shadow:0 0 0 1px rgba(122,185,41,.25); }
.xll-dv-authorized-card:disabled { cursor:default; opacity:1; color:inherit; }
.xll-dv-authorized-card:not(.active):disabled { border-color:${COLOR_LINE}; background:${COLOR_PAGE}; }
.xll-dv-authorized-card--readonly { flex-direction:row; align-items:center; justify-content:flex-start; gap:12px; min-height:0; padding:12px 14px; text-align:left; cursor:default; grid-column:1 / -1; }
.xll-dv-authorized-avatar { flex-shrink:0; width:40px; height:40px; border-radius:50%; background:${COLOR_PAGE}; color:${COLOR_TEXT_SEC}; font-size:16px; font-weight:700; display:flex; align-items:center; justify-content:center; }
.xll-dv-authorized-card.active .xll-dv-authorized-avatar { background:${XLL_GREEN}; color:#fff; }
.xll-dv-authorized-card-body { min-width:0; flex:1; }
.xll-dv-authorized-card--readonly .xll-dv-authorized-avatar { width:44px; height:44px; }
.xll-dv-authorized-card-check { position:absolute; top:8px; right:8px; width:18px; height:18px; border-radius:50%; border:1.5px solid ${COLOR_LINE}; background:${COLOR_BG}; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; color:transparent; line-height:1; }
.xll-dv-authorized-card.active .xll-dv-authorized-card-check { border-color:${XLL_GREEN}; background:${XLL_GREEN}; color:#fff; }
.xll-dv-authorized-card-name { font-size:14px; font-weight:700; color:${COLOR_TEXT}; line-height:1.35; }
.xll-dv-authorized-card-phone { margin-top:4px; font-size:12px; color:${COLOR_TEXT_SEC}; line-height:1.4; }
.xll-dv-authorized-card.active .xll-dv-authorized-card-name { color:${XLL_GREEN_DEEP}; }
.xll-dv-authorized-card.active .xll-dv-authorized-card-phone { color:${XLL_GREEN_DEEP}; opacity:.88; }
.xll-dv-photo-readonly-empty { margin:0 14px 14px; padding:12px 14px; border-radius:10px; background:${COLOR_PAGE}; border:1px dashed ${COLOR_LINE}; font-size:12px; color:${COLOR_MUTED}; text-align:center; line-height:1.55; }
.xll-dv-photo-thumb-watermark { position:absolute; left:0; right:0; bottom:0; padding:4px 5px; background:linear-gradient(180deg, transparent, rgba(0,0,0,.72)); color:#fff; font-size:9px; line-height:1.35; text-align:left; pointer-events:none; }
.xll-dv-photo-thumb-watermark-loc { opacity:.92; margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.xll-dv-summary-card strong { color:${COLOR_TEXT}; }
.xll-dv-module .tc-section { margin-top:12px; }
.xll-dv-module .tc-scroll { padding-bottom:calc(88px + env(safe-area-inset-bottom,0px)); }
.xll-vr-module .xll-mod-tab.active { color:#E11D48; }
.xll-vr-module .xll-mod-chip.active { border-color:#F43F5E; color:#E11D48; background:rgba(244,63,94,.12); font-weight:600; }
.xll-vr-module .xll-mod-card-btn { border-color:rgba(244,63,94,.35); background:rgba(244,63,94,.1); color:#E11D48; }
.xll-vr-module .xll-vr-add-btn { flex-shrink:0; min-height:32px; padding:0 14px; border-radius:8px; border:1px solid #F43F5E; background:rgba(244,63,94,.1); color:#E11D48; font-size:13px; font-weight:600; cursor:pointer; touch-action:manipulation; }
.xll-vr-module .xll-mod-form-input:focus { border-color:#F43F5E; box-shadow:0 0 0 2px rgba(244,63,94,.12); }
.xll-vr-module .xll-vr-form-textarea { width:100%; min-height:72px; border:1px solid ${COLOR_LINE}; border-radius:8px; padding:8px 10px; font-size:14px; resize:vertical; outline:none; box-sizing:border-box; }
.xll-vr-module .xll-vr-form-textarea:focus { border-color:#F43F5E; box-shadow:0 0 0 2px rgba(244,63,94,.12); }
.xll-vr-module .xll-mod-btn-rose { background:#F43F5E; color:#fff; box-shadow:0 4px 12px rgba(244,63,94,.3); border:none; }
.xll-vr-module .tc-section-form { padding:0 14px 14px; }
.xll-vr-module .tc-section-form .xll-mod-form-row { padding:10px 0; }
.xll-vr-module .tc-section-form .xll-mod-form-row:last-child { border-bottom:none; }
.xll-vr-module .tc-section-hint { padding:0 14px 12px; font-size:12px; color:${COLOR_MUTED}; line-height:1.55; }
.xll-vr-module .tc-section-chips { padding:0 14px 14px; display:flex; flex-wrap:wrap; gap:8px; }
.xll-vm-online { display:inline-flex; align-items:center; gap:4px; font-size:11px; font-weight:600; }
.xll-vm-dot { width:6px; height:6px; border-radius:50%; background:${COLOR_MUTED}; }
.xll-vm-dot.on { background:${COLOR_SUCCESS}; }
.xll-vm-dot.off { background:${COLOR_MUTED}; }
.xll-vm-plate-row { display:flex; align-items:center; flex-wrap:wrap; gap:8px; }
.xll-vm-badge { font-size:11px; font-weight:600; padding:2px 8px; border-radius:999px; background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; }
.xll-vm-badge.warn { background:rgba(255,125,0,.12); color:${COLOR_WARN}; }
.xll-vm-badge.danger { background:rgba(245,63,63,.1); color:${COLOR_DANGER}; }
.xll-vm-badge.neutral { background:${COLOR_PAGE}; color:${COLOR_TEXT_SEC}; }
.xll-vm-filter-btn { width:44px; height:44px; border:none; background:${COLOR_PAGE}; border-radius:10px; color:${COLOR_TEXT_SEC}; display:flex; align-items:center; justify-content:center; cursor:pointer; flex-shrink:0; }
.xll-vm-filter-btn.active { background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; }
.xll-vm-detail-hero { margin:12px 14px 0; background:${COLOR_BG}; border-radius:14px; padding:16px; box-shadow:0 2px 8px rgba(15,23,42,.05); border:1px solid rgba(0,0,0,.04); }
.xll-vm-detail-plate { font-size:20px; font-weight:800; color:${COLOR_TEXT}; margin-bottom:6px; }
.xll-vm-detail-sub { font-size:13px; color:${COLOR_TEXT_SEC}; line-height:1.5; margin-bottom:10px; }
.xll-vm-detail-tags { display:flex; flex-wrap:wrap; gap:6px; }
.xll-vm-subtabs { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:0; flex-shrink:0; background:${COLOR_BG}; border-bottom:1px solid ${COLOR_LINE}; position:sticky; top:0; z-index:5; }
.xll-vm-subtab { min-height:44px; padding:8px 4px; border:none; border-radius:0; background:transparent; color:${COLOR_MUTED}; font-size:12px; font-weight:500; cursor:pointer; touch-action:manipulation; border-bottom:2px solid transparent; margin-bottom:-1px; line-height:1.3; white-space:normal; word-break:keep-all; }
.xll-vm-subtab.active { color:${XLL_GREEN_DEEP}; border-bottom-color:${XLL_GREEN}; font-weight:700; background:rgba(122,185,41,.06); }
.xll-vm-detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px 14px; }
.xll-vm-detail-kv { min-width:0; }
.xll-vm-detail-kv.full { grid-column:1 / -1; }
.xll-vm-detail-kv-label { font-size:11px; color:${COLOR_MUTED}; margin-bottom:3px; line-height:1.3; }
.xll-vm-detail-kv-val { font-size:13px; color:${COLOR_TEXT}; font-weight:500; line-height:1.45; word-break:break-word; overflow-wrap:anywhere; }
.xll-vm-cert-nav { display:flex; gap:6px; padding:10px 0 12px; overflow-x:auto; scrollbar-width:none; margin:0 -2px; }
.xll-vm-cert-nav::-webkit-scrollbar { display:none; }
.xll-vm-cert-nav-btn { flex-shrink:0; min-height:32px; padding:0 12px; border:1px solid ${COLOR_LINE}; border-radius:999px; background:${COLOR_BG}; font-size:12px; color:${COLOR_TEXT_SEC}; cursor:pointer; }
.xll-vm-cert-nav-btn.active { border-color:${XLL_GREEN}; color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; font-weight:600; }
.xll-vm-photo-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; margin-bottom:12px; }
.xll-vm-photo { aspect-ratio:3/2; border-radius:10px; overflow:hidden; background:${COLOR_PAGE}; border:1px solid ${COLOR_LINE}; }
.xll-vm-photo img { width:100%; height:100%; object-fit:cover; display:block; }
.xll-vm-photo-empty { aspect-ratio:3/2; border-radius:10px; background:${COLOR_PAGE}; border:1px dashed ${COLOR_LINE}; display:flex; align-items:center; justify-content:center; font-size:12px; color:${COLOR_MUTED}; }
.xll-vm-ins-card { background:${COLOR_PAGE}; border-radius:12px; padding:12px 14px; margin-bottom:10px; border:1px solid rgba(0,0,0,.04); }
.xll-vm-ins-head { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:8px; }
.xll-vm-ins-type { font-size:14px; font-weight:700; color:${COLOR_TEXT}; }
.xll-vm-ins-status { font-size:11px; font-weight:600; padding:2px 8px; border-radius:999px; background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; }
.xll-vm-ins-status.warn { background:rgba(255,125,0,.12); color:${COLOR_WARN}; }
.xll-vm-ins-status.empty { background:${COLOR_PAGE}; color:${COLOR_MUTED}; }
.xll-vm-ins-row { display:flex; justify-content:space-between; gap:10px; font-size:12px; color:${COLOR_TEXT_SEC}; margin-bottom:4px; }
.xll-vm-ins-pdf { margin-top:10px; min-height:36px; padding:0 12px; border-radius:8px; border:1px solid rgba(122,185,41,.35); background:${XLL_GREEN_SOFT}; color:${XLL_GREEN}; font-size:12px; font-weight:600; cursor:pointer; width:100%; touch-action:manipulation; }
.xll-vm-event-list { position:relative; padding-left:18px; }
.xll-vm-event-item { position:relative; padding:0 0 16px 14px; }
.xll-vm-event-item:last-child { padding-bottom:0; }
.xll-vm-event-item::before { content:''; position:absolute; left:-18px; top:6px; bottom:-6px; width:2px; background:${COLOR_LINE}; }
.xll-vm-event-item:last-child::before { bottom:auto; height:6px; }
.xll-vm-event-dot { position:absolute; left:-23px; top:4px; width:10px; height:10px; border-radius:50%; background:${XLL_GREEN}; border:2px solid ${COLOR_BG}; box-shadow:0 0 0 1px ${XLL_GREEN}; }
.xll-vm-event-type { font-size:13px; font-weight:700; color:${COLOR_TEXT}; margin-bottom:4px; }
.xll-vm-event-meta { font-size:12px; color:${COLOR_MUTED}; line-height:1.55; }
.xll-vm-event-summary { font-size:13px; color:${COLOR_TEXT_SEC}; margin-top:4px; line-height:1.5; }
.xll-vm-ins-pdf { margin-top:10px; min-height:36px; padding:0 12px; border-radius:8px; border:1px solid rgba(122,185,41,.35); background:${XLL_GREEN_SOFT}; color:${XLL_GREEN}; font-size:12px; font-weight:600; cursor:pointer; width:100%; touch-action:manipulation; }
.xll-mod-form-row--stack { flex-direction:column; align-items:stretch; gap:6px; }
.xll-mod-form-row--stack .xll-mod-form-label { font-size:12px; line-height:1.4; }
.xll-mod-form-row--stack .xll-mod-form-value { text-align:left; line-height:1.55; word-break:break-word; overflow-wrap:anywhere; }
.xll-vm-meta-cell { min-width:0; }
.xll-vm-meta-cell .xll-mod-meta-val { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin-top:2px; }
.xll-vm-card-vin-wrap { flex:1; min-width:0; display:flex; align-items:center; gap:6px; padding-right:8px; }
.xll-vm-card-vin-label { flex-shrink:0; font-size:11px; color:${COLOR_MUTED}; }
.xll-vm-card-vin { flex:1; min-width:0; font-size:11px; color:${COLOR_TEXT_SEC}; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing:.02em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.xll-vm-customer-bar { display:flex; align-items:flex-start; gap:8px; margin-bottom:10px; padding:9px 10px; background:${COLOR_PAGE}; border-radius:10px; min-width:0; border:1px solid rgba(0,0,0,.04); }
.xll-vm-customer-bar--detail { margin:10px 0 0; background:${XLL_GREEN_SOFT}; border-color:rgba(122,185,41,.18); }
.xll-vm-customer-bar.is-long { cursor:pointer; touch-action:manipulation; }
.xll-vm-customer-bar.is-long:active { background:rgba(122,185,41,.12); }
.xll-vm-customer-icon { flex-shrink:0; width:24px; height:24px; border-radius:7px; background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; font-size:11px; font-weight:700; display:flex; align-items:center; justify-content:center; margin-top:1px; }
.xll-vm-customer-bar--detail .xll-vm-customer-icon { background:rgba(255,255,255,.85); }
.xll-vm-customer-body { flex:1; min-width:0; }
.xll-vm-customer-label { display:block; font-size:11px; color:${COLOR_MUTED}; margin-bottom:3px; line-height:1.3; }
.xll-vm-customer-text { display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden; font-size:13px; color:${COLOR_TEXT}; font-weight:600; line-height:1.45; word-break:break-all; }
.xll-vm-customer-bar:not(.is-long) .xll-vm-customer-text { -webkit-line-clamp:1; }
.xll-vm-customer-hint { display:block; margin-top:4px; font-size:11px; color:${XLL_GREEN}; font-weight:500; }
.xll-vm-name-modal-text { font-size:15px; color:${COLOR_TEXT}; line-height:1.6; word-break:break-word; overflow-wrap:anywhere; padding:4px 0; }
.xll-list-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px 4px; font-size:12px; color:${COLOR_MUTED}; }
.xll-list-count { font-variant-numeric:tabular-nums; font-weight:600; }
.xll-task-card { position:relative; margin:0 14px 12px; background:${COLOR_BG}; border-radius:14px; overflow:hidden; box-shadow:0 2px 8px rgba(15,23,42,.05); border:1px solid rgba(0,0,0,.04); animation:xll-card-in 0.35s ease both; }
.xll-task-card::before { content:''; position:absolute; left:0; top:12px; bottom:12px; width:3px; border-radius:0 3px 3px 0; background:var(--xll-accent, ${XLL_GREEN}); }
.xll-task-badge { position:absolute; top:10px; right:10px; min-width:22px; height:22px; padding:0 6px; border-radius:6px; background:rgba(255,229,143,.95); color:#AD6800; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; z-index:1; font-variant-numeric:tabular-nums; }
.xll-task-head { display:flex; align-items:flex-start; justify-content:space-between; padding:14px 14px 10px 16px; gap:10px; }
.xll-task-title-row { display:flex; align-items:center; gap:10px; flex:1; min-width:0; padding-right:24px; }
.xll-task-icon { width:40px; height:40px; border-radius:10px; display:flex; align-items:center; justify-content:center; flex-shrink:0; background:var(--xll-soft, ${XLL_GREEN_SOFT}); color:var(--xll-accent, ${XLL_GREEN}); }
.xll-task-title-wrap { min-width:0; flex:1; }
.xll-task-type-tag { display:inline-flex; font-size:11px; font-weight:600; padding:2px 8px; border-radius:999px; margin-bottom:4px; color:var(--xll-accent, ${XLL_GREEN}); background:var(--xll-soft, ${XLL_GREEN_SOFT}); }
.xll-task-title { font-size:15px; font-weight:700; color:${COLOR_TEXT}; line-height:1.35; }
.xll-task-action { flex-shrink:0; min-height:44px; padding:0 12px; font-size:14px; font-weight:600; color:${XLL_GREEN}; border:1px solid rgba(122,185,41,.35); background:${XLL_GREEN_SOFT}; border-radius:10px; cursor:pointer; touch-action:manipulation; transition:transform 0.15s ease, background 0.15s ease; white-space:nowrap; }
.xll-task-action:active { transform:scale(0.97); background:rgba(122,185,41,.22); }
.xll-task-action:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:2px; }
.xll-task-body { padding:0 16px 14px; border-top:1px solid ${COLOR_LINE}; margin-top:0; padding-top:12px; }
.xll-kv { display:flex; gap:8px; font-size:13px; line-height:1.55; margin-bottom:6px; }
.xll-kv:last-child { margin-bottom:0; }
.xll-kv-label { color:${COLOR_MUTED}; flex-shrink:0; min-width:72px; }
.xll-kv-value { color:${COLOR_TEXT_SEC}; flex:1; word-break:break-all; }
.xll-kv-value.warn { color:${COLOR_DANGER}; font-weight:600; }
.xll-kv-value.warn::after { content:' '; }
.xll-warn-tag { display:inline-flex; align-items:center; gap:4px; font-size:11px; font-weight:600; color:${COLOR_DANGER}; background:rgba(245,63,63,.1); padding:1px 6px; border-radius:4px; margin-left:4px; vertical-align:middle; }
.xll-biz-section { margin:0 14px 14px; background:${COLOR_BG}; border-radius:14px; padding:14px 12px 6px; box-shadow:0 2px 8px rgba(15,23,42,.04); border:1px solid rgba(0,0,0,.03); }
.xll-biz-section-title { font-size:13px; font-weight:600; color:${COLOR_MUTED}; margin-bottom:12px; padding-left:4px; letter-spacing:0.02em; }
.xll-biz-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px 8px; }
.xll-biz-item { display:flex; flex-direction:column; align-items:center; gap:8px; padding:6px 2px 10px; border:none; background:transparent; cursor:pointer; touch-action:manipulation; position:relative; border-radius:12px; transition:background 0.15s ease; min-height:88px; -webkit-tap-highlight-color:transparent; }
.xll-biz-item:active { background:${COLOR_PAGE}; }
.xll-biz-item:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:2px; }
.xll-biz-icon-wrap { position:relative; display:inline-flex; flex-shrink:0; overflow:visible; }
.xll-biz-icon { width:48px; height:48px; border-radius:12px; background:${COLOR_PAGE}; border:1px solid ${COLOR_LINE}; display:flex; align-items:center; justify-content:center; color:${XLL_GREEN_DEEP}; transition:transform 0.15s ease, box-shadow 0.15s ease; }
.xll-biz-item:active .xll-biz-icon { transform:scale(0.95); }
.xll-biz-label { font-size:12px; color:${COLOR_TEXT}; text-align:center; line-height:1.35; font-weight:500; }
.xll-biz-badge { position:absolute; top:-4px; right:-4px; min-width:18px; height:18px; padding:0 4px; border-radius:999px; background:${XLL_GREEN}; color:#fff; font-size:10px; font-weight:700; display:flex; align-items:center; justify-content:center; font-variant-numeric:tabular-nums; box-shadow:0 1px 4px rgba(122,185,41,.4); z-index:2; pointer-events:none; }
.xll-map-tabs { display:flex; background:${COLOR_BG}; border-bottom:1px solid ${COLOR_LINE}; }
.xll-map-tab { flex:1; min-height:44px; border:none; background:transparent; font-size:15px; color:${COLOR_TEXT_SEC}; cursor:pointer; position:relative; font-weight:500; touch-action:manipulation; transition:color 0.2s ease; }
.xll-map-tab.active { color:${XLL_GREEN}; font-weight:700; }
.xll-map-tab.active::after { content:''; position:absolute; left:20%; right:20%; bottom:0; height:3px; border-radius:3px 3px 0 0; background:${XLL_GREEN}; }
.xll-map-tab:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:-2px; }
.xll-map-toolbar { display:flex; gap:10px; padding:10px 14px; background:${COLOR_BG}; align-items:center; border-bottom:1px solid ${COLOR_LINE}; }
.xll-map-search-wrap { flex:1; display:flex; align-items:center; gap:8px; min-height:44px; padding:0 12px; background:${COLOR_PAGE}; border-radius:12px; border:1px solid transparent; transition:border-color 0.2s ease, box-shadow 0.2s ease; }
.xll-map-search-wrap:focus-within { border-color:rgba(122,185,41,.45); box-shadow:0 0 0 3px ${XLL_GREEN_SOFT}; background:${COLOR_BG}; }
.xll-map-search-wrap svg { flex-shrink:0; color:${COLOR_MUTED}; }
.xll-map-search { flex:1; border:none; background:transparent; font-size:15px; color:${COLOR_TEXT}; outline:none; min-width:0; }
.xll-map-search::placeholder { color:${COLOR_MUTED}; }
.xll-map-filter { width:44px; height:44px; border:1px solid ${COLOR_LINE}; border-radius:12px; background:${COLOR_BG}; cursor:pointer; display:flex; align-items:center; justify-content:center; color:${COLOR_MUTED}; flex-shrink:0; touch-action:manipulation; transition:background 0.15s ease, border-color 0.15s ease; }
.xll-map-filter:active { background:${COLOR_PAGE}; border-color:${XLL_GREEN}; color:${XLL_GREEN}; }
.xll-map-filter:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:2px; }
.xll-map-page { display:flex; flex-direction:column; min-height:min-content; }
.xll-map-area { margin:12px 14px 14px; height:min(420px, calc(100dvh - 320px)); min-height:240px; border-radius:14px; overflow:hidden; position:relative; background:linear-gradient(180deg,#e8f4e8 0%,#d4e8d4 50%,#c5dcc5 100%); border:1px solid ${COLOR_LINE}; box-shadow:inset 0 1px 4px rgba(0,0,0,.04); flex-shrink:0; }
.xll-map-placeholder { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; color:${COLOR_MUTED}; font-size:13px; gap:8px; pointer-events:none; text-align:center; padding:0 24px; line-height:1.5; }
.xll-map-full { position:absolute; top:12px; left:12px; min-width:52px; min-height:52px; padding:8px 6px; border-radius:12px; background:rgba(255,255,255,.95); border:1px solid ${COLOR_LINE}; box-shadow:0 2px 8px rgba(0,0,0,.08); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; font-size:11px; font-weight:600; color:${XLL_GREEN}; cursor:pointer; z-index:2; touch-action:manipulation; transition:transform 0.15s ease; }
.xll-map-full:active { transform:scale(0.96); }
.xll-map-full:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:2px; }
.xll-map-marker { position:absolute; width:36px; height:36px; border-radius:50%; background:${COLOR_BG}; border:2px solid ${XLL_GREEN}; box-shadow:0 2px 8px rgba(0,0,0,.15); display:flex; align-items:center; justify-content:center; color:${XLL_GREEN}; z-index:1; }
.xll-map-marker--station { border-color:#2563EB; color:#2563EB; }
.xll-map-brand { position:absolute; left:8px; bottom:8px; font-size:10px; color:${COLOR_MUTED}; background:rgba(255,255,255,.85); padding:3px 8px; border-radius:6px; border:1px solid ${COLOR_LINE}; }
.xll-mine-hero { margin:12px 14px 0; padding:20px 16px; background:linear-gradient(135deg, ${XLL_GREEN} 0%, ${XLL_GREEN_DEEP} 100%); border-radius:14px; display:flex; align-items:center; gap:14px; box-shadow:0 4px 16px rgba(122,185,41,.3); }
.xll-mine-avatar { width:56px; height:56px; border-radius:50%; background:rgba(255,255,255,.25); border:2px solid rgba(255,255,255,.6); display:flex; align-items:center; justify-content:center; color:#fff; font-size:22px; font-weight:800; flex-shrink:0; }
.xll-mine-hero-info { min-width:0; }
.xll-mine-hero-name { font-size:18px; font-weight:700; color:#fff; margin-bottom:4px; }
.xll-mine-hero-role { font-size:13px; color:rgba(255,255,255,.85); }
.xll-mine-card { margin:12px 14px; background:${COLOR_BG}; border-radius:14px; overflow:hidden; box-shadow:0 2px 8px rgba(15,23,42,.04); border:1px solid rgba(0,0,0,.03); }
.xll-mine-row { display:flex; align-items:center; justify-content:space-between; padding:16px; border-bottom:1px solid ${COLOR_LINE}; font-size:15px; min-height:52px; }
.xll-mine-row:last-child { border-bottom:none; }
.xll-mine-label { color:${COLOR_MUTED}; font-size:14px; }
.xll-mine-value { color:${COLOR_TEXT}; font-weight:500; font-size:15px; text-align:right; max-width:60%; word-break:break-all; }
.xll-logout { display:block; width:calc(100% - 28px); margin:20px 14px 8px; min-height:48px; border:none; border-radius:999px; background:${COLOR_BG}; color:${COLOR_DANGER}; font-size:16px; font-weight:600; cursor:pointer; border:1px solid rgba(245,63,63,.25); touch-action:manipulation; transition:background 0.15s ease; }
.xll-logout:active { background:rgba(245,63,63,.06); }
.xll-logout:focus-visible { outline:2px solid ${COLOR_DANGER}; outline-offset:2px; }
.xll-tabbar { flex-shrink:0; display:flex; height:52px; padding-bottom:env(safe-area-inset-bottom,0); background:${COLOR_BG}; border-top:1px solid ${COLOR_LINE}; z-index:30; }
.xll-tabbar-btn { flex:1; border:none; background:transparent; font-size:11px; color:${COLOR_MUTED}; cursor:pointer; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px; padding-top:4px; touch-action:manipulation; transition:color 0.2s ease; min-height:52px; }
.xll-tabbar-btn.active { color:${XLL_GREEN}; font-weight:700; }
.xll-tabbar-btn:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:-2px; }
.xll-tabbar-icon { display:flex; align-items:center; justify-content:center; width:24px; height:24px; }
.xll-login-wrap { flex:1; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:32px 24px; }
.xll-login-logo { width:88px; height:88px; border-radius:22px; background:linear-gradient(135deg,${XLL_GREEN} 0%,${XLL_GREEN_DEEP} 100%); display:flex; align-items:center; justify-content:center; color:#fff; font-size:28px; font-weight:800; margin-bottom:16px; box-shadow:0 12px 28px rgba(122,185,41,.35); }
.xll-login-name { font-size:24px; font-weight:800; color:${COLOR_TEXT}; margin-bottom:8px; letter-spacing:-0.02em; }
.xll-login-desc { font-size:14px; color:${COLOR_MUTED}; margin-bottom:40px; text-align:center; line-height:1.6; }
.xll-login-btn { width:100%; max-width:280px; min-height:48px; border:none; border-radius:999px; background:${XLL_GREEN}; color:#fff; font-size:16px; font-weight:700; cursor:pointer; touch-action:manipulation; box-shadow:0 4px 14px rgba(122,185,41,.35); transition:transform 0.15s ease, opacity 0.15s ease; }
.xll-login-btn:active:not(:disabled) { transform:scale(0.98); }
.xll-login-btn:disabled { opacity:0.65; cursor:not-allowed; }
.xll-login-btn:focus-visible { outline:2px solid ${XLL_GREEN_DEEP}; outline-offset:3px; }
.xll-sub-page { padding:14px; }
.xll-sub-hero { background:linear-gradient(135deg, var(--xll-accent, ${XLL_GREEN}) 0%, ${XLL_GREEN_DEEP} 100%); border-radius:14px; padding:16px; margin-bottom:12px; color:#fff; box-shadow:0 4px 16px rgba(122,185,41,.25); }
.xll-sub-hero-tag { display:inline-flex; font-size:11px; font-weight:600; padding:2px 10px; border-radius:999px; background:rgba(255,255,255,.22); margin-bottom:8px; }
.xll-sub-hero-title { font-size:17px; font-weight:700; line-height:1.4; }
.xll-sub-card { background:${COLOR_BG}; border-radius:14px; padding:16px; margin-bottom:12px; box-shadow:0 2px 8px rgba(15,23,42,.04); border:1px solid rgba(0,0,0,.03); }
.xll-sub-section-title { font-size:13px; font-weight:600; color:${COLOR_MUTED}; margin-bottom:12px; }
.xll-sub-foot { display:flex; gap:10px; margin-top:16px; }
.xll-sub-btn { flex:1; min-height:48px; border-radius:12px; font-size:15px; font-weight:600; cursor:pointer; border:none; touch-action:manipulation; transition:transform 0.15s ease; }
.xll-sub-btn:active { transform:scale(0.98); }
.xll-sub-btn:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:2px; }
.xll-sub-btn-primary { background:${XLL_GREEN}; color:#fff; box-shadow:0 4px 12px rgba(122,185,41,.3); }
.xll-sub-btn-ghost { background:${COLOR_PAGE}; color:${COLOR_TEXT_SEC}; border:1px solid ${COLOR_LINE}; }
.xll-prd-doc { font-size:13px; color:${COLOR_TEXT_SEC}; line-height:1.65; }
.xll-prd-h2 { font-size:15px; font-weight:700; color:${COLOR_TEXT}; margin:16px 0 8px; }
.xll-prd-h2:first-child { margin-top:0; }
.xll-prd-h3 { font-size:14px; font-weight:600; color:${COLOR_TEXT}; margin:12px 0 6px; }
.xll-prd-p { margin:0 0 8px; }
.xll-prd-ul { margin:0 0 8px; padding-left:18px; }
.xll-prd-li { margin-bottom:4px; }
.xll-prd-meta { font-size:12px; color:${COLOR_MUTED}; margin-bottom:12px; padding-bottom:12px; border-bottom:1px dashed ${COLOR_LINE}; }
.xll-prd-tag { display:inline-block; font-size:11px; font-weight:600; color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; padding:2px 8px; border-radius:999px; margin-bottom:8px; }
.xll-prd-highlight { background:${XLL_GREEN_SOFT}; border-left:3px solid ${XLL_GREEN}; padding:10px 12px; border-radius:0 8px 8px 0; margin:8px 0; font-size:13px; color:${COLOR_TEXT_SEC}; }
@keyframes xll-card-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
.tc-scroll { flex:1; overflow-y:auto; -webkit-overflow-scrolling:touch; overscroll-behavior:contain; padding-bottom:88px; }
.tc-hero { margin:12px 14px 0; padding:18px 16px 16px; border-radius:16px; background:linear-gradient(135deg,#F97316 0%,#EA580C 100%); color:#fff; box-shadow:0 10px 28px rgba(249,115,22,.35); }
.tc-hero-label { font-size:13px; opacity:.92; margin-bottom:6px; }
.tc-hero-amount { font-size:36px; font-weight:800; line-height:1.1; font-variant-numeric:tabular-nums; letter-spacing:-.02em; }
.tc-hero-sub { margin-top:14px; padding-top:12px; border-top:1px solid rgba(255,255,255,.22); display:flex; align-items:center; justify-content:space-between; gap:12px; }
.tc-hero-compare { font-size:12px; opacity:.9; line-height:1.45; }
.tc-hero-detail-btn { flex-shrink:0; min-height:32px; padding:0 12px; border:1px solid rgba(255,255,255,.45); border-radius:999px; background:rgba(255,255,255,.14); color:#fff; font-size:12px; font-weight:600; cursor:pointer; }
.tc-hero-meta { display:flex; gap:16px; margin-top:10px; font-size:12px; opacity:.88; }
.tc-section { margin:12px 14px 0; background:${COLOR_BG}; border-radius:14px; overflow:hidden; box-shadow:0 2px 8px rgba(15,23,42,.04); border:1px solid rgba(0,0,0,.04); }
.tc-section-head { display:flex; align-items:center; justify-content:space-between; padding:12px 14px; border-bottom:1px solid ${COLOR_LINE}; }
.tc-section-title { font-size:15px; font-weight:700; color:${COLOR_TEXT}; }
.tc-section-badge { font-size:11px; font-weight:600; color:#F97316; background:rgba(249,115,22,.12); padding:2px 8px; border-radius:999px; }
.tc-kv-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px 14px; padding:12px 14px 14px; }
.tc-kv-item { min-width:0; }
.tc-kv-label { font-size:11px; color:${COLOR_MUTED}; margin-bottom:3px; }
.tc-kv-value { font-size:13px; color:${COLOR_TEXT}; font-weight:500; line-height:1.4; word-break:break-all; }
.tc-kv-value--full { grid-column:1 / -1; }
.tc-vehicle-card { margin:0 14px 10px; padding:12px 14px; background:${COLOR_PAGE}; border-radius:12px; border:1px solid ${COLOR_LINE}; }
.tc-vehicle-card:last-child { margin-bottom:14px; }
.tc-vehicle-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:10px; }
.tc-vehicle-plate { font-size:15px; font-weight:700; color:${COLOR_TEXT}; }
.tc-vehicle-model { font-size:12px; color:${COLOR_MUTED}; margin-top:2px; }
.tc-vehicle-idx { font-size:11px; font-weight:700; color:#F97316; background:rgba(249,115,22,.12); padding:2px 8px; border-radius:999px; flex-shrink:0; }
.tc-vehicle-amount-row { display:flex; align-items:baseline; justify-content:space-between; gap:8px; padding:10px 12px; background:${COLOR_BG}; border-radius:10px; margin-bottom:8px; }
.tc-vehicle-amount-label { font-size:12px; color:${COLOR_MUTED}; }
.tc-vehicle-amount-val { font-size:18px; font-weight:800; color:#F97316; font-variant-numeric:tabular-nums; }
.tc-vehicle-kv { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px 10px; }
.tc-vehicle-kv-item { font-size:12px; color:${COLOR_TEXT_SEC}; }
.tc-vehicle-kv-item span { color:${COLOR_MUTED}; }
.tc-service-toggle { width:100%; margin-top:8px; min-height:32px; border:none; background:transparent; color:${XLL_GREEN_DEEP}; font-size:12px; font-weight:600; cursor:pointer; text-align:left; padding:0; }
.tc-service-list { margin-top:8px; padding-top:8px; border-top:1px dashed ${COLOR_LINE}; }
.tc-service-row { display:grid; grid-template-columns:1fr auto; gap:4px 8px; font-size:12px; padding:6px 0; border-bottom:1px solid rgba(0,0,0,.04); }
.tc-service-name { color:${COLOR_TEXT}; font-weight:500; }
.tc-service-amt { color:#F97316; font-weight:700; text-align:right; font-variant-numeric:tabular-nums; }
.tc-service-sub { grid-column:1 / -1; color:${COLOR_MUTED}; font-size:11px; }
.tc-timeline { padding:4px 14px 14px; }
.tc-step { display:flex; gap:12px; position:relative; padding-bottom:16px; }
.tc-step:not(:last-child)::before { content:''; position:absolute; left:9px; top:22px; bottom:0; width:2px; background:${COLOR_LINE}; }
.tc-step-dot { width:20px; height:20px; border-radius:50%; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:10px; font-weight:700; margin-top:1px; }
.tc-step-dot.done { background:${COLOR_SUCCESS}; color:#fff; }
.tc-step-dot.wait { background:rgba(249,115,22,.12); color:#F97316; border:2px solid #F97316; box-sizing:border-box; }
.tc-step-title { font-size:14px; font-weight:600; color:${COLOR_TEXT}; }
.tc-step-meta { font-size:12px; color:${COLOR_MUTED}; margin-top:4px; line-height:1.5; }
.tc-action-bar { position:absolute; left:0; right:0; bottom:0; z-index:20; display:flex; gap:8px; padding:10px 12px calc(10px + env(safe-area-inset-bottom,0px)); background:rgba(255,255,255,.96); border-top:1px solid ${COLOR_LINE}; backdrop-filter:blur(8px); }
.tc-action-bar--view { justify-content:flex-end; }
.tc-btn { flex:1; min-height:44px; border-radius:12px; font-size:14px; font-weight:700; cursor:pointer; border:none; touch-action:manipulation; }
.tc-btn-comment { flex:0 0 auto; min-width:64px; background:${COLOR_BG}; color:${COLOR_TEXT_SEC}; border:1px solid ${COLOR_LINE}; }
.tc-btn-terminate { background:${COLOR_PAGE}; color:${COLOR_WARN}; border:1px solid rgba(255,125,0,.3); }
.tc-btn-reject { background:${COLOR_PAGE}; color:${COLOR_DANGER}; border:1px solid rgba(245,63,63,.25); }
.tc-btn-approve { background:linear-gradient(135deg,${XLL_GREEN} 0%,${XLL_GREEN_DEEP} 100%); color:#fff; box-shadow:0 4px 14px rgba(122,185,41,.3); }
.tc-btn:active { opacity:.92; transform:scale(.98); }
.tc-approval-form { display:flex; flex-direction:column; gap:16px; padding:4px 0 8px; }
.tc-form-field { display:flex; flex-direction:column; gap:8px; }
.tc-form-label { font-size:14px; color:${COLOR_TEXT}; font-weight:500; }
.tc-form-label-required::before { content:'*'; color:${COLOR_DANGER}; margin-right:4px; }
.tc-notify-group { display:flex; flex-wrap:wrap; gap:16px; }
.tc-notify-item { display:inline-flex; align-items:center; gap:6px; font-size:14px; color:${COLOR_TEXT_SEC}; cursor:pointer; min-height:32px; }
.tc-notify-item input { width:16px; height:16px; accent-color:${XLL_GREEN}; }
.tc-notify-item.disabled { opacity:.55; cursor:not-allowed; }
.tc-upload-btn { display:inline-flex; align-items:center; gap:6px; min-height:36px; padding:0 14px; border:1px dashed ${COLOR_LINE}; border-radius:8px; background:${COLOR_PAGE}; color:${COLOR_TEXT_SEC}; font-size:13px; cursor:pointer; }
.tc-upload-hint { font-size:12px; color:${COLOR_MUTED}; line-height:1.55; margin-top:4px; }
.tc-file-list { display:flex; flex-direction:column; gap:6px; margin-top:6px; }
.tc-file-item { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:8px 10px; background:${COLOR_PAGE}; border-radius:8px; font-size:13px; color:${COLOR_TEXT}; }
.tc-file-remove { border:none; background:transparent; color:${COLOR_MUTED}; font-size:12px; cursor:pointer; padding:4px; }
.tc-select-person { min-height:44px; padding:0 14px; border:1px solid ${COLOR_LINE}; border-radius:10px; background:${COLOR_BG}; color:${COLOR_MUTED}; font-size:14px; text-align:left; cursor:pointer; width:100%; }
.tc-select-person.has-value { color:${COLOR_TEXT}; }
.tc-cc-chips { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; }
.tc-cc-chip { display:inline-flex; align-items:center; gap:4px; padding:4px 10px; border-radius:999px; background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; font-size:12px; font-weight:600; }
.tc-cc-chip button { border:none; background:transparent; color:inherit; cursor:pointer; padding:0 2px; font-size:14px; line-height:1; }
.tc-textarea { width:100%; min-height:96px; padding:10px 12px; border:1px solid ${COLOR_LINE}; border-radius:10px; font-size:14px; color:${COLOR_TEXT}; resize:vertical; box-sizing:border-box; font-family:inherit; outline:none; }
.tc-textarea:focus { border-color:rgba(122,185,41,.55); box-shadow:0 0 0 3px rgba(122,185,41,.12); }
.tc-textarea-counter { text-align:right; font-size:12px; color:${COLOR_MUTED}; margin-top:4px; }
.tc-drawer-foot { display:flex; gap:10px; padding-top:12px; margin-top:4px; border-top:1px solid ${COLOR_LINE}; }
.tc-drawer-foot-btn { flex:1; min-height:44px; border-radius:10px; font-size:15px; font-weight:600; cursor:pointer; border:none; }
.tc-drawer-foot-cancel { background:${COLOR_BG}; color:${COLOR_TEXT_SEC}; border:1px solid ${COLOR_LINE}; }
.tc-drawer-foot-confirm { background:#1677ff; color:#fff; }
.tc-person-list { display:flex; flex-direction:column; gap:8px; }
.tc-person-item { min-height:48px; padding:0 14px; border:1px solid ${COLOR_LINE}; border-radius:10px; background:${COLOR_BG}; font-size:14px; color:${COLOR_TEXT}; text-align:left; cursor:pointer; }
.tc-person-item.selected { border-color:${XLL_GREEN}; background:${XLL_GREEN_SOFT}; color:${XLL_GREEN_DEEP}; font-weight:600; }
.tc-drawer-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 0; border-bottom:1px solid ${COLOR_LINE}; font-size:14px; }
.tc-drawer-row-val.highlight { color:#F97316; font-weight:700; }
.tc-drawer-total { margin-top:8px; padding-top:12px; border-top:2px solid ${COLOR_LINE}; display:flex; justify-content:space-between; font-size:15px; font-weight:700; }
.tc-drawer-total span:last-child { color:#F97316; font-size:18px; font-variant-numeric:tabular-nums; }
.tc-hero--return { background:linear-gradient(135deg,#8B5CF6 0%,#7C3AED 100%); box-shadow:0 10px 28px rgba(139,92,246,.35); }
.tc-section-badge--purple { color:#8B5CF6; background:rgba(139,92,246,.12); }
.hc-stat-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; padding:12px 14px 14px; }
.hc-stat-card { padding:12px; background:${COLOR_PAGE}; border-radius:10px; border:1px solid ${COLOR_LINE}; }
.hc-stat-label { font-size:11px; color:${COLOR_MUTED}; margin-bottom:6px; }
.hc-stat-val { font-size:18px; font-weight:800; font-variant-numeric:tabular-nums; color:${COLOR_TEXT}; }
.hc-stat-val.warn { color:#F97316; }
.hc-stat-val.success { color:${COLOR_SUCCESS}; }
.hc-stat-val.danger { color:${COLOR_DANGER}; }
.hc-insurance-row { display:flex; gap:8px; margin-top:4px; }
.hc-insurance-item { flex:1; min-width:0; display:flex; align-items:center; justify-content:space-between; gap:4px; padding:6px 8px; background:rgba(255,255,255,.14); border-radius:8px; font-size:11px; line-height:1.2; }
.hc-insurance-icon { width:18px; height:18px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; flex-shrink:0; line-height:1; }
.hc-insurance-icon--yes { background:rgba(255,255,255,.32); color:#fff; }
.hc-insurance-icon--no { background:rgba(0,0,0,.18); color:rgba(255,255,255,.55); }
.hc-group-block { padding:0 14px 12px; }
.hc-group-head { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:10px 0; cursor:pointer; touch-action:manipulation; }
.hc-group-title { font-size:14px; font-weight:700; color:${COLOR_TEXT}; }
.hc-group-meta { font-size:11px; color:${COLOR_MUTED}; margin-top:2px; }
.hc-fee-row { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 12px; background:${COLOR_PAGE}; border-radius:8px; margin-bottom:6px; font-size:13px; }
.hc-fee-row-name { color:${COLOR_TEXT_SEC}; flex:1; min-width:0; }
.hc-fee-row-amt { font-weight:700; color:#8B5CF6; font-variant-numeric:tabular-nums; flex-shrink:0; }
.hc-settle-body { padding:0 14px 4px; }
.hc-settle-total { margin:0 14px 12px; padding:12px 14px; background:rgba(139,92,246,.08); border-radius:10px; border:1px solid rgba(139,92,246,.14); display:flex; align-items:center; justify-content:space-between; gap:12px; }
.hc-settle-total-label { font-size:13px; font-weight:600; color:${COLOR_TEXT_SEC}; }
.hc-settle-total-val { font-size:18px; font-weight:800; color:#8B5CF6; font-variant-numeric:tabular-nums; flex-shrink:0; }
.hc-fee-table { border:1px solid ${COLOR_LINE}; border-radius:10px; overflow:hidden; margin-bottom:8px; }
.hc-fee-table-head, .hc-fee-table-row { display:grid; grid-template-columns:44px minmax(0,1fr) 96px; align-items:center; gap:0; }
.hc-fee-table-head { background:${COLOR_PAGE}; border-bottom:1px solid ${COLOR_LINE}; font-size:11px; font-weight:600; color:${COLOR_MUTED}; }
.hc-fee-table-row { border-bottom:1px solid ${COLOR_LINE}; font-size:12px; color:${COLOR_TEXT}; background:${COLOR_BG}; }
.hc-fee-table-row:last-child { border-bottom:none; }
.hc-fee-table-row--custom { background:rgba(139,92,246,.03); }
.hc-fee-table-col { padding:10px 8px; min-width:0; }
.hc-fee-table-col--seq { text-align:center; color:${COLOR_MUTED}; }
.hc-fee-table-col--item { display:flex; align-items:center; gap:6px; flex-wrap:wrap; line-height:1.4; }
.hc-fee-table-col--amt { text-align:right; font-weight:700; color:#8B5CF6; font-variant-numeric:tabular-nums; padding-right:10px; }
.hc-fee-table-tag { font-size:10px; font-weight:600; color:#8B5CF6; background:rgba(139,92,246,.1); padding:1px 6px; border-radius:999px; flex-shrink:0; }
.hc-safety-stat-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-bottom:4px; }
.hc-safety-stat-item { padding:12px 8px; background:${COLOR_PAGE}; border-radius:10px; border:1px solid ${COLOR_LINE}; text-align:center; }
.hc-safety-stat-label { font-size:11px; color:${COLOR_MUTED}; margin-bottom:6px; }
.hc-safety-stat-val { font-size:16px; font-weight:800; color:${COLOR_TEXT}; font-variant-numeric:tabular-nums; }
.hc-safety-stat-val.warn { color:#F97316; }
.hc-sub-block { margin-top:8px; padding-top:8px; border-top:1px dashed ${COLOR_LINE}; }
.hc-violation-card { margin:0 0 8px; padding:10px 12px; background:${COLOR_PAGE}; border-radius:10px; border:1px solid ${COLOR_LINE}; font-size:12px; color:${COLOR_TEXT_SEC}; line-height:1.55; }
.tc-hero--bill { background:linear-gradient(135deg,#0EA5E9 0%,#0284C7 100%); box-shadow:0 10px 28px rgba(14,165,233,.35); }
.tc-hero-period { margin-top:10px; font-size:12px; line-height:1.5; opacity:.92; display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
.tc-hero-period-tag { display:inline-flex; align-items:center; padding:2px 8px; border-radius:999px; background:rgba(255,255,255,.22); font-size:11px; font-weight:700; flex-shrink:0; }
.tc-hero-foot { margin-top:12px; padding-top:12px; border-top:1px solid rgba(255,255,255,.22); font-size:12px; line-height:1.55; opacity:.9; }
.tc-hero-foot-row { margin-bottom:4px; }
.tc-hero-foot-row:last-child { margin-bottom:0; }
.tc-hero-foot-label { opacity:.85; margin-right:4px; }
.tc-hero-foot--compact { margin-top:6px; padding-top:0; border-top:none; }
.tc-section-badge--blue { color:#0EA5E9; background:rgba(14,165,233,.12); }
.tc-vehicle-idx--blue { color:#0EA5E9; background:rgba(14,165,233,.12); }
.tc-vehicle-amount-val--blue { color:#0EA5E9; }
.tc-service-amt--blue { color:#0EA5E9; }
.tc-step-dot.wait--blue { background:rgba(14,165,233,.12); color:#0EA5E9; border:2px solid #0EA5E9; box-sizing:border-box; }
.tc-drawer-row-val.highlight--blue { color:#0EA5E9; font-weight:700; }
.tc-drawer-total--blue span:last-child { color:#0EA5E9; }
.tc-drawer-row-val.highlight--purple { color:#8B5CF6; font-weight:700; }
.tc-drawer-total--purple span:last-child { color:#8B5CF6; }
.tc-hero--transfer { background:linear-gradient(135deg,#10B981 0%,#059669 100%); box-shadow:0 10px 28px rgba(16,185,129,.35); }
.tc-hero--replace { background:linear-gradient(135deg,#F43F5E 0%,#E11D48 100%); box-shadow:0 10px 28px rgba(244,63,94,.35); }
.tc-hero--delivery { background:linear-gradient(135deg,${XLL_GREEN} 0%,${XLL_GREEN_DEEP} 100%); box-shadow:0 10px 28px rgba(122,185,41,.35); }
.tc-section-badge--rose { color:#F43F5E; background:rgba(244,63,94,.12); }
.tc-step-dot.wait--rose { background:rgba(244,63,94,.12); color:#F43F5E; border:2px solid #F43F5E; box-sizing:border-box; }
.tc-vehicle-idx--rose { color:#F43F5E; background:rgba(244,63,94,.12); }
.vr-replace-swap { display:flex; align-items:stretch; gap:8px; margin:12px 14px 8px; }
.vr-replace-side { flex:1; min-width:0; padding:10px 10px 8px; border-radius:10px; }
.vr-replace-side--old { background:rgba(251,191,36,.08); border:1px solid rgba(251,191,36,.28); }
.vr-replace-side--new { background:rgba(16,185,129,.08); border:1px solid rgba(16,185,129,.22); }
.vr-replace-side-tag { display:inline-block; font-size:10px; font-weight:700; padding:2px 7px; border-radius:999px; margin-bottom:8px; letter-spacing:.02em; }
.vr-replace-side--old .vr-replace-side-tag { color:#B45309; background:rgba(251,191,36,.22); }
.vr-replace-side--new .vr-replace-side-tag { color:#047857; background:rgba(16,185,129,.18); }
.vr-replace-side .tc-vehicle-plate { font-size:14px; }
.vr-replace-side .tc-vehicle-model { margin-top:4px; }
.vr-replace-mid { display:flex; align-items:center; justify-content:center; flex-shrink:0; width:24px; }
.vr-replace-mid-icon { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; border-radius:999px; font-size:13px; color:#F43F5E; font-weight:700; background:rgba(244,63,94,.1); }
.vr-replace-reason.tc-kv-grid { padding:0 14px 14px; gap:8px 14px; }
.tc-hero-route { margin-top:10px; font-size:13px; line-height:1.5; opacity:.95; display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
.tc-hero-route-arrow { opacity:.75; font-size:12px; }
.tc-section-badge--green { color:#059669; background:rgba(16,185,129,.12); }
.tc-vehicle-idx--green { color:#059669; background:rgba(16,185,129,.12); }
.tc-step-dot.wait--green { background:rgba(16,185,129,.12); color:#059669; border:2px solid #059669; box-sizing:border-box; }
.zl-proof-list { display:flex; flex-direction:column; gap:4px; margin-top:4px; }
.zl-proof-link { border:none; background:transparent; padding:0; font-size:12px; color:#0EA5E9; font-weight:600; text-align:left; cursor:pointer; text-decoration:underline; }
.tc-mini-sheet { position:absolute; inset:0; z-index:40; display:flex; flex-direction:column; justify-content:flex-end; }
.tc-mini-sheet-mask { position:absolute; inset:0; background:rgba(0,0,0,.45); border:none; padding:0; cursor:pointer; }
.tc-mini-sheet-panel { position:relative; z-index:1; background:${COLOR_BG}; border-radius:16px 16px 0 0; max-height:min(72vh,520px); display:flex; flex-direction:column; box-shadow:0 -8px 28px rgba(15,23,42,.14); animation:tc-sheet-up .28s ease; }
.tc-mini-sheet-handle { width:36px; height:4px; background:rgba(0,0,0,.12); border-radius:999px; margin:10px auto 0; flex-shrink:0; }
.tc-mini-sheet-head { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:8px 16px 12px; border-bottom:1px solid ${COLOR_LINE}; flex-shrink:0; }
.tc-mini-sheet-title { font-size:16px; font-weight:700; color:${COLOR_TEXT}; }
.tc-mini-sheet-close { width:32px; height:32px; border:none; background:${COLOR_PAGE}; border-radius:999px; font-size:20px; line-height:1; color:${COLOR_MUTED}; cursor:pointer; flex-shrink:0; }
.tc-mini-sheet-body { flex:1; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch; padding:4px 20px calc(16px + env(safe-area-inset-bottom,0px)); }
.tc-mini-sheet-foot { flex-shrink:0; padding:10px 16px calc(10px + env(safe-area-inset-bottom,0px)); border-top:1px solid ${COLOR_LINE}; }
.tc-mini-sheet-ok { width:100%; min-height:44px; border:none; border-radius:10px; font-size:15px; font-weight:600; cursor:pointer; background:${XLL_GREEN_DEEP}; color:#fff; touch-action:manipulation; }
.tc-mini-sheet-ok:active { opacity:.88; }
@keyframes tc-sheet-up { from { transform:translateY(100%); } to { transform:translateY(0); } }
@media (prefers-reduced-motion: reduce) {
.tc-mini-sheet-panel { animation:none; }
.xll-task-card { animation:none; }
.xll-task-action:active, .xll-biz-item:active .xll-biz-icon, .xll-map-full:active, .xll-sub-btn:active, .xll-login-btn:active { transform:none; }
}
`;
/* ── 审批中心 / 年审(内联模块,单页原型) ── */
const AC_TAB_ITEMS = [
{ key: 'initiated', label: '我发起的', short: '发起' },
{ key: 'todo', label: '我的待办', short: '待办' },
{ key: 'done', label: '我的已办', short: '已办' },
{ key: 'cc', label: '我的抄送', short: '抄送' },
];
const AC_FLOW_THEME = {
合同审批: { accent: '#2563EB', soft: 'rgba(37, 99, 235, 0.12)' },
提车应收款: { accent: '#F97316', soft: 'rgba(249, 115, 22, 0.12)' },
租赁账单: { accent: '#0EA5E9', soft: 'rgba(14, 165, 233, 0.12)' },
还车应结款: { accent: '#8B5CF6', soft: 'rgba(139, 92, 246, 0.12)' },
'氢费对账单(对站)': { accent: '#10B981', soft: 'rgba(16, 185, 129, 0.12)' },
'氢费对账单(对客)': { accent: '#059669', soft: 'rgba(5, 150, 105, 0.12)' },
车辆调拨: { accent: XLL_GREEN_DEEP, soft: XLL_GREEN_SOFT },
替换车申请: { accent: '#F43F5E', soft: 'rgba(244, 63, 94, 0.12)' },
车辆异动: { accent: '#14B8A6', soft: 'rgba(20, 184, 166, 0.12)' },
};
const AC_FLOW_TYPES = [
'合同审批', '提车应收款', '租赁账单', '还车应结款',
'氢费对账单(对站)', '氢费对账单(对客)', '车辆调拨', '替换车申请', '车辆异动',
];
const AC_QUICK_FILTERS = ['合同审批', '提车应收款', '租赁账单', '车辆调拨'];
const AC_MOCK_TASKS = [
{ id: 'ap-1', flowType: '合同审批', bizNo: 'HT-ZL-2025-088', summary: '嘉兴氢能示范项目 · 正式合同', initiator: '张明辉', initiateTime: '2026-05-28 09:15', arriveTime: '2026-05-28 14:20', finishTime: '', currentNode: '法务审核', currentAssignee: '王法务', status: '审批中', ccUsers: ['李晓彤'], handledBy: [] },
{ id: 'ap-2', flowType: '提车应收款', bizNo: 'TC-2026-0312', summary: '上海迅杰物流 · 3 台提车收款', customerName: '上海迅杰物流有限公司', projectName: '上海氢能城际物流项目', vehicleCount: 3, actualAmount: '186800.00', initiator: '李晓彤', initiateTime: '2026-05-30 10:00', arriveTime: '2026-05-30 11:30', finishTime: '', currentNode: '财务审核', currentAssignee: '张明辉', status: '审批中', ccUsers: ['张明辉', '陈高伟'], handledBy: [] },
{ id: 'ap-3', flowType: '租赁账单', bizNo: 'ZD-2026-06-001', summary: '2026年6月 · 粤B58888F 等 5 车', customerName: '嘉兴某某物流有限公司', projectName: '嘉兴氢能示范项目', vehicleCount: 5, actualAmount: '142380.00', period: 6, billStartDate: '2026-06-01', billEndDate: '2026-06-30', initiator: '陈高伟', initiateTime: '2026-06-01 08:40', arriveTime: '2026-06-01 09:10', finishTime: '', currentNode: '业管主管', currentAssignee: '张明辉', approvers: ['张明辉', '李晓彤'], status: '审批中', ccUsers: [], handledBy: [] },
{ id: 'ap-4', flowType: '还车应结款', bizNo: 'HC-2026-0520', summary: '沪A03561F 还车结算', plateNo: '沪A03561F', customerName: '上海迅杰物流有限公司', projectName: '上海氢能城际物流项目', pendingSettle: '927.50', depositAmount: '5000.00', refundTotal: '4072.50', payTotal: '0.00', actualRent: '0.00', initiator: '张明辉', initiateTime: '2026-05-20 16:00', arriveTime: '2026-05-21 09:00', finishTime: '2026-05-21 15:30', currentNode: '—', currentAssignee: '', status: '已通过', ccUsers: ['李晓彤'], handledBy: ['张明辉', '财务-赵敏'] },
{ id: 'ap-5', flowType: '氢费对账单(对站)', bizNo: 'H2-ST-202605', summary: '平湖加氢站 · 2026年5月对账', initiator: '能源部-周工', initiateTime: '2026-05-25 11:00', arriveTime: '2026-05-26 08:30', finishTime: '2026-05-26 17:00', currentNode: '—', currentAssignee: '', status: '已通过', ccUsers: ['张明辉'], handledBy: ['张明辉'] },
{ id: 'ap-6', flowType: '氢费对账单(对客)', bizNo: 'H2-CU-202605', summary: '嘉兴某某物流 · 5月氢费账单', initiator: '李晓彤', initiateTime: '2026-05-27 14:20', arriveTime: '2026-05-28 09:00', finishTime: '', currentNode: 'CEO审批', currentAssignee: 'CEO办公室', status: '审批中', ccUsers: ['张明辉', '陈高伟'], handledBy: ['张明辉'] },
{
id: 'ap-8', flowType: '车辆调拨', transferStage: 'create', bizNo: 'DB-2026-018',
summary: '广东省-广州市 → 浙江省-杭州市 · 2 台 · 司机运输',
transferDate: '2026-06-01 09:30', departRegion: '广东省-广州市', receiveRegion: '浙江省-杭州市',
reason: '华东业务增量,需将华南车辆调至杭州节点保障运力。',
vehicleCount: 2,
transferInfo: {
method: '司机运输', transportLeader: '李强', transportPhone: '13912345678',
receivePerson: '赵强(运维一部-驻场)', transportCost: '0.00', contractFiles: [],
},
vehicles: [
{ id: 1, brand: '小鹏', model: 'P7', plateNo: '浙A11111', departParking: '天河智慧停车场', departMileageKm: '5620.00', departHydrogen: '78.50', departElectricKwh: '86.20', h2Unit: '%' },
{ id: 2, brand: '蔚来', model: 'ET5', plateNo: '浙B22222', departParking: '南山科技园停车场', departMileageKm: '4315.80', departHydrogen: '38.20', departElectricKwh: '72.60', h2Unit: 'MPa' },
],
initiator: '王东东', initiateTime: '2026-06-01 08:00', arriveTime: '2026-06-01 08:45', finishTime: '',
currentNode: '运维主管', currentAssignee: '张明辉', status: '审批中', ccUsers: ['张明辉'], handledBy: [],
},
{
id: 'ap-16', flowType: '车辆调拨', transferStage: 'ops', bizNo: 'TP202603310001',
summary: '广东省-广州市 → 浙江省-嘉兴市 · 2 台 · 第三方运输',
transferDate: '2026-03-31 08:00', departRegion: '广东省-广州市', receiveRegion: '浙江省-嘉兴市',
reason: '华南业务增量,需将车辆调至华东仓储节点保障运力。',
vehicleCount: 2,
transferInfo: {
method: '第三方运输', transportLeader: '张明', transportPhone: '13800138000',
receivePerson: '王芳(运维二部-调度)', transportCost: '2800.00',
contractFiles: [{ name: '华南至华东调拨运输合同.pdf' }, { name: '运输费用确认单.docx' }],
},
vehicles: [
{ id: 1, brand: '小鹏', model: 'P7', plateNo: '浙A11111', departParking: '天河智慧停车场', departMileageKm: '12580.50', departHydrogen: '85.00', departElectricKwh: '92.30', h2Unit: '%' },
{ id: 2, brand: '蔚来', model: 'ET5', plateNo: '浙B22222', departParking: '白云维修基地停车场', departMileageKm: '8920.00', departHydrogen: '42.50', departElectricKwh: '68.00', h2Unit: 'MPa' },
],
initiator: '王东东', initiateTime: '2026-03-31 07:40', arriveTime: '2026-03-31 08:10', finishTime: '',
currentNode: '运维中心', currentAssignee: '张明辉', status: '审批中', ccUsers: ['陈高伟'], handledBy: ['运维主管-陈高伟'],
},
{ id: 'ap-9', flowType: '车辆异动', bizNo: 'YD-2026-042', summary: '浙F06900F · 保养至检测站', initiator: '张明辉', initiateTime: '2026-05-18 13:20', arriveTime: '2026-05-18 14:00', finishTime: '2026-05-18 16:10', currentNode: '—', currentAssignee: '', status: '已驳回', ccUsers: ['李晓彤'], handledBy: ['运维主管-刘强'] },
{ id: 'ap-10', flowType: '合同审批', bizNo: 'HT-ZL-2024-066', summary: '上海迅杰物流 · 续签合同', initiator: '张明辉', initiateTime: '2026-04-10 10:00', arriveTime: '2026-04-10 11:00', finishTime: '2026-04-12 09:30', currentNode: '—', currentAssignee: '', status: '已通过', ccUsers: ['李晓彤', '王法务'], handledBy: ['法务-王法务', 'CEO办公室'] },
{ id: 'ap-11', flowType: '租赁账单', bizNo: 'ZD-2026-05-008', summary: '2026年5月 · 批量租赁账单', customerName: '嘉兴某某物流有限公司', projectName: '嘉兴氢能示范项目', vehicleCount: 3, actualAmount: '88600.00', period: 1, billStartDate: '2025-01-01', billEndDate: '2025-01-31', initiator: '陈高伟', initiateTime: '2026-05-05 09:00', arriveTime: '2026-05-05 09:30', finishTime: '2026-05-05 18:00', currentNode: '—', currentAssignee: '', status: '已通过', ccUsers: ['张明辉'], handledBy: ['张明辉'] },
{ id: 'ap-12', flowType: '提车应收款', bizNo: 'TC-2026-0228', summary: '嘉兴某某物流 · 2 台提车', customerName: '嘉兴某某物流有限公司', projectName: '嘉兴氢能示范项目', vehicleCount: 2, actualAmount: '98600.00', initiator: '张明辉', initiateTime: '2026-02-28 15:00', arriveTime: '2026-03-01 09:00', finishTime: '2026-03-01 11:20', currentNode: '—', currentAssignee: '', status: '已通过', ccUsers: ['财务-赵敏'], handledBy: ['财务-赵敏'] },
{ id: 'ap-14', flowType: '还车应结款', bizNo: 'HC-2026-0418', summary: '粤B58888F 还车应结', plateNo: '粤B58888F', customerName: '嘉兴某某物流有限公司', projectName: '嘉兴腾4.5T租赁', pendingSettle: '927.50', depositAmount: '5000.00', refundTotal: '4072.50', payTotal: '0.00', actualRent: '0.00', initiator: '李晓彤', initiateTime: '2026-04-18 09:00', arriveTime: '2026-04-18 09:30', finishTime: '', currentNode: '财务审核', currentAssignee: '张明辉', status: '审批中', ccUsers: ['张明辉'], handledBy: ['业管主管-陈高伟'] },
{
id: 'ap-17', flowType: '替换车申请', bizNo: 'TH-2026-0218',
summary: '嘉兴氢能示范项目 · 2 辆车替换',
customerName: '嘉兴某某物流有限公司', projectName: '嘉兴氢能示范项目', projectType: '租赁',
contractCode: 'HT-ZL-2025-001', deliveryRegion: '浙江省-嘉兴市', vehicleCount: 2,
pairs: [
{
id: 'pair_1', replaceType: '永久替换', replaceReason: '车辆原因',
replaceReasonDesc: '原车故障需维修,临时用替换车保障客户用车。',
originalPlate: '浙A12345', originalBrand: '东风', originalModel: 'DFH1180',
replacePlate: '浙A67890', replaceBrand: '福田', replaceModel: 'BJ1180',
},
{
id: 'pair_2', replaceType: '临时替换', replaceReason: '客户原因', replaceFee: '500.00',
replaceReasonDesc: '',
originalPlate: '浙A55555', originalBrand: '重汽', originalModel: 'ZZ1160',
replacePlate: '浙A66666', replaceBrand: '江淮', replaceModel: 'HFC1190',
},
],
initiator: '王东东', initiateTime: '2026-02-18 09:30', arriveTime: '2026-02-18 10:00', finishTime: '',
currentNode: '运维主管', currentAssignee: '张明辉', approvers: ['张明辉', '姚守涛'], status: '审批中',
ccUsers: ['李晓彤'], handledBy: [],
},
];
const acIsPending = (status) => status === '审批中' || status === '待审批';
const acFilterByTab = (task, tabKey, user) => {
if (tabKey === 'initiated') return task.initiator === user;
if (tabKey === 'todo') return task.currentAssignee === user && acIsPending(task.status);
if (tabKey === 'done') return (task.handledBy || []).includes(user);
if (tabKey === 'cc') return (task.ccUsers || []).includes(user);
return false;
};
const acStatusClass = (status) => {
if (status === '已通过') return 'ok';
if (status === '已驳回' || status === '已撤回') return 'reject';
return 'pending';
};
const acTaskStatusLabel = (task) => {
if (!acIsPending(task.status)) return task.status;
const list = task.approvers?.length
? task.approvers
: task.currentAssignee
? [task.currentAssignee]
: [];
if (!list.length) return task.status;
return `${task.status}${list.join(',')}`;
};
const acTransferRouteTitle = (task) => {
if (task.departRegion && task.receiveRegion) {
return `${task.departRegion}调拨到${task.receiveRegion}`;
}
return task.summary || '—';
};
const acTransferVehicleLines = (vehicles = []) => {
const map = new Map();
vehicles.forEach((v) => {
const label = `${v.brand || '—'}/${v.model || '—'}`;
map.set(label, (map.get(label) || 0) + 1);
});
return Array.from(map.entries()).map(([label, count]) => ({ label, count }));
};
const acReturnSettleDisplay = (task) => {
const depositAmount = task?.depositAmount || '0';
const pendingSettle = task?.pendingSettle || '0';
const diff = (parseFloat(depositAmount) || 0) - (parseFloat(pendingSettle) || 0);
const refundTotal = task?.refundTotal || (diff > 0 ? diff.toFixed(2) : '0.00');
const payTotal = task?.payTotal || (diff < 0 ? Math.abs(diff).toFixed(2) : '0.00');
const isRefund = parseFloat(depositAmount) >= parseFloat(pendingSettle);
return {
label: isRefund ? '应退还' : '应补缴',
amount: isRefund ? refundTotal : payTotal,
};
};
const AR_MOCK_TASKS = [
{ id: 'ar-1', plateNo: '粤B58888F', brand: '福田', model: '奥铃4.5吨冷藏车', operateStatus: '租赁', expireDate: '2026-07-20', daysLeft: 49, tab: 'pending', province: '广东省', city: '深圳市' },
{ id: 'ar-2', plateNo: '沪A03561F', brand: '宇通', model: '49吨牵引车头', operateStatus: '自营', expireDate: '2026-07-31', daysLeft: 60, tab: 'pending', province: '上海市', city: '上海市' },
{ id: 'ar-3', plateNo: '苏E33333', brand: '陕汽', model: '德龙X3000混动牵引车', operateStatus: '库存', expireDate: '2026-05-15', daysLeft: -17, tab: 'pending', province: '江苏省', city: '苏州市' },
{ id: 'ar-7', plateNo: '鲁Q88901', brand: '重汽', model: '豪沃T7H牵引车', operateStatus: '租赁', expireDate: '2026-04-10', daysLeft: -52, tab: 'pending', province: '山东省', city: '临沂市' },
{ id: 'ar-8', plateNo: '闽D55662', brand: '金龙', model: '凯歌纯电动厢货', operateStatus: '自营', expireDate: '2026-04-27', daysLeft: -35, tab: 'pending', province: '福建省', city: '厦门市' },
{ id: 'ar-4', plateNo: '浙A88888', brand: '宇通', model: '氢燃料电池大巴', operateStatus: '库存', expireDate: '2026-08-10', daysLeft: 70, tab: 'pending', province: '浙江省', city: '杭州市' },
{ id: 'ar-6', plateNo: '皖B66221', brand: '江淮', model: '格尔发A5', operateStatus: '库存', expireDate: '2026-06-28', daysLeft: 27, tab: 'pending', province: '安徽省', city: '合肥市' },
{ id: 'ar-h1', plateNo: '苏A88991', brand: '解放', model: 'J6P牵引车', operateStatus: '自营', expireDate: '2026-03-10', daysLeft: 0, tab: 'history', province: '江苏省', city: '南京市', executor: '张明辉', executeTime: '2026-03-08 14:20', newValidUntil: '2027-03-31', station: '南京机动车检测站', cost: '380.00' },
{ id: 'ar-h2', plateNo: '粤A11223', brand: '比亚迪', model: 'T5纯电轻卡', operateStatus: '库存', expireDate: '2026-02-20', daysLeft: 0, tab: 'history', province: '广东省', city: '广州市', executor: '李晓彤', executeTime: '2026-02-18 09:45', newValidUntil: '2027-02-28', station: '广州南沙检测站', cost: '420.00' },
{ id: 'ar-h3', plateNo: '京A55667', brand: '东风', model: '天龙KL', operateStatus: '租赁', expireDate: '2026-01-15', daysLeft: 0, tab: 'history', province: '广东省', city: '东莞市', executor: '王建国', executeTime: '2026-01-12 16:30', newValidUntil: '2027-01-31', station: '东莞厚街检测站', cost: '350.00' },
];
const arSortPending = (tasks) =>
[...tasks].sort((a, b) => {
const overdueA = a.daysLeft < 0 ? -a.daysLeft : 0;
const overdueB = b.daysLeft < 0 ? -b.daysLeft : 0;
if (overdueB !== overdueA) return overdueB - overdueA;
return a.daysLeft - b.daysLeft;
});
const arDaysTag = (task) => {
if (task.tab === 'history') return null;
if (task.daysLeft > 0) return { text: `剩余${task.daysLeft}`, cls: 'warn' };
return { text: `逾期${Math.abs(task.daysLeft)}`, cls: 'danger' };
};
/* ── 交车(参照 web端/交车管理.jsx + Axhub 交车原型) ── */
const DV_OPERATOR_REGIONS = ['浙江省-嘉兴市'];
/** 运维人员权限下可操作的停车场(备车库) */
const DV_OPERATOR_PARKING_LOTS = [
{ key: 'all', label: '全部停车场' },
{ key: 'jiaxing', label: '嘉兴港区氢能停车场' },
{ key: 'pinghu', label: '平湖指定停车场' },
{ key: 'nanhu', label: '南湖科技大道停车场' },
];
/**
* 交车选车候选(已备车 · 权限停车场内)
* readiness: ready | ctp_expired | commercial_expired | license_expired
*/
const DV_DELIVERY_PICK_VEHICLES = [
{ plateNo: '浙F80088', parkingLot: '嘉兴港区氢能停车场', parkingKey: 'jiaxing', brand: '苏龙', model: '海格牌18吨双飞翼货车', vin: 'LKLG7C4E4NA774701', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '已备车', onlineStatus: '在线', vehicleSource: '自有', insuranceStatus: '正常', licenseStatus: '正常', readiness: 'ready' },
{ plateNo: '浙F88601', parkingLot: '平湖指定停车场', parkingKey: 'pinghu', brand: '现代', model: '帕力安牌4.5吨冷链车', vin: 'LNBSCPKB8RR123401', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '已备车', onlineStatus: '在线', vehicleSource: '自有', insuranceStatus: '正常', licenseStatus: '正常', readiness: 'ready' },
{ plateNo: '浙F88602', parkingLot: '平湖指定停车场', parkingKey: 'pinghu', brand: '现代', model: '帕力安牌4.5吨冷链车', vin: 'LNBSCPKB8RR123402', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '已备车', onlineStatus: '离线', vehicleSource: '自有', insuranceStatus: '正常', licenseStatus: '正常', readiness: 'ready' },
{ plateNo: '浙F88603', parkingLot: '平湖指定停车场', parkingKey: 'pinghu', brand: '飞驰', model: '49吨牵引车头', vin: 'LNBSCPKB8RR123403', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '已备车', onlineStatus: '在线', vehicleSource: '自有', insuranceStatus: '异常', licenseStatus: '正常', readiness: 'ctp_expired' },
{ plateNo: '浙F88604', parkingLot: '嘉兴港区氢能停车场', parkingKey: 'jiaxing', brand: '宇通', model: '18吨双飞翼货车', vin: 'LKLG7C4E4NA774702', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '已备车', onlineStatus: '在线', vehicleSource: '外租', insuranceStatus: '异常', licenseStatus: '正常', readiness: 'commercial_expired' },
{ plateNo: '浙F88605', parkingLot: '南湖科技大道停车场', parkingKey: 'nanhu', brand: '东风', model: 'DFH1180厢式货车', vin: 'LKLG7C4E4NA774759', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '已备车', onlineStatus: '离线', vehicleSource: '自有', insuranceStatus: '正常', licenseStatus: '异常', readiness: 'license_expired' },
{ plateNo: '浙F88606', parkingLot: '南湖科技大道停车场', parkingKey: 'nanhu', brand: '福田', model: '奥铃4.5吨冷藏车', vin: 'LKLG7C4E4NA774760', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '已备车', onlineStatus: '在线', vehicleSource: '自有', insuranceStatus: '正常', licenseStatus: '正常', readiness: 'ready' },
];
const DV_READINESS_META = {
ready: { label: '已就绪可交车', canPick: true, blocked: false },
ctp_expired: { label: '交强险已到期无法交车', canPick: false, blocked: true },
commercial_expired: { label: '商业险已到期无法交车', canPick: false, blocked: true },
ctp_and_commercial_expired: { label: '交强险、商业险已到期无法交车', canPick: false, blocked: true },
license_expired: { label: '行驶证已到期无法交车', canPick: false, blocked: true },
};
const dvGetReadinessMeta = (v) => DV_READINESS_META[v.readiness] || DV_READINESS_META.ready;
/** 交车位置坐标(原型 · 按备车库/交车地点) */
const DV_DELIVERY_COORD_BY_PARKING = {
jiaxing: { lat: 30.7428, lng: 121.0562, label: '嘉兴港区氢能停车场' },
pinghu: { lat: 30.6772, lng: 121.0153, label: '平湖指定停车场' },
nanhu: { lat: 30.7465, lng: 120.7582, label: '南湖科技大道停车场' },
};
const dvGetDeliveryLocationMeta = (plateNo, row) => {
const vehicle = DV_DELIVERY_PICK_VEHICLES.find((v) => v.plateNo === String(plateNo || '').trim());
if (vehicle?.parkingKey && DV_DELIVERY_COORD_BY_PARKING[vehicle.parkingKey]) {
const coord = DV_DELIVERY_COORD_BY_PARKING[vehicle.parkingKey];
return {
lat: coord.lat,
lng: coord.lng,
label: coord.label,
address: vehicle.parkingLot || coord.label,
plateNo: vehicle.plateNo,
};
}
const addr = String(row?.deliveryAddress || '').trim();
if (/港区|氢能/.test(addr)) {
const coord = DV_DELIVERY_COORD_BY_PARKING.jiaxing;
return { ...coord, address: addr || coord.label, plateNo: plateNo || '' };
}
if (/平湖/.test(addr)) {
const coord = DV_DELIVERY_COORD_BY_PARKING.pinghu;
return { ...coord, address: addr || coord.label, plateNo: plateNo || '' };
}
if (/南湖/.test(addr)) {
const coord = DV_DELIVERY_COORD_BY_PARKING.nanhu;
return { ...coord, address: addr || coord.label, plateNo: plateNo || '' };
}
return {
lat: 30.7102,
lng: 121.0208,
label: row?.deliveryRegion || '嘉兴市',
address: addr || row?.deliveryRegion || '交车区域',
plateNo: plateNo || '',
};
};
/** 车辆是否已接入 GPS在线视为有 GPS 坐标) */
const dvVehicleHasGpsDevice = (plateNo) => {
const vehicle = DV_DELIVERY_PICK_VEHICLES.find((v) => v.plateNo === String(plateNo || '').trim());
if (!vehicle) return false;
return vehicle.onlineStatus === '在线';
};
const dvResolveDeliveryLocation = (plateNo, row, formLocation) => {
if (dvVehicleHasGpsDevice(plateNo)) {
return { ...dvGetDeliveryLocationMeta(plateNo, row), source: 'vehicle' };
}
if (formLocation && formLocation.lat != null && formLocation.lng != null) {
return {
lat: formLocation.lat,
lng: formLocation.lng,
address: formLocation.address || '当前定位',
label: formLocation.address || '当前定位',
plateNo: plateNo || '',
source: 'current',
};
}
return {
...dvGetDeliveryLocationMeta(plateNo, row),
address: '暂未定位,请点击获取当前定位',
source: 'pending',
};
};
/** 识别车牌校验:以下运营状态视为系统不可交车匹配(待运营走 2/3/4 明细校验) */
const DV_RECOGNIZE_OPERATE_NOT_FOUND = ['租赁', '自营', '退出运营'];
/** 识别车牌扩展车辆池(含各类校验场景,选车列表仍仅用 DV_DELIVERY_PICK_VEHICLES */
const DV_RECOGNIZE_EXTRA_VEHICLES = [
{ plateNo: '浙F88701', parkingLot: '嘉兴港区氢能停车场', parkingKey: 'jiaxing', brand: '东风', model: 'DFH1180厢式货车', vin: 'LKLG7C4E4NA774801', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '未备车', onlineStatus: '离线', vehicleSource: '自有', insuranceStatus: '正常', licenseStatus: '正常', readiness: 'ready' },
{ plateNo: '浙F88702', parkingLot: '平湖指定停车场', parkingKey: 'pinghu', brand: '现代', model: '帕力安牌4.5吨冷链车', vin: 'LNBSCPKB8RR123501', region: '浙江省 · 嘉兴市', operateStatus: '租赁', vehicleStatus: '已交车', onlineStatus: '在线', vehicleSource: '自有', insuranceStatus: '正常', licenseStatus: '正常', readiness: 'ready' },
{ plateNo: '浙F88704', parkingLot: '嘉兴港区氢能停车场', parkingKey: 'jiaxing', brand: '宇通', model: '18吨双飞翼货车', vin: 'LKLG7C4E4NA774804', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '已备车', onlineStatus: '在线', vehicleSource: '外租', insuranceStatus: '异常', licenseStatus: '正常', readiness: 'ctp_and_commercial_expired', ctpExpired: true, commercialExpired: true },
{ plateNo: '浙F88706', parkingLot: '南湖科技大道停车场', parkingKey: 'nanhu', brand: '福田', model: '奥铃4.5吨冷藏车', vin: 'LKLG7C4E4NA774806', region: '浙江省 · 嘉兴市', operateStatus: '可运营', vehicleStatus: '已备车', onlineStatus: '在线', vehicleSource: '自有', insuranceStatus: '异常', licenseStatus: '异常', readiness: 'ctp_expired', ctpExpired: true },
{ plateNo: '浙F88707', parkingLot: '平湖指定停车场', parkingKey: 'pinghu', brand: '飞驰', model: '49吨牵引车头', vin: 'LNBSCPKB8RR123507', region: '浙江省 · 嘉兴市', operateStatus: '待运营', vehicleStatus: '已备车', onlineStatus: '在线', vehicleSource: '自有', insuranceStatus: '异常', licenseStatus: '正常', readiness: 'commercial_expired', commercialExpired: true },
];
const DV_RECOGNIZE_VEHICLE_POOL = [...DV_DELIVERY_PICK_VEHICLES, ...DV_RECOGNIZE_EXTRA_VEHICLES];
const dvFindRecognizeVehicle = (plateNo) => {
const q = String(plateNo || '').trim().toUpperCase();
if (!q) return null;
return DV_RECOGNIZE_VEHICLE_POOL.find((v) => v.plateNo.toUpperCase() === q) || null;
};
const dvBuildInsuranceExpireMsg = (vehicle) => {
if (!vehicle || vehicle.vehicleStatus !== '已备车') return null;
const ctp = vehicle.readiness === 'ctp_expired' || vehicle.readiness === 'ctp_and_commercial_expired' || vehicle.ctpExpired === true;
const commercial = vehicle.readiness === 'commercial_expired' || vehicle.readiness === 'ctp_and_commercial_expired' || vehicle.commercialExpired === true;
if (ctp && commercial) return '该车辆交强险、商业险已到期,如已购买请联系采购部上传';
if (ctp) return '该车辆交强险已到期,如已购买请联系采购部上传';
if (commercial) return '该车辆商业险已到期,如已购买请联系采购部上传';
if (vehicle.insuranceStatus === '异常') return '该车辆交强险、商业险已到期,如已购买请联系采购部上传';
return null;
};
const dvValidateRecognizedPlate = (vehicle) => {
if (!vehicle) {
return { ok: false, messages: ['系统未匹配到该车辆,请联系管理员确认'] };
}
if (DV_RECOGNIZE_OPERATE_NOT_FOUND.includes(vehicle.operateStatus)) {
return { ok: false, messages: ['系统未匹配到该车辆,请联系管理员确认'] };
}
const messages = [];
if (vehicle.vehicleStatus !== '已备车') {
messages.push('该车辆未备车,请先进行备车');
} else {
const insMsg = dvBuildInsuranceExpireMsg(vehicle);
if (insMsg) messages.push(insMsg);
if (vehicle.licenseStatus === '异常' || vehicle.readiness === 'license_expired') {
messages.push('该车辆行驶证已到期,如已年审请联系运维部上传');
}
}
return { ok: messages.length === 0, messages };
};
/** 原型:识别演示车牌序列(循环展示各类校验结果) */
const DV_OCR_DEMO_PLATES = ['浙F88601', '浙F99999', '浙F88702', '浙F88701', '浙F88603', '浙F88704', '浙F88605', '浙F88706', '浙F88707'];
/** 原型:备胎胎纹检测仪 OCR 演示读数mm */
const DV_SPARE_TREAD_OCR_DEMO = ['5.2', '4.8', '6.0', '3.6', '5.5', ''];
const DV_SPARE_TIRE_DEMO_PHOTO = 'https://picsum.photos/seed/spare-tire-tread/800/600';
/** 原型:驾驶培训证件演示图 */
const DV_DRIVER_DOC_DEMO = {
idFront: 'https://picsum.photos/seed/driver-id-front/400/300',
idBack: 'https://picsum.photos/seed/driver-id-back/400/300',
licenseFront: 'https://picsum.photos/seed/driver-lic-front/400/300',
licenseBack: 'https://picsum.photos/seed/driver-lic-back/400/300',
qualification: 'https://picsum.photos/seed/driver-qual/400/300',
portrait: 'https://picsum.photos/seed/driver-portrait/400/300',
};
/** 原型:驾驶培训视频二维码(微信扫码) */
const DV_DRIVER_TRAINING_QR = 'https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=https%3A%2F%2Fone-os.driver-training.demo%2Fwatch';
const dvBuildDriverTrainingCodeUrl = (draft) => {
const q = new URLSearchParams({
mode: 'manual',
phone: String(draft?.driverPhone || '').trim(),
name: String(draft?.driverName || '').trim(),
idNo: String(draft?.driverIdNo || '').trim(),
});
return `https://one-os.driver-training.demo/manual-sign?${q.toString()}`;
};
const dvDriverTrainingCodeQrUrl = (signUrl) => (
`https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(signUrl)}`
);
const dvValidateDriverManualDraft = (draft, heavy) => {
if (!String(draft?.driverPhone || '').trim()) return { ok: false, message: '请输入手机号' };
if (!String(draft?.driverName || '').trim()) return { ok: false, message: '请输入姓名' };
if (!String(draft?.driverIdNo || '').trim()) return { ok: false, message: '请输入身份证号' };
if (!draft?.driverIdFront || !draft?.driverIdBack) return { ok: false, message: '请上传身份证正反面' };
if (!draft?.driverLicenseFront || !draft?.driverLicenseBack) return { ok: false, message: '请上传驾驶证正反面' };
if (!draft?.driverFrontPhoto) return { ok: false, message: '请上传司机正面照片' };
if (heavy && !draft?.driverQualification) return { ok: false, message: '请上传从业资格证' };
return { ok: true, message: '' };
};
const DV_DRIVER_MANUAL_EMPTY = {
driverPhone: '',
driverName: '',
driverIdNo: '',
driverIdFront: false,
driverIdBack: false,
driverLicenseFront: false,
driverLicenseBack: false,
driverQualification: false,
driverFrontPhoto: false,
driverIdFrontUrl: '',
driverIdBackUrl: '',
driverLicenseFrontUrl: '',
driverLicenseBackUrl: '',
driverQualificationUrl: '',
driverFrontPhotoUrl: '',
};
const DV_RESERVE_PLATES = DV_DELIVERY_PICK_VEHICLES.filter((v) => dvGetReadinessMeta(v).canPick);
/** 后装设备记录(按车牌,选车后只读反写) */
const DV_REAR_EQUIP_BY_PLATE = {
浙F80088: { hasAd: false, hasBigWord: false, adPhotoDone: false, bigWordPhotoDone: false, hasTailgate: true, tailgatePhotoDone: true },
浙F88601: { hasAd: false, hasBigWord: false, adPhotoDone: false, bigWordPhotoDone: false, hasTailgate: true, tailgatePhotoDone: true },
浙F88602: { hasAd: false, hasBigWord: false, adPhotoDone: false, bigWordPhotoDone: false, hasTailgate: true, tailgatePhotoDone: false },
浙F88603: { hasAd: true, hasBigWord: true, adPhotoDone: true, bigWordPhotoDone: false, hasTailgate: false, tailgatePhotoDone: false },
浙F88604: { hasAd: false, hasBigWord: false, adPhotoDone: false, bigWordPhotoDone: false, hasTailgate: true, tailgatePhotoDone: true },
浙F88605: { hasAd: true, hasBigWord: false, adPhotoDone: false, bigWordPhotoDone: false, hasTailgate: true, tailgatePhotoDone: true },
浙F88606: { hasAd: true, hasBigWord: true, adPhotoDone: true, bigWordPhotoDone: true, hasTailgate: false, tailgatePhotoDone: false },
};
const dvIsHeavyVehicle = (vehicleType, model) => {
const text = `${vehicleType || ''}${model || ''}`;
const tonMatch = text.match(/(\d+(?:\.\d+)?)\s*吨/);
if (tonMatch) return parseFloat(tonMatch[1]) >= 18;
return /18\s*吨|18T|49\s*吨/i.test(text);
};
const dvGetRearEquipRecord = (plateNo, row) => {
const key = String(plateNo || '').trim();
if (key && DV_REAR_EQUIP_BY_PLATE[key]) return { ...DV_REAR_EQUIP_BY_PLATE[key] };
const hasAd = row?.hasAd === '有';
const hasTailgate = row?.hasTailgate === '有';
return {
hasAd,
hasBigWord: hasAd,
adPhotoDone: hasAd,
bigWordPhotoDone: hasAd,
hasTailgate,
tailgatePhotoDone: hasTailgate,
};
};
const dvMockDriverTrainingInfo = (heavy) => ({
driverName: '王涛',
driverPhone: '13812345678',
driverIdNo: '330421199001011234',
driverIdFront: true,
driverIdBack: true,
driverLicenseFront: true,
driverLicenseBack: true,
driverQualification: !!heavy,
driverFrontPhoto: true,
driverIdFrontUrl: DV_DRIVER_DOC_DEMO.idFront,
driverIdBackUrl: DV_DRIVER_DOC_DEMO.idBack,
driverLicenseFrontUrl: DV_DRIVER_DOC_DEMO.licenseFront,
driverLicenseBackUrl: DV_DRIVER_DOC_DEMO.licenseBack,
driverQualificationUrl: heavy ? DV_DRIVER_DOC_DEMO.qualification : '',
driverFrontPhotoUrl: DV_DRIVER_DOC_DEMO.portrait,
});
/** 交车照片分类 */
const DV_PHOTO_CATEGORIES = [
{ key: 'body', label: '车身情况' },
{ key: 'chassis', label: '底盘情况' },
{ key: 'tire', label: '轮胎情况' },
{ key: 'defect', label: '瑕疵情况' },
{ key: 'other', label: '其他情况' },
];
/** 交车照片项(连拍顺序与分类) */
const DV_PHOTO_ITEMS = [
{ key: 'dashboard', label: '仪表盘', category: 'body', required: true, tread: false },
{ key: 'front', label: '车辆正面', category: 'body', required: true, tread: false },
{ key: 'front_bottom', label: '正前方底部', category: 'chassis', required: true, tread: false },
{ key: 'left_front', label: '车辆左前方', category: 'body', required: true, tread: false },
{ key: 'left_front_bottom', label: '左侧前方底部', category: 'chassis', required: true, tread: false },
{ key: 'left_front_tire', label: '左前轮', category: 'tire', required: true, tread: true },
{ key: 'left_rear', label: '车辆左后方', category: 'body', required: true, tread: false },
{ key: 'left_rear_bottom', label: '左侧后方底部', category: 'chassis', required: true, tread: false },
{ key: 'left_rear_tire_inner', label: '左后轮(内)', category: 'tire', required: true, tread: true },
{ key: 'left_rear_tire_outer', label: '左后轮(外)', category: 'tire', required: true, tread: true },
{ key: 'right_rear', label: '车辆右后方', category: 'body', required: true, tread: false },
{ key: 'right_rear_bottom', label: '右侧后方底部', category: 'chassis', required: true, tread: false },
{ key: 'right_rear_tire_inner', label: '右后轮(内)', category: 'tire', required: true, tread: true },
{ key: 'right_rear_tire_outer', label: '右后轮(外)', category: 'tire', required: true, tread: true },
{ key: 'right_front', label: '车辆右前方', category: 'body', required: true, tread: false },
{ key: 'right_front_bottom', label: '右侧前方底部', category: 'chassis', required: true, tread: false },
{ key: 'right_front_tire', label: '右前轮', category: 'tire', required: true, tread: true },
{ key: 'spare', label: '备胎', category: 'tire', required: true, tread: true },
];
const DV_PHOTO_CAPTURE_SEQUENCE = DV_PHOTO_ITEMS.map((item) => item.key);
const dvPhotoItemByKey = (key) => DV_PHOTO_ITEMS.find((item) => item.key === key);
const dvPhotoCategoryLabel = (categoryKey) => (
DV_PHOTO_CATEGORIES.find((cat) => cat.key === categoryKey)?.label || ''
);
const dvPhotoCaptured = (photos, key) => {
const val = photos?.[key];
if (!val) return false;
if (val === true) return true;
return !!val.captured;
};
const dvGetPhotoRecord = (photos, key) => {
const val = photos?.[key];
if (!val || val === true) return val === true ? { captured: true } : null;
return val;
};
const dvGetCaptureSequence = (formDraft) => {
let list = DV_PHOTO_CAPTURE_SEQUENCE.map((key) => dvPhotoItemByKey(key)).filter(Boolean);
if (formDraft?.spareTire === '无') {
list = list.filter((item) => item.key !== 'spare');
}
return list;
};
const dvRequiredPhotosComplete = (photos, formDraft) => (
dvGetCaptureSequence(formDraft)
.filter((item) => dvIsPhotoItemRequired(item))
.every((item) => dvPhotoCaptured(photos, item.key))
);
const dvCountCapturedPhotos = (photos, formDraft) => (
dvGetCaptureSequence(formDraft).filter((item) => dvPhotoCaptured(photos, item.key)).length
);
const dvGetNextCaptureIndex = (photos, formDraft) => {
const seq = dvGetCaptureSequence(formDraft);
const idx = seq.findIndex((item) => !dvPhotoCaptured(photos, item.key));
return idx >= 0 ? idx : seq.length;
};
const dvPhotoItemsByCategory = (categoryKey) => DV_PHOTO_ITEMS.filter((item) => item.category === categoryKey);
const DV_PHOTO_REQUIRED_CATEGORIES = new Set(['body', 'chassis', 'tire']);
const dvIsPhotoItemRequired = (item) => DV_PHOTO_REQUIRED_CATEGORIES.has(item.category);
/** 原型:交车照片演示图(按项固定 seed避免加载失败 */
const dvGetPhotoDemoUrl = (photoKey) => `https://picsum.photos/seed/oneos-dv-${encodeURIComponent(photoKey)}/480/480`;
const dvSimulatePhotoUpload = (photoKey, cameraDraft, formDraft, row) => {
const loc = dvResolveDeliveryLocation(formDraft?.plateNo, row, formDraft?.deliveryLocation);
const watermarkTime = dvFormatOpsSignTime();
const watermarkAddress = loc.address || loc.label || '未知地点';
const baseUrl = cameraDraft?.photoUrl || dvGetPhotoDemoUrl(photoKey);
return {
photoUrl: `${baseUrl.split('?')[0]}?wm=1&t=${encodeURIComponent(watermarkTime)}&loc=${encodeURIComponent(watermarkAddress)}`,
watermarkTime,
watermarkAddress,
uploaded: true,
};
};
/** 查看页:补齐已拍摄交车照片(车身/底盘/轮胎及瑕疵/其他) */
const dvEnsureViewDeliveryPhotos = (row, formDraft) => {
const photos = { ...(formDraft?.deliveryPhotos || row?.deliveryPhotos || {}) };
const ctx = formDraft || row;
dvGetCaptureSequence(ctx).forEach((item) => {
if (!dvPhotoCaptured(photos, item.key)) {
const upload = dvSimulatePhotoUpload(item.key, {}, formDraft, row);
photos[item.key] = {
captured: true,
photoUrl: upload.photoUrl,
uploaded: true,
watermarkTime: upload.watermarkTime,
watermarkAddress: upload.watermarkAddress,
...(item.tread ? { treadDepth: '6.50' } : {}),
};
}
});
['defect', 'other'].forEach((catKey) => {
const hasExtra = Object.keys(photos).some((key) => key.indexOf(`${catKey}_extra_`) === 0 && dvPhotoCaptured(photos, key));
if (!hasExtra) {
const extraKey = `${catKey}_extra_1`;
const upload = dvSimulatePhotoUpload(extraKey, { photoUrl: dvGetPhotoDemoUrl(extraKey) }, formDraft, row);
photos[extraKey] = {
captured: true,
photoUrl: upload.photoUrl,
uploaded: true,
watermarkTime: upload.watermarkTime,
watermarkAddress: upload.watermarkAddress,
};
}
});
return photos;
};
const dvResolvePhotoUrl = (photoKey, record) => {
if (!photoKey) return '';
const url = record?.photoUrl;
if (url && typeof url === 'string' && !url.includes('&sig=') && !url.includes('&extra=')) return url;
return dvGetPhotoDemoUrl(photoKey);
};
const dvBuildCategoryViewerItems = (categoryKey, photos, formDraft) => {
if (categoryKey === 'defect' || categoryKey === 'other') {
return Object.keys(photos || {})
.filter((key) => key.indexOf(`${categoryKey}_extra_`) === 0 && dvPhotoCaptured(photos, key))
.sort()
.map((key, idx) => {
const record = dvGetPhotoRecord(photos, key);
return {
key,
label: `照片${idx + 1}`,
photoUrl: dvResolvePhotoUrl(key, record),
treadDepth: record?.treadDepth || '',
};
});
}
return dvPhotoItemsByCategory(categoryKey)
.filter((item) => !(item.key === 'spare' && formDraft?.spareTire === '无'))
.filter((item) => dvPhotoCaptured(photos, item.key))
.map((item) => {
const record = dvGetPhotoRecord(photos, item.key);
return {
key: item.key,
label: item.label,
photoUrl: dvResolvePhotoUrl(item.key, record),
treadDepth: record?.treadDepth || '',
};
});
};
const dvExtraPhotoKeys = (categoryKey, photos) => (
Object.keys(photos || {}).filter((key) => key.indexOf(`${categoryKey}_extra_`) === 0 && dvPhotoCaptured(photos, key))
);
/** 交车表单步骤 */
const DV_FORM_STEPS = [
{ key: 'vehicle', label: '车辆情况' },
{ key: 'inspection', label: '交车检查项' },
{ key: 'photos', label: '拍摄照片' },
];
/** 型号参数 · 仪表盘氢量单位(% / MPa */
const DV_MODEL_GAUGE_UNIT = {
'东风|DFH1180': 'MPa',
'福田|BJ1180': '%',
'现代|帕力安牌4.5吨冷链车': '%',
'苏龙|海格牌18吨双飞翼货车': '%',
'宇通|18吨双飞翼货车': 'MPa',
'福田|奥铃4.5吨冷藏车': '%',
'飞驰|49吨牵引车头': 'MPa',
};
const dvGetModelGaugeUnit = (brand, model) => {
const key = `${String(brand || '').trim()}|${String(model || '').trim()}`;
if (DV_MODEL_GAUGE_UNIT[key]) return DV_MODEL_GAUGE_UNIT[key];
const m = String(model || '');
if (/DFH1180|SX1180/.test(m)) return 'MPa';
return '%';
};
/** 交车检查单类别与项目(对齐 web 交车检查单) */
const DV_INSPECTION_TIRE_CATEGORY = '轮胎检查';
const DV_INSPECTION_TIRE_TREAD_DEMO = ['13.05', '13.22', '13.01', '13.47', '13.09', '13.36'];
const DV_INSPECTION_SECTIONS = [
{
category: '证件信息',
items: ['行驶证', '营运证', '加氢证', 'ETC设备', 'ETC卡', '前后车牌照', '通行证', 'GPS设备(服务中)'],
},
{
category: '工具信息',
items: ['钥匙', '备胎', '三角木', '千斤顶', '工具包', '三角警示牌', '灭火器', '其他'],
},
{
category: '外观检查',
items: [
'检查玻璃无划痕、破裂',
'检查座椅无划痕、破损',
'检查车身漆面无划痕、变形',
'检查货箱反光贴完好',
'检查货箱防撞块完好',
'检查所有灯光完好',
'检查冷机工作(如有)',
'车辆清洗',
'其他',
],
},
{
category: DV_INSPECTION_TIRE_CATEGORY,
items: ['左前 (1轴)', '左后内 (2轴)', '左后外 (2轴)', '右前 (1轴)', '右后内 (2轴)', '右后外 (2轴)'],
tread: true,
},
];
const dvInspectionIsTireCategory = (category) => category === DV_INSPECTION_TIRE_CATEGORY;
const dvBuildInspectionList = () => {
const list = [];
let tireIdx = 0;
DV_INSPECTION_SECTIONS.forEach((section, ci) => {
(section.items || []).forEach((item, ji) => {
const isTire = !!section.tread;
list.push({
key: `ins-${ci}-${ji}`,
category: section.category,
item,
checked: item === '检查冷机工作(如有)' ? false : true,
treadDepth: isTire ? (DV_INSPECTION_TIRE_TREAD_DEMO[tireIdx++] || '6.50') : '',
remark: '',
});
});
});
return list;
};
/** 车辆情况步骤:校验必填项是否已填写 */
const dvValidateVehicleStep = (formDraft, row) => {
if (!formDraft?.plateNo) return { ok: false, message: '请先选择交车车辆' };
const rearEquip = formDraft.rearEquip || dvGetRearEquipRecord(formDraft.plateNo, row);
if (rearEquip.hasAd) {
if (!(formDraft.adPhotoUploaded || rearEquip.adPhotoDone)) {
return { ok: false, message: '请拍摄车身广告照片' };
}
if (!(formDraft.bigWordPhotoUploaded || rearEquip.bigWordPhotoDone)) {
return { ok: false, message: '请拍摄放大字照片' };
}
}
const trainingDone = formDraft.driverTrainingDone || formDraft.driverTraining === '已完成';
if (formDraft.driverTrainingPending) return { ok: false, message: '请等待司机微信扫码完成培训签字' };
if (!trainingDone) return { ok: false, message: '请完成驾驶培训' };
if (formDraft.deliveryMileage === '' || formDraft.deliveryH2 === '' || formDraft.deliveryElec === '') {
return { ok: false, message: '请填写里程、氢量与电量' };
}
if (!dvVehicleHasGpsDevice(formDraft.plateNo)) {
const loc = formDraft.deliveryLocation;
if (!loc || loc.lat == null || loc.lng == null) {
return { ok: false, message: '请先获取交车位置' };
}
}
return { ok: true };
};
/** 交车检查项步骤:校验轮胎胎纹等必填项 */
const dvValidateInspectionStep = (formDraft) => {
const list = formDraft?.inspectionList || [];
for (const row of list) {
if (!dvInspectionIsTireCategory(row.category)) continue;
if (!String(row.treadDepth || '').trim()) {
return { ok: false, message: `请填写${row.item}胎纹深度` };
}
}
return { ok: true };
};
const dvFormatMetric2 = (v, suffix) => {
if (v == null || v === '') return '—';
const n = Number(v);
if (!Number.isFinite(n)) return '—';
const text = n.toFixed(2);
return suffix ? `${text} ${suffix}` : text;
};
const dvFormatServiceFee = (v) => dvFormatMetric2(v, '元');
const dvParseMetric2 = (v) => {
const s = String(v ?? '').trim();
if (!s) return null;
const n = parseFloat(s.replace(/,/g, ''));
if (!Number.isFinite(n)) return null;
return Math.round(n * 100) / 100;
};
const dvMetricInputChange = (raw) => {
if (raw === '' || raw === '-') return raw;
if (/^\d*\.?\d{0,2}$/.test(raw)) return raw;
return null;
};
const DV_MOCK_ORDERS = [
{
id: 'o1', expectedDate: '2025-02-28 至 2025-03-05', contractCode: 'LNZLHT 20260104001', projectName: '桐乡韵达租赁4.5T*10', customerName: '桐乡市丰韵快递有限责任公司', businessDept: '业务二部', businessOwner: '刘念念', taskSource: '替换车', bizType: '租赁', deliveryRegion: '浙江省-嘉兴市', deliveryAddress: '平湖指定停车场', createTime: '2026-06-04 11:28', createBy: '赵小峰',
vehicleList: [
{ vehicleKey: 1, seq: 1, vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', vin: 'LNBSCPKB8RR123401', replaceOldPlate: '浙A88601F', plateNo: '', deliveryTime: '', deliveryPerson: '', deliveryStatus: '未开始', deliveryMileage: null, deliveryH2: null, deliveryH2Unit: '%', deliveryElec: null, hasAd: '', hasTailgate: '', spareTire: '', driverTraining: '' },
{ vehicleKey: 2, seq: 2, vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', vin: 'LNBSCPKB8RR123402', replaceOldPlate: '浙A88602F', plateNo: '浙F88601', deliveryTime: '2026-06-03 14:20', deliveryPerson: '张明辉', deliveryStatus: '待客户签章', deliveryMileage: 12500, deliveryH2: 18, deliveryH2Unit: '%', deliveryElec: 76, serviceFee: 200, hasAd: '无', hasTailgate: '有', spareTire: '有', spareTirePhotoUploaded: true, spareTirePhotoUrl: DV_SPARE_TIRE_DEMO_PHOTO, spareTireTreadDepth: '5.2', driverTraining: '已完成', authorizedPersonId: 'ap1', authorizedPersonName: '李晓明', authorizedPersonPhone: '13800138001', signSent: true },
],
},
{
id: 'o4', expectedDate: '2024-11-15', contractCode: 'LNZLHT2024111401', projectName: '聚德11月新增苏龙18T*2', customerName: '沈阳聚德物流有限公司', businessDept: '业务三部', businessOwner: '金可鹏', taskSource: '交车任务', bizType: '租赁', deliveryRegion: '浙江省-嘉兴市', deliveryAddress: '嘉兴港区氢能停车场', createTime: '2024-11-15 15:05', createBy: '何苗苗',
vehicleList: [
{ vehicleKey: 1, seq: 1, vehicleType: '18吨双飞翼货车', brand: '苏龙', model: '海格牌18吨双飞翼货车', vin: 'LKLG7C4E4NA774701', plateNo: '浙F80088', deliveryTime: '2026-06-02 16:00', deliveryPerson: '魏山', deliveryStatus: '待客户签章', deliveryMileage: 46200, deliveryH2: 21, deliveryH2Unit: '%', deliveryElec: 80, hasAd: '无', hasTailgate: '有', spareTire: '有', spareTirePhotoUploaded: true, spareTirePhotoUrl: DV_SPARE_TIRE_DEMO_PHOTO, spareTireTreadDepth: '4.8', driverTraining: '已完成', authorizedPersonId: 'ap2', authorizedPersonName: '王芳', authorizedPersonPhone: '13900139002', signSent: true },
{ vehicleKey: 2, seq: 2, vehicleType: '18吨双飞翼货车', brand: '苏龙', model: '海格牌18吨双飞翼货车', vin: 'LKLG7C4E4NA774702', plateNo: '沪A03802F', deliveryTime: '2026-06-01 10:15', deliveryPerson: '魏山', deliveryStatus: '待重新签章', deliveryMileage: 38800, deliveryH2: 24, deliveryH2Unit: '%', deliveryElec: 72, hasAd: '无', hasTailgate: '有', spareTire: '有', spareTirePhotoUploaded: true, spareTirePhotoUrl: DV_SPARE_TIRE_DEMO_PHOTO, spareTireTreadDepth: '5.0', driverTraining: '已完成', authorizedPersonId: '', authorizedPersonName: '', authorizedPersonPhone: '', signSent: false },
],
},
{
id: 'o5', expectedDate: '2025-02-15', contractCode: 'HT-ZL-2024-001', projectName: '嘉兴氢能示范项目', customerName: '嘉兴某某物流有限公司', businessDept: '业务一部', businessOwner: '张经理', taskSource: '交车任务', bizType: '租赁', deliveryRegion: '浙江省-嘉兴市', deliveryAddress: '南湖科技大道停车场', createTime: '2025-02-10 09:00', createBy: '系统',
vehicleList: [
{ vehicleKey: 1, seq: 1, vehicleType: '厢式货车', brand: '东风', model: 'DFH1180', vin: 'LKLG7C4E4NA774759', plateNo: '京A12345', deliveryTime: '2025-02-15 10:30', deliveryPerson: '张三', deliveryStatus: '客户已签章', deliveryMileage: 12580, deliveryH2: 35, deliveryH2Unit: 'MPa', deliveryElec: 45, serviceFee: 150, hasAd: '有', hasTailgate: '有', spareTire: '有', driverTraining: '已完成', customerSignTime: '2025-02-15 11:45', vehicleReturned: true, vehicleReturnTime: '2025-03-01 16:30' },
{ vehicleKey: 2, seq: 2, vehicleType: '厢式货车', brand: '福田', model: 'BJ1180', vin: 'LKLG7C4E4NA774760', plateNo: '京C11111', deliveryTime: '2025-02-15 14:00', deliveryPerson: '李四', deliveryStatus: '客户已签章', deliveryMileage: 13200, deliveryH2: 68, deliveryH2Unit: '%', deliveryElec: 38, hasAd: '无', hasTailgate: '无', spareTire: '有', driverTraining: '已完成', customerSignTime: '2025-02-15 16:20', vehicleReturned: false },
],
},
];
/** 原型:交车被授权人(客户方短信签章) */
const DV_AUTHORIZED_PERSONS = [
{ id: 'ap1', name: '李晓明', phone: '13800138001' },
{ id: 'ap2', name: '王芳', phone: '13900139002' },
{ id: 'ap3', name: '赵强', phone: '13700137003' },
{ id: 'ap4', name: '陈静', phone: '13600136004' },
];
const dvFindAuthorizedPerson = (id) => DV_AUTHORIZED_PERSONS.find((p) => p.id === id) || null;
const dvPersonInitial = (name) => {
const s = String(name || '').trim();
return s ? s.slice(-1) : '?';
};
const DV_IN_PROGRESS_STATUSES = ['未开始', '已保存', '待客户签章', '待重新签章'];
const DV_STATUS_FILTER_OPTIONS = ['', '未开始', '已保存', '待客户签章', '待重新签章'];
const DV_LIST_TABS = [
{ key: 'inProgress', short: '进行中', label: '进行中' },
{ key: 'completed', short: '已完成', label: '已完成' },
{ key: 'all', short: '全部', label: '全部任务' },
];
const DV_EMPTY_MORE_FILTER = { customerName: '', projectName: '', dateStart: '', dateEnd: '' };
const DV_EMPTY_FILTER_DRAFT = { status: '', ...DV_EMPTY_MORE_FILTER };
const dvIsHistoryStatus = (s) => s === '客户已签章';
const dvIsInProgressStatus = (s) => DV_IN_PROGRESS_STATUSES.indexOf(s || '未开始') >= 0;
const dvDisplayPlate = (p) => (p && String(p).trim() ? p : '车辆待选');
const dvFormatExpectedDate = (expectedDate) => {
if (!expectedDate || !String(expectedDate).trim()) return '—';
return String(expectedDate).trim().replace(/\s*至\s*/g, ' - ');
};
const dvDisplayActualTime = (t) => (t && String(t).trim() ? String(t).trim() : '—');
const dvDisplayMinuteTime = (t) => {
const s = dvDisplayActualTime(t);
if (s === '—') return s;
const m = s.match(/^(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2})/);
return m ? m[1] : s;
};
/** 运维人员完成 E签宝签字时间提交签章时自动写入不在表单中填写 */
const dvFormatOpsSignTime = (date = new Date()) => {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const dvVehicleDesc = (row) => [row.brand, row.model].filter(Boolean).join('·') || '—';
const dvStatusTag = (status) => {
if (status === '客户已签章') return { text: status, cls: 'ok' };
if (status === '待客户签章') return { text: status, cls: 'info' };
if (status === '待重新签章') return { text: status, cls: 'warn' };
if (status === '已保存') return { text: status, cls: 'warn' };
return { text: status || '未开始', cls: 'neutral' };
};
const dvCardStatusClass = (status) => {
if (status === '客户已签章') return 'ok';
if (status === '待客户签章') return 'info';
if (status === '待重新签章') return 'pending';
return 'pending';
};
const dvParseDateOnly = (value) => {
if (!value || !String(value).trim()) return null;
const raw = String(value).trim().replace('T', ' ').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : null;
};
const dvIsDeliveredStatus = (status) => status === '待客户签章' || status === '客户已签章';
const dvGetDeliveryDateForFilter = (row) => {
if (!dvIsDeliveredStatus(row.deliveryStatus)) return null;
return dvParseDateOnly(row.deliveryTime);
};
const dvIsReplaceDeliveryTask = (row) => row.taskSource === '替换车';
const dvRowMatchesDateRange = (row, dateStart, dateEnd) => {
if (!dateStart && !dateEnd) return true;
const d = dvGetDeliveryDateForFilter(row);
if (!d) return false;
if (dateStart && d < dateStart) return false;
if (dateEnd && d > dateEnd) return false;
return true;
};
const dvFlattenOrders = (orders) => {
const rows = [];
orders.forEach((order) => {
(order.vehicleList || []).forEach((v) => {
if (DV_OPERATOR_REGIONS.indexOf(order.deliveryRegion) < 0) return;
rows.push({
id: `${order.id}-${v.vehicleKey}`,
orderId: order.id,
vehicleKey: v.vehicleKey,
seq: v.seq,
expectedDate: order.expectedDate,
contractCode: order.contractCode,
projectName: order.projectName,
customerName: order.customerName,
businessDept: order.businessDept,
businessOwner: order.businessOwner,
taskSource: order.taskSource,
bizType: order.bizType,
deliveryRegion: order.deliveryRegion,
deliveryAddress: order.deliveryAddress,
createTime: order.createTime,
createBy: order.createBy,
vehicleType: v.vehicleType,
brand: v.brand,
model: v.model,
vin: v.vin,
replaceOldPlate: v.replaceOldPlate,
plateNo: v.plateNo || '',
deliveryTime: v.deliveryTime || '',
deliveryPerson: v.deliveryPerson || '',
deliveryStatus: v.deliveryStatus || '未开始',
deliveryMileage: v.deliveryMileage,
deliveryH2: v.deliveryH2,
deliveryH2Unit: v.deliveryH2Unit || '%',
deliveryElec: v.deliveryElec,
deliveryRemark: v.deliveryRemark || '',
serviceFee: v.serviceFee,
deliveryLocation: v.deliveryLocation || null,
hasAd: v.hasAd || '',
hasTailgate: v.hasTailgate || '',
spareTire: v.spareTire || '',
spareTirePhotoUploaded: !!v.spareTirePhotoUploaded,
spareTirePhotoUrl: v.spareTirePhotoUrl || '',
spareTireTreadDepth: v.spareTireTreadDepth || '',
driverTraining: v.driverTraining || '',
vehicleReturned: v.vehicleReturned,
vehicleReturnTime: v.vehicleReturnTime || '',
customerSignTime: v.customerSignTime || '',
inspectionList: v.inspectionList,
authorizedPersonId: v.authorizedPersonId || '',
authorizedPersonName: v.authorizedPersonName || '',
authorizedPersonPhone: v.authorizedPersonPhone || '',
signSent: !!v.signSent,
});
});
});
return rows;
};
const dvBuildEmptyForm = (row) => {
const plateNo = row.plateNo || '';
const rearEquip = dvGetRearEquipRecord(plateNo, row);
const heavy = dvIsHeavyVehicle(row.vehicleType, row.model);
const trainingDone = row.driverTraining === '已完成';
const driverInfo = trainingDone ? dvMockDriverTrainingInfo(heavy) : {
...DV_DRIVER_MANUAL_EMPTY,
};
return {
plateNo,
brand: row.brand || '',
model: row.model || '',
vin: row.vin || '',
vehicleType: row.vehicleType || '',
hasAd: rearEquip.hasAd ? '有' : '无',
hasTailgate: rearEquip.hasTailgate ? '有' : '无',
spareTire: row.spareTire || '',
spareTirePhotoUploaded: !!row.spareTirePhotoUploaded,
spareTirePhotoUrl: row.spareTirePhotoUrl || '',
spareTireTreadDepth: row.spareTireTreadDepth || '',
rearEquip,
adPhotoUploaded: false,
bigWordPhotoUploaded: false,
driverTraining: row.driverTraining || '',
driverTrainingDone: trainingDone,
...driverInfo,
deliveryMileage: row.deliveryMileage != null ? String(row.deliveryMileage) : '',
deliveryH2: row.deliveryH2 != null ? String(row.deliveryH2) : '',
deliveryH2Unit: row.deliveryH2Unit || dvGetModelGaugeUnit(row.brand, row.model),
deliveryElec: row.deliveryElec != null ? String(row.deliveryElec) : '',
serviceFee: row.serviceFee != null ? String(row.serviceFee) : '',
deliveryRemark: row.deliveryRemark || '',
inspectionList: Array.isArray(row.inspectionList) && row.inspectionList.length ? row.inspectionList : dvBuildInspectionList(),
deliveryPhotos: row.deliveryPhotos && typeof row.deliveryPhotos === 'object' ? { ...row.deliveryPhotos } : {},
deliveryLocation: row.deliveryLocation || null,
authorizedPersonId: row.authorizedPersonId || '',
authorizedPersonName: row.authorizedPersonName || '',
authorizedPersonPhone: row.authorizedPersonPhone || '',
signSent: !!row.signSent,
};
};
const dvMergeVehicleIntoForm = (prev, vehicle, row) => {
const rearEquip = dvGetRearEquipRecord(vehicle.plateNo, row);
const brand = vehicle.brand || prev.brand || row.brand;
const model = vehicle.model || prev.model || row.model;
return {
...prev,
plateNo: vehicle.plateNo,
brand,
model,
vin: vehicle.vin || prev.vin || row.vin,
vehicleType: row.vehicleType || prev.vehicleType,
hasAd: rearEquip.hasAd ? '有' : '无',
hasTailgate: rearEquip.hasTailgate ? '有' : '无',
rearEquip,
deliveryH2Unit: dvGetModelGaugeUnit(brand, model),
deliveryLocation: null,
adPhotoUploaded: false,
bigWordPhotoUploaded: false,
};
};
const dvFormatH2 = (v, unit) => (v == null || v === '' ? '—' : `${dvFormatMetric2(v)} ${unit || '%'}`);
const dvFormatMileage = (v) => (v == null || v === '' ? '—' : `${dvFormatMetric2(v)} km`);
const buildPickupDetail = (task) => {
const isShanghai = task?.bizNo === 'TC-2026-0312';
const projectInfo = isShanghai
? {
collectCode: 'HT-ZL-2025-088TC0001', contractCode: 'HT-ZL-2025-088', contractType: '正式合同',
projectName: '上海氢能城际物流项目', customerName: '上海迅杰物流有限公司',
paymentMethod: '预付', paymentCycle: '6个月', contractStart: '2025-03-01', contractEnd: '2026-02-28',
businessDept: '业务2部', businessPerson: '李晓彤',
}
: {
collectCode: 'HT-ZL-2025-001TC0001', contractCode: 'HT-ZL-2025-001', contractType: '正式合同',
projectName: '嘉兴氢能示范项目', customerName: '嘉兴某某物流有限公司',
paymentMethod: '预付', paymentCycle: '6个月', contractStart: '2025-01-15', contractEnd: '2026-01-14',
businessDept: '业务1部', businessPerson: '张经理',
};
const vehicles = isShanghai
? [
{ key: 'v1', index: 1, brand: '东风', model: 'DFH1180', plateNo: '沪A88123', receivableRent: 62000, actualRent: '61200.00', rentRemark: '首期六期一次性付清', receivableDeposit: 12000, receivableService: 880, actualService: '860.00', discountAmount: '200.00', discountRemark: '首月优惠', serviceItems: [{ name: '代处理费用', receivable: 280, actual: '280.00', discount: '0.00', remark: '' }, { name: '保险上浮', receivable: 600, actual: '580.00', discount: '20.00', remark: '客户协商' }] },
{ key: 'v2', index: 2, brand: '福田', model: 'BJ1180', plateNo: '沪A88234', receivableRent: 58000, actualRent: '58000.00', rentRemark: '', receivableDeposit: 10000, receivableService: 500, actualService: '500.00', discountAmount: '0.00', discountRemark: '', serviceItems: [{ name: '保养费用', receivable: 500, actual: '500.00', discount: '0.00', remark: '含首保' }] },
{ key: 'v3', index: 3, brand: '重汽', model: 'ZZ1187', plateNo: '沪A88345', receivableRent: 64000, actualRent: '63600.00', rentRemark: '按合同约定', receivableDeposit: 12000, receivableService: 720, actualService: '700.00', discountAmount: '150.00', discountRemark: '批量提车减免', serviceItems: [{ name: '代处理费用', receivable: 220, actual: '220.00', discount: '0.00', remark: '' }, { name: '上牌服务', receivable: 500, actual: '480.00', discount: '20.00', remark: '已含上牌' }] },
]
: [
{ key: 'v1', index: 1, brand: '东风', model: 'DFH1180', plateNo: '浙A12345', receivableRent: 30000, actualRent: '29800.00', rentRemark: '首期六期一次性付清', receivableDeposit: 10000, receivableService: 700, actualService: '680.00', discountAmount: '200.00', discountRemark: '首月优惠', serviceItems: [{ name: '代处理费用', receivable: 200, actual: '200.00', discount: '0.00', remark: '' }, { name: '保险上浮', receivable: 500, actual: '480.00', discount: '20.00', remark: '客户协商' }] },
{ key: 'v2', index: 2, brand: '福田', model: 'BJ1180', plateNo: '浙A23456', receivableRent: 27000, actualRent: '27000.00', rentRemark: '', receivableDeposit: 8000, receivableService: 300, actualService: '300.00', discountAmount: '0.00', discountRemark: '', serviceItems: [{ name: '保养费用', receivable: 300, actual: '300.00', discount: '0.00', remark: '含首保' }] },
];
const hasHydrogenPrepay = true;
const hydrogen = isShanghai
? { receivable: '4200.00', actual: '4100.00', discount: '100.00', discountRemark: '预付款批量减免' }
: { receivable: '3580.00', actual: '3500.00', discount: '80.00', discountRemark: '预付款批量减免' };
const approvalSteps = isShanghai
? [
{ title: '业务部主管', department: '业务2部', status: '已通过', person: '李晓彤', approveTime: '2026-05-30 10:30' },
{ title: '财务部', department: '财务部', status: '待审批', person: '张明辉', approveTime: '—' },
]
: [
{ title: '业务部主管', department: '业务1部', status: '已通过', person: '张经理', approveTime: '2026-02-28 09:30' },
{ title: '财务部', department: '财务部', status: '已通过', person: '李财务', approveTime: '2026-02-28 10:15' },
{ title: '事业部主管', department: '事业部', status: '已通过', person: '王总', approveTime: '2026-03-01 11:20' },
];
return {
projectInfo, vehicles, hasHydrogenPrepay, hydrogen, approvalSteps,
invoiceMethod: '先开票后付款',
invoiceRemark: isShanghai
? '增值税专用发票税率13%,开票项目:*现代服务*车辆租赁费;备注:上海氢能城际物流项目-提车首付款'
: '增值税专用发票税率13%,开票项目:*现代服务*车辆租赁费;备注:嘉兴氢能示范项目-提车首付款',
};
};
const calcPickupTotals = (vehicles, hasHydrogenPrepay, hydrogen) => {
let receivableRent = 0; let actualRent = 0; let receivableDeposit = 0;
let receivableService = 0; let actualService = 0; let discountTotal = 0;
vehicles.forEach((v) => {
receivableRent += Number(v.receivableRent) || 0;
actualRent += parseFloat(v.actualRent) || 0;
receivableDeposit += Number(v.receivableDeposit) || 0;
receivableService += Number(v.receivableService) || 0;
actualService += parseFloat(v.actualService) || 0;
discountTotal += parseFloat(v.discountAmount) || 0;
});
const hydrogenReceivable = hasHydrogenPrepay ? (parseFloat(hydrogen.receivable) || 0) : 0;
const hydrogenActual = hasHydrogenPrepay ? (parseFloat(hydrogen.actual) || 0) : 0;
return {
receivableRent: receivableRent.toFixed(2), actualRent: actualRent.toFixed(2),
receivableDeposit: receivableDeposit.toFixed(2), receivableService: receivableService.toFixed(2),
actualService: actualService.toFixed(2), discountTotal: discountTotal.toFixed(2),
receivableTotal: (receivableRent + receivableDeposit + receivableService + hydrogenReceivable).toFixed(2),
actualTotal: (actualRent + receivableDeposit + actualService - discountTotal + hydrogenActual).toFixed(2),
};
};
const TcInfoRow = ({ label, value, full }) => (
<div className={`tc-kv-item${full ? ' tc-kv-value--full' : ''}`}>
<div className="tc-kv-label">{label}</div>
<div className="tc-kv-value">{value || '—'}</div>
</div>
);
const TcVehicleCard = ({ vehicle }) => {
const [serviceOpen, setServiceOpen] = useState(false);
const vehicleActual = (
parseFloat(vehicle.actualRent || 0)
+ parseFloat(vehicle.receivableDeposit || 0)
+ parseFloat(vehicle.actualService || 0)
- parseFloat(vehicle.discountAmount || 0)
).toFixed(2);
return (
<div className="tc-vehicle-card">
<div className="tc-vehicle-head">
<div>
<div className="tc-vehicle-plate">{vehicle.plateNo || '待上牌'}</div>
<div className="tc-vehicle-model">{vehicle.brand} · {vehicle.model}</div>
</div>
<span className="tc-vehicle-idx">#{vehicle.index}</span>
</div>
<div className="tc-vehicle-amount-row">
<span className="tc-vehicle-amount-label">本车实收合计</span>
<span className="tc-vehicle-amount-val">{formatMoneySymbol(vehicleActual)}</span>
</div>
<div className="tc-vehicle-kv">
<div className="tc-vehicle-kv-item"><span>实收月租金 </span>{formatYuan(vehicle.actualRent)}</div>
<div className="tc-vehicle-kv-item"><span>应收保证金 </span>{formatYuan(vehicle.receivableDeposit)}</div>
<div className="tc-vehicle-kv-item"><span>实收服务费 </span>{formatYuan(vehicle.actualService)}</div>
<div className="tc-vehicle-kv-item"><span>减免金额 </span>{formatYuan(vehicle.discountAmount)}</div>
</div>
{(vehicle.serviceItems || []).length > 0 && (
<>
<button type="button" className="tc-service-toggle" onClick={() => setServiceOpen((o) => !o)}>
{serviceOpen ? '收起' : '查看'}服务费明细
</button>
{serviceOpen && (
<div className="tc-service-list">
{(vehicle.serviceItems || []).map((s, i) => (
<div className="tc-service-row" key={i}>
<span className="tc-service-name">{s.name}</span>
<span className="tc-service-amt">{formatMoneySymbol(s.actual)}</span>
<span className="tc-service-sub">应收 {formatYuan(s.receivable)} · 减免 {formatYuan(s.discount)}</span>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
const APPROVAL_DECISION_META = {
approve: { title: '审批通过', success: '审批已通过(原型)' },
reject: { title: '审批驳回', success: '已驳回(原型)' },
terminate: { title: '审批终止', success: '已终止(原型)' },
};
const MOCK_CC_CANDIDATES = ['李晓彤', '陈高伟', '王法务', '财务-赵敏'];
const UPLOAD_ACCEPT = '.png,.jpg,.jpeg,.doc,.docx,.xlsx,.xls,.ppt,.pdf';
const UPLOAD_HINT = '请上传不超过 20MB 的 png, jpg, jpeg, doc, docx, xlsx, xls, ppt, pdf 格式文件';
const emptyDecisionForm = () => ({
notifyEmail: false,
notifySms: false,
ccUsers: [],
opinion: '',
attachments: [],
});
const ApprovalDecisionDrawer = ({ open, actionType, onClose, onConfirm }) => {
const meta = APPROVAL_DECISION_META[actionType] || APPROVAL_DECISION_META.approve;
const [form, setForm] = useState(emptyDecisionForm);
const [ccPickerOpen, setCcPickerOpen] = useState(false);
const fileInputRef = useRef(null);
useEffect(() => {
if (open) {
setForm(emptyDecisionForm());
setCcPickerOpen(false);
}
}, [open, actionType]);
const handleDrawerClose = () => {
if (ccPickerOpen) {
setCcPickerOpen(false);
return;
}
onClose();
};
const toggleCcUser = (name) => {
setForm((p) => ({
...p,
ccUsers: p.ccUsers.includes(name) ? p.ccUsers.filter((n) => n !== name) : [...p.ccUsers, name],
}));
};
const handleUpload = (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
setForm((p) => ({
...p,
attachments: [
...p.attachments,
...files.map((f) => ({ id: `${f.name}-${Date.now()}`, name: f.name, size: f.size })),
],
}));
e.target.value = '';
};
const handleConfirm = () => {
onConfirm?.({ actionType, ...form, notifyInternal: true });
onClose();
};
if (!Drawer) return null;
return (
<Drawer
title={ccPickerOpen ? '选择抄送人' : meta.title}
placement="bottom"
height={ccPickerOpen ? 360 : 'auto'}
open={open}
onClose={handleDrawerClose}
destroyOnClose
zIndex={1000}
styles={{ body: { padding: '8px 16px 24px', maxHeight: ccPickerOpen ? undefined : '78vh', overflowY: 'auto' } }}
>
{ccPickerOpen ? (
<>
<div className="tc-person-list">
{MOCK_CC_CANDIDATES.map((name) => (
<button
key={name}
type="button"
className={`tc-person-item${form.ccUsers.includes(name) ? ' selected' : ''}`}
onClick={() => toggleCcUser(name)}
>
{name}
</button>
))}
</div>
<div className="tc-drawer-foot">
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-cancel" onClick={() => setCcPickerOpen(false)}>返回</button>
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-confirm" onClick={() => setCcPickerOpen(false)}>确定</button>
</div>
</>
) : (
<>
<div className="tc-approval-form">
<div className="tc-form-field">
<div className="tc-form-label">通知方式</div>
<div className="tc-notify-group">
<label className="tc-notify-item disabled">
<input type="checkbox" checked disabled readOnly />站内信
</label>
<label className="tc-notify-item">
<input
type="checkbox"
checked={form.notifyEmail}
onChange={(e) => setForm((p) => ({ ...p, notifyEmail: e.target.checked }))}
/>
邮件
</label>
<label className="tc-notify-item">
<input
type="checkbox"
checked={form.notifySms}
onChange={(e) => setForm((p) => ({ ...p, notifySms: e.target.checked }))}
/>
短信
</label>
</div>
</div>
<div className="tc-form-field">
<div className="tc-form-label">附件上传</div>
<button type="button" className="tc-upload-btn" onClick={() => fileInputRef.current?.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
上传
</button>
<input ref={fileInputRef} type="file" accept={UPLOAD_ACCEPT} multiple style={{ display: 'none' }} onChange={handleUpload} />
<div className="tc-upload-hint">{UPLOAD_HINT}</div>
{form.attachments.length > 0 && (
<div className="tc-file-list">
{form.attachments.map((f) => (
<div className="tc-file-item" key={f.id}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<button
type="button"
className="tc-file-remove"
onClick={() => setForm((p) => ({ ...p, attachments: p.attachments.filter((a) => a.id !== f.id) }))}
>
删除
</button>
</div>
))}
</div>
)}
</div>
<div className="tc-form-field">
<div className="tc-form-label">抄送人</div>
<button
type="button"
className={`tc-select-person${form.ccUsers.length ? ' has-value' : ''}`}
onClick={() => setCcPickerOpen(true)}
>
{form.ccUsers.length ? `已选 ${form.ccUsers.length}` : '选择人员'}
</button>
{form.ccUsers.length > 0 && (
<div className="tc-cc-chips">
{form.ccUsers.map((name) => (
<span className="tc-cc-chip" key={name}>
{name}
<button type="button" aria-label={`移除${name}`} onClick={() => toggleCcUser(name)}>×</button>
</span>
))}
</div>
)}
</div>
<div className="tc-form-field">
<div className="tc-form-label">审批意见</div>
<textarea
className="tc-textarea"
placeholder="请输入"
value={form.opinion}
onChange={(e) => setForm((p) => ({ ...p, opinion: e.target.value }))}
maxLength={500}
/>
</div>
</div>
<div className="tc-drawer-foot">
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-cancel" onClick={onClose}>取消</button>
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-confirm" onClick={handleConfirm}>确认</button>
</div>
</>
)}
</Drawer>
);
};
const ApprovalCommentDrawer = ({ open, onClose, onConfirm }) => {
const [content, setContent] = useState('');
useEffect(() => {
if (open) setContent('');
}, [open]);
const handleConfirm = () => {
if (!content.trim()) {
message.warning('请输入评论内容');
return;
}
onConfirm?.({ content: content.trim() });
onClose();
};
if (!Drawer) return null;
return (
<Drawer
title="添加评论"
placement="bottom"
height="auto"
open={open}
onClose={onClose}
destroyOnClose
zIndex={1000}
styles={{ body: { padding: '8px 16px 24px' } }}
>
<div className="tc-form-field">
<div className="tc-form-label tc-form-label-required">评论内容</div>
<textarea
className="tc-textarea"
placeholder="请输入评论内容"
value={content}
onChange={(e) => setContent(e.target.value.slice(0, 500))}
maxLength={500}
/>
<div className="tc-textarea-counter">{content.length} / 500</div>
</div>
<div className="tc-drawer-foot">
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-cancel" onClick={onClose}>取消</button>
<button type="button" className="tc-drawer-foot-btn tc-drawer-foot-confirm" onClick={handleConfirm}>确认</button>
</div>
</Drawer>
);
};
const TcMiniBottomSheet = ({ open, title, onClose, rows, totalLabel, totalValue, totalTheme }) => {
if (!open) return null;
const totalCls = totalTheme === 'blue' ? ' tc-drawer-total--blue' : totalTheme === 'purple' ? ' tc-drawer-total--purple' : '';
const highlightCls = totalTheme === 'blue' ? ' highlight--blue' : totalTheme === 'purple' ? ' highlight--purple' : totalTheme === 'orange' ? ' highlight' : '';
return (
<div className="tc-mini-sheet" role="dialog" aria-modal="true" aria-label={title}>
<button type="button" className="tc-mini-sheet-mask" onClick={onClose} aria-label="关闭" />
<div className="tc-mini-sheet-panel">
<div className="tc-mini-sheet-handle" aria-hidden="true" />
<div className="tc-mini-sheet-head">
<span className="tc-mini-sheet-title">{title}</span>
<button type="button" className="tc-mini-sheet-close" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="tc-mini-sheet-body">
{(rows || []).map((r) => (
<div className="tc-drawer-row" key={r.label}>
<span style={{ color: COLOR_TEXT_SEC, flex: 1, minWidth: 0, paddingRight: 8 }}>{r.label}</span>
<span className={`tc-drawer-row-val${r.highlight ? highlightCls : ''}`} style={{ fontWeight: 700, flexShrink: 0, textAlign: 'right' }}>{r.value}</span>
</div>
))}
<div className={`tc-drawer-total${totalCls}`}>
<span>{totalLabel}</span>
<span>{totalValue}</span>
</div>
</div>
</div>
</div>
);
};
const ApprovalActionBar = ({ mode, onComment, onTerminate, onReject, onApprove }) => {
if (mode === 'approve') {
return (
<div className="tc-action-bar">
<button type="button" className="tc-btn tc-btn-comment" onClick={onComment}>评论</button>
<button type="button" className="tc-btn tc-btn-terminate" onClick={onTerminate}>终止</button>
<button type="button" className="tc-btn tc-btn-reject" onClick={onReject}>驳回</button>
<button type="button" className="tc-btn tc-btn-approve" onClick={onApprove}>通过</button>
</div>
);
}
return (
<div className="tc-action-bar tc-action-bar--view">
<button type="button" className="tc-btn tc-btn-comment" style={{ flex: '0 0 88px' }} onClick={onComment}>评论</button>
</div>
);
};
const PickupReceivableApprovePage = ({ task, mode, onBack }) => {
const detail = useMemo(() => buildPickupDetail(task), [task]);
const { projectInfo, vehicles, hasHydrogenPrepay, hydrogen, approvalSteps, invoiceMethod, invoiceRemark } = detail;
const totals = useMemo(() => calcPickupTotals(vehicles, hasHydrogenPrepay, hydrogen), [vehicles, hasHydrogenPrepay, hydrogen]);
const [actualDrawerOpen, setActualDrawerOpen] = useState(false);
const displayActualTotal = task?.actualAmount || totals.actualTotal;
const diff = (parseFloat(totals.receivableTotal) - parseFloat(displayActualTotal)).toFixed(2);
const actualRows = [
{ label: '总计实收车辆月租金', value: formatYuan(totals.actualRent) },
{ label: '总计应收车辆保证金', value: formatYuan(totals.receivableDeposit) },
{ label: '总计实收服务费', value: formatYuan(totals.actualService) },
{ label: '总计减免金额', value: `- ${formatYuan(totals.discountTotal)}` },
];
if (hasHydrogenPrepay) actualRows.push({ label: '氢费预充值实收金额', value: formatYuan(hydrogen.actual), highlight: true });
const [decisionOpen, setDecisionOpen] = useState(false);
const [decisionType, setDecisionType] = useState('approve');
const [commentOpen, setCommentOpen] = useState(false);
const showActions = mode === 'approve' || mode === 'view';
const openDecision = (type) => {
setDecisionType(type);
setDecisionOpen(true);
};
const handleDecisionConfirm = (payload) => {
const meta = APPROVAL_DECISION_META[payload.actionType];
if (payload.actionType === 'reject' || payload.actionType === 'terminate') {
message.warning(meta?.success || '操作成功(原型)');
} else {
message.success(meta?.success || '操作成功(原型)');
}
if (mode === 'approve') onBack();
};
const handleCommentConfirm = () => {
message.success('评论已添加(原型)');
};
return (
<>
<div className="tc-scroll">
<div className="tc-hero">
<div className="tc-hero-label">实收款总额</div>
<div className="tc-hero-amount">{formatMoneySymbol(displayActualTotal)}</div>
<div className="tc-hero-meta">
<span>{projectInfo.customerName}</span>
</div>
<div className="tc-hero-foot tc-hero-foot--compact">
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">合同编码</span>{projectInfo.contractCode || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">项目名称</span>{projectInfo.projectName || ''}</div>
</div>
<div className="tc-hero-period">
<span className="tc-hero-period-tag">{vehicles.length} 台车</span>
</div>
<div className="tc-hero-sub">
<div className="tc-hero-compare">
应收款总额 {formatMoneySymbol(totals.receivableTotal)}
<br />
较应收减免 {formatMoneySymbol(diff)}
</div>
<button type="button" className="tc-hero-detail-btn" onClick={() => setActualDrawerOpen(true)}>实收明细</button>
</div>
</div>
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">提车应收款 · 车辆明细</span>
<span className="tc-section-badge">{vehicles.length} </span>
</div>
{vehicles.map((v) => <TcVehicleCard key={v.key} vehicle={v} />)}
</div>
{hasHydrogenPrepay && (
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">氢费预付款</span></div>
<div className="tc-kv-grid">
<TcInfoRow label="应收金额" value={formatYuan(hydrogen.receivable)} />
<TcInfoRow label="实收金额" value={formatYuan(hydrogen.actual)} />
<TcInfoRow label="减免金额" value={formatYuan(hydrogen.discount)} />
<TcInfoRow label="减免备注" value={hydrogen.discountRemark} full />
</div>
</div>
)}
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">开票信息</span></div>
<div className="tc-kv-grid">
<TcInfoRow label="开票方式" value={invoiceMethod} />
<TcInfoRow label="开票备注" value={invoiceRemark} full />
</div>
</div>
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">审批情况</span></div>
<div className="tc-timeline">
{approvalSteps.map((step, idx) => (
<div className="tc-step" key={idx}>
<div className={`tc-step-dot ${step.status === '已通过' ? 'done' : 'wait'}`}>{step.status === '已通过' ? '✓' : ''}</div>
<div>
<div className="tc-step-title">{step.department || step.title}</div>
<div className="tc-step-meta">状态{step.status}<br />审批人{step.person}<br />时间{step.approveTime}</div>
</div>
</div>
))}
</div>
</div>
<div style={{ height: showActions ? 16 : 24 }} />
</div>
{showActions && !decisionOpen && !commentOpen && (
<ApprovalActionBar
mode={mode}
onComment={() => setCommentOpen(true)}
onTerminate={() => openDecision('terminate')}
onReject={() => openDecision('reject')}
onApprove={() => openDecision('approve')}
/>
)}
<ApprovalDecisionDrawer
open={decisionOpen}
actionType={decisionType}
onClose={() => setDecisionOpen(false)}
onConfirm={handleDecisionConfirm}
/>
<ApprovalCommentDrawer
open={commentOpen}
onClose={() => setCommentOpen(false)}
onConfirm={handleCommentConfirm}
/>
<TcMiniBottomSheet
open={actualDrawerOpen}
title="实收款明细"
onClose={() => setActualDrawerOpen(false)}
rows={actualRows}
totalLabel="实收款总额"
totalValue={formatMoneySymbol(displayActualTotal)}
totalTheme="orange"
/>
</>
);
};
const HC_CUSTOMER_SERVICE_FIXED = ['违章处理违约金', '保险上浮', 'ETC-客户未缴费用', 'ETC卡缺损费', 'ETC设备缺损费'];
const HC_OPERATION_FIXED = ['清洗费', '未结算保养', '未结算维修', '车损费用', '工具损坏丢失费用', '证件丢失费用', '广告损坏丢失费用', '送车服务费', '接车服务费', '轮胎磨损费用'];
const buildHcFeeRows = (fixedItems, amountMap, customRows) => {
const fixed = fixedItems.map((name, i) => ({
key: `fixed-${i}-${name}`,
seq: i + 1,
feeItem: name,
amount: amountMap[name] != null ? amountMap[name] : '0.00',
isCustom: false,
}));
const custom = (customRows || []).map((r, i) => ({
key: r.key || `custom-${i}`,
seq: fixed.length + i + 1,
feeItem: r.feeItem,
amount: r.amount || '0.00',
isCustom: true,
}));
return [...fixed, ...custom];
};
const sumFeeRows = (rows) => rows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toFixed(2);
const calcSafetyInfo = (violations) => {
const list = violations || [];
let paid = 0;
let unpaid = 0;
list.forEach((v) => {
const amt = parseFloat(v.penaltyAmount) || 0;
const status = v.paymentStatus || '';
if (status === '已缴费' || status === '已缴') paid += amt;
else unpaid += amt;
});
return {
violationCount: list.length,
paidAmount: paid.toFixed(2),
unpaidAmount: unpaid.toFixed(2),
};
};
const HcFeeItemTable = ({ rows }) => (
<div className="hc-fee-table">
<div className="hc-fee-table-head">
<span className="hc-fee-table-col hc-fee-table-col--seq">序号</span>
<span className="hc-fee-table-col hc-fee-table-col--item">费用项</span>
<span className="hc-fee-table-col hc-fee-table-col--amt">金额</span>
</div>
{(rows || []).map((r) => (
<div className={`hc-fee-table-row${r.isCustom ? ' hc-fee-table-row--custom' : ''}`} key={r.key}>
<span className="hc-fee-table-col hc-fee-table-col--seq">{r.seq}</span>
<span className="hc-fee-table-col hc-fee-table-col--item">
{r.feeItem}
{r.isCustom ? <span className="hc-fee-table-tag">手动</span> : null}
</span>
<span className="hc-fee-table-col hc-fee-table-col--amt">{formatYuan(r.amount)}</span>
</div>
))}
</div>
);
const buildReturnSettlementDetail = (task) => {
const isGuangzhou = task?.bizNo === 'HC-2026-0418';
const vehicle = isGuangzhou
? {
plateNo: '粤B58888F', contractCode: 'LNZLHT20251106001', projectName: '嘉兴腾4.5T租赁',
customerName: '嘉兴某某物流有限公司', deliveryTime: '2026-02-01 09:30', returnTime: '2026-02-27 16:20',
fragileInsurance: '是', tireInsurance: '否', maintenanceInsurance: '是',
}
: {
plateNo: '沪A03561F', contractCode: 'LNZLHT20251012003', projectName: '上海氢能城际物流项目',
customerName: '上海迅杰物流有限公司', deliveryTime: '2026-04-01 10:00', returnTime: '2026-05-18 15:40',
fragileInsurance: '是', tireInsurance: '是', maintenanceInsurance: '是',
};
const businessServiceRows = buildHcFeeRows(
HC_CUSTOMER_SERVICE_FIXED,
{
违章处理违约金: '0.00',
保险上浮: '0.00',
'ETC-客户未缴费用': '100.00',
ETC卡缺损费: '0.00',
ETC设备缺损费: '0.00',
},
[{ key: 'bs-custom-1', feeItem: '停车费', amount: '50.00' }],
);
const billInfo = { receivedRent: '0.00', actualRent: task?.actualRent || '0.00', shouldRefundRent: '0.00' };
const energy = {
deliveryHydrogen: '85.00', returnHydrogen: '72.00', hydrogenUnitPrice: '35.00',
hydrogenSupplement: '455.00', hydrogenFee: '0.00', electricFee: '0.00', prepayRefund: '0.00', userBalance: '1200.00',
};
const operationRows = buildHcFeeRows(
HC_OPERATION_FIXED,
{
清洗费: '0.00',
未结算保养: '372.50',
未结算维修: '0.00',
车损费用: '0.00',
工具损坏丢失费用: '0.00',
证件丢失费用: '0.00',
广告损坏丢失费用: '0.00',
送车服务费: '0.00',
接车服务费: '0.00',
轮胎磨损费用: '0.00',
},
[{ key: 'op-custom-1', feeItem: '补办行驶证', amount: '80.00' }],
);
const violations = [
{
code: 'WZ202602010001', plateNo: vehicle.plateNo, violationBehavior: '闯红灯',
violationTime: '2026-02-01', penaltyAmount: '100.00', paymentStatus: '未缴费', handleStatus: '未处理',
},
{
code: 'WZ202602150002', plateNo: vehicle.plateNo, violationBehavior: '违停',
violationTime: '2026-02-15', penaltyAmount: '200.00', paymentStatus: '已缴费', handleStatus: '已处理',
},
];
const safetyInfo = calcSafetyInfo(violations);
const approvalSteps = isGuangzhou
? [
{ department: '客户服务组', status: '已通过', person: '张三', approveTime: '2026-04-17 16:00' },
{ department: '能源采购组', status: '已通过', person: '李四', approveTime: '2026-04-17 17:30' },
{ department: '运维部', status: '已通过', person: '王五', approveTime: '2026-04-18 08:20' },
{ department: '财务部', status: '待审批', person: '张明辉', approveTime: '—' },
]
: [
{ department: '客户服务组', status: '已通过', person: '张三', approveTime: '2026-05-19 14:00' },
{ department: '财务部', status: '已通过', person: '李财务', approveTime: '2026-05-21 15:30' },
];
const businessServiceTotal = sumFeeRows(businessServiceRows);
const customerServiceTotal = (parseFloat(businessServiceTotal) + parseFloat(billInfo.shouldRefundRent || 0)).toFixed(2);
const operationTotal = sumFeeRows(operationRows);
const energySettleTotal = (
(parseFloat(energy.hydrogenSupplement) || 0) - (parseFloat(energy.prepayRefund) || 0)
).toFixed(2);
const safetyTotal = safetyInfo.unpaidAmount;
const depositAmount = task?.depositAmount || '5000.00';
const pendingSettle = task?.pendingSettle || (
parseFloat(customerServiceTotal) + parseFloat(energySettleTotal)
+ parseFloat(operationTotal) + parseFloat(safetyTotal)
).toFixed(2);
const diff = (parseFloat(depositAmount) || 0) - (parseFloat(pendingSettle) || 0);
const refundTotal = task?.refundTotal || (diff > 0 ? diff.toFixed(2) : '0.00');
const payTotal = task?.payTotal || (diff < 0 ? Math.abs(diff).toFixed(2) : '0.00');
const settleBreakdown = [
{ label: '保证金', value: formatMoneySymbol(depositAmount) },
{ label: '客户服务组总计', value: formatYuan(customerServiceTotal) },
{ label: '能源采购组总计', value: formatYuan(energySettleTotal) },
{ label: '运维部总计', value: formatYuan(operationTotal) },
{ label: '安全部总计', value: formatYuan(safetyTotal) },
];
return {
vehicle, businessServiceRows, billInfo, energy, operationRows, violations, safetyInfo,
approvalSteps, businessServiceTotal, customerServiceTotal, operationTotal, energySettleTotal, safetyTotal,
depositAmount, pendingSettle, refundTotal, payTotal, settleBreakdown,
};
};
const HcInsuranceBadge = ({ label, value }) => {
const yes = value === '是';
return (
<div className="hc-insurance-item">
<span>{label}</span>
<span className={`hc-insurance-icon${yes ? ' hc-insurance-icon--yes' : ' hc-insurance-icon--no'}`} aria-label={value || '否'} title={value || '否'}>
{yes ? '✓' : '×'}
</span>
</div>
);
};
const HcSettleGroupCard = ({ title, submitter, settleTotal, children }) => (
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">{title}</span>
{submitter ? <span className="tc-section-badge tc-section-badge--purple">{submitter}</span> : null}
</div>
<div className="hc-settle-body">{children}</div>
<div className="hc-settle-total">
<span className="hc-settle-total-label">应结算总额</span>
<span className="hc-settle-total-val">{formatYuan(settleTotal)}</span>
</div>
</div>
);
const ReturnSettlementApprovePage = ({ task, mode, onBack }) => {
const detail = useMemo(() => buildReturnSettlementDetail(task), [task]);
const {
vehicle, businessServiceRows, billInfo, energy, operationRows, safetyInfo, approvalSteps,
businessServiceTotal, customerServiceTotal, operationTotal, energySettleTotal, safetyTotal,
depositAmount, pendingSettle, refundTotal, payTotal, settleBreakdown,
} = detail;
const [settleDrawerOpen, setSettleDrawerOpen] = useState(false);
const [decisionOpen, setDecisionOpen] = useState(false);
const [decisionType, setDecisionType] = useState('approve');
const [commentOpen, setCommentOpen] = useState(false);
const showActions = mode === 'approve' || mode === 'view';
const displayPending = task?.pendingSettle || pendingSettle;
const isDepositCoverPending = parseFloat(depositAmount) >= parseFloat(displayPending);
const heroSettleLabel = isDepositCoverPending ? '应退还总额' : '应补缴总额';
const heroSettleAmount = isDepositCoverPending ? refundTotal : payTotal;
const openDecision = (type) => { setDecisionType(type); setDecisionOpen(true); };
const handleDecisionConfirm = (payload) => {
const meta = APPROVAL_DECISION_META[payload.actionType];
if (payload.actionType === 'reject' || payload.actionType === 'terminate') message.warning(meta?.success || '操作成功(原型)');
else message.success(meta?.success || '操作成功(原型)');
if (mode === 'approve') onBack();
};
return (
<>
<div className="tc-scroll">
<div className="tc-hero tc-hero--return">
<div className="tc-hero-label">{heroSettleLabel}</div>
<div className="tc-hero-amount">{formatMoneySymbol(heroSettleAmount)}</div>
<div className="tc-hero-meta">
<span>{vehicle.customerName}</span>
</div>
<div className="tc-hero-foot tc-hero-foot--compact">
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">车牌号</span>{vehicle.plateNo || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">合同编码</span>{vehicle.contractCode || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">项目名称</span>{vehicle.projectName || ''}</div>
<div className="hc-insurance-row">
<HcInsuranceBadge label="易损保" value={vehicle.fragileInsurance} />
<HcInsuranceBadge label="轮胎保" value={vehicle.tireInsurance} />
<HcInsuranceBadge label="养护保" value={vehicle.maintenanceInsurance} />
</div>
</div>
<div className="tc-hero-period">
<span>交车{vehicle.deliveryTime}</span>
<span>还车{vehicle.returnTime}</span>
</div>
<div className="tc-hero-sub">
<div className="tc-hero-compare">
保证金 {formatMoneySymbol(depositAmount)}
<br />
待结算总额 {formatMoneySymbol(displayPending)}
<br />
车辆实际租金 {formatYuan(billInfo.actualRent)}
</div>
<button type="button" className="tc-hero-detail-btn" onClick={() => setSettleDrawerOpen(true)}>结算明细</button>
</div>
</div>
<HcSettleGroupCard title="客户服务组" submitter="张三 · 已提交" settleTotal={customerServiceTotal}>
<HcFeeItemTable rows={businessServiceRows} />
</HcSettleGroupCard>
<HcSettleGroupCard title="能源采购组" submitter="李四 · 已提交" settleTotal={energySettleTotal}>
<div className="tc-kv-grid" style={{ padding: '0 0 8px' }}>
<TcInfoRow label="氢量差补缴" value={formatYuan(energy.hydrogenSupplement)} />
<TcInfoRow label="交车氢量" value={`${energy.deliveryHydrogen} MPa`} />
<TcInfoRow label="还车氢量" value={`${energy.returnHydrogen} MPa`} />
<TcInfoRow label="退还氢气单价" value={formatYuan(energy.hydrogenUnitPrice)} />
<TcInfoRow label="预付款退费" value={formatYuan(energy.prepayRefund)} />
</div>
</HcSettleGroupCard>
<HcSettleGroupCard title="运维部" submitter="王五 · 已提交" settleTotal={operationTotal}>
<HcFeeItemTable rows={operationRows} />
</HcSettleGroupCard>
<HcSettleGroupCard title="安全部" submitter="赵六 · 已提交" settleTotal={safetyTotal}>
<div className="hc-safety-stat-grid">
<div className="hc-safety-stat-item">
<div className="hc-safety-stat-label">违章次数</div>
<div className="hc-safety-stat-val">{safetyInfo.violationCount}</div>
</div>
<div className="hc-safety-stat-item">
<div className="hc-safety-stat-label">已缴金额</div>
<div className="hc-safety-stat-val">{formatYuan(safetyInfo.paidAmount)}</div>
</div>
<div className="hc-safety-stat-item">
<div className="hc-safety-stat-label">未缴金额</div>
<div className="hc-safety-stat-val warn">{formatYuan(safetyInfo.unpaidAmount)}</div>
</div>
</div>
</HcSettleGroupCard>
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">审批情况</span></div>
<div className="tc-timeline">
{approvalSteps.map((step, idx) => (
<div className="tc-step" key={idx}>
<div className={`tc-step-dot ${step.status === '已通过' ? 'done' : 'wait'}`}>{step.status === '已通过' ? '✓' : ''}</div>
<div>
<div className="tc-step-title">{step.department}</div>
<div className="tc-step-meta">状态{step.status}<br />审批人{step.person}<br />时间{step.approveTime}</div>
</div>
</div>
))}
</div>
</div>
<div style={{ height: showActions ? 16 : 24 }} />
</div>
{showActions && !decisionOpen && !commentOpen && (
<ApprovalActionBar mode={mode} onComment={() => setCommentOpen(true)} onTerminate={() => openDecision('terminate')} onReject={() => openDecision('reject')} onApprove={() => openDecision('approve')} />
)}
<ApprovalDecisionDrawer open={decisionOpen} actionType={decisionType} onClose={() => setDecisionOpen(false)} onConfirm={handleDecisionConfirm} />
<ApprovalCommentDrawer open={commentOpen} onClose={() => setCommentOpen(false)} onConfirm={() => message.success('评论已添加(原型)')} />
<TcMiniBottomSheet
open={settleDrawerOpen}
title="结算明细"
onClose={() => setSettleDrawerOpen(false)}
rows={settleBreakdown}
totalLabel={heroSettleLabel}
totalValue={formatMoneySymbol(heroSettleAmount)}
totalTheme="purple"
/>
</>
);
};
const WEB_BILL_VEHICLES = [
{ key: 'v1', index: 1, brand: '东风', model: 'DFH1180', plateNo: '浙A12345', receivableRent: 30000, actualRent: '29800.00', rentRemark: '首期六期一次性付清', discountAmount: '200.00', discountRemark: '首月优惠', discountProof: [{ name: '优惠审批单.pdf' }], receivableDeposit: 10000, serviceItems: [{ name: '代处理费用', receivable: 200, actual: '200.00', discount: '0.00', remark: '' }, { name: '保险上浮', receivable: 500, actual: '480.00', discount: '20.00', remark: '客户协商' }], receivableService: 700, actualService: '680.00' },
{ key: 'v2', index: 2, brand: '福田', model: 'BJ1180', plateNo: '浙A23456', receivableRent: 27000, actualRent: '27000.00', rentRemark: '', discountAmount: '0.00', discountRemark: '', discountProof: [], receivableDeposit: 8000, serviceItems: [{ name: '保养费用', receivable: 300, actual: '300.00', discount: '0.00', remark: '含首保' }], receivableService: 300, actualService: '300.00' },
{ key: 'v3', index: 3, brand: '重汽', model: 'ZZ1187', plateNo: '浙A34567', receivableRent: 31200, actualRent: '31200.00', rentRemark: '', discountAmount: '0.00', discountRemark: '', discountProof: [], receivableDeposit: 10000, serviceItems: [{ name: '代处理费用', receivable: 180, actual: '180.00', discount: '0.00', remark: '' }, { name: '上牌服务', receivable: 400, actual: '400.00', discount: '0.00', remark: '' }], receivableService: 580, actualService: '580.00' },
];
const buildLeaseBillDetail = (task) => {
const isJuneBill = task?.bizNo === 'ZD-2026-06-001';
const billInfo = isJuneBill
? {
contractCode: 'HT-ZL-2025-001', contractType: '正式合同', projectName: '嘉兴氢能示范项目',
customerName: '嘉兴某某物流有限公司', deliveryTaskCode: 'JT-2025-001-A',
billCode: 'HT-ZL-2025-0010006', period: 6, billStartDate: '2026-06-01', billEndDate: '2026-06-30',
}
: {
contractCode: 'HT-ZL-2025-001', contractType: '正式合同', projectName: '嘉兴氢能示范项目',
customerName: '嘉兴某某物流有限公司', deliveryTaskCode: 'JT-2025-001-A',
billCode: 'HT-ZL-2025-0010001', period: 1, billStartDate: '2025-01-01', billEndDate: '2025-01-31',
};
const vehicles = isJuneBill
? [
{ key: 'v1', index: 1, brand: '东风', model: 'DFH1180', plateNo: '粤B58888F', receivableRent: 30000, actualRent: '29800.00', rentRemark: '', discountAmount: '200.00', discountRemark: '批量优惠', discountProof: [], receivableDeposit: 0, serviceItems: [{ name: '代处理费用', receivable: 200, actual: '200.00', discount: '0.00', remark: '' }], receivableService: 200, actualService: '200.00' },
{ key: 'v2', index: 2, brand: '福田', model: 'BJ1180', plateNo: '粤B59999F', receivableRent: 28500, actualRent: '28500.00', rentRemark: '', discountAmount: '0.00', discountRemark: '', discountProof: [], receivableDeposit: 0, serviceItems: [{ name: '保养费用', receivable: 300, actual: '300.00', discount: '0.00', remark: '' }], receivableService: 300, actualService: '300.00' },
{ key: 'v3', index: 3, brand: '重汽', model: 'ZZ1187', plateNo: '粤B60001F', receivableRent: 29200, actualRent: '29000.00', rentRemark: '按合同约定', discountAmount: '200.00', discountRemark: '协商减免', discountProof: [{ name: '减免说明.pdf' }], receivableDeposit: 0, serviceItems: [{ name: '保险上浮', receivable: 500, actual: '480.00', discount: '20.00', remark: '' }], receivableService: 500, actualService: '480.00' },
{ key: 'v4', index: 4, brand: '东风', model: 'DFH1180', plateNo: '粤B61112F', receivableRent: 27800, actualRent: '27800.00', rentRemark: '', discountAmount: '0.00', discountRemark: '', discountProof: [], receivableDeposit: 0, serviceItems: [], receivableService: 0, actualService: '0.00' },
{ key: 'v5', index: 5, brand: '福田', model: 'BJ1180', plateNo: '粤B62223F', receivableRent: 26880, actualRent: '26880.00', rentRemark: '', discountAmount: '0.00', discountRemark: '', discountProof: [], receivableDeposit: 0, serviceItems: [{ name: '代处理费用', receivable: 180, actual: '180.00', discount: '0.00', remark: '' }], receivableService: 180, actualService: '180.00' },
]
: WEB_BILL_VEHICLES;
const isFirstPeriod = billInfo.period === 1;
const hydrogen = { receivable: '3580.00', actual: '3500.00', discount: '80.00', discountRemark: '预付款批量减免' };
const approvalSteps = isJuneBill
? [
{ department: '业务服务组', status: '已通过', person: '陈高伟', approveTime: '2026-06-01 09:00' },
{ department: '业管主管', status: '待审批', person: '张明辉', approveTime: '—' },
]
: [
{ department: '业务服务组', status: '已通过', person: '陈高伟', approveTime: '2026-05-05 09:20' },
{ department: '业管主管', status: '已通过', person: '张明辉', approveTime: '2026-05-05 14:30' },
{ department: '财务部', status: '已通过', person: '财务-赵敏', approveTime: '2026-05-05 18:00' },
];
return { billInfo, vehicles, isFirstPeriod, hydrogen, approvalSteps };
};
const calcLeaseBillTotals = (vehicles, hydrogen, isFirstPeriod) => {
let receivableRent = 0; let actualRent = 0; let receivableDeposit = 0;
let receivableService = 0; let actualService = 0; let discountTotal = 0; let serviceDiscountTotal = 0;
vehicles.forEach((v) => {
receivableRent += Number(v.receivableRent) || 0;
actualRent += parseFloat(v.actualRent) || 0;
receivableDeposit += isFirstPeriod ? (Number(v.receivableDeposit) || 0) : 0;
receivableService += Number(v.receivableService) || 0;
actualService += parseFloat(v.actualService) || 0;
discountTotal += parseFloat(v.discountAmount) || 0;
(v.serviceItems || []).forEach((s) => { serviceDiscountTotal += parseFloat(s.discount) || 0; });
});
const hydrogenReceivable = isFirstPeriod ? (parseFloat(hydrogen.receivable) || 0) : 0;
const hydrogenActual = isFirstPeriod ? (parseFloat(hydrogen.actual) || 0) : 0;
const hydrogenDiscount = isFirstPeriod ? (parseFloat(hydrogen.discount) || 0) : 0;
const receivableTotal = (receivableRent + receivableDeposit + receivableService + hydrogenReceivable).toFixed(2);
const actualTotal = (actualRent + receivableDeposit + actualService - discountTotal - serviceDiscountTotal + hydrogenActual - hydrogenDiscount).toFixed(2);
const invoiceTotal = (actualRent + actualService - discountTotal - serviceDiscountTotal + hydrogenActual - hydrogenDiscount).toFixed(2);
return {
receivableRent: receivableRent.toFixed(2), actualRent: actualRent.toFixed(2),
receivableDeposit: receivableDeposit.toFixed(2), receivableService: receivableService.toFixed(2),
actualService: actualService.toFixed(2), discountTotal: discountTotal.toFixed(2),
serviceDiscountTotal: serviceDiscountTotal.toFixed(2),
hydrogenReceivable: hydrogenReceivable.toFixed(2), hydrogenActual: hydrogenActual.toFixed(2),
hydrogenDiscount: hydrogenDiscount.toFixed(2),
receivableTotal, actualTotal, invoiceTotal,
};
};
const ZlVehicleCard = ({ vehicle, isFirstPeriod }) => {
const [serviceOpen, setServiceOpen] = useState(false);
const serviceDiscount = (vehicle.serviceItems || []).reduce((s, item) => s + (parseFloat(item.discount) || 0), 0);
const deposit = isFirstPeriod ? (Number(vehicle.receivableDeposit) || 0) : 0;
const vehicleActual = (
parseFloat(vehicle.actualRent || 0) + deposit + parseFloat(vehicle.actualService || 0)
- parseFloat(vehicle.discountAmount || 0) - serviceDiscount
).toFixed(2);
return (
<div className="tc-vehicle-card">
<div className="tc-vehicle-head">
<div>
<div className="tc-vehicle-plate">{vehicle.plateNo || '—'}</div>
<div className="tc-vehicle-model">{vehicle.brand} · {vehicle.model}</div>
</div>
<span className="tc-vehicle-idx tc-vehicle-idx--blue">#{vehicle.index}</span>
</div>
<div className="tc-vehicle-amount-row">
<span className="tc-vehicle-amount-label">本车实收合计</span>
<span className="tc-vehicle-amount-val tc-vehicle-amount-val--blue">{formatMoneySymbol(vehicleActual)}</span>
</div>
<div className="tc-vehicle-kv">
<div className="tc-vehicle-kv-item"><span>应收月租金 </span>{formatYuan(vehicle.receivableRent)}</div>
<div className="tc-vehicle-kv-item"><span>实收月租金 </span>{formatYuan(vehicle.actualRent)}</div>
<div className="tc-vehicle-kv-item"><span>应收保证金 </span>{formatYuan(isFirstPeriod ? vehicle.receivableDeposit : 0)}</div>
<div className="tc-vehicle-kv-item"><span>减免金额 </span>{formatYuan(vehicle.discountAmount)}</div>
<div className="tc-vehicle-kv-item"><span>应收服务费 </span>{formatYuan(vehicle.receivableService)}</div>
<div className="tc-vehicle-kv-item"><span>实收服务费 </span>{formatYuan(vehicle.actualService)}</div>
</div>
{vehicle.rentRemark ? (
<div className="tc-vehicle-kv-item" style={{ marginTop: 6, fontSize: 12, color: COLOR_TEXT_SEC }}>
<span style={{ color: COLOR_MUTED }}>租金备注 </span>{vehicle.rentRemark}
</div>
) : null}
{vehicle.discountRemark ? (
<div className="tc-vehicle-kv-item" style={{ marginTop: 4, fontSize: 12, color: COLOR_TEXT_SEC }}>
<span style={{ color: COLOR_MUTED }}>减免备注 </span>{vehicle.discountRemark}
</div>
) : null}
{(vehicle.discountProof || []).length > 0 && (
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: 11, color: COLOR_MUTED, marginBottom: 4 }}>减免证明</div>
<div className="zl-proof-list">
{(vehicle.discountProof || []).map((p, i) => (
<button key={i} type="button" className="zl-proof-link" onClick={() => message.info(`预览:${p.name || '附件'}`)}>
{p.name || '附件'}
</button>
))}
</div>
</div>
)}
{(vehicle.serviceItems || []).length > 0 && (
<>
<button type="button" className="tc-service-toggle" onClick={() => setServiceOpen((o) => !o)}>
{serviceOpen ? '收起' : '查看'}服务费明细
</button>
{serviceOpen && (
<div className="tc-service-list">
{(vehicle.serviceItems || []).map((s, i) => (
<div className="tc-service-row" key={i}>
<span className="tc-service-name">{s.name}</span>
<span className="tc-service-amt tc-service-amt--blue">{formatMoneySymbol(s.actual)}</span>
<span className="tc-service-sub">应收 {formatYuan(s.receivable)} · 减免 {formatYuan(s.discount)}{s.remark ? ` · ${s.remark}` : ''}</span>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
const DEFAULT_TRANSFER_APPROVAL_STEPS = [
{ department: '运维主管', status: '已通过', person: '陈高伟', approveTime: '2026-06-01 08:30' },
{ department: '运维中心', status: '审批中', person: '张明辉', approveTime: '—' },
];
const parseTransferInfo = (task) => {
const info = task?.transferInfo || {};
return {
method: info.method || '—',
transportLeader: info.transportLeader || '—',
transportPhone: info.transportPhone || '—',
receivePerson: info.receivePerson || '—',
transportCost: info.transportCost || '',
contractFiles: info.contractFiles || [],
};
};
const buildTransferCreateDetail = (task) => ({
transferDate: task?.transferDate || '2026-06-01 09:30',
departRegion: task?.departRegion || '广东省-广州市',
receiveRegion: task?.receiveRegion || '浙江省-杭州市',
reason: task?.reason || '—',
transferInfo: parseTransferInfo(task),
vehicles: (task?.vehicles || []).map((v, i) => ({ ...v, index: i + 1 })),
approvalSteps: task?.approvalSteps || DEFAULT_TRANSFER_APPROVAL_STEPS,
});
const buildTransferOpsDetail = (task) => ({
transferDate: task?.transferDate || '2026-03-31 08:00',
departRegion: task?.departRegion || '广东省-广州市',
receiveRegion: task?.receiveRegion || '浙江省-嘉兴市',
reason: task?.reason || '—',
transferInfo: parseTransferInfo(task),
vehicles: (task?.vehicles || []).map((v, i) => ({ ...v, index: i + 1 })),
approvalSteps: task?.approvalSteps || [
{ department: '运维主管', status: '已通过', person: '陈高伟', approveTime: '2026-03-31 08:00' },
{ department: '运维中心', status: '审批中', person: '张明辉', approveTime: '—' },
],
});
const TransferInfoSection = ({ transferInfo }) => (
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">调拨信息</span></div>
<div className="tc-kv-grid">
<TcInfoRow label="调拨方式" value={transferInfo.method} />
<TcInfoRow label="运输负责人" value={transferInfo.transportLeader} />
<TcInfoRow label="运输方联系方式" value={transferInfo.transportPhone} full />
<TcInfoRow label="接收人员" value={transferInfo.receivePerson} full />
<TcInfoRow label="运输费用" value={transferInfo.transportCost ? formatMoneySymbol(transferInfo.transportCost) : '—'} />
{(transferInfo.contractFiles || []).length > 0 ? (
<div className="tc-kv-item tc-kv-value--full">
<div className="tc-kv-label">运输合同附件</div>
<div className="zl-proof-list">
{(transferInfo.contractFiles || []).map((f, i) => (
<button key={i} type="button" className="zl-proof-link" onClick={() => message.info(`预览:${f.name || '附件'}`)}>{f.name || '附件'}</button>
))}
</div>
</div>
) : null}
</div>
</div>
);
const DbVehicleCard = ({ vehicle }) => (
<div className="tc-vehicle-card">
<div className="tc-vehicle-head">
<div>
<div className="tc-vehicle-plate">{vehicle.plateNo || '—'}</div>
<div className="tc-vehicle-model">{vehicle.brand} · {vehicle.model}</div>
</div>
<span className="tc-vehicle-idx tc-vehicle-idx--green">#{vehicle.index}</span>
</div>
<div className="tc-vehicle-kv">
<div className="tc-vehicle-kv-item"><span>出发停车场 </span>{vehicle.departParking || ''}</div>
<div className="tc-vehicle-kv-item"><span>出发里程 </span>{vehicle.departMileageKm ? `${vehicle.departMileageKm} km` : ''}</div>
<div className="tc-vehicle-kv-item"><span>出发氢量 </span>{vehicle.departHydrogen ? `${vehicle.departHydrogen} ${vehicle.h2Unit || '%'}` : ''}</div>
<div className="tc-vehicle-kv-item"><span>出发电量 </span>{vehicle.departElectricKwh ? `${vehicle.departElectricKwh} kWh` : ''}</div>
</div>
</div>
);
const TransferCreateApprovePage = ({ task, mode, onBack }) => {
const detail = useMemo(() => buildTransferCreateDetail(task), [task]);
const { transferDate, departRegion, receiveRegion, reason, transferInfo, vehicles, approvalSteps } = detail;
const [decisionOpen, setDecisionOpen] = useState(false);
const [decisionType, setDecisionType] = useState('approve');
const [commentOpen, setCommentOpen] = useState(false);
const showActions = mode === 'approve' || mode === 'view';
const openDecision = (type) => { setDecisionType(type); setDecisionOpen(true); };
const handleDecisionConfirm = (payload) => {
const meta = APPROVAL_DECISION_META[payload.actionType];
if (payload.actionType === 'reject' || payload.actionType === 'terminate') message.warning(meta?.success || '操作成功(原型)');
else message.success(meta?.success || '操作成功(原型)');
if (mode === 'approve') onBack();
};
return (
<>
<div className="tc-scroll">
<div className="tc-hero tc-hero--transfer">
<div className="tc-hero-label">调拨车辆</div>
<div className="tc-hero-amount">{vehicles.length} </div>
<div className="tc-hero-route">
<span>{departRegion}</span>
<span className="tc-hero-route-arrow"></span>
<span>{receiveRegion}</span>
</div>
<div className="tc-hero-foot tc-hero-foot--compact">
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">调拨日期</span>{transferDate}</div>
</div>
<div className="tc-hero-period">
<span className="tc-hero-period-tag">调拨申请</span>
</div>
</div>
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">调拨情况</span></div>
<div className="tc-kv-grid">
<TcInfoRow label="调拨日期" value={transferDate} full />
<TcInfoRow label="出发区域" value={departRegion} />
<TcInfoRow label="接收区域" value={receiveRegion} />
<TcInfoRow label="调拨原因" value={reason} full />
</div>
</div>
<TransferInfoSection transferInfo={transferInfo} />
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">调拨车辆清单</span>
<span className="tc-section-badge tc-section-badge--green">{vehicles.length} </span>
</div>
{vehicles.map((v) => <DbVehicleCard key={v.id || v.index} vehicle={v} />)}
</div>
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">审批情况</span></div>
<div className="tc-timeline">
{approvalSteps.map((step, idx) => (
<div className="tc-step" key={idx}>
<div className={`tc-step-dot ${step.status === '已通过' ? 'done' : 'wait--green'}`}>{step.status === '已通过' ? '✓' : ''}</div>
<div>
<div className="tc-step-title">{step.department}</div>
<div className="tc-step-meta">状态{step.status}<br />审批人{step.person}<br />时间{step.approveTime}</div>
</div>
</div>
))}
</div>
</div>
<div style={{ height: showActions ? 16 : 24 }} />
</div>
{showActions && !decisionOpen && !commentOpen && (
<ApprovalActionBar mode={mode} onComment={() => setCommentOpen(true)} onTerminate={() => openDecision('terminate')} onReject={() => openDecision('reject')} onApprove={() => openDecision('approve')} />
)}
<ApprovalDecisionDrawer open={decisionOpen} actionType={decisionType} onClose={() => setDecisionOpen(false)} onConfirm={handleDecisionConfirm} />
<ApprovalCommentDrawer open={commentOpen} onClose={() => setCommentOpen(false)} onConfirm={() => message.success('评论已添加(原型)')} />
</>
);
};
const TransferOpsApprovePage = ({ task, mode, onBack }) => {
const detail = useMemo(() => buildTransferOpsDetail(task), [task]);
const { transferDate, departRegion, receiveRegion, reason, transferInfo, vehicles, approvalSteps } = detail;
const [decisionOpen, setDecisionOpen] = useState(false);
const [decisionType, setDecisionType] = useState('approve');
const [commentOpen, setCommentOpen] = useState(false);
const showActions = mode === 'approve' || mode === 'view';
const openDecision = (type) => { setDecisionType(type); setDecisionOpen(true); };
const handleDecisionConfirm = (payload) => {
const meta = APPROVAL_DECISION_META[payload.actionType];
if (payload.actionType === 'reject' || payload.actionType === 'terminate') message.warning(meta?.success || '操作成功(原型)');
else message.success(meta?.success || '操作成功(原型)');
if (mode === 'approve') onBack();
};
return (
<>
<div className="tc-scroll">
<div className="tc-hero tc-hero--transfer">
<div className="tc-hero-label">调拨车辆</div>
<div className="tc-hero-amount">{vehicles.length} </div>
<div className="tc-hero-route">
<span>{departRegion}</span>
<span className="tc-hero-route-arrow"></span>
<span>{receiveRegion}</span>
</div>
<div className="tc-hero-foot tc-hero-foot--compact">
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">调拨日期</span>{transferDate}</div>
</div>
<div className="tc-hero-period">
<span className="tc-hero-period-tag">运维调拨方</span>
</div>
</div>
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">调拨情况</span></div>
<div className="tc-kv-grid">
<TcInfoRow label="调拨日期" value={transferDate} full />
<TcInfoRow label="出发区域" value={departRegion} />
<TcInfoRow label="接收区域" value={receiveRegion} />
<TcInfoRow label="调拨原因" value={reason} full />
</div>
</div>
<TransferInfoSection transferInfo={transferInfo} />
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">调拨车辆清单</span>
<span className="tc-section-badge tc-section-badge--green">{vehicles.length} </span>
</div>
{vehicles.map((v) => <DbVehicleCard key={v.id || v.index} vehicle={v} />)}
</div>
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">审批情况</span></div>
<div className="tc-timeline">
{approvalSteps.map((step, idx) => (
<div className="tc-step" key={idx}>
<div className={`tc-step-dot ${step.status === '已通过' ? 'done' : 'wait--green'}`}>{step.status === '已通过' ? '✓' : ''}</div>
<div>
<div className="tc-step-title">{step.department}</div>
<div className="tc-step-meta">状态{step.status}<br />审批人{step.person}<br />时间{step.approveTime}</div>
</div>
</div>
))}
</div>
</div>
<div style={{ height: showActions ? 16 : 24 }} />
</div>
{showActions && !decisionOpen && !commentOpen && (
<ApprovalActionBar mode={mode} onComment={() => setCommentOpen(true)} onTerminate={() => openDecision('terminate')} onReject={() => openDecision('reject')} onApprove={() => openDecision('approve')} />
)}
<ApprovalDecisionDrawer open={decisionOpen} actionType={decisionType} onClose={() => setDecisionOpen(false)} onConfirm={handleDecisionConfirm} />
<ApprovalCommentDrawer open={commentOpen} onClose={() => setCommentOpen(false)} onConfirm={() => message.success('评论已添加(原型)')} />
</>
);
};
const LeaseBillApprovePage = ({ task, mode, onBack }) => {
const detail = useMemo(() => buildLeaseBillDetail(task), [task]);
const { billInfo, vehicles, isFirstPeriod, hydrogen, approvalSteps } = detail;
const totals = useMemo(() => calcLeaseBillTotals(vehicles, hydrogen, isFirstPeriod), [vehicles, hydrogen, isFirstPeriod]);
const [receivableDrawerOpen, setReceivableDrawerOpen] = useState(false);
const [actualDrawerOpen, setActualDrawerOpen] = useState(false);
const displayActualTotal = task?.actualAmount || totals.actualTotal;
const receivableRows = [
{ label: '总计应收车辆月租金', value: formatYuan(totals.receivableRent) },
{ label: '总计应收车辆保证金', value: formatYuan(totals.receivableDeposit) },
{ label: '总计应收服务费', value: formatYuan(totals.receivableService) },
];
if (isFirstPeriod) receivableRows.push({ label: '氢费预付款应收金额', value: formatYuan(totals.hydrogenReceivable), highlight: true });
const actualRows = [
{ label: '总计实收车辆月租金', value: formatYuan(totals.actualRent) },
{ label: '总计应收车辆保证金', value: formatYuan(totals.receivableDeposit) },
{ label: '总计实收服务费', value: formatYuan(totals.actualService) },
{ label: '总计减免金额', value: formatYuan(totals.discountTotal) },
{ label: '服务费各项减免费用总和', value: formatYuan(totals.serviceDiscountTotal) },
];
if (isFirstPeriod) {
actualRows.push({ label: '氢费预付款实收金额', value: formatYuan(totals.hydrogenActual), highlight: true });
actualRows.push({ label: '氢费预付款减免金额', value: formatYuan(totals.hydrogenDiscount) });
}
const [decisionOpen, setDecisionOpen] = useState(false);
const [decisionType, setDecisionType] = useState('approve');
const [commentOpen, setCommentOpen] = useState(false);
const showActions = mode === 'approve' || mode === 'view';
const openDecision = (type) => { setDecisionType(type); setDecisionOpen(true); };
const handleDecisionConfirm = (payload) => {
const meta = APPROVAL_DECISION_META[payload.actionType];
if (payload.actionType === 'reject' || payload.actionType === 'terminate') message.warning(meta?.success || '操作成功(原型)');
else message.success(meta?.success || '操作成功(原型)');
if (mode === 'approve') onBack();
};
return (
<>
<div className="tc-scroll">
<div className="tc-hero tc-hero--bill">
<div className="tc-hero-label">实收款总额</div>
<div className="tc-hero-amount">{formatMoneySymbol(displayActualTotal)}</div>
<div className="tc-hero-meta">
<span>{billInfo.customerName}</span>
</div>
<div className="tc-hero-foot tc-hero-foot--compact">
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">合同编码</span>{billInfo.contractCode || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">项目名称</span>{billInfo.projectName || ''}</div>
</div>
<div className="tc-hero-period">
<span>账单周期{billInfo.billStartDate}{billInfo.billEndDate}</span>
{billInfo.period != null && <span className="tc-hero-period-tag">{billInfo.period}</span>}
</div>
<div className="tc-hero-sub">
<div className="tc-hero-compare">
应收款总额 {formatMoneySymbol(totals.receivableTotal)}
<br />
开票总额 {formatMoneySymbol(totals.invoiceTotal)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button type="button" className="tc-hero-detail-btn" onClick={() => setReceivableDrawerOpen(true)}>应收明细</button>
<button type="button" className="tc-hero-detail-btn" onClick={() => setActualDrawerOpen(true)}>实收明细</button>
</div>
</div>
</div>
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">账单明细 · 车辆列表</span>
<span className="tc-section-badge tc-section-badge--blue">{vehicles.length} </span>
</div>
{vehicles.map((v) => <ZlVehicleCard key={v.key} vehicle={v} isFirstPeriod={isFirstPeriod} />)}
</div>
{isFirstPeriod && (
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">氢费预付款情况</span></div>
<div className="tc-kv-grid">
<TcInfoRow label="氢费预付款应收金额" value={formatYuan(hydrogen.receivable)} />
<TcInfoRow label="氢费预付款实收金额" value={formatYuan(hydrogen.actual)} />
<TcInfoRow label="减免金额" value={formatYuan(hydrogen.discount)} />
<TcInfoRow label="减免金额备注" value={hydrogen.discountRemark} full />
</div>
</div>
)}
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">审批情况</span></div>
<div className="tc-timeline">
{approvalSteps.map((step, idx) => (
<div className="tc-step" key={idx}>
<div className={`tc-step-dot ${step.status === '已通过' ? 'done' : 'wait--blue'}`}>{step.status === '已通过' ? '✓' : ''}</div>
<div>
<div className="tc-step-title">{step.department}</div>
<div className="tc-step-meta">状态{step.status}<br />审批人{step.person}<br />时间{step.approveTime}</div>
</div>
</div>
))}
</div>
</div>
<div style={{ height: showActions ? 16 : 24 }} />
</div>
{showActions && !decisionOpen && !commentOpen && (
<ApprovalActionBar
mode={mode}
onComment={() => setCommentOpen(true)}
onTerminate={() => openDecision('terminate')}
onReject={() => openDecision('reject')}
onApprove={() => openDecision('approve')}
/>
)}
<ApprovalDecisionDrawer open={decisionOpen} actionType={decisionType} onClose={() => setDecisionOpen(false)} onConfirm={handleDecisionConfirm} />
<ApprovalCommentDrawer open={commentOpen} onClose={() => setCommentOpen(false)} onConfirm={() => message.success('评论已添加(原型)')} />
<TcMiniBottomSheet
open={receivableDrawerOpen}
title="应收款明细"
onClose={() => setReceivableDrawerOpen(false)}
rows={receivableRows}
totalLabel="应收款总额"
totalValue={formatMoneySymbol(totals.receivableTotal)}
totalTheme="blue"
/>
<TcMiniBottomSheet
open={actualDrawerOpen}
title="实收款明细"
onClose={() => setActualDrawerOpen(false)}
rows={actualRows}
totalLabel="实收款总额"
totalValue={formatMoneySymbol(displayActualTotal)}
totalTheme="blue"
/>
</>
);
};
const DEFAULT_REPLACE_PAIRS = [
{
id: 'pair_1', replaceType: '永久替换', replaceReason: '车辆原因',
replaceReasonDesc: '原车故障需维修,临时用替换车保障客户用车。',
originalPlate: '浙A12345', originalBrand: '东风', originalModel: 'DFH1180',
replacePlate: '浙A67890', replaceBrand: '福田', replaceModel: 'BJ1180',
},
{
id: 'pair_2', replaceType: '临时替换', replaceReason: '客户原因', replaceFee: '500.00',
replaceReasonDesc: '',
originalPlate: '浙A55555', originalBrand: '重汽', originalModel: 'ZZ1160',
replacePlate: '浙A66666', replaceBrand: '江淮', replaceModel: 'HFC1190',
},
];
const buildReplaceVehicleDetail = (task) => {
const pairs = (task?.pairs && task.pairs.length) ? task.pairs : DEFAULT_REPLACE_PAIRS;
const projectInfo = {
customerName: task?.customerName || '嘉兴某某物流有限公司',
projectName: task?.projectName || '嘉兴氢能示范项目',
projectType: task?.projectType || '租赁',
contractCode: task?.contractCode || 'HT-ZL-2025-001',
deliveryRegion: task?.deliveryRegion || '浙江省-嘉兴市',
};
const approvalSteps = task?.approvalSteps || [
{ title: '业务部主管', department: '业务部主管', status: '已通过', person: '姚守涛', approveTime: '2026-02-18 10:05' },
{ title: '事业部主管', department: '事业部主管', status: '已通过', person: '尚建华', approveTime: '2026-02-18 10:40' },
{ title: '运维主管', department: '运维主管', status: '待审批', person: '张明辉', approveTime: '—' },
];
return { pairs, projectInfo, approvalSteps };
};
const VrReplaceVehicleCard = ({ pair, index }) => (
<div className="tc-section">
<div className="tc-section-head">
<span className="tc-section-title">车辆替换 · #{index + 1}</span>
<span className="tc-section-badge tc-section-badge--rose">{pair.replaceType || '—'}</span>
</div>
<div className="vr-replace-swap">
<div className="vr-replace-side vr-replace-side--old">
<span className="vr-replace-side-tag">被替换</span>
<div className="tc-vehicle-plate">{pair.originalPlate || '—'}</div>
<div className="tc-vehicle-model">{pair.originalBrand || '—'} · {pair.originalModel || '—'}</div>
</div>
<div className="vr-replace-mid" aria-hidden="true">
<span className="vr-replace-mid-icon"></span>
</div>
<div className="vr-replace-side vr-replace-side--new">
<span className="vr-replace-side-tag">替换为</span>
<div className="tc-vehicle-plate">{pair.replacePlate || '—'}</div>
<div className="tc-vehicle-model">{pair.replaceBrand || '—'} · {pair.replaceModel || '—'}</div>
</div>
</div>
<div className="tc-kv-grid vr-replace-reason">
<TcInfoRow label="替换原因" value={pair.replaceReason} />
{pair.replaceReason === '客户原因' ? (
<TcInfoRow label="替换费用" value={pair.replaceFee ? formatMoneySymbol(pair.replaceFee) : '—'} />
) : null}
<TcInfoRow label="替换原因说明" value={pair.replaceReasonDesc || '—'} full />
</div>
</div>
);
const getAuditPrdKey = (task) => {
if (!task) return 'audit';
if (task.flowType === '提车应收款') return 'audit-pickup';
if (task.flowType === '还车应结款') return 'audit-return';
if (task.flowType === '租赁账单') return 'audit-lease-bill';
if (task.flowType === '替换车申请') return 'audit-replace';
if (task.flowType === '车辆调拨') return task.transferStage === 'ops' ? 'audit-transfer-ops' : 'audit-transfer-create';
return 'audit';
};
const ReplaceVehicleApprovePage = ({ task, mode, onBack }) => {
const detail = useMemo(() => buildReplaceVehicleDetail(task), [task]);
const { pairs, projectInfo, approvalSteps } = detail;
const [decisionOpen, setDecisionOpen] = useState(false);
const [decisionType, setDecisionType] = useState('approve');
const [commentOpen, setCommentOpen] = useState(false);
const showActions = mode === 'approve' || mode === 'view';
const openDecision = (type) => { setDecisionType(type); setDecisionOpen(true); };
const handleDecisionConfirm = (payload) => {
const meta = APPROVAL_DECISION_META[payload.actionType];
if (payload.actionType === 'reject' || payload.actionType === 'terminate') message.warning(meta?.success || '操作成功(原型)');
else message.success(meta?.success || '操作成功(原型)');
if (mode === 'approve') onBack();
};
return (
<>
<div className="tc-scroll">
<div className="tc-hero tc-hero--replace">
<div className="tc-hero-label">替换车辆</div>
<div className="tc-hero-amount">{pairs.length} </div>
<div className="tc-hero-meta">
<span>{projectInfo.customerName}</span>
</div>
<div className="tc-hero-foot tc-hero-foot--compact">
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">合同编码</span>{projectInfo.contractCode || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">项目名称</span>{projectInfo.projectName || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">交车区域</span>{projectInfo.deliveryRegion || ''}</div>
</div>
<div className="tc-hero-period">
<span className="tc-hero-period-tag">{projectInfo.projectType || '租赁'}</span>
</div>
</div>
{pairs.map((pair, index) => (
<VrReplaceVehicleCard key={pair.id || index} pair={pair} index={index} />
))}
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">审批情况</span></div>
<div className="tc-timeline">
{approvalSteps.map((step, idx) => (
<div className="tc-step" key={idx}>
<div className={`tc-step-dot ${step.status === '已通过' ? 'done' : 'wait--rose'}`}>{step.status === '已通过' ? '✓' : ''}</div>
<div>
<div className="tc-step-title">{step.department || step.title}</div>
<div className="tc-step-meta">状态{step.status}<br />审批人{step.person}<br />时间{step.approveTime}</div>
</div>
</div>
))}
</div>
</div>
<div style={{ height: showActions ? 16 : 24 }} />
</div>
{showActions && !decisionOpen && !commentOpen && (
<ApprovalActionBar mode={mode} onComment={() => setCommentOpen(true)} onTerminate={() => openDecision('terminate')} onReject={() => openDecision('reject')} onApprove={() => openDecision('approve')} />
)}
<ApprovalDecisionDrawer open={decisionOpen} actionType={decisionType} onClose={() => setDecisionOpen(false)} onConfirm={handleDecisionConfirm} />
<ApprovalCommentDrawer open={commentOpen} onClose={() => setCommentOpen(false)} onConfirm={() => message.success('评论已添加(原型)')} />
</>
);
};
const ApprovalCenterModule = ({ onRegisterBack, onPrdKeyChange }) => {
const [mainTab, setMainTab] = useState('todo');
const [flowFilter, setFlowFilter] = useState('');
const [searchKey, setSearchKey] = useState('');
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [detailTask, setDetailTask] = useState(null);
useEffect(() => {
onPrdKeyChange?.(getAuditPrdKey(detailTask));
return () => onPrdKeyChange?.(null);
}, [detailTask, onPrdKeyChange]);
const tabCounts = useMemo(() => {
const counts = { initiated: 0, todo: 0, done: 0, cc: 0 };
AC_MOCK_TASKS.forEach((t) => {
AC_TAB_ITEMS.forEach((tab) => {
if (acFilterByTab(t, tab.key, MOCK_USER)) counts[tab.key] += 1;
});
});
return counts;
}, []);
const filteredList = useMemo(() => {
const q = searchKey.trim().toLowerCase();
let list = AC_MOCK_TASKS.filter((t) => acFilterByTab(t, mainTab, MOCK_USER));
if (flowFilter) list = list.filter((t) => t.flowType === flowFilter);
if (q) {
list = list.filter(
(t) =>
t.bizNo.toLowerCase().includes(q)
|| t.summary.toLowerCase().includes(q)
|| t.flowType.toLowerCase().includes(q)
|| t.initiator.toLowerCase().includes(q)
|| (t.customerName && t.customerName.toLowerCase().includes(q))
|| (t.projectName && t.projectName.toLowerCase().includes(q))
|| (t.plateNo && t.plateNo.toLowerCase().includes(q))
);
}
return [...list].sort((a, b) => String(b.arriveTime || b.initiateTime || '').localeCompare(String(a.arriveTime || a.initiateTime || '')));
}, [mainTab, flowFilter, searchKey]);
const handleCardClick = useCallback((task) => {
if (task.flowType === '提车应收款' || task.flowType === '还车应结款' || task.flowType === '租赁账单' || task.flowType === '车辆调拨' || task.flowType === '替换车申请') {
setDetailTask(task);
return;
}
if (mainTab === 'todo') message.info(`打开「${task.flowType}」审批办理页(原型)\n单据:${task.bizNo}`);
else message.info(`查看「${task.flowType}」详情(原型)\n单据:${task.bizNo}`);
}, [mainTab]);
useEffect(() => {
if (!onRegisterBack) return undefined;
onRegisterBack(() => {
if (detailTask) { setDetailTask(null); return true; }
return false;
});
return () => onRegisterBack(null);
}, [detailTask, onRegisterBack]);
if (detailTask) {
const flowType = detailTask.flowType;
const Detail = flowType === '还车应结款'
? ReturnSettlementApprovePage
: flowType === '租赁账单'
? LeaseBillApprovePage
: flowType === '车辆调拨'
? (detailTask.transferStage === 'ops' ? TransferOpsApprovePage : TransferCreateApprovePage)
: flowType === '替换车申请'
? ReplaceVehicleApprovePage
: PickupReceivableApprovePage;
return (
<div className="xll-mod-root">
<div className="xll-mod-detail-wrap">
<Detail task={detailTask} mode={mainTab === 'todo' ? 'approve' : 'view'} onBack={() => setDetailTask(null)} />
</div>
</div>
);
}
const activeTabLabel = AC_TAB_ITEMS.find((t) => t.key === mainTab)?.label || '';
return (
<div className="xll-mod-root">
<div className="xll-mod-tabs" role="tablist" aria-label="审批列表分类">
{AC_TAB_ITEMS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={mainTab === tab.key}
className={`xll-mod-tab${mainTab === tab.key ? ' active' : ''}`}
onClick={() => setMainTab(tab.key)}
>
{tab.short}
<span className="xll-mod-tab-count">{tabCounts[tab.key]} </span>
</button>
))}
</div>
<div className="xll-mod-toolbar">
<div className="xll-mod-search">
<IconSearch />
<input type="search" placeholder="搜索单据号、摘要、发起人" value={searchKey} onChange={(e) => setSearchKey(e.target.value)} aria-label="搜索审批任务" />
</div>
<div className="xll-mod-chips" role="group" aria-label="流程类型筛选">
<button type="button" className={`xll-mod-chip${!flowFilter ? ' active' : ''}`} onClick={() => setFlowFilter('')}>全部</button>
{AC_QUICK_FILTERS.map((type) => (
<button key={type} type="button" className={`xll-mod-chip${flowFilter === type ? ' active' : ''}`} onClick={() => setFlowFilter(flowFilter === type ? '' : type)}>{type}</button>
))}
<button type="button" className="xll-mod-chip" style={{ borderStyle: 'dashed' }} onClick={() => setFilterDrawerOpen(true)}>更多类型</button>
</div>
</div>
<div className="xll-mod-list-head"><span>{activeTabLabel}</span><span> {filteredList.length} </span></div>
<div className="xll-mod-list">
{filteredList.length === 0 ? (
<div className="xll-mod-empty">暂无{activeTabLabel}任务<br />{searchKey || flowFilter ? '试试清空搜索或切换流程类型' : '新的审批任务到达后将在此展示'}</div>
) : (
filteredList.map((task) => {
const theme = AC_FLOW_THEME[task.flowType] || { accent: XLL_GREEN_DEEP, soft: XLL_GREEN_SOFT };
const stCls = acStatusClass(task.status);
const isPickup = task.flowType === '提车应收款';
const isReturn = task.flowType === '还车应结款';
const isBill = task.flowType === '租赁账单';
const isTransfer = task.flowType === '车辆调拨';
const isReplace = task.flowType === '替换车申请';
const isCustomerFlow = isBill || isPickup || isReturn || isReplace;
const returnSettle = isReturn ? acReturnSettleDisplay(task) : null;
const statusLabel = acTaskStatusLabel(task);
return (
<div
key={task.id}
className="xll-mod-card"
style={{ '--mod-accent': theme.accent, '--mod-soft': theme.soft }}
role="button"
tabIndex={0}
onClick={() => handleCardClick(task)}
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(task)}
>
<div className="xll-mod-card-head">
<span className="xll-mod-card-type">{task.flowType}</span>
<span className={`xll-mod-card-status ${stCls}${statusLabel !== task.status ? ' with-approvers' : ''}`}>{statusLabel}</span>
</div>
<div className="xll-mod-card-title">
{isCustomerFlow ? (task.customerName || '—') : isTransfer ? acTransferRouteTitle(task) : task.bizNo}
</div>
{!isTransfer && (
<div className="xll-mod-card-sub">
{isCustomerFlow ? (task.projectName || '—') : task.summary}
</div>
)}
{isBill && task.billStartDate && task.billEndDate && (
<div className="xll-mod-card-period">
<span>账单周期{task.billStartDate}{task.billEndDate}</span>
{task.period != null && <span className="xll-mod-card-period-tag">{task.period}</span>}
</div>
)}
{isTransfer && (
<>
<div className="xll-mod-card-vehicles">
{acTransferVehicleLines(task.vehicles).map((row) => (
<div key={row.label} className="xll-mod-card-vehicle-line">{row.label} {row.count}</div>
))}
</div>
{task.transferDate && (
<div className="xll-mod-card-period">
<span>调拨日期{task.transferDate}</span>
</div>
)}
</>
)}
{isReplace && (task.pairs || []).length > 0 && (
<div className="xll-mod-card-vehicles">
{(task.pairs || []).map((p) => (
<div key={p.id || p.originalPlate} className="xll-mod-card-vehicle-line">{p.originalPlate} {p.replacePlate}</div>
))}
</div>
)}
<div className="xll-mod-meta">
<div><span className="xll-mod-meta-label">发起人 </span><span className="xll-mod-meta-val">{task.initiator}</span></div>
{isPickup && <div><span className="xll-mod-meta-label">实收金额 </span><span className="xll-mod-meta-val" style={{ color: '#F97316', fontWeight: 700 }}>{formatMoney(task.actualAmount)}</span></div>}
{isBill && <div><span className="xll-mod-meta-label">实收金额 </span><span className="xll-mod-meta-val" style={{ color: '#0EA5E9', fontWeight: 700 }}>{formatMoney(task.actualAmount)}</span></div>}
{returnSettle && <div><span className="xll-mod-meta-label">{returnSettle.label} </span><span className="xll-mod-meta-val" style={{ color: '#8B5CF6', fontWeight: 700 }}>{formatMoney(returnSettle.amount)}</span></div>}
{isReplace && <div><span className="xll-mod-meta-label">替换车辆 </span><span className="xll-mod-meta-val" style={{ color: '#F43F5E', fontWeight: 700 }}>{(task.pairs || []).length || task.vehicleCount || 0} </span></div>}
</div>
<div className="xll-mod-card-foot">
<span style={{ fontSize: 12, color: COLOR_MUTED }}>发起时间 {task.initiateTime}</span>
{mainTab === 'todo' && (
<button type="button" className="xll-mod-card-btn" onClick={(e) => { e.stopPropagation(); handleCardClick(task); }}>去审批</button>
)}
</div>
</div>
);
})
)}
</div>
{Drawer ? (
<Drawer title="选择流程类型" placement="bottom" height={420} open={filterDrawerOpen} onClose={() => setFilterDrawerOpen(false)} styles={{ body: { padding: '12px 16px 24px' } }}>
<div className="xll-mod-drawer-types">
<button type="button" className={`xll-mod-drawer-type-btn${!flowFilter ? ' active' : ''}`} onClick={() => { setFlowFilter(''); setFilterDrawerOpen(false); }}>全部类型</button>
{AC_FLOW_TYPES.map((type) => (
<button key={type} type="button" className={`xll-mod-drawer-type-btn${flowFilter === type ? ' active' : ''}`} onClick={() => { setFlowFilter(type); setFilterDrawerOpen(false); }}>{type}</button>
))}
</div>
</Drawer>
) : null}
</div>
);
};
const AnnualReviewModule = ({ onRegisterBack }) => {
const [mainTab, setMainTab] = useState('pending');
const [searchKey, setSearchKey] = useState('');
const [operateTask, setOperateTask] = useState(null);
const [historyTask, setHistoryTask] = useState(null);
const [form, setForm] = useState({ station: '嘉兴机动车检测站', cost: '380', validUntil: '2027-07-31', remark: '' });
const [tasks, setTasks] = useState(AR_MOCK_TASKS);
const filteredList = useMemo(() => {
const q = searchKey.trim().toUpperCase();
let list = tasks.filter((t) => t.tab === mainTab);
if (q) list = list.filter((t) => t.plateNo.toUpperCase().includes(q));
return mainTab === 'pending' ? arSortPending(list) : list;
}, [tasks, mainTab, searchKey]);
const tabCounts = useMemo(() => ({
pending: tasks.filter((t) => t.tab === 'pending').length,
history: tasks.filter((t) => t.tab === 'history').length,
}), [tasks]);
const handleCardClick = useCallback((task) => {
if (task.tab === 'pending') {
setForm({ station: '嘉兴机动车检测站', cost: '380', validUntil: '2027-07-31', remark: '' });
setOperateTask(task);
return;
}
setHistoryTask(task);
}, []);
const handleSubmit = useCallback(() => {
if (!form.station || !form.cost || !form.validUntil) {
message.warning('请填写检测服务站、费用和检验有效期');
return;
}
setTasks((prev) => prev.filter((t) => t.id !== operateTask.id).concat({
...operateTask,
tab: 'history',
executor: MOCK_USER,
executeTime: '2026-06-01 10:00',
newValidUntil: form.validUntil,
station: form.station,
cost: form.cost,
}));
message.success('年审办理完成(原型)');
setOperateTask(null);
}, [form, operateTask]);
useEffect(() => {
if (!onRegisterBack) return undefined;
onRegisterBack(() => {
if (operateTask) { setOperateTask(null); return true; }
if (historyTask) { setHistoryTask(null); return true; }
return false;
});
return () => onRegisterBack(null);
}, [operateTask, historyTask, onRegisterBack]);
if (historyTask) {
const t = historyTask;
return (
<div className="xll-mod-root">
<div className="xll-mod-scroll" style={{ paddingBottom: 24 }}>
<div className="xll-mod-hero green">
<div className="xll-mod-hero-label">检验有效期</div>
<div className="xll-mod-hero-amt" style={{ fontSize: 22 }}>{t.newValidUntil || t.expireDate}</div>
<div className="xll-mod-hero-meta">{t.plateNo} · {t.brand} {t.model}</div>
</div>
<div className="xll-mod-section">
<div className="xll-mod-section-title">办理记录</div>
<div className="xll-mod-form-row"><span className="xll-mod-form-label">办理人</span><span className="xll-mod-form-value">{t.executor}</span></div>
<div className="xll-mod-form-row"><span className="xll-mod-form-label">完成时间</span><span className="xll-mod-form-value">{t.executeTime}</span></div>
<div className="xll-mod-form-row"><span className="xll-mod-form-label">检测服务站</span><span className="xll-mod-form-value">{t.station || ''}</span></div>
<div className="xll-mod-form-row"><span className="xll-mod-form-label">费用</span><span className="xll-mod-form-value">{t.cost ? formatMoneySymbol(t.cost) : ''}</span></div>
<div className="xll-mod-form-row"><span className="xll-mod-form-label">运营区域</span><span className="xll-mod-form-value">{t.province} {t.city}</span></div>
</div>
</div>
</div>
);
}
if (operateTask) {
const t = operateTask;
return (
<div className="xll-mod-root xll-mod-form-page">
<div className="xll-mod-scroll">
<div className="xll-mod-section">
<div className="xll-mod-section-title">车辆信息</div>
<div className="xll-mod-form-row"><span className="xll-mod-form-label">车牌号</span><span className="xll-mod-form-value xll-mod-ar-plate">{t.plateNo}</span></div>
<div className="xll-mod-form-row"><span className="xll-mod-form-label">品牌型号</span><span className="xll-mod-form-value">{t.brand} · {t.model}</span></div>
<div className="xll-mod-form-row"><span className="xll-mod-form-label">检验有效期</span><span className="xll-mod-form-value">{t.expireDate}</span></div>
<div className="xll-mod-form-row"><span className="xll-mod-form-label">运营状态</span><span className="xll-mod-form-value">{t.operateStatus}</span></div>
</div>
<div className="xll-mod-section">
<div className="xll-mod-section-title">更新行驶证</div>
<div className="xll-mod-upload" onClick={() => message.info('上传行驶证照片(原型)')} role="button" tabIndex={0}>点击上传行驶证照片<br /><span style={{ fontSize: 12 }}>上传后自动识别检验有效期</span></div>
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">检验有效期</span>
<input className="xll-mod-form-input" type="date" value={form.validUntil} onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} />
</div>
</div>
<div className="xll-mod-section">
<div className="xll-mod-section-title">检测服务站信息</div>
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">检测服务站</span>
<input className="xll-mod-form-input xll-mod-form-picker" value={form.station} onChange={(e) => setForm((f) => ({ ...f, station: e.target.value }))} placeholder="请选择" />
</div>
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">费用</span>
<input className="xll-mod-form-input" type="number" value={form.cost} onChange={(e) => setForm((f) => ({ ...f, cost: e.target.value }))} placeholder="0.00" />
</div>
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">备注</span>
<input className="xll-mod-form-input" value={form.remark} onChange={(e) => setForm((f) => ({ ...f, remark: e.target.value }))} placeholder="检测备注" />
</div>
</div>
</div>
<div className="xll-mod-foot-btns">
<button type="button" style={{ background: COLOR_PAGE, color: COLOR_TEXT_SEC, border: `1px solid ${COLOR_LINE}` }} onClick={() => { message.success('草稿已保存(原型)'); }}>保存</button>
<button type="button" style={{ background: XLL_GREEN, color: '#fff' }} onClick={handleSubmit}>提交</button>
</div>
</div>
);
}
return (
<div className="xll-mod-root">
<div className="xll-mod-tabs" role="tablist">
<button type="button" role="tab" aria-selected={mainTab === 'pending'} className={`xll-mod-tab${mainTab === 'pending' ? ' active' : ''}`} onClick={() => setMainTab('pending')}>
待处理<span className="xll-mod-tab-count">{tabCounts.pending} </span>
</button>
<button type="button" role="tab" aria-selected={mainTab === 'history'} className={`xll-mod-tab${mainTab === 'history' ? ' active' : ''}`} onClick={() => setMainTab('history')}>
历史记录<span className="xll-mod-tab-count">{tabCounts.history} </span>
</button>
</div>
<div className="xll-mod-toolbar">
<div className="xll-mod-search">
<IconSearch />
<input type="search" placeholder="搜索车牌号" value={searchKey} onChange={(e) => setSearchKey(e.target.value)} aria-label="搜索车牌" />
</div>
</div>
<div className="xll-mod-list-head"><span>{mainTab === 'pending' ? '待办任务' : '历史记录'}</span><span> {filteredList.length} </span></div>
<div className="xll-mod-list">
{filteredList.length === 0 ? (
<div className="xll-mod-empty">暂无{mainTab === 'pending' ? '待办' : '历史'}年审任务</div>
) : (
filteredList.map((task) => {
const tag = arDaysTag(task);
return (
<div key={task.id} className="xll-mod-card" style={{ '--mod-accent': COLOR_WARN, '--mod-soft': 'rgba(255,125,0,.12)' }} role="button" tabIndex={0} onClick={() => handleCardClick(task)} onKeyDown={(e) => e.key === 'Enter' && handleCardClick(task)}>
<div className="xll-mod-card-head">
<span className="xll-mod-ar-plate">{task.plateNo}{tag ? <span className={`xll-mod-ar-tag ${tag.cls}`}>{tag.text}</span> : null}</span>
</div>
<div className="xll-mod-meta">
<div><span className="xll-mod-meta-label">运营状态 </span><span className="xll-mod-meta-val">{task.operateStatus}</span></div>
<div><span className="xll-mod-meta-label">运营区域 </span><span className="xll-mod-meta-val">{task.province} {task.city}</span></div>
<div><span className="xll-mod-meta-label">到期时间 </span><span className="xll-mod-meta-val">{task.expireDate}</span></div>
{task.tab === 'history' && (
<div><span className="xll-mod-meta-label">办理人 </span><span className="xll-mod-meta-val">{task.executor}</span></div>
)}
</div>
{task.tab === 'pending' && (
<div className="xll-mod-card-foot">
<span style={{ fontSize: 12, color: COLOR_MUTED }}>{task.brand} · {task.model}</span>
<button type="button" className="xll-mod-card-btn" onClick={(e) => { e.stopPropagation(); handleCardClick(task); }}>去办理</button>
</div>
)}
</div>
);
})
)}
</div>
</div>
);
};
const DV_EQUIP_PHOTO_DEMO = {
ad: 'https://picsum.photos/seed/dv-ad-photo/400/300',
bigWord: 'https://picsum.photos/seed/dv-bigword-photo/400/300',
};
const DvCameraViewfinder = ({
photoUrl,
alt = '',
placeholder = '相机取景中…',
zoom = 1,
onZoomChange,
focusPoint,
onFocusTap,
onImgError,
showFocusTip = true,
}) => {
const wrapRef = useRef(null);
const handleTap = (e) => {
if (!onFocusTap || !wrapRef.current) return;
const rect = wrapRef.current.getBoundingClientRect();
if (!rect.width || !rect.height) return;
onFocusTap({
x: Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)),
y: Math.max(0, Math.min(100, ((e.clientY - rect.top) / rect.height) * 100)),
});
};
const origin = focusPoint || { x: 50, y: 50 };
return (
<div className="xll-dv-camera-viewfinder" ref={wrapRef} onClick={handleTap} role="presentation">
<div
className="xll-dv-camera-preview"
style={{ transform: `scale(${zoom})`, transformOrigin: `${origin.x}% ${origin.y}%` }}
>
{photoUrl ? (
<img src={photoUrl} alt={alt} onError={onImgError} />
) : (
<span className="xll-dv-photo-camera-placeholder">{placeholder}</span>
)}
</div>
{focusPoint ? (
<span className="xll-dv-camera-focus-ring" style={{ left: `${focusPoint.x}%`, top: `${focusPoint.y}%` }} aria-hidden />
) : null}
{onZoomChange ? (
<div className="xll-dv-camera-zoom" onClick={(e) => e.stopPropagation()} role="group" aria-label="调焦倍数">
<button type="button" className="xll-dv-camera-zoom-btn" onClick={() => onZoomChange(Math.max(1, Math.round((zoom - 0.1) * 10) / 10))} aria-label="缩小"></button>
<span className="xll-dv-camera-zoom-val">{zoom.toFixed(1)}×</span>
<button type="button" className="xll-dv-camera-zoom-btn" onClick={() => onZoomChange(Math.min(3, Math.round((zoom + 0.1) * 10) / 10))} aria-label="放大">+</button>
</div>
) : null}
{showFocusTip ? <span className="xll-dv-camera-focus-tip">点击画面调焦</span> : null}
</div>
);
};
const DeliveryModule = ({ onRegisterBack }) => {
const [orders, setOrders] = useState(DV_MOCK_ORDERS);
const [listFilter, setListFilter] = useState('inProgress');
const [statusFilter, setStatusFilter] = useState('');
const [searchKey, setSearchKey] = useState('');
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [moreFilter, setMoreFilter] = useState(DV_EMPTY_MORE_FILTER);
const [filterDraft, setFilterDraft] = useState(DV_EMPTY_FILTER_DRAFT);
const [activeRow, setActiveRow] = useState(null);
const [formDraft, setFormDraft] = useState(null);
const [vehiclePickOpen, setVehiclePickOpen] = useState(false);
const [vehiclePickSearch, setVehiclePickSearch] = useState('');
const [vehiclePickParking, setVehiclePickParking] = useState('all');
const [parkingSheetOpen, setParkingSheetOpen] = useState(false);
const [plateValidateModal, setPlateValidateModal] = useState(null);
const [clearSignConfirmOpen, setClearSignConfirmOpen] = useState(false);
const [spareTireCaptureOpen, setSpareTireCaptureOpen] = useState(false);
const [spareTireCaptureDraft, setSpareTireCaptureDraft] = useState({ photoUrl: '', treadDepth: '' });
const [dvCameraFocus, setDvCameraFocus] = useState(null);
const [dvCameraZoom, setDvCameraZoom] = useState(1);
const [dvPhotoCamera, setDvPhotoCamera] = useState(null);
const [photoSourceSheet, setPhotoSourceSheet] = useState(null);
const [driverManualOpen, setDriverManualOpen] = useState(false);
const [driverManualStep, setDriverManualStep] = useState('form');
const [driverETrainingOpen, setDriverETrainingOpen] = useState(false);
const [driverManualDraft, setDriverManualDraft] = useState(DV_DRIVER_MANUAL_EMPTY);
const [deliveryFormStep, setDeliveryFormStep] = useState('vehicle');
const [photoCapturePhase, setPhotoCapturePhase] = useState('ready');
const [photoCaptureMode, setPhotoCaptureMode] = useState('continuous');
const [photoCaptureIndex, setPhotoCaptureIndex] = useState(0);
const [photoCountdown, setPhotoCountdown] = useState(3);
const [photoCameraDraft, setPhotoCameraDraft] = useState({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
const [photoViewer, setPhotoViewer] = useState(null);
const [deliverySignFlow, setDeliverySignFlow] = useState(null);
const [deliverySignSuccessCd, setDeliverySignSuccessCd] = useState(3);
const deliverySignTimerRef = useRef(null);
const photoViewerTouchRef = useRef({ x: 0, y: 0 });
const ocrDemoIdxRef = useRef(0);
const spareTreadOcrIdxRef = useRef(0);
const photoCaptureBusyRef = useRef(false);
const photoTreadOcrIdxRef = useRef(0);
const deliveryLocAutoRef = useRef('');
const [locationFetching, setLocationFetching] = useState(false);
const rows = useMemo(() => dvFlattenOrders(orders), [orders]);
const kpi = useMemo(() => ({
all: rows.length,
inProgress: rows.filter((r) => dvIsInProgressStatus(r.deliveryStatus)).length,
completed: rows.filter((r) => dvIsHistoryStatus(r.deliveryStatus)).length,
}), [rows]);
const activeMoreFilterCount = useMemo(() => {
let n = 0;
if (moreFilter.customerName.trim()) n += 1;
if (moreFilter.projectName.trim()) n += 1;
if (moreFilter.dateStart || moreFilter.dateEnd) n += 1;
return n;
}, [moreFilter]);
const activeFilterCount = useMemo(() => activeMoreFilterCount + (statusFilter ? 1 : 0), [activeMoreFilterCount, statusFilter]);
const filteredList = useMemo(() => {
const q = searchKey.trim().toUpperCase();
const customerQ = moreFilter.customerName.trim();
const projectQ = moreFilter.projectName.trim();
let list = rows;
if (listFilter === 'inProgress') list = list.filter((r) => dvIsInProgressStatus(r.deliveryStatus));
if (listFilter === 'completed') list = list.filter((r) => dvIsHistoryStatus(r.deliveryStatus));
if (statusFilter) list = list.filter((r) => r.deliveryStatus === statusFilter);
if (q) {
list = list.filter((r) => {
const plate = dvDisplayPlate(r.plateNo).toUpperCase();
const customer = (r.customerName || '').toUpperCase();
const project = (r.projectName || '').toUpperCase();
return plate.includes(q) || customer.includes(q) || project.includes(q);
});
}
if (customerQ) list = list.filter((r) => (r.customerName || '').includes(customerQ));
if (projectQ) list = list.filter((r) => (r.projectName || '').includes(projectQ));
if (moreFilter.dateStart || moreFilter.dateEnd) {
list = list.filter((r) => dvRowMatchesDateRange(r, moreFilter.dateStart, moreFilter.dateEnd));
}
return list;
}, [rows, listFilter, statusFilter, searchKey, moreFilter]);
const isHistoryView = !!(activeRow && dvIsHistoryStatus(activeRow.deliveryStatus));
const isSignPendingView = !!(activeRow && activeRow.deliveryStatus === '待客户签章' && formDraft?.deliveryStatus !== '待重新签章');
const isResignPendingView = !!(activeRow && (activeRow.deliveryStatus === '待重新签章' || formDraft?.deliveryStatus === '待重新签章'));
const readOnly = isHistoryView || isSignPendingView || isResignPendingView;
const canEditAuthorizedPerson = isResignPendingView;
const vehiclePickList = useMemo(() => {
const q = vehiclePickSearch.trim().toUpperCase();
let list = DV_DELIVERY_PICK_VEHICLES.filter((v) => v.vehicleStatus === '已备车');
if (vehiclePickParking && vehiclePickParking !== 'all') {
list = list.filter((v) => v.parkingKey === vehiclePickParking);
}
if (q) list = list.filter((v) => v.plateNo.toUpperCase().includes(q));
return list.sort((a, b) => {
const ab = dvGetReadinessMeta(a).blocked ? 1 : 0;
const bb = dvGetReadinessMeta(b).blocked ? 1 : 0;
if (ab !== bb) return ab - bb;
return a.plateNo.localeCompare(b.plateNo, 'zh-CN');
});
}, [vehiclePickSearch, vehiclePickParking]);
const vehiclePickParkingLabel = useMemo(() => {
const found = DV_OPERATOR_PARKING_LOTS.find((p) => p.key === vehiclePickParking);
return found ? found.label : '全部停车场';
}, [vehiclePickParking]);
const patchRow = useCallback((rowId, patch) => {
setOrders((prev) => prev.map((order) => {
const has = (order.vehicleList || []).some((v) => `${order.id}-${v.vehicleKey}` === rowId);
if (!has) return order;
return {
...order,
vehicleList: order.vehicleList.map((v) => {
if (`${order.id}-${v.vehicleKey}` !== rowId) return v;
return { ...v, ...patch };
}),
};
}));
}, []);
const resetDvCameraAssist = useCallback(() => {
setDvCameraFocus(null);
setDvCameraZoom(1);
}, []);
const clearDeliverySignTimer = useCallback(() => {
if (deliverySignTimerRef.current) {
window.clearTimeout(deliverySignTimerRef.current);
deliverySignTimerRef.current = null;
}
}, []);
const resetDeliverySignFlow = useCallback(() => {
clearDeliverySignTimer();
setDeliverySignFlow(null);
setDeliverySignSuccessCd(3);
}, [clearDeliverySignTimer]);
const openRow = useCallback((row) => {
setActiveRow(row);
const viewOnly = row.deliveryStatus === '待客户签章' || row.deliveryStatus === '待重新签章' || row.deliveryStatus === '客户已签章';
const resignPending = row.deliveryStatus === '待重新签章';
let form = dvBuildEmptyForm(row);
if (viewOnly) {
form = { ...form, deliveryPhotos: dvEnsureViewDeliveryPhotos(row, form) };
}
setFormDraft(form);
setDeliveryFormStep(resignPending ? 'photos' : 'vehicle');
setPhotoCapturePhase(viewOnly ? 'done' : 'ready');
setPhotoCaptureMode('continuous');
setPhotoCaptureIndex(0);
setPhotoCountdown(3);
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
resetDeliverySignFlow();
}, [resetDeliverySignFlow]);
const closeForm = useCallback(() => {
resetDeliverySignFlow();
setActiveRow(null);
setFormDraft(null);
setDeliveryFormStep('vehicle');
setPhotoCapturePhase('ready');
setPhotoCaptureMode('continuous');
setPhotoCaptureIndex(0);
setPhotoCountdown(3);
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
setPhotoViewer(null);
deliveryLocAutoRef.current = '';
setVehiclePickOpen(false);
setVehiclePickSearch('');
setVehiclePickParking('all');
setParkingSheetOpen(false);
setPlateValidateModal(null);
setClearSignConfirmOpen(false);
}, [resetDeliverySignFlow]);
const syncActiveRow = useCallback(() => {
if (!activeRow) return;
const next = dvFlattenOrders(orders).find((r) => r.id === activeRow.id);
if (next) setActiveRow(next);
}, [activeRow, orders]);
useEffect(() => { syncActiveRow(); }, [orders, syncActiveRow]);
const handlePlatePick = useCallback((vehicle) => {
if (!vehicle) return;
const meta = dvGetReadinessMeta(vehicle);
if (!meta.canPick) {
message.warning(meta.label);
return;
}
deliveryLocAutoRef.current = '';
setFormDraft((f) => dvMergeVehicleIntoForm(f, vehicle, activeRow));
setVehiclePickOpen(false);
setVehiclePickSearch('');
message.success(`已选择 ${vehicle.plateNo}`);
}, [activeRow]);
const applyRecognizedVehicle = useCallback((vehicle) => {
deliveryLocAutoRef.current = '';
setFormDraft((f) => dvMergeVehicleIntoForm(f, vehicle, activeRow));
message.success(`已识别 ${vehicle.plateNo}`);
}, [activeRow]);
const handleScanPickupCode = useCallback(() => {
if (!activeRow || !formDraft) return;
const heavy = dvIsHeavyVehicle(activeRow.vehicleType || formDraft.vehicleType, formDraft.model || activeRow.model);
const hide = message.loading('正在识别提车码…', 0);
window.setTimeout(() => {
hide();
const info = dvMockDriverTrainingInfo(heavy);
setFormDraft((f) => ({
...f,
driverTrainingDone: true,
driverTraining: '已完成',
...info,
}));
setDriverETrainingOpen(false);
message.success('驾驶培训已完成');
}, 900);
}, [activeRow, formDraft]);
const openDriverETraining = useCallback(() => {
setDriverETrainingOpen(true);
}, []);
const openDriverManualRecord = useCallback(() => {
if (!formDraft) return;
const draft = {
driverPhone: formDraft.driverPhone || '',
driverName: formDraft.driverName || '',
driverIdNo: formDraft.driverIdNo || '',
driverIdFront: !!formDraft.driverIdFront,
driverIdBack: !!formDraft.driverIdBack,
driverLicenseFront: !!formDraft.driverLicenseFront,
driverLicenseBack: !!formDraft.driverLicenseBack,
driverQualification: !!formDraft.driverQualification,
driverFrontPhoto: !!formDraft.driverFrontPhoto,
driverIdFrontUrl: formDraft.driverIdFrontUrl || '',
driverIdBackUrl: formDraft.driverIdBackUrl || '',
driverLicenseFrontUrl: formDraft.driverLicenseFrontUrl || '',
driverLicenseBackUrl: formDraft.driverLicenseBackUrl || '',
driverQualificationUrl: formDraft.driverQualificationUrl || '',
driverFrontPhotoUrl: formDraft.driverFrontPhotoUrl || '',
};
setDriverManualDraft(draft);
setDriverManualStep(formDraft.driverTrainingPending && formDraft.driverTrainingCodeGenerated ? 'qr' : 'form');
setDriverManualOpen(true);
}, [formDraft]);
const closeDriverManualRecord = useCallback(() => {
setDriverManualOpen(false);
setDriverManualStep('form');
}, []);
const uploadDriverManualPhoto = useCallback((field, url) => {
setDriverManualDraft((d) => ({ ...d, [field]: true, [`${field}Url`]: url }));
message.success('已上传(原型)');
}, []);
const generateDriverTrainingCode = useCallback(() => {
if (!activeRow || !formDraft) return;
const heavy = dvIsHeavyVehicle(activeRow.vehicleType || formDraft.vehicleType, formDraft.model || activeRow.model);
const result = dvValidateDriverManualDraft(driverManualDraft, heavy);
if (!result.ok) {
message.warning(result.message);
return;
}
setFormDraft((f) => ({
...f,
...driverManualDraft,
driverTrainingCodeGenerated: true,
driverTrainingPending: true,
driverTrainingDone: false,
driverTraining: '待司机确认',
}));
setDriverManualStep('qr');
message.success('培训码已生成,请司机微信扫码完成培训');
}, [activeRow, formDraft, driverManualDraft]);
const refreshDriverTrainingStatus = useCallback(() => {
if (!activeRow || !formDraft) return;
const hide = message.loading('正在查询培训状态…', 0);
window.setTimeout(() => {
hide();
setFormDraft((f) => ({
...f,
...driverManualDraft,
driverTrainingPending: false,
driverTrainingCodeGenerated: true,
driverTrainingDone: true,
driverTraining: '已完成',
}));
closeDriverManualRecord();
message.success('司机已完成培训签字,信息已同步至安全培训记录(原型)');
}, 900);
}, [activeRow, formDraft, driverManualDraft, closeDriverManualRecord]);
const runPlateRecognize = useCallback(() => {
const hide = message.loading('正在识别车牌…', 0);
const plate = DV_OCR_DEMO_PLATES[ocrDemoIdxRef.current % DV_OCR_DEMO_PLATES.length];
ocrDemoIdxRef.current += 1;
window.setTimeout(() => {
hide();
const vehicle = dvFindRecognizeVehicle(plate);
const result = dvValidateRecognizedPlate(vehicle);
if (result.ok && vehicle) {
applyRecognizedVehicle(vehicle);
return;
}
setPlateValidateModal({ plateNo: plate, messages: result.messages });
}, 900);
}, [applyRecognizedVehicle]);
const handleRecognizePlate = useCallback(() => {
setPhotoSourceSheet({
title: '识别车牌号',
options: [
{
key: 'camera',
label: '拍照识别',
desc: '调用相机拍摄车牌',
onSelect: () => {
message.info('调用相机拍摄车牌(原型)');
window.setTimeout(() => runPlateRecognize(), 480);
},
},
{
key: 'album',
label: '相册识别',
desc: '从相册选择车牌照片',
onSelect: () => {
const hide = message.loading('正在打开相册…', 0);
window.setTimeout(() => {
hide();
message.success('已从相册选择车牌照片(原型)');
runPlateRecognize();
}, 420);
},
},
],
});
}, [runPlateRecognize]);
const renderPlateValidateModal = () => {
if (!plateValidateModal) return null;
const { plateNo, messages } = plateValidateModal;
return (
<div className="xll-dv-validate-overlay" role="dialog" aria-modal="true" aria-label="车辆校验提示">
<button type="button" className="xll-dv-validate-mask" onClick={() => setPlateValidateModal(null)} aria-label="关闭" />
<div className="xll-dv-validate-card">
<div className="xll-dv-validate-title">无法选择该车辆</div>
{plateNo ? <div className="xll-dv-validate-plate">识别车牌{plateNo}</div> : null}
<ul className="xll-dv-validate-list">
{messages.map((msg) => <li key={msg}>{msg}</li>)}
</ul>
<button type="button" className="xll-dv-validate-ok" onClick={() => setPlateValidateModal(null)}>我知道了</button>
</div>
</div>
);
};
const renderClearSignConfirmOverlay = () => {
if (!clearSignConfirmOpen) return null;
return (
<div className="xll-dv-validate-overlay" role="dialog" aria-modal="true" aria-label="清除签章确认">
<button type="button" className="xll-dv-validate-mask" onClick={() => setClearSignConfirmOpen(false)} aria-label="关闭" />
<div className="xll-dv-validate-card">
<div className="xll-dv-validate-title">清除签章后需要重新发起是否确认</div>
<div className="xll-dv-confirm-actions">
<button type="button" className="xll-dv-confirm-cancel" onClick={() => setClearSignConfirmOpen(false)}>取消</button>
<button
type="button"
className="xll-dv-confirm-ok"
onClick={() => {
setClearSignConfirmOpen(false);
performClearSign();
}}
>
确认
</button>
</div>
</div>
</div>
);
};
const buildFormPatch = useCallback((draft) => ({
...draft,
deliveryMileage: dvParseMetric2(draft.deliveryMileage),
deliveryH2: dvParseMetric2(draft.deliveryH2),
deliveryElec: dvParseMetric2(draft.deliveryElec),
serviceFee: draft.serviceFee === '' ? null : dvParseMetric2(draft.serviceFee),
deliveryLocation: draft.deliveryLocation || null,
authorizedPersonId: draft.authorizedPersonId || '',
authorizedPersonName: draft.authorizedPersonName || '',
authorizedPersonPhone: draft.authorizedPersonPhone || '',
signSent: !!draft.signSent,
}), []);
const completeDeliveryOpsSign = useCallback((draft) => {
if (!activeRow || !draft) return;
patchRow(activeRow.id, {
...buildFormPatch({ ...draft, signSent: true }),
deliveryTime: dvFormatOpsSignTime(),
deliveryPerson: MOCK_USER,
deliveryStatus: '待客户签章',
});
setFormDraft((d) => (d ? { ...d, signSent: true, deliveryStatus: '待客户签章' } : d));
}, [activeRow, patchRow, buildFormPatch]);
const initPhotoCaptureOnEnter = useCallback((photos, draft) => {
const seq = dvGetCaptureSequence(draft);
const nextIdx = dvGetNextCaptureIndex(photos, draft);
const allDone = nextIdx >= seq.length;
const capturedCount = dvCountCapturedPhotos(photos, draft);
setPhotoCaptureIndex(allDone ? 0 : nextIdx);
setPhotoCountdown(3);
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
if (allDone) {
setPhotoCaptureMode('continuous');
setPhotoCapturePhase('done');
return;
}
setPhotoCaptureMode('continuous');
if (capturedCount > 0) {
setPhotoCapturePhase('countdown');
return;
}
setPhotoCapturePhase('ready');
}, []);
const validateDeliveryBeforeSign = useCallback(() => {
if (!activeRow || !formDraft) return false;
if (!formDraft.plateNo) {
message.warning('请先选择交车车辆');
return false;
}
if (formDraft.deliveryMileage === '' || formDraft.deliveryH2 === '' || formDraft.deliveryElec === '') {
message.warning('请填写里程、氢量与电量');
return false;
}
const requiredPhotos = dvGetCaptureSequence(formDraft).filter((item) => dvIsPhotoItemRequired(item));
const missingPhoto = requiredPhotos.find((item) => !dvPhotoCaptured(formDraft.deliveryPhotos, item.key));
if (missingPhoto) {
message.warning(`请先完成交车照片连续拍摄(缺少:${missingPhoto.label}`);
setDeliveryFormStep('photos');
initPhotoCaptureOnEnter(formDraft.deliveryPhotos || {}, formDraft);
return false;
}
if (!formDraft.authorizedPersonId) {
message.warning('请选择被授权人');
return false;
}
return true;
}, [activeRow, formDraft, initPhotoCaptureOnEnter]);
const handleAuthorizedPersonPick = useCallback((person) => {
if (!person || !canEditAuthorizedPerson) return;
setFormDraft((d) => ({
...d,
authorizedPersonId: person.id,
authorizedPersonName: person.name,
authorizedPersonPhone: person.phone,
}));
}, [canEditAuthorizedPerson]);
const persistDeliveryDraft = useCallback((silent = true) => {
if (!activeRow || !formDraft) return false;
const nextStatus = formDraft.deliveryStatus === '待重新签章'
? '待重新签章'
: (!formDraft.deliveryStatus || formDraft.deliveryStatus === '未开始') ? '已保存' : formDraft.deliveryStatus;
patchRow(activeRow.id, {
...buildFormPatch(formDraft),
deliveryStatus: nextStatus,
});
if (!silent) message.success('交车单已保存(原型)');
return true;
}, [activeRow, formDraft, patchRow, buildFormPatch]);
const handleSendSignDoc = useCallback(() => {
if (!validateDeliveryBeforeSign() || !activeRow || !formDraft || deliverySignFlow) return;
const nextDraft = { ...formDraft, signSent: true };
setFormDraft(nextDraft);
if (!formDraft.signSent) {
const interimStatus = formDraft.deliveryStatus === '待重新签章'
? '待重新签章'
: formDraft.deliveryStatus === '待客户签章'
? '待客户签章'
: '已保存';
patchRow(activeRow.id, {
...buildFormPatch(nextDraft),
deliveryStatus: interimStatus,
});
}
message.info('跳转E签宝完成运维人员签字后跳转成功页');
setDeliverySignFlow('jumping');
clearDeliverySignTimer();
deliverySignTimerRef.current = window.setTimeout(() => {
deliverySignTimerRef.current = null;
completeDeliveryOpsSign(nextDraft);
setDeliverySignFlow('success');
setDeliverySignSuccessCd(3);
}, 3000);
}, [validateDeliveryBeforeSign, activeRow, formDraft, deliverySignFlow, patchRow, buildFormPatch, clearDeliverySignTimer, completeDeliveryOpsSign]);
useEffect(() => () => clearDeliverySignTimer(), [clearDeliverySignTimer]);
useEffect(() => {
if (deliverySignFlow !== 'success') return undefined;
setDeliverySignSuccessCd(3);
const tick = window.setInterval(() => {
setDeliverySignSuccessCd((c) => (c > 1 ? c - 1 : 1));
}, 1000);
const autoClose = window.setTimeout(() => closeForm(), 3000);
return () => {
window.clearInterval(tick);
window.clearTimeout(autoClose);
};
}, [deliverySignFlow, closeForm]);
const performClearSign = useCallback(() => {
if (!activeRow || !formDraft) return;
const cleared = {
...formDraft,
signSent: false,
deliveryStatus: '待重新签章',
authorizedPersonId: '',
authorizedPersonName: '',
authorizedPersonPhone: '',
};
setFormDraft(cleared);
patchRow(activeRow.id, {
...buildFormPatch(cleared),
signSent: false,
deliveryStatus: '待重新签章',
});
setDeliveryFormStep('photos');
setPhotoCapturePhase('done');
message.success('已清除签章,请重新选择被授权人后发起签章');
}, [activeRow, formDraft, patchRow, buildFormPatch]);
const handleClearSign = useCallback(() => {
if (!activeRow || !formDraft) return;
setClearSignConfirmOpen(true);
}, [activeRow, formDraft]);
const handleDownloadSignFile = useCallback(() => {
message.success('下载签章 PDF原型');
}, []);
const handlePreviewSignFile = useCallback(() => {
message.info('预览签章 PDF原型');
}, []);
const handleSave = useCallback(() => {
persistDeliveryDraft(false);
}, [persistDeliveryDraft]);
const handleVehicleNext = useCallback(() => {
const result = dvValidateVehicleStep(formDraft, activeRow);
if (!result.ok) {
message.warning(result.message);
return;
}
persistDeliveryDraft(true);
setDeliveryFormStep('inspection');
}, [formDraft, activeRow, persistDeliveryDraft]);
const handleInspectionNext = useCallback(() => {
const result = dvValidateInspectionStep(formDraft);
if (!result.ok) {
message.warning(result.message);
return;
}
persistDeliveryDraft(true);
const photos = formDraft?.deliveryPhotos || {};
initPhotoCaptureOnEnter(photos, formDraft);
setDeliveryFormStep('photos');
}, [formDraft, initPhotoCaptureOnEnter, persistDeliveryDraft]);
const getCurrentCaptureItem = useCallback(() => {
const seq = dvGetCaptureSequence(formDraft);
return seq[photoCaptureIndex] || null;
}, [formDraft, photoCaptureIndex]);
const simulateCameraShutter = useCallback(() => {
const item = getCurrentCaptureItem();
if (!item) return;
let tread = '';
let treadOcrOk = true;
if (item.tread) {
const ocrIdx = photoTreadOcrIdxRef.current % DV_SPARE_TREAD_OCR_DEMO.length;
tread = DV_SPARE_TREAD_OCR_DEMO[ocrIdx];
photoTreadOcrIdxRef.current += 1;
treadOcrOk = !!String(tread || '').trim();
}
setPhotoCameraDraft({
photoUrl: dvGetPhotoDemoUrl(item.key),
treadDepth: tread,
treadOcrOk,
captured: true,
});
if (item.tread) {
if (treadOcrOk) {
message.success(`已识别胎纹深度 ${tread} mm可编辑后点击完成`);
} else {
message.warning('胎纹识别失败,请重新拍摄');
}
} else {
message.success(`已拍摄「${item.label}」(原型)`);
}
}, [getCurrentCaptureItem]);
const enterCameraPhase = useCallback(() => {
resetDvCameraAssist();
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
setPhotoCapturePhase('camera');
}, [resetDvCameraAssist]);
const advancePhotoCapture = useCallback(() => {
const seq = dvGetCaptureSequence(formDraft);
const item = seq[photoCaptureIndex];
if (!item) return;
const upload = dvSimulatePhotoUpload(item.key, photoCameraDraft, formDraft, activeRow);
setFormDraft((d) => ({
...d,
deliveryPhotos: {
...(d.deliveryPhotos || {}),
[item.key]: {
captured: true,
photoUrl: upload.photoUrl,
treadDepth: item.tread ? String(photoCameraDraft.treadDepth || '').trim() : '',
uploaded: true,
watermarkTime: upload.watermarkTime,
watermarkAddress: upload.watermarkAddress,
},
},
}));
if (photoCaptureMode === 'single') {
setPhotoCapturePhase('done');
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
return;
}
const nextIdx = photoCaptureIndex + 1;
if (nextIdx >= seq.length) {
setPhotoCapturePhase('done');
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
} else {
setPhotoCaptureIndex(nextIdx);
setPhotoCountdown(3);
setPhotoCapturePhase('countdown');
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
}
}, [formDraft, photoCaptureIndex, photoCameraDraft, photoCaptureMode, activeRow]);
const startPhotoCaptureAtKey = useCallback((photoKey) => {
const seq = dvGetCaptureSequence(formDraft);
const idx = seq.findIndex((item) => item.key === photoKey);
if (idx < 0) return;
setPhotoCaptureMode('single');
setPhotoCaptureIndex(idx);
setPhotoCountdown(3);
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
setPhotoCapturePhase('countdown');
}, [formDraft]);
const startPhotoCapture = useCallback(() => {
const photos = formDraft?.deliveryPhotos || {};
const seq = dvGetCaptureSequence(formDraft);
const nextIdx = dvGetNextCaptureIndex(photos, formDraft);
if (nextIdx >= seq.length) return;
setPhotoCaptureMode('continuous');
setPhotoCaptureIndex(nextIdx);
setPhotoCountdown(3);
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
setPhotoCapturePhase('countdown');
}, [formDraft]);
const confirmPhotoCameraCapture = useCallback(() => {
const item = getCurrentCaptureItem();
if (!item) return;
if (!photoCameraDraft.captured || !photoCameraDraft.photoUrl) {
message.warning('请先拍照或从相册选择照片');
return;
}
if (item.tread) {
if (!photoCameraDraft.treadOcrOk || !String(photoCameraDraft.treadDepth || '').trim()) {
message.warning('胎纹未识别成功,请重新拍摄后再继续');
return;
}
}
advancePhotoCapture();
}, [getCurrentCaptureItem, photoCameraDraft, advancePhotoCapture]);
useEffect(() => {
if (photoCapturePhase !== 'countdown') return undefined;
if (photoCountdown <= 0) {
enterCameraPhase();
return undefined;
}
const timer = window.setTimeout(() => setPhotoCountdown((c) => c - 1), 1000);
return () => window.clearTimeout(timer);
}, [photoCapturePhase, photoCountdown, enterCameraPhase]);
const capturePhotoNow = useCallback(() => {
if (photoCapturePhase === 'countdown') {
enterCameraPhase();
return;
}
if (photoCapturePhase === 'camera') {
confirmPhotoCameraCapture();
}
}, [photoCapturePhase, enterCameraPhase, confirmPhotoCameraCapture]);
const updateInspectionRow = useCallback((key, patch) => {
setFormDraft((d) => ({
...d,
inspectionList: (d.inspectionList || []).map((r) => (r.key === key ? { ...r, ...patch } : r)),
}));
}, []);
const handleMetricFieldChange = useCallback((field, raw) => {
const next = dvMetricInputChange(raw);
if (next === null) return;
setFormDraft((d) => ({ ...d, [field]: next }));
}, []);
const applyCurrentDeliveryLocation = useCallback((loc) => {
setFormDraft((d) => ({
...d,
deliveryLocation: {
lat: loc.lat,
lng: loc.lng,
address: loc.address || '当前定位',
source: 'current',
},
}));
}, []);
const handleGetCurrentLocation = useCallback((silent) => {
if (!formDraft?.plateNo || dvVehicleHasGpsDevice(formDraft.plateNo)) return;
setLocationFetching(true);
const finish = (loc, toast) => {
applyCurrentDeliveryLocation(loc);
setLocationFetching(false);
if (toast) message.success(toast);
};
const fallback = () => finish({
lat: 30.7286,
lng: 121.0125,
address: '当前定位(运维手机)',
}, silent ? null : '已获取当前定位(原型)');
if (typeof navigator !== 'undefined' && navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => finish({
lat: Math.round(pos.coords.latitude * 10000) / 10000,
lng: Math.round(pos.coords.longitude * 10000) / 10000,
address: '当前定位(运维手机)',
}, silent ? null : '已获取当前定位'),
() => fallback(),
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60000 }
);
return;
}
fallback();
}, [formDraft, applyCurrentDeliveryLocation]);
useEffect(() => {
if (!formDraft?.plateNo || readOnly) return undefined;
if (dvVehicleHasGpsDevice(formDraft.plateNo)) return undefined;
if (formDraft.deliveryLocation?.source === 'current') return undefined;
if (deliveryLocAutoRef.current === formDraft.plateNo) return undefined;
deliveryLocAutoRef.current = formDraft.plateNo;
handleGetCurrentLocation(true);
return undefined;
}, [formDraft?.plateNo, formDraft?.deliveryLocation?.source, readOnly, handleGetCurrentLocation]);
const openDeliveryPhotoViewer = useCallback((categoryKey, photoKey) => {
const photos = formDraft?.deliveryPhotos || {};
const items = dvBuildCategoryViewerItems(categoryKey, photos, formDraft);
const index = items.findIndex((item) => item.key === photoKey);
if (index < 0) return;
setPhotoViewer({
categoryKey,
index,
items,
categoryLabel: dvPhotoCategoryLabel(categoryKey),
});
}, [formDraft]);
useEffect(() => {
if (typeof window === 'undefined') return undefined;
window.__DV_CAPTURE__ = {
openDeliveryCameraDemo: () => {
if (!formDraft) return false;
setDeliveryFormStep('photos');
setPhotoCaptureIndex(0);
setPhotoCapturePhase('camera');
setPhotoCameraDraft({
photoUrl: dvGetPhotoDemoUrl('dashboard'),
treadDepth: '',
treadOcrOk: false,
captured: true,
});
setDvCameraFocus({ x: 42, y: 38 });
setDvCameraZoom(1.4);
return true;
},
};
return () => { delete window.__DV_CAPTURE__; };
}, [formDraft]);
useEffect(() => {
if (!onRegisterBack) return undefined;
onRegisterBack(() => {
if (deliverySignFlow === 'success') { closeForm(); return true; }
if (photoViewer) { setPhotoViewer(null); return true; }
if (dvPhotoCamera) { setDvPhotoCamera(null); resetDvCameraAssist(); return true; }
if (spareTireCaptureOpen) { setSpareTireCaptureOpen(false); resetDvCameraAssist(); return true; }
if (photoSourceSheet) { setPhotoSourceSheet(null); return true; }
if (driverManualOpen) {
if (driverManualStep === 'qr') { setDriverManualStep('form'); return true; }
closeDriverManualRecord();
return true;
}
if (driverETrainingOpen) { setDriverETrainingOpen(false); return true; }
if (plateValidateModal) { setPlateValidateModal(null); return true; }
if (clearSignConfirmOpen) { setClearSignConfirmOpen(false); return true; }
if (filterDrawerOpen) { setFilterDrawerOpen(false); return true; }
if (parkingSheetOpen) { setParkingSheetOpen(false); return true; }
if (vehiclePickOpen) { setVehiclePickOpen(false); setParkingSheetOpen(false); return true; }
if (activeRow && deliveryFormStep === 'photos' && (photoCapturePhase === 'countdown' || photoCapturePhase === 'camera')) {
if (photoCapturePhase === 'camera') {
resetDvCameraAssist();
setPhotoCapturePhase('countdown');
setPhotoCameraDraft({ photoUrl: '', treadDepth: '', treadOcrOk: false, captured: false });
} else {
setPhotoCapturePhase('ready');
setPhotoCountdown(3);
}
return true;
}
if (activeRow) { closeForm(); return true; }
return false;
});
return () => onRegisterBack(null);
}, [activeRow, vehiclePickOpen, parkingSheetOpen, filterDrawerOpen, plateValidateModal, clearSignConfirmOpen, photoSourceSheet, spareTireCaptureOpen, dvPhotoCamera, driverManualOpen, driverManualStep, driverETrainingOpen, deliveryFormStep, photoCapturePhase, photoViewer, deliverySignFlow, closeForm, closeDriverManualRecord, resetDvCameraAssist, onRegisterBack]);
const runAlbumPick = useCallback((applyPhoto, label) => {
const hide = message.loading('正在打开相册…', 0);
window.setTimeout(() => {
hide();
applyPhoto();
message.success(`已从相册选择${label}(原型)`);
}, 420);
}, []);
const pickContinuousPhotoFromAlbum = useCallback(() => {
const item = getCurrentCaptureItem();
if (!item) return;
runAlbumPick(() => {
let tread = '';
let treadOcrOk = true;
if (item.tread) {
const ocrIdx = photoTreadOcrIdxRef.current % DV_SPARE_TREAD_OCR_DEMO.length;
tread = DV_SPARE_TREAD_OCR_DEMO[ocrIdx];
photoTreadOcrIdxRef.current += 1;
treadOcrOk = !!String(tread || '').trim();
}
setPhotoCameraDraft({
photoUrl: dvGetPhotoDemoUrl(item.key),
treadDepth: tread,
treadOcrOk,
captured: true,
});
if (item.tread && !treadOcrOk) {
message.warning('胎纹识别失败,请重新选择照片');
}
}, `${item.label}`);
}, [getCurrentCaptureItem, runAlbumPick]);
const renderPhotoCaptureActionBar = ({
captured,
onShutter,
onAlbum,
onComplete,
completeDisabled = false,
}) => (
<div className="xll-dv-photo-camera-actions">
{!captured ? (
<>
<button type="button" className="xll-dv-spare-capture-retake" onClick={onShutter}>拍照</button>
<button type="button" className="xll-dv-photo-album-btn" onClick={onAlbum}>相册</button>
</>
) : (
<>
<button type="button" className="xll-dv-spare-capture-retake" onClick={onShutter}>重新拍摄</button>
<button type="button" className="xll-dv-photo-album-btn" onClick={onAlbum}>相册</button>
<button type="button" className="xll-dv-spare-capture-done" onClick={onComplete} disabled={completeDisabled}>完成</button>
</>
)}
</div>
);
const renderPhotoSourceSheet = () => {
if (!photoSourceSheet) return null;
return (
<div className="xll-mod-sheet-overlay" role="dialog" aria-modal="true" aria-label={photoSourceSheet.title}>
<button type="button" className="xll-mod-sheet-mask" onClick={() => setPhotoSourceSheet(null)} aria-label="关闭" />
<div className="xll-mod-sheet-panel">
<div className="xll-mod-sheet-handle" aria-hidden />
<div className="xll-mod-sheet-header">
<div className="xll-mod-sheet-title">{photoSourceSheet.title}</div>
<button type="button" className="xll-mod-sheet-close" onClick={() => setPhotoSourceSheet(null)} aria-label="关闭">×</button>
</div>
<div className="xll-mod-sheet-body">
{photoSourceSheet.options.map((opt) => (
<button
key={opt.key}
type="button"
className="xll-mod-sheet-option"
onClick={() => {
setPhotoSourceSheet(null);
opt.onSelect();
}}
>
<span className="xll-mod-sheet-option-text">
<span className="xll-mod-sheet-option-main">{opt.label}</span>
{opt.desc ? <span className="xll-mod-sheet-option-sub">{opt.desc}</span> : null}
</span>
<span style={{ color: COLOR_MUTED, fontSize: 20, lineHeight: 1 }} aria-hidden></span>
</button>
))}
</div>
</div>
</div>
);
};
const renderRequiredTag = () => (!readOnly ? <span className="xll-dv-required-tag">必填</span> : null);
const renderDriverTrainingQrBlock = ({ title, hint, qrSrc, boundDraft } = {}) => {
const d = boundDraft || null;
return (
<div className="xll-dv-training-qr-wrap">
{d ? (
<div className="xll-dv-training-bound-kv">
<div className="xll-dv-training-bound-row"><span className="xll-dv-training-bound-label">司机姓名</span><span className="xll-dv-training-bound-val">{d.driverName || ''}</span></div>
<div className="xll-dv-training-bound-row"><span className="xll-dv-training-bound-label">手机号</span><span className="xll-dv-training-bound-val">{d.driverPhone || ''}</span></div>
<div className="xll-dv-training-bound-row"><span className="xll-dv-training-bound-label">身份证号</span><span className="xll-dv-training-bound-val">{d.driverIdNo || ''}</span></div>
</div>
) : null}
<div className="xll-dv-training-qr-card">
<img className="xll-dv-training-qr-img" src={qrSrc || DV_DRIVER_TRAINING_QR} alt="驾驶培训二维码" />
<div className="xll-dv-training-qr-title">{title || '驾驶培训视频'}</div>
<div className="xll-dv-training-qr-hint">{hint || '请司机使用微信扫描二维码,观看完整培训视频'}</div>
<div className="xll-dv-training-qr-wechat">
<span aria-hidden>微信扫一扫</span>
</div>
</div>
</div>
);
};
const renderDriverDocThumb = (uploaded, url) => {
if (url) return <img src={url} alt="" />;
return uploaded ? '已上传' : '—';
};
const renderDriverManualPhotoSlot = (label, uploaded, url, field, demoUrl, required) => (
<div className="xll-dv-driver-manual-photo-item">
<div className="xll-dv-driver-manual-photo-label">
<span>{label}</span>
{required ? renderRequiredTag() : null}
</div>
<div
className={`xll-dv-driver-manual-photo-slot${uploaded ? ' done' : ''}`}
role="button"
tabIndex={0}
onClick={() => {
openDvPhotoCamera({
key: field,
label,
demoUrl,
photoUrl: url,
onComplete: () => uploadDriverManualPhoto(field, demoUrl),
});
}}
onKeyDown={(e) => e.key === 'Enter' && openDvPhotoCamera({
key: field,
label,
demoUrl,
photoUrl: url,
onComplete: () => uploadDriverManualPhoto(field, demoUrl),
})}
>
{url ? <img src={url} alt={label} /> : (uploaded ? '已上传' : '拍照/相册')}
</div>
</div>
);
const renderDriverETrainingPage = () => (
<div className="xll-mod-root xll-dv-module xll-mod-form-page xll-dv-driver-manual-page">
<div className="xll-mod-detail-wrap">
<div className="tc-scroll">
<div className="tc-section">
<div className="tc-section-head"><span className="xll-dv-section-head-title"><span className="xll-dv-section-badge">1</span></span></div>
<div className="tc-section-hint" style={{ padding: '0 14px 12px' }}>司机完成视频培训后点击下方按钮扫描提车码</div>
{renderDriverTrainingQrBlock({
title: '驾驶培训视频',
hint: '请司机使用微信扫描二维码,观看完整培训视频',
})}
</div>
<div style={{ height: 16 }} />
</div>
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-primary" onClick={handleScanPickupCode}>识别提车码</button>
</div>
</div>
</div>
);
const renderDriverManualPage = () => {
const d = driverManualDraft;
const heavy = dvIsHeavyVehicle(activeRow?.vehicleType || formDraft?.vehicleType, formDraft?.model || activeRow?.model);
const trainingCodeUrl = dvBuildDriverTrainingCodeUrl(d);
const trainingCodeQr = dvDriverTrainingCodeQrUrl(trainingCodeUrl);
if (driverManualStep === 'qr') {
return (
<div className="xll-mod-root xll-dv-module xll-mod-form-page xll-dv-driver-manual-page">
<div className="xll-mod-detail-wrap">
<div className="tc-scroll">
<div className="tc-section">
<div className="tc-section-head"><span className="xll-dv-section-head-title"><span className="xll-dv-section-badge">1</span></span></div>
<div className="tc-section-hint" style={{ padding: '0 14px 12px' }}>培训码已与司机信息绑定请司机使用微信扫描二维码完成安全培训与签字</div>
{renderDriverTrainingQrBlock({
title: '安全培训码',
hint: '请司机使用微信扫描二维码;司机签字完成后可点击下方「刷新培训状态」',
qrSrc: trainingCodeQr,
boundDraft: d,
})}
</div>
<div style={{ height: 16 }} />
</div>
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-ghost" onClick={() => setDriverManualStep('form')}>返回修改</button>
<button type="button" className="xll-mod-btn-primary" onClick={refreshDriverTrainingStatus}>刷新培训状态</button>
</div>
</div>
</div>
);
}
return (
<div className="xll-mod-root xll-dv-module xll-mod-form-page xll-dv-driver-manual-page">
<div className="xll-mod-detail-wrap">
<div className="tc-scroll">
<div className="tc-section">
<div className="tc-section-head"><span className="xll-dv-section-head-title"><span className="xll-dv-section-badge">1</span></span></div>
<div className="tc-section-form">
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">手机号{renderRequiredTag()}</span>
<input
className="xll-mod-form-input"
type="tel"
inputMode="tel"
value={d.driverPhone}
onChange={(e) => setDriverManualDraft((prev) => ({ ...prev, driverPhone: e.target.value }))}
placeholder="请输入"
/>
</div>
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">姓名{renderRequiredTag()}</span>
<input
className="xll-mod-form-input"
value={d.driverName}
onChange={(e) => setDriverManualDraft((prev) => ({ ...prev, driverName: e.target.value }))}
placeholder="请输入"
/>
</div>
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">身份证号{renderRequiredTag()}</span>
<input
className="xll-mod-form-input"
value={d.driverIdNo}
onChange={(e) => setDriverManualDraft((prev) => ({ ...prev, driverIdNo: e.target.value }))}
placeholder="请输入"
/>
</div>
</div>
</div>
<div className="tc-section">
<div className="tc-section-head"><span className="xll-dv-section-head-title"><span className="xll-dv-section-badge">2</span></span></div>
<div className="tc-section-hint" style={{ padding: '0 14px 12px' }}>支持拍照或从相册选择重卡须上传从业资格证</div>
<div className="xll-dv-driver-manual-photos">
{renderDriverManualPhotoSlot('身份证(正面)', d.driverIdFront, d.driverIdFrontUrl, 'driverIdFront', DV_DRIVER_DOC_DEMO.idFront, true)}
{renderDriverManualPhotoSlot('身份证(反面)', d.driverIdBack, d.driverIdBackUrl, 'driverIdBack', DV_DRIVER_DOC_DEMO.idBack, true)}
{renderDriverManualPhotoSlot('驾驶证(正面)', d.driverLicenseFront, d.driverLicenseFrontUrl, 'driverLicenseFront', DV_DRIVER_DOC_DEMO.licenseFront, true)}
{renderDriverManualPhotoSlot('驾驶证(反面)', d.driverLicenseBack, d.driverLicenseBackUrl, 'driverLicenseBack', DV_DRIVER_DOC_DEMO.licenseBack, true)}
{renderDriverManualPhotoSlot('司机正面照片', d.driverFrontPhoto, d.driverFrontPhotoUrl, 'driverFrontPhoto', DV_DRIVER_DOC_DEMO.portrait, true)}
{renderDriverManualPhotoSlot(`从业资格证${heavy ? '' : '(选填)'}`, d.driverQualification, d.driverQualificationUrl, 'driverQualification', DV_DRIVER_DOC_DEMO.qualification, heavy)}
</div>
</div>
<div style={{ height: 16 }} />
</div>
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-primary" onClick={generateDriverTrainingCode}>生成培训码</button>
</div>
{renderDvPhotoCameraOverlay()}
</div>
</div>
);
};
const renderDvChipGroup = (field, options) => {
if (readOnly) {
const val = formDraft[field] || '—';
return <div className="xll-dv-kv-grid" style={{ paddingTop: 0 }}><div className="xll-dv-kv-item"><div className="xll-dv-kv-val">{val}</div></div></div>;
}
return (
<div className="xll-dv-chip-group">
{options.map((opt) => (
<button
key={opt.value || 'empty'}
type="button"
className={`xll-dv-chip-opt${formDraft[field] === opt.value ? ' active' : ''}`}
onClick={() => setFormDraft((d) => ({ ...d, [field]: opt.value }))}
>
{opt.label}
</button>
))}
</div>
);
};
const renderDeliverySignSuccessPage = () => {
const person = dvFindAuthorizedPerson(formDraft?.authorizedPersonId);
const personName = person?.name || formDraft?.authorizedPersonName || '—';
return (
<div className="xll-mod-root xll-dv-module xll-dv-sign-success-page">
<div className="xll-dv-sign-success-body">
<div className="xll-dv-sign-success-icon" aria-hidden></div>
<div className="xll-dv-sign-success-title">运维人员签字成功</div>
<div className="xll-dv-sign-success-desc">
签章文件已发送短信已推送至被授权人 {personName}等待客户完成 E签宝签章
</div>
<div className="xll-dv-sign-success-countdown">{deliverySignSuccessCd} 秒后自动返回列表</div>
</div>
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-primary" onClick={closeForm}>返回列表</button>
</div>
</div>
);
};
const renderSectionHead = (num, title, extra, required) => (
<div className="tc-section-head">
<span className="xll-dv-section-head-title">
<span className="xll-dv-section-badge">{num}</span>
{title}
{required ? renderRequiredTag() : null}
</span>
{extra || null}
</div>
);
const renderDeliveryLocationMap = (plateNo, row, formLocation) => {
const loc = dvResolveDeliveryLocation(plateNo, row, formLocation);
const pending = loc.source === 'pending';
return (
<div className="xll-dv-delivery-location">
<div
className={`xll-dv-delivery-map${pending ? ' xll-dv-delivery-map--pending' : ''}`}
role="img"
aria-label={`交车位置地图,${loc.address}`}
>
<div className="xll-dv-delivery-map-canvas" aria-hidden>
<div className="xll-dv-delivery-map-water" />
<div className="xll-dv-delivery-map-road xll-dv-delivery-map-road--h" />
<div className="xll-dv-delivery-map-road xll-dv-delivery-map-road--v" />
<div className="xll-dv-delivery-map-road xll-dv-delivery-map-road--d" />
</div>
{!pending ? (
<span className="xll-dv-delivery-map-marker" title={loc.plateNo || '当前车辆位置'}>
<span className="xll-dv-delivery-map-marker-pin" />
</span>
) : null}
<div className="xll-dv-delivery-map-foot">
<span><strong>{loc.address}</strong></span>
{!pending ? <span>{loc.lat.toFixed(4)}, {loc.lng.toFixed(4)}</span> : null}
</div>
<span className="xll-map-brand">腾讯地图</span>
</div>
</div>
);
};
const renderEquipSwitch = (on, onChange, label) => (
<div className="xll-dv-equip-switch-wrap">
{readOnly ? (
<span className="xll-dv-equip-switch-label">{on ? '有' : '无'}</span>
) : (
<>
<span className="xll-dv-equip-switch-label">{on ? '有' : '无'}</span>
<button
type="button"
className={`xll-dv-equip-switch${on ? ' on' : ''}`}
onClick={() => onChange(!on)}
aria-label={label}
aria-pressed={on}
/>
</>
)}
</div>
);
const toggleRearEquipAd = useCallback((checked) => {
setFormDraft((d) => {
const base = d.rearEquip || dvGetRearEquipRecord(d.plateNo, activeRow);
return {
...d,
hasAd: checked ? '有' : '无',
rearEquip: {
...base,
hasAd: checked,
hasBigWord: checked,
...(checked ? {} : { adPhotoDone: false, bigWordPhotoDone: false }),
},
adPhotoUploaded: checked ? d.adPhotoUploaded : false,
bigWordPhotoUploaded: checked ? d.bigWordPhotoUploaded : false,
};
});
}, [activeRow]);
const toggleRearEquipTailgate = useCallback((checked) => {
setFormDraft((d) => {
const base = d.rearEquip || dvGetRearEquipRecord(d.plateNo, activeRow);
return {
...d,
hasTailgate: checked ? '有' : '无',
rearEquip: { ...base, hasTailgate: checked },
};
});
}, [activeRow]);
const handleSpareTireChange = useCallback((value) => {
setFormDraft((d) => {
const nextPhotos = { ...(d.deliveryPhotos || {}) };
if (value !== '有') delete nextPhotos.spare;
return {
...d,
spareTire: value,
deliveryPhotos: nextPhotos,
...(value === '有' ? {} : {
spareTirePhotoUploaded: false,
spareTirePhotoUrl: '',
spareTireTreadDepth: '',
}),
};
});
}, []);
const simulateSpareTireCapture = useCallback(() => {
const tread = DV_SPARE_TREAD_OCR_DEMO[spareTreadOcrIdxRef.current % DV_SPARE_TREAD_OCR_DEMO.length];
spareTreadOcrIdxRef.current += 1;
setSpareTireCaptureDraft({ photoUrl: DV_SPARE_TIRE_DEMO_PHOTO, treadDepth: tread });
message.success(`已识别胎纹深度 ${tread} mm可编辑后点击完成`);
}, []);
const openDvPhotoCamera = useCallback((session) => {
resetDvCameraAssist();
setDvPhotoCamera({
...session,
photoUrl: session.photoUrl || '',
captured: !!session.photoUrl,
});
if (!session.photoUrl) {
message.info(`拍摄${session.label}(支持拍照或相册)`);
}
}, [resetDvCameraAssist]);
const shutterDvPhotoCamera = useCallback(() => {
if (!dvPhotoCamera) return;
resetDvCameraAssist();
setDvPhotoCamera((prev) => (prev ? { ...prev, photoUrl: prev.demoUrl, captured: true } : prev));
message.success(`已拍摄「${dvPhotoCamera.label}」(原型)`);
}, [dvPhotoCamera, resetDvCameraAssist]);
const pickDvPhotoFromAlbum = useCallback(() => {
if (!dvPhotoCamera) return;
runAlbumPick(() => {
resetDvCameraAssist();
setDvPhotoCamera((prev) => (prev ? { ...prev, photoUrl: prev.demoUrl, captured: true } : prev));
}, `${dvPhotoCamera.label}`);
}, [dvPhotoCamera, resetDvCameraAssist, runAlbumPick]);
const completeDvPhotoCamera = useCallback(() => {
if (!dvPhotoCamera?.captured || !dvPhotoCamera.photoUrl) {
message.warning('请先拍照或从相册选择照片');
return;
}
dvPhotoCamera.onComplete?.();
setDvPhotoCamera(null);
resetDvCameraAssist();
}, [dvPhotoCamera, resetDvCameraAssist]);
const openSpareTireCapture = useCallback((existing) => {
resetDvCameraAssist();
if (existing?.spareTirePhotoUploaded) {
setSpareTireCaptureDraft({
photoUrl: existing.spareTirePhotoUrl || DV_SPARE_TIRE_DEMO_PHOTO,
treadDepth: existing.spareTireTreadDepth || '',
});
setSpareTireCaptureOpen(true);
return;
}
setSpareTireCaptureDraft({ photoUrl: '', treadDepth: '' });
setSpareTireCaptureOpen(true);
message.info('拍摄备胎(支持拍照或相册)');
}, [resetDvCameraAssist]);
const pickSpareTireFromAlbum = useCallback(() => {
runAlbumPick(() => {
const tread = DV_SPARE_TREAD_OCR_DEMO[spareTreadOcrIdxRef.current % DV_SPARE_TREAD_OCR_DEMO.length];
spareTreadOcrIdxRef.current += 1;
setSpareTireCaptureDraft({ photoUrl: DV_SPARE_TIRE_DEMO_PHOTO, treadDepth: tread });
}, '备胎照片');
}, [runAlbumPick]);
const completeSpareTireCapture = useCallback(() => {
const tread = String(spareTireCaptureDraft.treadDepth || '').trim();
if (!spareTireCaptureDraft.photoUrl) {
message.warning('请先拍照或从相册选择照片');
return;
}
setFormDraft((d) => ({
...d,
spareTirePhotoUploaded: true,
spareTirePhotoUrl: spareTireCaptureDraft.photoUrl,
spareTireTreadDepth: tread,
}));
setSpareTireCaptureOpen(false);
}, [spareTireCaptureDraft]);
const renderDeliveryStepBar = () => {
const stepOrder = DV_FORM_STEPS.map((s) => s.key);
const currentIdx = Math.max(0, stepOrder.indexOf(deliveryFormStep));
const goStep = (key) => {
const targetIdx = stepOrder.indexOf(key);
if (targetIdx < 0) return;
const enterPhotosStep = () => {
initPhotoCaptureOnEnter(formDraft?.deliveryPhotos || {}, formDraft);
setDeliveryFormStep('photos');
};
if (readOnly) {
if (key === 'photos') initPhotoCaptureOnEnter(formDraft?.deliveryPhotos || {}, formDraft);
setDeliveryFormStep(key);
return;
}
if (targetIdx <= currentIdx) {
if (key === 'photos') initPhotoCaptureOnEnter(formDraft?.deliveryPhotos || {}, formDraft);
setDeliveryFormStep(key);
return;
}
if (targetIdx >= 1) {
const vehicleResult = dvValidateVehicleStep(formDraft, activeRow);
if (!vehicleResult.ok) {
message.warning(vehicleResult.message);
return;
}
}
if (targetIdx >= 2) {
const inspectionResult = dvValidateInspectionStep(formDraft);
if (!inspectionResult.ok) {
message.warning(inspectionResult.message);
return;
}
}
if (key === 'photos') {
enterPhotosStep();
return;
}
setDeliveryFormStep(key);
};
return (
<div className="xll-dv-steps-wrap">
<div className="xll-dv-step-nav" role="tablist" aria-label="交车办理步骤">
{DV_FORM_STEPS.map((s, i) => (
<React.Fragment key={s.key}>
<button
type="button"
role="tab"
aria-selected={deliveryFormStep === s.key}
className={`xll-dv-step-nav-item${deliveryFormStep === s.key ? ' active' : ''}${i < currentIdx ? ' done' : ''}`}
onClick={() => goStep(s.key)}
>
<span className="xll-dv-step-nav-index">{i + 1}</span>
<span className="xll-dv-step-nav-label">{s.label}</span>
</button>
{i < DV_FORM_STEPS.length - 1 ? (
<span className={`xll-dv-step-nav-connector${i < currentIdx ? ' done' : ''}`} aria-hidden />
) : null}
</React.Fragment>
))}
</div>
</div>
);
};
const renderDeliveryInspection = (f) => {
const list = f.inspectionList || [];
const categories = [...new Set(list.map((row) => row.category))];
return (
<div className="tc-section">
{renderSectionHead(1, '交车检查单')}
{categories.map((cat) => (
<div key={cat} className="xll-dv-inspection-block">
<div className="xll-dv-inspection-cat">{cat}</div>
{list.filter((row) => row.category === cat).map((row) => (
<div key={row.key} className="xll-dv-inspection-row">
<span className="xll-dv-inspection-item">{row.item}</span>
<div className="xll-dv-inspection-controls">
{dvInspectionIsTireCategory(row.category) ? (
readOnly ? (
<span className="xll-dv-inspection-status">{row.treadDepth ? `${row.treadDepth} mm` : '—'}</span>
) : (
<input
className="xll-dv-inspection-tread"
type="text"
inputMode="decimal"
value={row.treadDepth}
onChange={(e) => {
const next = dvMetricInputChange(e.target.value);
if (next !== null) updateInspectionRow(row.key, { treadDepth: next });
}}
placeholder="mm"
aria-label={`${row.item}胎纹深度`}
/>
)
) : readOnly ? (
<span className={`xll-dv-inspection-status${row.checked ? '' : ' off'}`}>{row.checked ? '正常' : '异常'}</span>
) : (
<button
type="button"
className={`xll-dv-inspection-switch${row.checked ? ' on' : ''}`}
onClick={() => updateInspectionRow(row.key, { checked: !row.checked })}
aria-label={`${row.item}检查情况`}
aria-pressed={row.checked}
/>
)}
{readOnly ? (
row.remark ? <span className="xll-dv-inspection-remark-read">{row.remark}</span> : null
) : (
<input
className="xll-dv-inspection-remark"
type="text"
value={row.remark || ''}
onChange={(e) => updateInspectionRow(row.key, { remark: e.target.value })}
placeholder="请输入"
aria-label={`${row.item}备注`}
maxLength={40}
/>
)}
</div>
</div>
))}
</div>
))}
</div>
);
};
const renderPhotoViewerOverlay = () => {
if (!photoViewer) return null;
const { items, index, categoryLabel } = photoViewer;
const current = items[index];
if (!current) return null;
const goPrev = () => {
setPhotoViewer((v) => (v && v.index > 0 ? { ...v, index: v.index - 1 } : v));
};
const goNext = () => {
setPhotoViewer((v) => (v && v.index < v.items.length - 1 ? { ...v, index: v.index + 1 } : v));
};
const onTouchStart = (e) => {
const t = e.changedTouches?.[0] || e.touches?.[0];
if (!t) return;
photoViewerTouchRef.current = { x: t.clientX, y: t.clientY };
};
const onTouchEnd = (e) => {
const t = e.changedTouches?.[0];
if (!t) return;
const dx = t.clientX - photoViewerTouchRef.current.x;
const dy = t.clientY - photoViewerTouchRef.current.y;
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy)) return;
if (dx > 0) goPrev();
else goNext();
};
return (
<div className="xll-dv-photo-viewer" role="dialog" aria-modal="true" aria-label="照片预览">
<div className="xll-dv-photo-viewer-top">
<button type="button" className="xll-dv-photo-viewer-close" onClick={() => setPhotoViewer(null)}>关闭</button>
<div className="xll-dv-photo-viewer-meta">
<div className="xll-dv-photo-viewer-cat">{categoryLabel}</div>
<div className="xll-dv-photo-viewer-title">{current.label}</div>
</div>
<span className="xll-dv-photo-viewer-counter">{index + 1}/{items.length}</span>
</div>
<div
className="xll-dv-photo-viewer-stage"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<button type="button" className="xll-dv-photo-viewer-nav xll-dv-photo-viewer-nav--prev" disabled={index <= 0} onClick={goPrev} aria-label="上一张"></button>
<div className="xll-dv-photo-viewer-img-wrap">
<img
key={current.key}
src={current.photoUrl}
alt={current.label}
onError={(e) => { e.currentTarget.src = dvGetPhotoDemoUrl(current.key); }}
/>
</div>
<button type="button" className="xll-dv-photo-viewer-nav xll-dv-photo-viewer-nav--next" disabled={index >= items.length - 1} onClick={goNext} aria-label="下一张"></button>
</div>
<div className="xll-dv-photo-viewer-foot">
{current.treadDepth ? <div className="xll-dv-photo-viewer-tread">胎纹 {current.treadDepth} mm</div> : null}
<div className="xll-dv-photo-viewer-hint">左右滑动或点击箭头切换</div>
</div>
</div>
);
};
const renderPhotoCameraOverlay = () => {
if (photoCapturePhase !== 'camera' || deliveryFormStep !== 'photos') return null;
const item = getCurrentCaptureItem();
if (!item) return null;
const seq = dvGetCaptureSequence(formDraft);
return (
<div className="xll-dv-photo-camera-overlay" role="dialog" aria-modal="true" aria-label={`拍摄${item.label}`}>
<div className="xll-dv-photo-camera-top">
{item.label}
<span style={{ marginLeft: 8, fontSize: 12, opacity: 0.75 }}>{photoCaptureIndex + 1}/{seq.length}</span>
</div>
<div className="xll-dv-photo-camera-view">
<DvCameraViewfinder
photoUrl={photoCameraDraft.photoUrl}
alt={item.label}
placeholder="相机取景中…"
zoom={dvCameraZoom}
onZoomChange={setDvCameraZoom}
focusPoint={dvCameraFocus}
onFocusTap={setDvCameraFocus}
onImgError={(e) => { e.currentTarget.src = dvGetPhotoDemoUrl(item.key); }}
showFocusTip={!photoCameraDraft.captured}
/>
</div>
{item.tread && photoCameraDraft.captured ? (
<div className="xll-dv-photo-camera-tread">
<span className="xll-dv-photo-camera-tread-label">{item.label}胎纹</span>
<div className="xll-dv-spare-capture-tread-input-wrap">
<input
className="xll-dv-spare-capture-tread-input"
type="text"
inputMode="decimal"
value={photoCameraDraft.treadDepth}
onChange={(e) => {
const next = dvMetricInputChange(e.target.value);
if (next !== null) {
setPhotoCameraDraft((d) => ({
...d,
treadDepth: next,
treadOcrOk: !!String(next || '').trim(),
}));
}
}}
placeholder="—"
aria-label={`${item.label}胎纹深度`}
/>
<span className="xll-dv-spare-capture-tread-unit">mm</span>
</div>
</div>
) : null}
{item.tread && photoCameraDraft.captured && !photoCameraDraft.treadOcrOk ? (
<div style={{ padding: '8px 16px', fontSize: 12, color: COLOR_DANGER, background: 'rgba(245,63,63,.08)' }}>
胎纹未识别成功请重新拍摄后再继续
</div>
) : null}
{renderPhotoCaptureActionBar({
captured: !!photoCameraDraft.captured,
onShutter: () => { resetDvCameraAssist(); simulateCameraShutter(); },
onAlbum: pickContinuousPhotoFromAlbum,
onComplete: confirmPhotoCameraCapture,
})}
</div>
);
};
const renderSpareTireCaptureOverlay = () => {
if (!spareTireCaptureOpen) return null;
return (
<div className="xll-dv-spare-capture" role="dialog" aria-modal="true" aria-label="备胎拍照">
<div className="xll-dv-spare-capture-photo">
<DvCameraViewfinder
photoUrl={spareTireCaptureDraft.photoUrl}
alt="备胎照片"
placeholder="照片"
zoom={dvCameraZoom}
onZoomChange={setDvCameraZoom}
focusPoint={dvCameraFocus}
onFocusTap={setDvCameraFocus}
showFocusTip={!spareTireCaptureDraft.photoUrl}
/>
</div>
<div className="xll-dv-spare-capture-tread">
<span className="xll-dv-spare-capture-tread-label">备胎胎纹</span>
<div className="xll-dv-spare-capture-tread-input-wrap">
<input
className="xll-dv-spare-capture-tread-input"
type="number"
inputMode="decimal"
step="0.1"
min="0"
value={spareTireCaptureDraft.treadDepth}
onChange={(e) => setSpareTireCaptureDraft((d) => ({ ...d, treadDepth: e.target.value }))}
placeholder="—"
/>
<span className="xll-dv-spare-capture-tread-unit">mm</span>
</div>
</div>
{renderPhotoCaptureActionBar({
captured: !!spareTireCaptureDraft.photoUrl,
onShutter: () => { resetDvCameraAssist(); simulateSpareTireCapture(); },
onAlbum: pickSpareTireFromAlbum,
onComplete: completeSpareTireCapture,
})}
</div>
);
};
const renderDvPhotoCameraOverlay = () => {
if (!dvPhotoCamera) return null;
return (
<div className="xll-dv-photo-camera-overlay" role="dialog" aria-modal="true" aria-label={`拍摄${dvPhotoCamera.label}`}>
<div className="xll-dv-photo-camera-top">{dvPhotoCamera.label}</div>
<div className="xll-dv-photo-camera-view">
<DvCameraViewfinder
photoUrl={dvPhotoCamera.photoUrl}
alt={dvPhotoCamera.label}
placeholder="相机取景中…"
zoom={dvCameraZoom}
onZoomChange={setDvCameraZoom}
focusPoint={dvCameraFocus}
onFocusTap={setDvCameraFocus}
showFocusTip={!dvPhotoCamera.captured}
/>
</div>
{renderPhotoCaptureActionBar({
captured: !!dvPhotoCamera.captured,
onShutter: shutterDvPhotoCamera,
onAlbum: pickDvPhotoFromAlbum,
onComplete: completeDvPhotoCamera,
})}
</div>
);
};
const renderDeliveryFormPage = () => {
if (!activeRow || !formDraft) return null;
const r = activeRow;
const f = formDraft;
const st = dvStatusTag(r.deliveryStatus);
const pickedVehicle = f.plateNo ? DV_DELIVERY_PICK_VEHICLES.find((v) => v.plateNo === f.plateNo) : null;
const rearEquip = f.rearEquip || dvGetRearEquipRecord(f.plateNo, r);
const showDriverPanel = f.driverTrainingDone || f.driverTraining === '已完成';
const showDriverPending = !!(f.driverTrainingPending && !showDriverPanel);
const renderDeliveryHero = () => (
<div className="tc-hero tc-hero--delivery">
<div className="tc-hero-amount" style={{ fontSize: 22 }}>{r.customerName || '—'}</div>
<div className="tc-hero-meta"><span>{r.projectName || '—'}</span></div>
<div className="tc-hero-foot tc-hero-foot--compact">
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">交车区域</span>{r.deliveryRegion || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">计划交车</span>{dvFormatExpectedDate(r.expectedDate)}</div>
{r.deliveryAddress ? <div className="tc-hero-foot-row"><span className="tc-hero-foot-label">交车地点</span>{r.deliveryAddress}</div> : null}
{readOnly ? (
<>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">交车人</span>{r.deliveryPerson || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">交车时间</span>{dvDisplayActualTime(r.deliveryTime)}</div>
</>
) : null}
</div>
<div className="tc-hero-period">
<span className="tc-hero-period-tag">{r.bizType || '租赁'}</span>
<span className={`xll-dv-status ${st.cls}`} style={{ background: 'rgba(255,255,255,.22)', color: '#fff' }}>{st.text}</span>
{r.replaceOldPlate ? <span>替换旧车 {r.replaceOldPlate}</span> : null}
</div>
</div>
);
const renderVehicleStep = () => (
<>
{renderDeliveryHero()}
<div className={`tc-section xll-dv-vehicle-pick-section${f.plateNo ? ' has-vehicle' : ''}`}>
{renderSectionHead(1, '选择车辆', !readOnly ? (
<div className="xll-dv-section-actions">
<button type="button" className="xll-dv-section-action-btn xll-dv-section-action-btn--primary" onClick={() => setVehiclePickOpen(true)}>选择车辆</button>
<button type="button" className="xll-dv-section-action-btn xll-dv-section-action-btn--ghost" onClick={handleRecognizePlate}>识别车牌号</button>
</div>
) : null)}
{(f.plateNo || readOnly) ? (
<div className="xll-dv-selected-vehicle">
<div className="xll-dv-selected-vehicle-plate">{dvDisplayPlate(f.plateNo)}</div>
<div className="xll-dv-selected-vehicle-sub">
{f.brand || r.brand} · {f.model || r.model}
{pickedVehicle ? <><br />{pickedVehicle.parkingLot}</> : null}
</div>
</div>
) : null}
</div>
{f.plateNo ? (
<>
<div className="tc-section">
{renderSectionHead(2, '车辆配置')}
<div className="xll-dv-equip-block">
<div className="xll-dv-equip-row">
<span className="xll-dv-equip-label">车身广告及放大字{renderRequiredTag()}</span>
{renderEquipSwitch(rearEquip.hasAd, toggleRearEquipAd, '车身广告及放大字')}
</div>
{rearEquip.hasAd ? (
<div className="xll-dv-equip-photo-grid">
<div className="xll-dv-equip-photo-item">
<div className="xll-dv-equip-photo-item-label">车身广告照片</div>
<div
className={`xll-dv-equip-photo-slot${f.adPhotoUploaded || (readOnly && rearEquip.adPhotoDone) ? ' done' : ''}`}
role={readOnly ? undefined : 'button'}
tabIndex={readOnly ? undefined : 0}
onClick={() => !readOnly && openDvPhotoCamera({
key: 'equip-ad',
label: '车身广告照片',
demoUrl: DV_EQUIP_PHOTO_DEMO.ad,
photoUrl: f.adPhotoUploaded ? DV_EQUIP_PHOTO_DEMO.ad : '',
onComplete: () => setFormDraft((d) => ({ ...d, adPhotoUploaded: true })),
})}
onKeyDown={(e) => e.key === 'Enter' && !readOnly && openDvPhotoCamera({
key: 'equip-ad',
label: '车身广告照片',
demoUrl: DV_EQUIP_PHOTO_DEMO.ad,
photoUrl: f.adPhotoUploaded ? DV_EQUIP_PHOTO_DEMO.ad : '',
onComplete: () => setFormDraft((d) => ({ ...d, adPhotoUploaded: true })),
})}
>
{f.adPhotoUploaded || (readOnly && rearEquip.adPhotoDone) ? '已拍摄' : '拍照/相册'}
</div>
</div>
<div className="xll-dv-equip-photo-item">
<div className="xll-dv-equip-photo-item-label">放大字照片</div>
<div
className={`xll-dv-equip-photo-slot${f.bigWordPhotoUploaded || (readOnly && rearEquip.bigWordPhotoDone) ? ' done' : ''}`}
role={readOnly ? undefined : 'button'}
tabIndex={readOnly ? undefined : 0}
onClick={() => !readOnly && openDvPhotoCamera({
key: 'equip-bigword',
label: '放大字照片',
demoUrl: DV_EQUIP_PHOTO_DEMO.bigWord,
photoUrl: f.bigWordPhotoUploaded ? DV_EQUIP_PHOTO_DEMO.bigWord : '',
onComplete: () => setFormDraft((d) => ({ ...d, bigWordPhotoUploaded: true })),
})}
onKeyDown={(e) => e.key === 'Enter' && !readOnly && openDvPhotoCamera({
key: 'equip-bigword',
label: '放大字照片',
demoUrl: DV_EQUIP_PHOTO_DEMO.bigWord,
photoUrl: f.bigWordPhotoUploaded ? DV_EQUIP_PHOTO_DEMO.bigWord : '',
onComplete: () => setFormDraft((d) => ({ ...d, bigWordPhotoUploaded: true })),
})}
>
{f.bigWordPhotoUploaded || (readOnly && rearEquip.bigWordPhotoDone) ? '已拍摄' : '拍照/相册'}
</div>
</div>
</div>
) : null}
<div className="xll-dv-equip-row" style={{ marginTop: rearEquip.hasAd ? 12 : 0 }}>
<span className="xll-dv-equip-label">尾板{renderRequiredTag()}</span>
{renderEquipSwitch(rearEquip.hasTailgate, toggleRearEquipTailgate, '尾板')}
</div>
<div className="xll-dv-equip-row">
<span className="xll-dv-equip-label">备胎</span>
{renderEquipSwitch(f.spareTire === '有', (checked) => handleSpareTireChange(checked ? '有' : '无'), '备胎')}
</div>
</div>
</div>
<div className="tc-section">
{renderSectionHead(3, '驾驶培训', showDriverPanel ? (
<span className="xll-dv-training-done-tag">已完成驾驶培训</span>
) : showDriverPending ? (
<span className="xll-dv-training-pending-tag">待司机确认</span>
) : null, true)}
{!showDriverPanel && !showDriverPending && !readOnly ? (
<div className="xll-dv-training-cells">
<button type="button" className="xll-dv-training-cell" onClick={openDriverETraining}>
<span className="xll-dv-training-cell-icon xll-dv-training-cell-icon--scan" aria-hidden></span>
<span className="xll-dv-training-cell-main">
<span className="xll-dv-training-cell-title">电子培训</span>
<span className="xll-dv-training-cell-desc">司机微信扫码观看培训视频完成后扫描提车码</span>
</span>
<span className="xll-dv-training-cell-arrow" aria-hidden></span>
</button>
<button type="button" className="xll-dv-training-cell" onClick={openDriverManualRecord}>
<span className="xll-dv-training-cell-icon xll-dv-training-cell-icon--edit" aria-hidden></span>
<span className="xll-dv-training-cell-main">
<span className="xll-dv-training-cell-title">手动记录</span>
<span className="xll-dv-training-cell-desc">录入司机与证件信息后生成培训码司机微信扫码完成培训签字</span>
</span>
<span className="xll-dv-training-cell-arrow" aria-hidden></span>
</button>
</div>
) : null}
{showDriverPending && !readOnly ? (
<div className="xll-dv-training-pending-panel">
<div className="xll-dv-training-pending-hint">培训码已生成等待司机微信扫码阅读安全培训文件并签字确认</div>
<button type="button" className="xll-mod-btn-ghost" style={{ width: '100%', minHeight: 40, borderRadius: 10 }} onClick={openDriverManualRecord}>查看培训码</button>
</div>
) : null}
{showDriverPanel ? (
<div className="xll-dv-driver-panel">
<div className="xll-dv-driver-kv">
<div className="xll-dv-driver-kv-item"><div className="xll-dv-kv-label">司机姓名</div><div className="xll-dv-kv-val">{f.driverName || ''}</div></div>
<div className="xll-dv-driver-kv-item"><div className="xll-dv-kv-label">司机手机号</div><div className="xll-dv-kv-val">{f.driverPhone || ''}</div></div>
<div className="xll-dv-driver-kv-item full"><div className="xll-dv-kv-label">身份证号</div><div className="xll-dv-kv-val">{f.driverIdNo || ''}</div></div>
</div>
<div className="xll-dv-driver-licenses cols-3">
<div className="xll-dv-driver-license-item">
<div className="xll-dv-driver-license-label">身份证正面</div>
<div className="xll-dv-driver-license-thumb">{renderDriverDocThumb(f.driverIdFront, f.driverIdFrontUrl)}</div>
</div>
<div className="xll-dv-driver-license-item">
<div className="xll-dv-driver-license-label">身份证反面</div>
<div className="xll-dv-driver-license-thumb">{renderDriverDocThumb(f.driverIdBack, f.driverIdBackUrl)}</div>
</div>
<div className="xll-dv-driver-license-item">
<div className="xll-dv-driver-license-label">驾驶证正面</div>
<div className="xll-dv-driver-license-thumb">{renderDriverDocThumb(f.driverLicenseFront, f.driverLicenseFrontUrl)}</div>
</div>
<div className="xll-dv-driver-license-item">
<div className="xll-dv-driver-license-label">驾驶证反面</div>
<div className="xll-dv-driver-license-thumb">{renderDriverDocThumb(f.driverLicenseBack, f.driverLicenseBackUrl)}</div>
</div>
<div className="xll-dv-driver-license-item">
<div className="xll-dv-driver-license-label">司机正面照片</div>
<div className="xll-dv-driver-license-thumb">{renderDriverDocThumb(f.driverFrontPhoto, f.driverFrontPhotoUrl)}</div>
</div>
<div className="xll-dv-driver-license-item">
<div className="xll-dv-driver-license-label">从业资格证</div>
<div className="xll-dv-driver-license-thumb">{renderDriverDocThumb(f.driverQualification, f.driverQualificationUrl)}</div>
</div>
</div>
</div>
) : null}
</div>
<div className="tc-section">
{renderSectionHead(4, '交车数据', null, true)}
{readOnly ? (
<div className="xll-dv-kv-grid">
<div className="xll-dv-kv-item"><div className="xll-dv-kv-label">里程</div><div className="xll-dv-kv-val">{dvFormatMileage(f.deliveryMileage)}</div></div>
<div className="xll-dv-kv-item"><div className="xll-dv-kv-label">氢量</div><div className="xll-dv-kv-val">{dvFormatH2(f.deliveryH2, f.deliveryH2Unit)}</div></div>
<div className="xll-dv-kv-item"><div className="xll-dv-kv-label">电量</div><div className="xll-dv-kv-val">{dvFormatMetric2(f.deliveryElec, '%')}</div></div>
<div className="xll-dv-kv-item"><div className="xll-dv-kv-label">送车服务费</div><div className="xll-dv-kv-val">{dvFormatServiceFee(f.serviceFee)}</div></div>
{f.deliveryRemark ? <div className="xll-dv-kv-item full"><div className="xll-dv-kv-label">备注</div><div className="xll-dv-kv-val">{f.deliveryRemark}</div></div> : null}
{r.deliveryPerson ? <div className="xll-dv-kv-item"><div className="xll-dv-kv-label">交车人</div><div className="xll-dv-kv-val">{r.deliveryPerson}</div></div> : null}
</div>
) : (
<div className="xll-dv-metrics-grid">
<div className="xll-dv-metric-field">
<label className="xll-dv-metric-label">里程 (km){renderRequiredTag()}</label>
<input className="xll-dv-metric-input" type="text" inputMode="decimal" value={f.deliveryMileage} onChange={(e) => handleMetricFieldChange('deliveryMileage', e.target.value)} placeholder="0.00" />
</div>
<div className="xll-dv-metric-field">
<label className="xll-dv-metric-label">电量 (%){renderRequiredTag()}</label>
<input className="xll-dv-metric-input" type="text" inputMode="decimal" value={f.deliveryElec} onChange={(e) => handleMetricFieldChange('deliveryElec', e.target.value)} placeholder="0.00" />
</div>
<div className="xll-dv-metric-field">
<label className="xll-dv-metric-label">氢量{renderRequiredTag()}</label>
<div className="xll-dv-metric-unit-wrap">
<input className="xll-dv-metric-input" type="text" inputMode="decimal" value={f.deliveryH2} onChange={(e) => handleMetricFieldChange('deliveryH2', e.target.value)} placeholder="0.00" />
<span className="xll-dv-metric-unit-suffix" aria-label="氢量单位">{f.deliveryH2Unit || dvGetModelGaugeUnit(f.brand || r.brand, f.model || r.model)}</span>
</div>
</div>
<div className="xll-dv-metric-field">
<label className="xll-dv-metric-label">送车服务费</label>
<div className="xll-dv-metric-unit-wrap">
<input className="xll-dv-metric-input" type="text" inputMode="decimal" value={f.serviceFee} onChange={(e) => handleMetricFieldChange('serviceFee', e.target.value)} placeholder="0.00" aria-label="送车服务费" />
<span className="xll-dv-metric-unit-suffix" aria-label="金额单位"></span>
</div>
</div>
<div className="xll-dv-metric-field full">
<label className="xll-dv-metric-label">备注</label>
<textarea
className="xll-dv-metric-remark"
value={f.deliveryRemark || ''}
onChange={(e) => setFormDraft((d) => ({ ...d, deliveryRemark: e.target.value }))}
placeholder="可补充其他备注说明"
rows={3}
aria-label="交车备注"
/>
</div>
</div>
)}
</div>
<div className="tc-section">
{renderSectionHead(5, '交车位置', (
<div className="xll-dv-section-actions">
{f.plateNo ? <span className="xll-dv-delivery-location-plate">{f.plateNo}</span> : null}
{!readOnly && f.plateNo && !dvVehicleHasGpsDevice(f.plateNo) ? (
<button
type="button"
className="xll-dv-section-action-btn xll-dv-section-action-btn--primary"
onClick={() => handleGetCurrentLocation(false)}
disabled={locationFetching}
>
{locationFetching ? '定位中…' : '获取当前定位'}
</button>
) : null}
</div>
))}
{renderDeliveryLocationMap(f.plateNo, r, f.deliveryLocation)}
</div>
</>
) : null}
<div style={{ height: 16 }} />
</>
);
const renderPhotosStep = () => {
const photos = f.deliveryPhotos || {};
const seq = dvGetCaptureSequence(f);
const capturedCount = dvCountCapturedPhotos(photos, f);
const currentItem = seq[photoCaptureIndex] || null;
const sparePhotoTread = dvGetPhotoRecord(photos, 'spare')?.treadDepth;
const removePhoto = (photoKey) => {
setFormDraft((d) => {
const next = { ...(d.deliveryPhotos || {}) };
delete next[photoKey];
return { ...d, deliveryPhotos: next };
});
setPhotoViewer((v) => (v?.items?.some((item) => item.key === photoKey) ? null : v));
};
const renderPhotoThumbCell = (photoKey, label, categoryKey) => {
const record = dvGetPhotoRecord(photos, photoKey);
const captured = dvPhotoCaptured(photos, photoKey);
const resolvedUrl = captured ? dvResolvePhotoUrl(photoKey, record) : '';
const canPreview = !!resolvedUrl;
const isExtraCategory = categoryKey === 'defect' || categoryKey === 'other';
const isReshootSlot = !readOnly && photoCapturePhase === 'done' && !captured && !isExtraCategory;
const allowDelete = !readOnly && captured && (isExtraCategory || photoCapturePhase === 'done');
const placeholderText = isReshootSlot ? '补拍' : (captured ? 'PHOTO' : '+ 添加');
const handleThumbClick = () => {
if (canPreview) {
openDeliveryPhotoViewer(categoryKey, photoKey);
return;
}
if (isReshootSlot) startPhotoCaptureAtKey(photoKey);
};
return (
<div key={photoKey}>
<div
className={`xll-dv-photo-thumb${canPreview || isReshootSlot ? '' : ' xll-dv-photo-thumb--empty'}`}
role={canPreview || isReshootSlot ? 'button' : undefined}
tabIndex={canPreview || isReshootSlot ? 0 : undefined}
onClick={handleThumbClick}
onKeyDown={(e) => { if ((canPreview || isReshootSlot) && e.key === 'Enter') handleThumbClick(); }}
>
{resolvedUrl ? (
<>
<img
src={resolvedUrl}
alt={label}
onError={(e) => { e.currentTarget.src = dvGetPhotoDemoUrl(photoKey); }}
/>
{record?.watermarkTime ? (
<div className="xll-dv-photo-thumb-watermark" aria-hidden>
<div>{record.watermarkTime}</div>
{record.watermarkAddress ? <div className="xll-dv-photo-thumb-watermark-loc">{record.watermarkAddress}</div> : null}
</div>
) : null}
</>
) : (
<span className={`xll-dv-photo-thumb-placeholder${isReshootSlot ? ' xll-dv-photo-thumb-placeholder--reshoot' : ''}`}>{placeholderText}</span>
)}
{allowDelete ? (
<button
type="button"
className="xll-dv-photo-thumb-del"
onClick={(e) => { e.stopPropagation(); removePhoto(photoKey); }}
aria-label={`删除${label}`}
>
×
</button>
) : null}
</div>
<div className="xll-dv-photo-thumb-label">{label}</div>
{record?.treadDepth ? <div className="xll-dv-photo-thumb-tread">胎纹 {record.treadDepth} mm</div> : null}
</div>
);
};
const addExtraPhoto = (categoryKey) => {
const keys = Object.keys(photos).filter((key) => key.indexOf(`${categoryKey}_extra_`) === 0);
if (keys.length >= 6) {
message.warning('最多上传 6 张');
return;
}
const nextKey = `${categoryKey}_extra_${keys.length + 1}`;
const label = `照片${keys.length + 1}`;
openDvPhotoCamera({
key: nextKey,
label,
demoUrl: dvGetPhotoDemoUrl(nextKey),
onComplete: () => {
const upload = dvSimulatePhotoUpload(nextKey, { photoUrl: dvGetPhotoDemoUrl(nextKey) }, f, r);
setFormDraft((d) => ({
...d,
deliveryPhotos: {
...(d.deliveryPhotos || {}),
[nextKey]: {
captured: true,
photoUrl: upload.photoUrl,
uploaded: true,
watermarkTime: upload.watermarkTime,
watermarkAddress: upload.watermarkAddress,
},
},
}));
message.success('已添加照片(原型)');
},
});
};
const renderPhotoCategorySection = (cat, sectionNum) => {
let items = [];
if (cat.key === 'defect' || cat.key === 'other') {
items = Object.keys(photos)
.filter((key) => key.indexOf(`${cat.key}_extra_`) === 0 && dvPhotoCaptured(photos, key))
.sort()
.map((key, idx) => ({ key, label: `照片${idx + 1}` }));
} else {
items = dvPhotoItemsByCategory(cat.key).filter((item) => {
if (item.key === 'spare' && f.spareTire === '无') return false;
if (readOnly) return dvPhotoCaptured(photos, item.key);
return true;
});
}
if (readOnly && items.length === 0 && cat.key !== 'defect' && cat.key !== 'other') return null;
return (
<div key={cat.key} className="tc-section xll-dv-photo-cat-section">
{renderSectionHead(sectionNum, cat.label)}
<div className="xll-dv-photo-block">
<div className="xll-dv-photo-grid">
{items.map((item) => renderPhotoThumbCell(item.key, item.label, cat.key))}
{!readOnly && (cat.key === 'defect' || cat.key === 'other') ? (
<div>
<div
className="xll-dv-photo-slot"
role="button"
tabIndex={0}
onClick={() => addExtraPhoto(cat.key)}
onKeyDown={(e) => e.key === 'Enter' && addExtraPhoto(cat.key)}
>
+ 添加
</div>
</div>
) : null}
</div>
</div>
</div>
);
};
if (!readOnly && photoCapturePhase === 'ready') {
const progressPct = seq.length ? Math.round((capturedCount / seq.length) * 100) : 0;
return (
<div className="xll-dv-photo-capture-page xll-dv-photo-capture-page--ready">
<div className="xll-dv-photo-capture-card">
<div className="xll-dv-photo-capture-icon" aria-hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h3l2-3h6l2 3h3a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" />
<circle cx="12" cy="13" r="3.5" />
</svg>
</div>
<h2 className="xll-dv-photo-capture-title">请准备好连续拍摄</h2>
<p className="xll-dv-photo-capture-sub">请确认车辆与拍摄环境就绪连拍将自动按顺序进行</p>
{capturedCount > 0 ? (
<div className="xll-dv-photo-capture-resume">
<div className="xll-dv-photo-capture-progress-bar" aria-hidden>
<span style={{ width: `${progressPct}%` }} />
</div>
<div className="xll-dv-photo-capture-resume-text">
已完成 <strong>{capturedCount}/{seq.length}</strong>
</div>
</div>
) : (
<ul className="xll-dv-photo-capture-tips">
<li>请保持画面清晰光线充足</li>
<li>连拍过程中请勿退出下次将自动续拍</li>
</ul>
)}
</div>
</div>
);
}
if (!readOnly && photoCapturePhase === 'countdown') {
const catKey = currentItem?.category || 'body';
const catLabel = dvPhotoCategoryLabel(catKey);
return (
<div className="xll-dv-photo-capture-page xll-dv-photo-capture-page--countdown">
<div className="xll-dv-photo-capture-card">
{catLabel ? (
<span className={`xll-dv-photo-capture-badge xll-dv-photo-capture-badge--${catKey}`}>{catLabel}</span>
) : null}
<p className="xll-dv-photo-capture-soon">即将拍摄</p>
<div className="xll-dv-photo-capture-countdown-ring" aria-live="polite" aria-label={`${photoCountdown}秒后开始拍摄`}>
<span key={photoCountdown} className="xll-dv-photo-capture-countdown-num">{photoCountdown}</span>
</div>
<h2 className="xll-dv-photo-capture-target">{currentItem?.label || '—'}</h2>
<p className="xll-dv-photo-capture-countdown-tip">秒后自动开始 · 可点底部立即拍摄</p>
<div className="xll-dv-photo-capture-step-pill">
<em>{photoCaptureIndex + 1}</em> / {seq.length}
</div>
</div>
</div>
);
}
if (!readOnly && photoCapturePhase === 'camera') {
return null;
}
const photoCategoryNodes = DV_PHOTO_CATEGORIES.map((cat, idx) => renderPhotoCategorySection(cat, idx + 1));
const renderAuthorizedPersonBlock = () => {
if (readOnly && !canEditAuthorizedPerson) {
const person = dvFindAuthorizedPerson(f.authorizedPersonId);
const name = person?.name || f.authorizedPersonName;
const phone = person?.phone || f.authorizedPersonPhone;
if (!name) return null;
return (
<>
<div className="xll-dv-authorized-subtitle">被授权人</div>
<div className="xll-dv-authorized-grid">
<div className="xll-dv-authorized-card xll-dv-authorized-card--readonly active">
<span className="xll-dv-authorized-avatar" aria-hidden>{dvPersonInitial(name)}</span>
<div className="xll-dv-authorized-card-body">
<div className="xll-dv-authorized-card-name">{name}</div>
<div className="xll-dv-authorized-card-phone">{phone || '—'}</div>
</div>
</div>
</div>
</>
);
}
return (
<>
<div className="xll-dv-authorized-hint">选择客户方被授权人签章短信将发送至其手机</div>
<div className="xll-dv-authorized-grid">
{DV_AUTHORIZED_PERSONS.map((person) => {
const selected = f.authorizedPersonId === person.id;
return (
<button
key={person.id}
type="button"
className={`xll-dv-authorized-card${selected ? ' active' : ''}`}
disabled={!canEditAuthorizedPerson}
onClick={() => handleAuthorizedPersonPick(person)}
>
<span className="xll-dv-authorized-card-check" aria-hidden>{selected ? '✓' : ''}</span>
<span className="xll-dv-authorized-avatar" aria-hidden>{dvPersonInitial(person.name)}</span>
<div className="xll-dv-authorized-card-body">
<div className="xll-dv-authorized-card-name">{person.name}</div>
<div className="xll-dv-authorized-card-phone">{person.phone || '—'}</div>
</div>
</button>
);
})}
</div>
</>
);
};
return (
<>
{photoCategoryNodes}
{!readOnly && !dvRequiredPhotosComplete(photos, f) ? (
<div className="tc-section xll-dv-photo-reshoot-bar">
<div style={{ padding: '14px' }}>
<button type="button" className="xll-mod-btn-ghost" style={{ width: '100%', minHeight: 40, borderRadius: 10 }} onClick={startPhotoCapture}>
继续连拍
</button>
</div>
</div>
) : null}
<div className="tc-section xll-dv-authorized-section">
{renderSectionHead(6, '选择被授权人')}
<div className="xll-dv-authorized-panel">
{renderAuthorizedPersonBlock()}
{isSignPendingView && f.signSent ? (
<div className="xll-dv-sign-pending-foot">
<div className="xll-dv-sign-pending-hint">签章文件已发送等待被授权人通过短信完成 E签宝签章</div>
</div>
) : null}
{isResignPendingView ? (
<div className="xll-dv-sign-pending-foot">
<div className="xll-dv-sign-pending-hint">签章已清除交车信息已锁定请重新选择被授权人后发起签章</div>
</div>
) : null}
</div>
</div>
{isHistoryView ? (
<div className="tc-section">
{renderSectionHead(7, 'E签宝签章文件')}
<div className="tc-section-form" style={{ padding: '0 14px 14px' }}>
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">客户完成签章时间</span>
<span className="xll-mod-form-value">{dvDisplayMinuteTime(r.customerSignTime)}</span>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 10 }}>
<button
type="button"
className="xll-mod-btn-ghost"
style={{ flex: 1, minHeight: 40, borderRadius: 10 }}
onClick={handlePreviewSignFile}
>
预览
</button>
<button
type="button"
className="xll-mod-btn-primary"
style={{ flex: 1, minHeight: 40, borderRadius: 10 }}
onClick={handleDownloadSignFile}
>
下载
</button>
</div>
{isHistoryView && r.vehicleReturned != null ? (
<>
<div className="xll-mod-form-row" style={{ marginTop: 10 }}>
<span className="xll-mod-form-label">是否归还</span>
<span className="xll-mod-form-value">{r.vehicleReturned ? '已归还' : '未归还'}</span>
</div>
{r.vehicleReturned && r.vehicleReturnTime ? (
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">归还时间</span>
<span className="xll-mod-form-value">{dvDisplayMinuteTime(r.vehicleReturnTime)}</span>
</div>
) : null}
</>
) : null}
</div>
</div>
) : null}
<div style={{ height: 16 }} />
</>
);
};
if (deliveryFormStep === 'inspection') {
return (
<>
{readOnly ? renderDeliveryHero() : null}
{renderDeliveryInspection(f)}
<div style={{ height: 16 }} />
</>
);
}
if (deliveryFormStep === 'photos') {
return (
<>
{readOnly ? renderDeliveryHero() : null}
{renderPhotosStep()}
</>
);
}
return renderVehicleStep();
};
const renderDeliveryActionBar = () => {
if (deliverySignFlow) return null;
if (isSignPendingView) {
return (
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-ghost" onClick={closeForm}>返回</button>
<button type="button" className="xll-mod-btn-primary" onClick={handleClearSign}>清除签章</button>
</div>
);
}
if (isResignPendingView) {
return (
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-ghost" onClick={closeForm}>返回</button>
<button type="button" className="xll-mod-btn-primary" onClick={handleSendSignDoc}>重新发起签章</button>
</div>
);
}
if (isHistoryView) {
return (
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-ghost" onClick={closeForm}>返回</button>
<button type="button" className="xll-mod-btn-primary" onClick={handleDownloadSignFile}>下载签章文件</button>
</div>
);
}
if (readOnly) return null;
if (deliveryFormStep === 'vehicle') {
return (
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-ghost" onClick={handleSave}>保存</button>
<button type="button" className="xll-mod-btn-primary" onClick={handleVehicleNext}>下一步</button>
</div>
);
}
if (deliveryFormStep === 'inspection') {
return (
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-ghost" onClick={handleSave}>保存</button>
<button type="button" className="xll-mod-btn-primary" onClick={handleInspectionNext}>下一步</button>
</div>
);
}
if (deliveryFormStep === 'photos') {
if (photoCapturePhase === 'camera') return null;
if (photoCapturePhase === 'ready') {
return (
<div className="xll-mod-action-bar xll-dv-photo-action-bar">
<button type="button" className="xll-mod-btn-primary xll-dv-photo-action-btn" onClick={startPhotoCapture}>开始拍摄</button>
</div>
);
}
if (photoCapturePhase === 'countdown') {
return (
<div className="xll-mod-action-bar xll-dv-photo-action-bar">
<button type="button" className="xll-mod-btn-primary xll-dv-photo-action-btn" onClick={capturePhotoNow}>立即拍摄</button>
</div>
);
}
return (
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-ghost" onClick={handleSave}>保存</button>
<button type="button" className="xll-mod-btn-primary" onClick={handleSendSignDoc}>发送签章文件</button>
</div>
);
}
return null;
};
const activeTabLabel = DV_LIST_TABS.find((t) => t.key === listFilter)?.label || '';
const renderParkingSheetOverlay = () => {
if (!parkingSheetOpen) return null;
return (
<div className="xll-mod-sheet-overlay" role="dialog" aria-modal="true" aria-label="选择停车场备车库">
<button type="button" className="xll-mod-sheet-mask" onClick={() => setParkingSheetOpen(false)} aria-label="关闭" />
<div className="xll-mod-sheet-panel">
<div className="xll-mod-sheet-handle" aria-hidden />
<div className="xll-mod-sheet-header">
<div className="xll-mod-sheet-title">选择停车场备车库</div>
<button type="button" className="xll-mod-sheet-close" onClick={() => setParkingSheetOpen(false)} aria-label="关闭">×</button>
</div>
<div className="xll-mod-sheet-body">
{DV_OPERATOR_PARKING_LOTS.map((lot) => {
const active = vehiclePickParking === lot.key;
return (
<button
key={lot.key}
type="button"
className={`xll-mod-sheet-option${active ? ' active' : ''}`}
onClick={() => { setVehiclePickParking(lot.key); setParkingSheetOpen(false); }}
>
<span className="xll-mod-sheet-option-text">
<span className="xll-mod-sheet-option-main">{lot.label}</span>
{lot.key === 'all' ? <span className="xll-mod-sheet-option-sub">权限下全部备车库</span> : null}
</span>
{active ? <span className="xll-mod-sheet-option-check" aria-hidden></span> : null}
</button>
);
})}
</div>
</div>
</div>
);
};
const renderVehiclePickPage = () => (
<div className="xll-mod-root xll-dv-module" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
<div className="xll-dv-pick-toolbar">
<div className="xll-mod-search">
<IconSearch />
<input
type="search"
placeholder="输入车牌号搜索"
value={vehiclePickSearch}
onChange={(e) => setVehiclePickSearch(e.target.value)}
aria-label="输入车牌号搜索"
/>
</div>
<button
type="button"
className="xll-dv-pick-parking"
onClick={() => setParkingSheetOpen(true)}
aria-label="切换停车场备车库"
>
<span className="xll-dv-pick-parking-label">停车场</span>
<strong>{vehiclePickParkingLabel}</strong>
<span className="xll-dv-pick-parking-arrow">切换 </span>
</button>
</div>
<div className="xll-mod-list-head" style={{ padding: '0 14px' }}>
<span>备车库车辆</span>
<span> {vehiclePickList.length} </span>
</div>
<div className="xll-dv-pick-list">
{vehiclePickList.length === 0 ? (
<div className="xll-mod-empty">暂无匹配车辆<br />试试切换停车场或清空搜索</div>
) : (
vehiclePickList.map((v) => {
const meta = dvGetReadinessMeta(v);
return (
<div
key={v.plateNo}
className={`xll-dv-pick-card${meta.blocked ? ' blocked' : ''}`}
role={meta.canPick ? 'button' : undefined}
tabIndex={meta.canPick ? 0 : undefined}
onClick={() => meta.canPick && handlePlatePick(v)}
onKeyDown={(e) => e.key === 'Enter' && meta.canPick && handlePlatePick(v)}
>
<div
className="xll-mod-card"
style={{ '--mod-accent': meta.blocked ? COLOR_DANGER : XLL_GREEN, '--mod-soft': meta.blocked ? 'rgba(244,63,94,.1)' : XLL_GREEN_SOFT, marginBottom: 0 }}
>
<div className="xll-mod-card-head">
<span className="xll-mod-ar-plate">{v.plateNo}</span>
<span className={`xll-vm-online${v.onlineStatus === '在线' ? '' : ''}`}>
<span className={`xll-vm-dot ${v.onlineStatus === '在线' ? 'on' : 'off'}`} />
{v.onlineStatus}
</span>
</div>
<div className="xll-mod-card-sub">{v.brand} · {v.model}</div>
<div className={`xll-dv-readiness-bar ${meta.blocked ? 'blocked' : 'ready'}`}>{meta.label}</div>
<div className="xll-mod-meta">
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">停车场</span><span className="xll-mod-meta-val">{v.parkingLot}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">运营城市</span><span className="xll-mod-meta-val">{v.region || ''}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">车辆状态</span><span className="xll-mod-meta-val">{v.vehicleStatus}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">保险状态</span><span className="xll-mod-meta-val" style={{ color: v.insuranceStatus === '' ? COLOR_DANGER : COLOR_TEXT_SEC }}>{v.insuranceStatus}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">证照状态</span><span className="xll-mod-meta-val" style={{ color: v.licenseStatus === '' ? COLOR_DANGER : COLOR_TEXT_SEC }}>{v.licenseStatus}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">车辆来源</span><span className="xll-mod-meta-val">{v.vehicleSource || ''}</span></div>
</div>
<div className="xll-mod-card-foot">
<div className="xll-vm-card-vin-wrap">
<span className="xll-vm-card-vin-label">车辆识别代码</span>
<span className="xll-vm-card-vin" title={v.vin}>{v.vin}</span>
</div>
{meta.canPick ? (
<button type="button" className="xll-mod-card-btn" onClick={(e) => { e.stopPropagation(); handlePlatePick(v); }}>选择</button>
) : (
<span style={{ fontSize: 12, color: COLOR_DANGER, fontWeight: 600 }}>不可交车</span>
)}
</div>
</div>
</div>
);
})
)}
</div>
{renderParkingSheetOverlay()}
</div>
);
const handleApplyDeliveryFilter = useCallback(() => {
if (filterDraft.dateStart && filterDraft.dateEnd && filterDraft.dateStart > filterDraft.dateEnd) {
message.warning('开始日期不能晚于结束日期');
return;
}
setStatusFilter(filterDraft.status);
setMoreFilter({
customerName: filterDraft.customerName,
projectName: filterDraft.projectName,
dateStart: filterDraft.dateStart,
dateEnd: filterDraft.dateEnd,
});
setFilterDrawerOpen(false);
}, [filterDraft]);
const renderDeliveryFilterSheet = () => {
if (!filterDrawerOpen) return null;
return (
<div className="xll-mod-sheet-overlay" role="dialog" aria-modal="true" aria-label="筛选交车">
<button type="button" className="xll-mod-sheet-mask" onClick={() => setFilterDrawerOpen(false)} aria-label="关闭" />
<div className="xll-mod-sheet-panel xll-mod-sheet-panel--filter">
<div className="xll-mod-sheet-handle" aria-hidden />
<div className="xll-mod-sheet-header">
<div className="xll-mod-sheet-title">筛选交车</div>
<button type="button" className="xll-mod-sheet-close" onClick={() => setFilterDrawerOpen(false)} aria-label="关闭">×</button>
</div>
<div className="xll-mod-sheet-scroll">
{(listFilter === 'all' || listFilter === 'inProgress') ? (
<div className="xll-mod-drawer-section">
<div className="xll-mod-drawer-section-title">交车状态</div>
<div className="xll-mod-drawer-types">
{DV_STATUS_FILTER_OPTIONS.map((st) => (
<button
key={st || 'all-status'}
type="button"
className={`xll-mod-drawer-type-btn${filterDraft.status === st ? ' active' : ''}`}
onClick={() => setFilterDraft((f) => ({ ...f, status: st }))}
>
{st || '全部状态'}
</button>
))}
</div>
</div>
) : null}
<div className="xll-mod-drawer-section">
<div className="xll-mod-drawer-section-title">客户与项目</div>
<div className="xll-mod-drawer-form-card">
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">客户名称</span>
<input
className="xll-mod-form-input"
type="search"
placeholder="请输入"
value={filterDraft.customerName}
onChange={(e) => setFilterDraft((f) => ({ ...f, customerName: e.target.value }))}
aria-label="客户名称"
/>
</div>
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">项目名称</span>
<input
className="xll-mod-form-input"
type="search"
placeholder="请输入"
value={filterDraft.projectName}
onChange={(e) => setFilterDraft((f) => ({ ...f, projectName: e.target.value }))}
aria-label="项目名称"
/>
</div>
</div>
</div>
<div className="xll-mod-drawer-section" style={{ marginBottom: 0 }}>
<div className="xll-mod-drawer-section-title">交车日期</div>
<div className="xll-mod-drawer-form-card">
<div className="xll-mod-drawer-date-row">
<input
type="date"
aria-label="交车开始日期"
value={filterDraft.dateStart}
onChange={(e) => setFilterDraft((f) => ({ ...f, dateStart: e.target.value }))}
/>
<span className="xll-mod-drawer-date-sep"></span>
<input
type="date"
aria-label="交车结束日期"
value={filterDraft.dateEnd}
onChange={(e) => setFilterDraft((f) => ({ ...f, dateEnd: e.target.value }))}
/>
</div>
</div>
<div className="xll-mod-drawer-hint">实际交车时间=运维人员完成 E签宝 签字的时间待客户签章及之后状态参与日期筛选</div>
</div>
</div>
<div className="xll-mod-sheet-footer">
<button type="button" className="xll-mod-btn-ghost" onClick={() => setFilterDraft(DV_EMPTY_FILTER_DRAFT)}>重置</button>
<button type="button" className="xll-mod-btn-primary" onClick={handleApplyDeliveryFilter}>确定</button>
</div>
</div>
</div>
);
};
if (activeRow && driverManualOpen) {
return renderDriverManualPage();
}
if (activeRow && driverETrainingOpen) {
return renderDriverETrainingPage();
}
if (activeRow && deliverySignFlow === 'success') {
return renderDeliverySignSuccessPage();
}
if (activeRow && vehiclePickOpen) {
return renderVehiclePickPage();
}
if (activeRow) {
const hideStepBar = deliveryFormStep === 'photos' && (photoCapturePhase === 'countdown' || photoCapturePhase === 'camera');
const photoCaptureLayout = deliveryFormStep === 'photos' && (photoCapturePhase === 'ready' || photoCapturePhase === 'countdown');
return (
<div className="xll-mod-root xll-dv-module xll-mod-form-page">
<div className="xll-mod-detail-wrap">
{!hideStepBar ? renderDeliveryStepBar() : null}
<div className={`tc-scroll${photoCaptureLayout ? ' xll-dv-photo-capture-scroll' : ''}`} style={photoCaptureLayout ? { display: 'flex', flexDirection: 'column' } : undefined}>
{renderDeliveryFormPage()}
</div>
{renderDeliveryActionBar()}
{renderPlateValidateModal()}
{renderClearSignConfirmOverlay()}
{renderPhotoSourceSheet()}
{renderPhotoCameraOverlay()}
{renderPhotoViewerOverlay()}
{renderSpareTireCaptureOverlay()}
{renderDvPhotoCameraOverlay()}
</div>
</div>
);
}
return (
<div className="xll-mod-root xll-dv-module">
<div className="xll-mod-tabs" role="tablist" aria-label="交车列表分类">
{DV_LIST_TABS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={listFilter === tab.key}
className={`xll-mod-tab${listFilter === tab.key ? ' active' : ''}`}
onClick={() => { setListFilter(tab.key); if (tab.key === 'completed') setStatusFilter(''); }}
>
{tab.short}
<span className="xll-mod-tab-count">
{tab.key === 'inProgress' ? kpi.inProgress : tab.key === 'completed' ? kpi.completed : kpi.all}
</span>
</button>
))}
</div>
<div className="xll-mod-toolbar">
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="xll-mod-search" style={{ flex: 1 }}>
<IconSearch />
<input
type="search"
placeholder="搜索车牌、项目、客户"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
aria-label="搜索交车任务"
/>
</div>
<button
type="button"
className={`xll-vm-filter-btn${activeFilterCount ? ' active' : ''}`}
onClick={() => {
setFilterDraft({
status: statusFilter,
customerName: moreFilter.customerName,
projectName: moreFilter.projectName,
dateStart: moreFilter.dateStart,
dateEnd: moreFilter.dateEnd,
});
setFilterDrawerOpen(true);
}}
aria-label="筛选交车"
>
<IconFilter />
</button>
</div>
{(listFilter === 'all' || listFilter === 'inProgress') && (
<div className="xll-mod-chips" role="group" aria-label="交车状态快捷筛选">
{DV_STATUS_FILTER_OPTIONS.map((st) => (
<button
key={st || 'all-status'}
type="button"
className={`xll-mod-chip${statusFilter === st ? ' active' : ''}`}
onClick={() => setStatusFilter(statusFilter === st ? '' : st)}
>
{st || '全部状态'}
</button>
))}
</div>
)}
{activeFilterCount > 0 && (
<div className="xll-mod-chips" style={{ marginTop: (listFilter === 'all' || listFilter === 'inProgress') ? 0 : 10 }}>
{statusFilter && (listFilter === 'completed' || !DV_STATUS_FILTER_OPTIONS.includes(statusFilter)) ? (
<button type="button" className="xll-mod-chip active" onClick={() => setStatusFilter('')}>
状态{statusFilter} ×
</button>
) : null}
{moreFilter.customerName.trim() && (
<button type="button" className="xll-mod-chip active" onClick={() => setMoreFilter((f) => ({ ...f, customerName: '' }))}>
客户{moreFilter.customerName} ×
</button>
)}
{moreFilter.projectName.trim() && (
<button type="button" className="xll-mod-chip active" onClick={() => setMoreFilter((f) => ({ ...f, projectName: '' }))}>
项目{moreFilter.projectName} ×
</button>
)}
{(moreFilter.dateStart || moreFilter.dateEnd) && (
<button type="button" className="xll-mod-chip active" onClick={() => setMoreFilter((f) => ({ ...f, dateStart: '', dateEnd: '' }))}>
交车日期{moreFilter.dateStart || '…'} {moreFilter.dateEnd || '…'} ×
</button>
)}
</div>
)}
</div>
<div className="xll-mod-list-head"><span>{activeTabLabel}</span><span> {filteredList.length} </span></div>
<div className="xll-mod-list">
{filteredList.length === 0 ? (
<div className="xll-mod-empty">
暂无{activeTabLabel}交车任务
<br />
{searchKey || statusFilter || activeFilterCount ? '试试清空搜索或切换筛选' : '新的交车任务到达后将在此展示'}
</div>
) : (
filteredList.map((row) => {
const st = dvStatusTag(row.deliveryStatus);
const pendingPlate = !row.plateNo || !String(row.plateNo).trim();
const inProgress = dvIsInProgressStatus(row.deliveryStatus);
const isSignPending = row.deliveryStatus === '待客户签章';
const isResignPending = row.deliveryStatus === '待重新签章';
const actionLabel = row.deliveryStatus === '未开始'
? '去办理'
: isSignPending
? '查看'
: isResignPending
? '继续办理'
: '继续办理';
return (
<div
key={row.id}
className="xll-mod-card"
style={{ '--mod-accent': XLL_GREEN, '--mod-soft': XLL_GREEN_SOFT }}
role="button"
tabIndex={0}
onClick={() => openRow(row)}
onKeyDown={(e) => e.key === 'Enter' && openRow(row)}
>
<div className="xll-mod-card-head">
<span className="xll-dv-plate-row">
<span className={`xll-mod-ar-plate${pendingPlate ? ' xll-dv-plate-pending' : ''}`}>{dvDisplayPlate(row.plateNo)}</span>
{dvIsReplaceDeliveryTask(row) ? <span className="xll-dv-replace-tag">替换车交车</span> : null}
</span>
<span className={`xll-mod-card-status ${dvCardStatusClass(row.deliveryStatus)}`}>{st.text}</span>
</div>
<div className="xll-mod-card-title" style={{ fontSize: 14, fontWeight: 600 }}>{dvVehicleDesc(row)}</div>
<div className="xll-mod-card-sub" style={{ fontSize: 13, marginBottom: 2 }}>{row.customerName || '—'}</div>
<div className="xll-mod-card-sub" style={{ fontSize: 12, color: COLOR_MUTED, marginBottom: 8 }}>{row.projectName || '—'}</div>
<div className="xll-mod-card-period">
<span>计划交车 {dvFormatExpectedDate(row.expectedDate)}</span>
</div>
<div className="xll-mod-meta">
<div><span className="xll-mod-meta-label">交车区域 </span><span className="xll-mod-meta-val">{row.deliveryRegion || ''}</span></div>
<div><span className="xll-mod-meta-label">交车地点 </span><span className="xll-mod-meta-val">{row.deliveryAddress || ''}</span></div>
</div>
{inProgress ? (
<div className="xll-mod-card-foot" style={{ justifyContent: isSignPending ? 'space-between' : 'flex-end' }}>
{isSignPending && row.deliveryTime ? (
<span style={{ fontSize: 12, color: COLOR_MUTED, lineHeight: 1.45 }}>
实际交车 <span style={{ color: '#2563EB', fontWeight: 600 }}>{dvDisplayActualTime(row.deliveryTime)}</span>
</span>
) : null}
<button type="button" className="xll-mod-card-btn" onClick={(e) => { e.stopPropagation(); openRow(row); }}>
{actionLabel}
</button>
</div>
) : null}
</div>
);
})
)}
</div>
{renderDeliveryFilterSheet()}
</div>
);
};
/* ── 替换车(参照 web端/替换车管理-新增.jsx ── */
const VR_ACCENT = '#F43F5E';
const VR_SOFT = 'rgba(244, 63, 94, 0.12)';
const VR_ACTIVE_CONTRACTS = [
{ contractId: 'c1', projectName: '嘉兴氢能示范项目', projectType: '租赁', contractCode: 'HT-ZL-2025-001', customerName: '嘉兴某某物流有限公司', deliveryRegion: '浙江省-嘉兴市' },
{ contractId: 'c2', projectName: '上海物流租赁项目', projectType: '租赁', contractCode: 'HT-ZL-2025-002', customerName: '上海某某运输公司', deliveryRegion: '上海市-上海市' },
{ contractId: 'c3', projectName: '杭州城配自营项目', projectType: '自营', contractCode: 'HT-ZY-2025-003', customerName: '杭州某某租赁有限公司', deliveryRegion: '浙江省-杭州市' },
];
const VR_DELIVERED_VEHICLES = [
{ plateNo: '浙A12345', brand: '东风', model: 'DFH1180', contractId: 'c1' },
{ plateNo: '浙A55555', brand: '重汽', model: 'ZZ1160', contractId: 'c1' },
{ plateNo: '沪B11111', brand: '江淮', model: 'HFC1180', contractId: 'c2' },
{ plateNo: '浙C33333', brand: '东风', model: 'DFH1190', contractId: 'c3' },
];
const VR_PREPARED_BY_REGION = {
'浙江省-嘉兴市': [
{ plateNo: '浙A67890', brand: '福田', model: 'BJ1180' },
{ plateNo: '浙A66666', brand: '江淮', model: 'HFC1190' },
{ plateNo: '浙F88888', brand: '东风', model: 'DFH1180' },
],
'上海市-上海市': [
{ plateNo: '沪B22222', brand: '重汽', model: 'ZZ1180' },
{ plateNo: '沪B33333', brand: '福田', model: 'BJ1190' },
],
'浙江省-杭州市': [
{ plateNo: '浙C44444', brand: '东风', model: 'DFH1180' },
],
};
const VR_CONTRACT_MAP = VR_ACTIVE_CONTRACTS.reduce((m, c) => { m[c.contractId] = c; return m; }, {});
const VR_MOCK_APPLICATIONS = [
{
id: 'vr-o1', bizNo: 'TH-2026-0315', replaceDate: '2026-03-15', approvalStatus: '审批中', currentApprover: '张明辉',
projectName: '嘉兴氢能示范项目', customerName: '嘉兴某某物流有限公司', deliveryRegion: '浙江省-嘉兴市',
pairs: [{ originalPlate: '浙A12345', replacePlate: '浙A67890', replaceType: '永久替换' }],
creator: '王东东', createTime: '2026-03-14 10:00',
},
{
id: 'vr-o2', bizNo: 'TH-2026-0310', replaceDate: '2026-03-10', approvalStatus: '未提交', currentApprover: '—',
projectName: '上海物流租赁项目', customerName: '上海某某运输公司', deliveryRegion: '上海市-上海市',
pairs: [{ originalPlate: '沪B11111', replacePlate: '沪B22222', replaceType: '临时替换' }],
creator: '李四', createTime: '2026-03-09 15:30',
},
{
id: 'vr-h1', bizNo: 'TH-2026-0218', replaceDate: '2026-02-18', approvalStatus: '审批完成', currentApprover: '—',
projectName: '嘉兴氢能示范项目', customerName: '嘉兴某某物流有限公司', deliveryRegion: '浙江省-嘉兴市',
pairs: [
{ originalPlate: '浙A10001', replacePlate: '浙A10002', replaceType: '永久替换' },
{ originalPlate: '浙A55555', replacePlate: '浙A66666', replaceType: '临时替换' },
],
creator: '王东东', createTime: '2026-02-17 09:20',
},
];
const VR_LIST_TABS = [
{ key: 'ongoing', label: '进行中', short: '进行中' },
{ key: 'history', label: '历史记录', short: '历史' },
];
const VR_STATUS_CHIPS = ['', '未提交', '待审批', '审批中', '审批驳回'];
const vrIsOngoing = (status) => ['待审批', '审批中', '审批驳回', '未提交', '撤回'].includes(status);
const vrIsHistory = (status) => status === '审批完成';
const vrBizStatusClass = (status) => {
if (status === '审批完成') return 'ok';
if (status === '审批驳回' || status === '撤回') return 'reject';
return 'pending';
};
const vrBizStatusLabel = (row) => {
const status = row.approvalStatus;
if ((status === '审批中' || status === '待审批') && row.currentApprover && row.currentApprover !== '—') {
return `${status}${row.currentApprover}`;
}
return status || '—';
};
const vrRowToTask = (row) => {
const contract = VR_ACTIVE_CONTRACTS.find((c) => c.projectName === row.projectName) || {};
return {
flowType: '替换车申请',
bizNo: row.bizNo,
customerName: row.customerName,
projectName: row.projectName,
projectType: row.projectType || contract.projectType,
contractCode: row.contractCode || contract.contractCode,
deliveryRegion: row.deliveryRegion || contract.deliveryRegion,
pairs: row.pairs,
vehicleCount: (row.pairs || []).length,
initiator: row.creator,
initiateTime: row.createTime,
status: row.approvalStatus,
currentAssignee: row.currentApprover,
approvers: row.currentApprover && row.currentApprover !== '—' ? [row.currentApprover] : [],
approvalSteps: row.approvalSteps,
};
};
let vrPairIdSeed = 1;
const vrCreateEmptyPair = () => {
vrPairIdSeed += 1;
return {
id: `pair_${vrPairIdSeed}`,
replaceType: '',
replaceReason: '',
replaceFee: '',
replaceReasonDesc: '',
originalPlate: '',
originalBrand: '',
originalModel: '',
contractId: '',
replacePlate: '',
replaceBrand: '',
replaceModel: '',
};
};
const ReplaceModule = ({ onRegisterBack }) => {
const [applications, setApplications] = useState(VR_MOCK_APPLICATIONS);
const [listFilter, setListFilter] = useState('ongoing');
const [statusFilter, setStatusFilter] = useState('');
const [searchKey, setSearchKey] = useState('');
const [addMode, setAddMode] = useState(false);
const [detailRow, setDetailRow] = useState(null);
const [pairs, setPairs] = useState([]);
const [edited, setEdited] = useState(false);
const selectedOriginalPlates = useMemo(() => pairs.map((p) => p.originalPlate).filter(Boolean), [pairs]);
const projectInfo = useMemo(() => {
const anchor = pairs.find((p) => p.originalPlate && p.contractId);
if (!anchor) return null;
return VR_CONTRACT_MAP[anchor.contractId] || null;
}, [pairs]);
const multiOldPlateOptions = useMemo(() => {
const lockedContractId = projectInfo?.contractId;
return VR_DELIVERED_VEHICLES
.filter((v) => !lockedContractId || v.contractId === lockedContractId)
.map((v) => v.plateNo);
}, [projectInfo]);
const getDeliveredVehicle = useCallback((plateNo) => VR_DELIVERED_VEHICLES.find((v) => v.plateNo === plateNo) || null, []);
const getUsedPlates = useCallback((pairsList, field, exceptPairId) => {
const set = {};
pairsList.forEach((p) => {
if (p.id === exceptPairId) return;
if (p[field]) set[p[field]] = true;
});
return set;
}, []);
const buildPairForPlate = useCallback((plateNo, existing) => {
const vehicle = getDeliveredVehicle(plateNo);
if (!vehicle) return null;
const row = existing ? { ...existing } : vrCreateEmptyPair();
row.originalPlate = plateNo;
row.originalBrand = vehicle.brand;
row.originalModel = vehicle.model;
row.contractId = vehicle.contractId;
if (!existing) {
row.replacePlate = '';
row.replaceBrand = '';
row.replaceModel = '';
}
return row;
}, [getDeliveredVehicle]);
const onMultiOriginalPlateChange = useCallback((plateList) => {
const list = Array.isArray(plateList) ? plateList : [];
if (list.length === 0) {
setEdited(true);
setPairs([]);
return;
}
let anchorContractId = null;
const validPlates = [];
let rejected = false;
list.forEach((plate) => {
const vehicle = getDeliveredVehicle(plate);
if (!vehicle || !VR_CONTRACT_MAP[vehicle.contractId]) return;
if (!anchorContractId) anchorContractId = vehicle.contractId;
if (vehicle.contractId !== anchorContractId) {
rejected = true;
return;
}
validPlates.push(plate);
});
if (rejected) message.warning('多台替换须为同一客户、同一项目,已忽略不同项目的车辆');
setEdited(true);
setPairs((prev) => {
const prevByPlate = {};
prev.forEach((p) => { if (p.originalPlate) prevByPlate[p.originalPlate] = p; });
return validPlates.map((plate) => buildPairForPlate(plate, prevByPlate[plate])).filter(Boolean);
});
}, [getDeliveredVehicle, buildPairForPlate]);
const toggleOriginalPlate = useCallback((plate) => {
const next = selectedOriginalPlates.includes(plate)
? selectedOriginalPlates.filter((p) => p !== plate)
: [...selectedOriginalPlates, plate];
onMultiOriginalPlateChange(next);
}, [selectedOriginalPlates, onMultiOriginalPlateChange]);
const updatePair = useCallback((pairId, patch) => {
setEdited(true);
setPairs((prev) => prev.map((p) => (p.id === pairId ? { ...p, ...patch } : p)));
}, []);
const getNewOptionsForPair = useCallback((pair) => {
if (!projectInfo?.deliveryRegion) return [];
const used = getUsedPlates(pairs, 'replacePlate', pair.id);
return (VR_PREPARED_BY_REGION[projectInfo.deliveryRegion] || [])
.filter((v) => !used[v.plateNo])
.map((v) => v.plateNo);
}, [pairs, projectInfo, getUsedPlates]);
const onReplacePlateChange = useCallback((pairId, plateNo) => {
if (!plateNo) {
updatePair(pairId, { replacePlate: '', replaceBrand: '', replaceModel: '' });
return;
}
const pair = pairs.find((p) => p.id === pairId);
if (!pair?.originalPlate) {
message.info('请先选择被替换车辆');
return;
}
const vehicle = (VR_PREPARED_BY_REGION[projectInfo?.deliveryRegion] || []).find((v) => v.plateNo === plateNo);
if (!vehicle) return;
const used = getUsedPlates(pairs, 'replacePlate', pairId);
if (used[plateNo]) {
message.warning('该新车已在其他替换项中选择');
return;
}
updatePair(pairId, { replacePlate: plateNo, replaceBrand: vehicle.brand, replaceModel: vehicle.model });
}, [updatePair, pairs, projectInfo, getUsedPlates]);
const openAdd = useCallback(() => {
setPairs([]);
setEdited(false);
setAddMode(true);
setDetailRow(null);
}, []);
const closeAdd = useCallback((force) => {
if (!force && edited) {
if (Modal?.confirm) {
Modal.confirm({
title: '取消将会丢失所有已填写内容,是否确认?',
okText: '确认',
cancelText: '返回',
centered: true,
onOk: () => { setAddMode(false); setPairs([]); setEdited(false); },
});
return;
}
}
setAddMode(false);
setPairs([]);
setEdited(false);
}, [edited]);
const handleSave = useCallback(() => {
message.success('已保存,该条数据仅您可查看并编辑(原型)');
setEdited(false);
}, []);
const handleSubmit = useCallback(() => {
if (!pairs.length || !projectInfo?.contractId) {
message.warning('请选择被替换车辆并完善替换信息');
return;
}
const incomplete = pairs.find((p) => {
if (!p.originalPlate || !p.replacePlate || !p.replaceType || !p.replaceReason) return true;
if (p.replaceReason === '客户原因' && !(p.replaceFee || '').toString().trim()) return true;
return false;
});
if (incomplete) {
message.warning('请完善每条替换的新车、替换类型、替换原因及客户原因下的替换费用');
return;
}
const newApp = {
id: `vr-new-${Date.now()}`,
bizNo: `TH-2026-${String(applications.length + 1).padStart(4, '0')}`,
replaceDate: new Date().toISOString().slice(0, 10),
approvalStatus: '待审批',
currentApprover: '业务部主管',
projectName: projectInfo.projectName,
customerName: projectInfo.customerName,
deliveryRegion: projectInfo.deliveryRegion,
pairs: pairs.map((p) => ({
originalPlate: p.originalPlate,
replacePlate: p.replacePlate,
replaceType: p.replaceType,
replaceReason: p.replaceReason,
replaceFee: p.replaceFee,
replaceReasonDesc: p.replaceReasonDesc,
originalBrand: p.originalBrand,
originalModel: p.originalModel,
replaceBrand: p.replaceBrand,
replaceModel: p.replaceModel,
})),
creator: MOCK_USER,
createTime: new Date().toISOString().slice(0, 16).replace('T', ' '),
};
setApplications((prev) => [newApp, ...prev]);
message.success(`已提交 ${pairs.length} 条替换车申请(原型)`);
setAddMode(false);
setPairs([]);
setEdited(false);
setListFilter('ongoing');
}, [pairs, projectInfo, applications.length]);
const kpi = useMemo(() => ({
ongoing: applications.filter((a) => vrIsOngoing(a.approvalStatus)).length,
history: applications.filter((a) => vrIsHistory(a.approvalStatus)).length,
}), [applications]);
const filteredList = useMemo(() => {
const q = searchKey.trim().toUpperCase();
let list = applications;
if (listFilter === 'ongoing') list = list.filter((a) => vrIsOngoing(a.approvalStatus));
if (listFilter === 'history') list = list.filter((a) => vrIsHistory(a.approvalStatus));
if (statusFilter) list = list.filter((a) => a.approvalStatus === statusFilter);
if (q) {
list = list.filter((a) =>
(a.bizNo || '').toUpperCase().includes(q)
|| (a.projectName || '').toUpperCase().includes(q)
|| (a.customerName || '').toUpperCase().includes(q)
|| (a.pairs || []).some((p) => (p.originalPlate || '').toUpperCase().includes(q) || (p.replacePlate || '').toUpperCase().includes(q))
);
}
return list;
}, [applications, listFilter, statusFilter, searchKey]);
useEffect(() => {
if (!onRegisterBack) return undefined;
onRegisterBack(() => {
if (addMode) { closeAdd(); return true; }
if (detailRow) { setDetailRow(null); return true; }
return false;
});
return () => onRegisterBack(null);
}, [addMode, detailRow, closeAdd, onRegisterBack]);
const renderFormRow = (label, value, editor) => (
<div className="xll-mod-form-row">
<span className="xll-mod-form-label">{label}</span>
{editor || <span className="xll-mod-form-value">{value || '—'}</span>}
</div>
);
const renderPairForm = (pair, index) => {
const newOptions = getNewOptionsForPair(pair);
return (
<div className="tc-section" key={pair.id}>
<div className="tc-section-head">
<span className="tc-section-title">车辆替换 · #{index + 1}</span>
<span className="tc-section-badge tc-section-badge--rose">{pair.replaceType || '待填写'}</span>
</div>
<div className="vr-replace-swap">
<div className="vr-replace-side vr-replace-side--old">
<span className="vr-replace-side-tag">被替换</span>
<div className="tc-vehicle-plate">{pair.originalPlate || '—'}</div>
<div className="tc-vehicle-model">{pair.originalBrand || '—'} · {pair.originalModel || '—'}</div>
</div>
<div className="vr-replace-mid" aria-hidden="true"><span className="vr-replace-mid-icon"></span></div>
<div className="vr-replace-side vr-replace-side--new">
<span className="vr-replace-side-tag">替换为</span>
<div className="tc-vehicle-plate">{pair.replacePlate || '待选择'}</div>
<div className="tc-vehicle-model">{pair.replaceBrand ? `${pair.replaceBrand} · ${pair.replaceModel}` : '选择新车后自动显示'}</div>
</div>
</div>
<div className="tc-section-form">
{renderFormRow('替换类型', null, (
<select className="xll-mod-form-input" value={pair.replaceType} onChange={(e) => updatePair(pair.id, { replaceType: e.target.value })} aria-label="替换类型">
<option value="">请选择</option>
<option value="永久替换">永久替换</option>
<option value="临时替换">临时替换</option>
</select>
))}
{renderFormRow('替换原因', null, (
<select
className="xll-mod-form-input"
value={pair.replaceReason}
onChange={(e) => updatePair(pair.id, { replaceReason: e.target.value, replaceFee: e.target.value === '客户原因' ? pair.replaceFee : '' })}
aria-label="替换原因"
>
<option value="">请选择</option>
<option value="客户原因">客户原因</option>
<option value="车辆原因">车辆原因</option>
</select>
))}
{pair.replaceReason === '客户原因' && renderFormRow('替换费用', null, (
<input
className="xll-mod-form-input"
value={pair.replaceFee}
placeholder="请输入"
onChange={(e) => {
let val = e.target.value.replace(/[^\d.]/g, '');
const parts = val.split('.');
if (parts.length > 2) val = `${parts[0]}.${parts.slice(1).join('')}`;
updatePair(pair.id, { replaceFee: val });
}}
aria-label="替换费用"
/>
))}
<div className="xll-mod-form-row" style={{ flexDirection: 'column', alignItems: 'stretch', borderBottom: 'none' }}>
<span className="xll-mod-form-label" style={{ marginBottom: 8 }}>替换原因说明</span>
<textarea
className="xll-vr-form-textarea"
value={pair.replaceReasonDesc}
maxLength={500}
placeholder="请说明替换原因"
onChange={(e) => updatePair(pair.id, { replaceReasonDesc: e.target.value })}
/>
</div>
{renderFormRow('新车', null, (
<select
className="xll-mod-form-input"
value={pair.replacePlate}
disabled={!pair.originalPlate}
onChange={(e) => onReplacePlateChange(pair.id, e.target.value)}
aria-label="新车"
>
<option value="">{projectInfo?.deliveryRegion ? `交车区域:${projectInfo.deliveryRegion}` : '请先选择被替换车辆'}</option>
{newOptions.map((plate) => <option key={plate} value={plate}>{plate}</option>)}
</select>
))}
</div>
</div>
);
};
const activeTabLabel = VR_LIST_TABS.find((t) => t.key === listFilter)?.label || '';
if (addMode) {
return (
<div className="xll-mod-root xll-vr-module xll-mod-form-page">
<div className="xll-mod-detail-wrap">
<div className="tc-scroll">
<div className="tc-hero tc-hero--replace">
<div className="tc-hero-label">新增替换车</div>
<div className="tc-hero-amount">{pairs.length ? `${pairs.length}` : '—'}</div>
<div className="tc-hero-meta">
<span>{projectInfo ? projectInfo.customerName : '请选择被替换车辆'}</span>
</div>
{projectInfo && (
<>
<div className="tc-hero-foot tc-hero-foot--compact">
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">合同编码</span>{projectInfo.contractCode || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">项目名称</span>{projectInfo.projectName || ''}</div>
<div className="tc-hero-foot-row"><span className="tc-hero-foot-label">交车区域</span>{projectInfo.deliveryRegion || ''}</div>
</div>
<div className="tc-hero-period">
<span className="tc-hero-period-tag">{projectInfo.projectType || '租赁'}</span>
</div>
</>
)}
</div>
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">被替换车辆</span></div>
<div className="tc-section-hint">车牌支持多选每选中一辆生成一条替换明细须为同一客户同一项目</div>
<div className="tc-section-chips">
{multiOldPlateOptions.map((plate) => (
<button
key={plate}
type="button"
className={`xll-mod-chip${selectedOriginalPlates.includes(plate) ? ' active' : ''}`}
onClick={() => toggleOriginalPlate(plate)}
>
{plate}
</button>
))}
</div>
</div>
{pairs.length > 0 ? pairs.map(renderPairForm) : (
<div className="xll-mod-empty" style={{ padding: '32px 24px' }}>请在上方选择被替换车辆车牌号将自动生成替换明细</div>
)}
<div style={{ height: 16 }} />
</div>
<div className="xll-mod-action-bar">
<button type="button" className="xll-mod-btn-ghost" onClick={() => closeAdd()}>取消</button>
<button type="button" className="xll-mod-btn-ghost" onClick={handleSave}>保存</button>
<button type="button" className="xll-mod-btn-rose" onClick={handleSubmit}>提交审核</button>
</div>
</div>
</div>
);
}
if (detailRow) {
return (
<div className="xll-mod-root xll-vr-module">
<div className="xll-mod-detail-wrap">
<ReplaceVehicleApprovePage task={vrRowToTask(detailRow)} mode="view" onBack={() => setDetailRow(null)} />
</div>
</div>
);
}
return (
<div className="xll-mod-root xll-vr-module">
<div className="xll-mod-tabs" role="tablist" aria-label="替换车列表分类">
{VR_LIST_TABS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={listFilter === tab.key}
className={`xll-mod-tab${listFilter === tab.key ? ' active' : ''}`}
onClick={() => { setListFilter(tab.key); if (tab.key === 'history') setStatusFilter(''); }}
>
{tab.short}
<span className="xll-mod-tab-count">{tab.key === 'ongoing' ? kpi.ongoing : kpi.history} </span>
</button>
))}
</div>
<div className="xll-mod-toolbar">
<div className="xll-mod-search">
<IconSearch />
<input type="search" placeholder="搜索单号、项目、车牌" value={searchKey} onChange={(e) => setSearchKey(e.target.value)} aria-label="搜索替换车" />
</div>
<div className="xll-mod-chips" role="group" aria-label="审批状态筛选">
<button type="button" className={`xll-mod-chip${!statusFilter ? ' active' : ''}`} onClick={() => setStatusFilter('')}>全部</button>
{listFilter === 'ongoing' && VR_STATUS_CHIPS.filter(Boolean).map((st) => (
<button key={st} type="button" className={`xll-mod-chip${statusFilter === st ? ' active' : ''}`} onClick={() => setStatusFilter(statusFilter === st ? '' : st)}>{st}</button>
))}
</div>
</div>
<div className="xll-mod-list-head">
<span>{activeTabLabel}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span> {filteredList.length} </span>
<button type="button" className="xll-vr-add-btn" onClick={openAdd}>+ 新增</button>
</span>
</div>
<div className="xll-mod-list">
{filteredList.length === 0 ? (
<div className="xll-mod-empty">
暂无{activeTabLabel}申请
<br />
{searchKey || statusFilter ? '试试清空搜索或切换筛选' : '点击下方按钮发起替换车申请'}
{!searchKey && !statusFilter && (
<div style={{ marginTop: 12 }}>
<button type="button" className="xll-vr-add-btn" onClick={openAdd}>+ 新增替换车</button>
</div>
)}
</div>
) : (
filteredList.map((row) => {
const stCls = vrBizStatusClass(row.approvalStatus);
const statusLabel = vrBizStatusLabel(row);
const inProgress = vrIsOngoing(row.approvalStatus);
const actionLabel = row.approvalStatus === '未提交' ? '继续编辑' : '查看';
return (
<div
key={row.id}
className="xll-mod-card"
style={{ '--mod-accent': VR_ACCENT, '--mod-soft': VR_SOFT }}
role="button"
tabIndex={0}
onClick={() => setDetailRow(row)}
onKeyDown={(e) => e.key === 'Enter' && setDetailRow(row)}
>
<div className="xll-mod-card-head">
<span className="xll-mod-card-type">替换车申请</span>
<span className={`xll-mod-card-status ${stCls}${statusLabel !== row.approvalStatus ? ' with-approvers' : ''}`}>{statusLabel}</span>
</div>
<div className="xll-mod-card-title">{row.customerName || '—'}</div>
<div className="xll-mod-card-sub">{row.projectName || '—'}</div>
{(row.pairs || []).length > 0 && (
<div className="xll-mod-card-vehicles">
{(row.pairs || []).map((p) => (
<div key={p.id || `${p.originalPlate}-${p.replacePlate}`} className="xll-mod-card-vehicle-line">{p.originalPlate} {p.replacePlate}</div>
))}
</div>
)}
<div className="xll-mod-meta">
<div><span className="xll-mod-meta-label">发起人 </span><span className="xll-mod-meta-val">{row.creator}</span></div>
<div><span className="xll-mod-meta-label">替换车辆 </span><span className="xll-mod-meta-val" style={{ color: VR_ACCENT, fontWeight: 700 }}>{(row.pairs || []).length} </span></div>
</div>
<div className="xll-mod-card-foot">
<span style={{ fontSize: 12, color: COLOR_MUTED }}>发起时间 {row.createTime}</span>
{inProgress && (
<button type="button" className="xll-mod-card-btn" onClick={(e) => { e.stopPropagation(); row.approvalStatus === '未提交' ? openAdd() : setDetailRow(row); }}>
{actionLabel}
</button>
)}
</div>
</div>
);
})
)}
</div>
</div>
);
};
/* ── 车辆管理(参照 web端/车辆管理.jsx ── */
const VM_OPERATE_STATUSES = ['租赁', '自营', '可运营', '待运营', '退出运营'];
const VM_VEHICLE_STATUSES = ['待验车', '未备车', '已备车', '待交车', '已交车', '待还车', '销售中', '替换中', '调拨中', '异动中'];
const VM_DETAIL_TABS = [
{ key: 'detail', label: '车辆详情' },
{ key: 'license', label: '证照信息' },
{ key: 'insurance', label: '保险信息' },
{ key: 'events', label: '车辆事件' },
];
const VM_CERT_NAV = [
{ key: 'driverLicense', label: '行驶证' },
{ key: 'transportLicense', label: '道路运输证' },
{ key: 'registrationCert', label: '登记证' },
{ key: 'specialEquipCert', label: '特种设备登记证' },
{ key: 'specialEquipDecal', label: '特种设备标识' },
{ key: 'hydrogenCard', label: '加氢卡' },
{ key: 'safetyValve', label: '安全阀' },
{ key: 'pressureGauge', label: '压力表' },
];
const VM_INSURANCE_TYPES = ['交强险', '商业险', '超赔险', '货物险', '乘意险'];
const VM_LICENSE_DATA = {
'沪A03561F': {
driverLicense: { photos: ['https://picsum.photos/seed/license1/600/400', 'https://picsum.photos/seed/license2/600/400'], regDate: '2024-06-05', issueDate: '2024-06-05', scrapDate: '2039-06-04', expireDate: '2026-06-30', updateUser: '李明辉', shNextEvaluation: '2026-12-05' },
transportLicense: { photos: ['https://picsum.photos/seed/transport/600/400'], licenseNo: '交字310115102345号', issueDate: '2024-07-12', expireDate: '2026-07-31', inspectValidUntil: '2026-07-20', updateUser: '陈高伟' },
registrationCert: { photos: ['https://picsum.photos/seed/regcert1/600/400'] },
specialEquipCert: { photos: ['https://picsum.photos/seed/spec1/600/400'] },
specialEquipDecal: { photos: ['https://picsum.photos/seed/spec2/600/400'], nextInspectDate: '2026-07-20' },
hydrogenCard: { cardNo: 'H2-9988-7766-5544', cardType: '中石化加氢卡', balance: 12850.5, issueDate: '2025-01-15 14:30', issueUser: '能源管理部-张晓' },
safetyValve: { photos: ['https://picsum.photos/seed/valve/600/400'], inspectDate: '2025-10-10', nextInspectDate: '2026-10-09' },
pressureGauge: { photos: ['https://picsum.photos/seed/gauge/600/400'], inspectDate: '2025-12-15', nextInspectDate: '2026-06-14' },
},
'粤B58888F': {
driverLicense: { photos: [], regDate: '', issueDate: '', scrapDate: '', expireDate: '' },
transportLicense: { photos: ['https://picsum.photos/seed/trans_ocr/600/400'], licenseNo: '粤字440301102947号', issueDate: '2024-07-20', expireDate: '2026-07-20', inspectValidUntil: '2026-08-15', updateUser: '黄志杰' },
registrationCert: { photos: [] },
specialEquipCert: { photos: [] },
specialEquipDecal: { photos: [], nextInspectDate: '' },
hydrogenCard: { cardNo: 'H2-5566-4433-2211', cardType: '中石化加氢卡', balance: 5200, issueDate: '2025-03-10 10:15', issueUser: '能源管理部-张晓' },
safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' },
pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' },
},
'苏E33333': {
driverLicense: { photos: ['https://picsum.photos/seed/su_lic/600/400'], regDate: '2024-05-16', issueDate: '2024-05-16', scrapDate: '2039-05-15', expireDate: '2026-05-15', updateUser: '王东东' },
transportLicense: { photos: ['https://picsum.photos/seed/su_trans/600/400'], licenseNo: '苏字320501104829号', issueDate: '2024-08-10', expireDate: '2026-08-10', inspectValidUntil: '2026-08-10', updateUser: '王东东' },
registrationCert: { photos: [] },
specialEquipCert: { photos: [] },
specialEquipDecal: { photos: [], nextInspectDate: '' },
hydrogenCard: { cardNo: '', cardType: '中石化加氢卡', balance: 0, issueDate: '', issueUser: '' },
safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' },
pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' },
},
'浙F06900F': {
driverLicense: { photos: ['https://picsum.photos/seed/zjf-lic/600/400'], regDate: '2025-07-01', issueDate: '2025-07-01', scrapDate: '2038-12-31', expireDate: '2027-07-31', updateUser: '金可鹏' },
transportLicense: { photos: ['https://picsum.photos/seed/zjf-trans/600/400'], licenseNo: '浙字330482001234号', issueDate: '2025-07-15', expireDate: '2026-07-14', inspectValidUntil: '2026-07-20', updateUser: '金可鹏' },
registrationCert: { photos: ['https://picsum.photos/seed/zjf-reg/600/400'] },
specialEquipCert: { photos: [] },
specialEquipDecal: { photos: ['https://picsum.photos/seed/zjf-decal/600/400'], nextInspectDate: '2026-07-20' },
hydrogenCard: { cardNo: 'H2-3304-0690-0088', cardType: '中石化加氢卡', balance: 8650.5, issueDate: '2025-03-10 10:15', issueUser: '能源管理部-张晓' },
safetyValve: { photos: ['https://picsum.photos/seed/zjf-valve/600/400'], inspectDate: '2025-05-10', nextInspectDate: '2026-05-09' },
pressureGauge: { photos: ['https://picsum.photos/seed/zjf-gauge/600/400'], inspectDate: '2025-12-15', nextInspectDate: '2026-06-14' },
},
};
const VM_EMPTY_LICENSE = {
driverLicense: { photos: [], regDate: '', issueDate: '', scrapDate: '', expireDate: '' },
transportLicense: { photos: [], licenseNo: '', issueDate: '', expireDate: '', inspectValidUntil: '' },
registrationCert: { photos: [] },
specialEquipCert: { photos: [] },
specialEquipDecal: { photos: [], nextInspectDate: '' },
hydrogenCard: { cardNo: '', cardType: '', balance: 0, issueDate: '', issueUser: '' },
safetyValve: { photos: [], inspectDate: '', nextInspectDate: '' },
pressureGauge: { photos: [], inspectDate: '', nextInspectDate: '' },
};
const vmGetLicenseBundle = (plateNo) => VM_LICENSE_DATA[plateNo] || VM_EMPTY_LICENSE;
const vmGetInsurancePolicies = (v) => {
const base = v.plateNo.slice(-4);
const premiums = { 交强险: '950.00', 商业险: '12800.50', 超赔险: '3200.00', 货物险: '1800.00', 乘意险: '560.00' };
const ranges = {
交强险: ['2025-01-01', '2025-12-31'],
商业险: ['2025-01-01', '2025-12-31'],
超赔险: ['2025-07-01', '2026-06-30'],
货物险: ['2025-03-15', '2026-03-14'],
乘意险: ['2025-09-01', '2026-08-31'],
};
return VM_INSURANCE_TYPES.map((type, idx) => ({
type,
company: '中国人民财产保险股份有限公司',
policyNo: `PD${base}2025${String(idx + 1).padStart(3, '0')}`,
startDate: ranges[type][0],
endDate: ranges[type][1],
premium: premiums[type],
hasPdf: true,
}));
};
const vmInsuranceStatus = (endDate) => {
if (!endDate) return { label: '未投保', cls: 'empty' };
const end = new Date(endDate);
const now = new Date('2026-06-01');
const days = Math.ceil((end - now) / (86400000));
if (days < 0) return { label: '已过期', cls: 'warn' };
if (days <= 30) return { label: '即将到期', cls: 'warn' };
return { label: '保障中', cls: '' };
};
const VM_EVENTS_BY_PLATE = {
'粤B58888F': [
{ type: '入库', time: '2023-06-15 10:00', summary: '采购入库完成', operator: '仓储-刘工', extra: '入库单号 RK-2023-0615' },
{ type: '备车', time: '2023-07-01 14:30', summary: '整备完成,状态变更为已备车', operator: '运维-王东东' },
{ type: '交车', time: '2024-01-10 09:30', summary: '交付嘉兴某某物流有限公司', operator: '张三', extra: '交车里程 12000 KM' },
{ type: '保养', time: '2024-08-20 11:00', summary: '二保完成 · 嘉兴机动车检测站', operator: '李四' },
{ type: '年审', time: '2025-07-18 15:20', summary: '年审办理完成,检验有效期至 2026-07', operator: '张明辉' },
{ type: '故障', time: '2026-03-05 08:45', summary: '氢系统压力异常 · 已提报维修', operator: '司机-赵某' },
],
'沪A03561F': [
{ type: '入库', time: '2022-08-20 09:00', summary: '采购入库', operator: '系统' },
{ type: '交车', time: '2024-01-05 10:00', summary: '交付上海迅杰氢能物流运输有限公司', operator: '李晓彤', extra: '合同 HT-2024-002' },
{ type: '还车', time: '2024-01-20 16:00', summary: '客户临时还车', operator: '李晓彤', extra: '还车里程 25600 KM' },
{ type: '维修', time: '2025-02-12 13:30', summary: '更换电堆冷却液', operator: '维修站-周工' },
{ type: '年审', time: '2025-09-10 09:15', summary: '年审完成', operator: '陈高伟' },
],
};
const vmGetVehicleEvents = (v) => VM_EVENTS_BY_PLATE[v.plateNo] || [
{ type: '入库', time: v.purchaseDate ? `${v.purchaseDate} 09:00` : '—', summary: '车辆采购入库', operator: '系统' },
{ type: '交车', time: v.lastDeliveryTime || '—', summary: vmHasCustomer(v.customer) ? `交付 ${vmFormatStatus(v.customer)}` : '暂无交车记录', operator: vmFormatStatus(v.manager), extra: v.lastDeliveryMile ? `交车里程 ${v.lastDeliveryMile} KM` : '' },
{ type: '还车', time: v.lastReturnTime || '—', summary: '最近一次还车', operator: '—', extra: v.lastReturnMile ? `还车里程 ${v.lastReturnMile} KM` : '' },
];
const VmDetailKv = ({ label, value, full }) => (
<div className={`xll-vm-detail-kv${full ? ' full' : ''}`}>
<div className="xll-vm-detail-kv-label">{label}</div>
<div className="xll-vm-detail-kv-val">{value || '—'}</div>
</div>
);
const VM_MOCK_VEHICLES = [
{ id: '1', region: '广东省/深圳市', vin: 'LGHXCAE28M6789012', plateNo: '粤B58888F', vehicleNo: '22FHD0001', vehicleType: '厢式货车', brand: '福田', model: '奥铃4.5吨冷藏车', color: '白色', parking: '福田停车场', customer: '嘉兴某某物流有限公司', department: '华南区', manager: '张三', operateStatus: '租赁', vehicleStatus: '已交车', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '浙江羚牛氢能科技有限公司', operateCompany: '羚牛运营(嘉兴)', vehicleSource: '自有', leaseCompany: '嘉兴某某物流有限公司', onlineStatus: '在线', year: '2023', mileage: '12580.50', purchaseDate: '2023-06-15', regDate: '2023-07-01', inspectExpire: '2026-07', lastDeliveryTime: '2024-01-10', lastDeliveryMile: '12000.00', lastReturnTime: '2024-02-01', lastReturnMile: '12580.50', scrapDate: '2038-12-31', contractNo: 'HT-ZL-2025-088', location: '广东省深圳市南山区科技园南路', gpsTime: '2026-06-01 14:30', fuelType: '氢' },
{ id: '2', region: '上海市/上海市', vin: 'LMRKH9AC0R1004086', plateNo: '沪A03561F', vehicleNo: '22FHD0002', vehicleType: '牵引车头', brand: '宇通', model: '49吨牵引车头', color: '白色', parking: '浦东停车场', customer: '上海迅杰氢能物流运输有限公司', department: '华东区', manager: '李晓彤', operateStatus: '租赁', vehicleStatus: '已交车', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '浙江羚牛氢能科技有限公司', operateCompany: '羚牛运营(上海)', vehicleSource: '自有', leaseCompany: '上海迅杰氢能物流运输有限公司', onlineStatus: '在线', year: '2022', mileage: '25600.00', purchaseDate: '2022-08-20', regDate: '2022-09-01', inspectExpire: '2026-09', lastDeliveryTime: '2024-01-05', lastDeliveryMile: '25500.00', lastReturnTime: '2024-01-20', lastReturnMile: '25600.00', scrapDate: '2037-09-30', contractNo: 'HT-2024-002', location: '上海市浦东新区张江高科路500号', gpsTime: '2026-06-01 09:45', fuelType: '氢' },
{ id: '3', region: '江苏省/苏州市', vin: 'LSXCH9AE8M1094857', plateNo: '苏E33333', vehicleNo: 'V003', vehicleType: '牵引车', brand: '陕汽', model: '德龙X3000混动牵引车', color: '灰色', parking: '-', customer: '无', department: '无', manager: '-', operateStatus: '可运营', vehicleStatus: '未备车', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '浙江羚牛氢能科技有限公司', operateCompany: '羚牛运营(嘉兴)', vehicleSource: '外租', leaseCompany: '-', onlineStatus: '离线', year: '2022', mileage: '8320.00', purchaseDate: '2023-09-01', regDate: '2023-09-20', inspectExpire: '2026-05', lastDeliveryTime: '2024-02-05', lastDeliveryMile: '8100.00', lastReturnTime: '2024-02-10', lastReturnMile: '8320.00', scrapDate: '2039-09-30', contractNo: '-', location: '江苏省苏州市工业园区', gpsTime: '2026-05-28 10:15', fuelType: '氢' },
{ id: '4', region: '浙江省/嘉兴市', vin: 'LA9HE60A0NBAF4031', plateNo: '浙F06900F', vehicleNo: '22FHD0007', vehicleType: '18吨双飞翼货车', brand: '苏龙', model: '海格牌KLQ5180XYKFCEV', color: '白色', parking: '嘉兴港区停车场', customer: '上海迅杰物流有限公司', department: '业务三部', manager: '金可鹏', operateStatus: '租赁', vehicleStatus: '已交车', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '浙江羚牛氢能科技有限公司', operateCompany: '羚牛运营(嘉兴)', vehicleSource: '自有', leaseCompany: '上海迅杰物流有限公司', onlineStatus: '在线', year: '2025', mileage: '5600.00', purchaseDate: '2025-03-08', regDate: '2025-04-01', inspectExpire: '2027-09', lastDeliveryTime: '2026-02-02', lastDeliveryMile: '5200.00', lastReturnTime: '2026-02-11', lastReturnMile: '5600.00', scrapDate: '2038-04-30', contractNo: 'LNZLHTSH2023071301', location: '浙江省嘉兴市平湖市港区', gpsTime: '2026-06-01 11:20', fuelType: '氢' },
{ id: '5', region: '广东省/广州市', vin: 'LSJA24U70PS001234', plateNo: '粤A88K88', vehicleNo: 'V005', vehicleType: '厢式货车', brand: '比亚迪', model: 'T5纯电轻卡', color: '灰色', parking: '黄埔停车场', customer: '客户A', department: '华南区', manager: '王五', operateStatus: '租赁', vehicleStatus: '已交车', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '某某租赁公司', operateCompany: '羚牛运营(广东)', vehicleSource: '挂靠', leaseCompany: '第三方融资租赁有限公司', onlineStatus: '在线', year: '2023', mileage: '45200.80', purchaseDate: '2021-05-20', regDate: '2021-06-15', inspectExpire: '2026-06', lastDeliveryTime: '2024-02-01', lastDeliveryMile: '44800.00', lastReturnTime: '2024-02-08', lastReturnMile: '45200.80', scrapDate: '2036-06-30', contractNo: 'HT-2024-003', location: '广东省广州市黄埔区开泰大道200号', gpsTime: '2026-06-01 09:45', fuelType: '电' },
{ id: '6', region: '广东省/深圳市', vin: '5YJ3E1EA1NF123456', plateNo: '粤B12345', vehicleNo: '-', vehicleType: '小型轿车', brand: '特斯拉', model: 'Model 3', color: '红色', parking: '福田停车场', customer: '客户B', department: '华南区', manager: '李四', operateStatus: '租赁', vehicleStatus: '已交车', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '某某租赁公司', operateCompany: '羚牛运营(广东)', vehicleSource: '挂靠', leaseCompany: '某某科技有限公司', onlineStatus: '在线', year: '2023', mileage: '5600.00', purchaseDate: '2023-03-08', regDate: '2023-04-01', inspectExpire: '2025-04', lastDeliveryTime: '2024-02-02', lastDeliveryMile: '5200.00', lastReturnTime: '2024-02-11', lastReturnMile: '5600.00', scrapDate: '2038-04-30', contractNo: 'HT-2024-004', location: '广东省深圳市福田区福华路188号', gpsTime: '2026-06-01 11:20', fuelType: '电' },
{ id: '7', region: '广东省/广州市', vin: 'LGWEF4A59NS234567', plateNo: '粤A99A99', vehicleNo: 'V007', vehicleType: '小型轿车', brand: '比亚迪', model: '汉EV', color: '黑色', parking: '天河停车场', customer: '客户A', department: '华南区', manager: '张三', operateStatus: '可运营', vehicleStatus: '待还车', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '某某租赁公司', operateCompany: '羚牛运营(嘉兴)', vehicleSource: '自有', leaseCompany: '第三方融资租赁有限公司', onlineStatus: '离线', year: '2022', mileage: '22100.30', purchaseDate: '2022-07-15', regDate: '2022-08-01', inspectExpire: '2026-08', lastDeliveryTime: '2024-01-20', lastDeliveryMile: '21800.00', lastReturnTime: '2024-02-05', lastReturnMile: '22100.30', scrapDate: '2037-08-31', contractNo: '-', location: '广东省广州市天河区体育西路200号', gpsTime: '2026-05-31 18:30', fuelType: '电' },
{ id: '8', region: '山东省/临沂市', vin: 'LZZ5CLSB8NC778899', plateNo: '鲁Q88901', vehicleNo: 'V008', vehicleType: '牵引车', brand: '重汽', model: '豪沃T7H牵引车', color: '蓝色', parking: '-', customer: '无', department: '无', manager: '-', operateStatus: '租赁', vehicleStatus: '调拨中', outStatus: '无', licenseStatus: '异常', insuranceStatus: '正常', ownership: '某某租赁公司', operateCompany: '羚牛运营(嘉兴)', vehicleSource: '自有', leaseCompany: '-', onlineStatus: '离线', year: '2021', mileage: '67800.25', purchaseDate: '2020-06-10', regDate: '2020-07-01', inspectExpire: '2026-04', lastDeliveryTime: '2024-01-25', lastDeliveryMile: '67500.00', lastReturnTime: '2024-02-09', lastReturnMile: '67800.25', scrapDate: '2035-07-31', contractNo: 'HT-2024-011', location: '山东省临沂市兰山区', gpsTime: '2026-05-30 08:20', fuelType: '氢' },
{ id: '9', region: '福建省/厦门市', vin: 'LFWNHXSD8P1122334', plateNo: '闽D55662', vehicleNo: 'V009', vehicleType: '厢式货车', brand: '金龙', model: '凯歌纯电动厢货', color: '白色', parking: '厦门停车场', customer: '客户C', department: '华东区', manager: '赵六', operateStatus: '自营', vehicleStatus: '已备车', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '某某科技有限公司', operateCompany: '羚牛运营(上海)', vehicleSource: '外租', leaseCompany: '某某租赁公司', onlineStatus: '在线', year: '2022', mileage: '19800.00', purchaseDate: '2022-12-01', regDate: '2023-01-05', inspectExpire: '2026-04', lastDeliveryTime: '2024-02-07', lastDeliveryMile: '19500.00', lastReturnTime: '2024-02-12', lastReturnMile: '19800.00', scrapDate: '2038-01-31', contractNo: 'HT-2024-008', location: '福建省厦门市思明区', gpsTime: '2026-06-01 15:45', fuelType: '电' },
{ id: '10', region: '安徽省/合肥市', vin: 'LZZ5CLSB8NA123456', plateNo: '皖B66221', vehicleNo: 'V010', vehicleType: '厢式货车', brand: '江淮', model: '格尔发A5', color: '白色', parking: '-', customer: '无', department: '无', manager: '-', operateStatus: '可运营', vehicleStatus: '未备车', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '某某租赁公司', operateCompany: '羚牛运营(广东)', vehicleSource: '挂靠', leaseCompany: '-', onlineStatus: '离线', year: '2020', mileage: '52100.00', purchaseDate: '2020-09-01', regDate: '2020-10-01', inspectExpire: '2026-06', lastDeliveryTime: '2024-01-12', lastDeliveryMile: '51800.00', lastReturnTime: '2024-01-30', lastReturnMile: '52100.00', scrapDate: '2035-10-31', contractNo: '-', location: '安徽省合肥市蜀山区', gpsTime: '2026-05-29 11:00', fuelType: '氢' },
{ id: '11', region: '广东省/广州市', vin: 'LSJA24U70PS555666', plateNo: '粤A11B22', vehicleNo: '-', vehicleType: '小型轿车', brand: '蔚来', model: 'ET5', color: '蓝色', parking: '番禺停车场', customer: '客户A', department: '华南区', manager: '王五', operateStatus: '租赁', vehicleStatus: '替换中', outStatus: '无', licenseStatus: '正常', insuranceStatus: '正常', ownership: '某某租赁公司', operateCompany: '羚牛运营(上海)', vehicleSource: '外租', leaseCompany: '第三方融资租赁有限公司', onlineStatus: '在线', year: '2023', mileage: '7200.50', purchaseDate: '2023-07-20', regDate: '2023-08-05', inspectExpire: '2025-08', lastDeliveryTime: '2024-02-03', lastDeliveryMile: '7000.00', lastReturnTime: '2024-02-11', lastReturnMile: '7200.50', scrapDate: '2038-08-31', contractNo: 'HT-2024-007', location: '广东省广州市番禺区市桥街100号', gpsTime: '2026-06-01 12:00', fuelType: '电' },
{ id: '12', region: '北京市/北京市', vin: 'LSJA24U70PS999000', plateNo: '京H88888', vehicleNo: '-', vehicleType: '小型轿车', brand: '蔚来', model: 'ET5', color: '白色', parking: '昌平停车场', customer: '客户D', department: '华北区', manager: '孙七', operateStatus: '退出运营', vehicleStatus: '无', outStatus: '报废出库', licenseStatus: '无', insuranceStatus: '正常', ownership: '某某科技有限公司', operateCompany: '羚牛运营(上海)', vehicleSource: '外租', leaseCompany: '-', onlineStatus: '离线', year: '2022', mileage: '15600.00', purchaseDate: '2022-06-20', regDate: '2022-07-10', inspectExpire: '2024-07', lastDeliveryTime: '2024-01-10', lastDeliveryMile: '15300.00', lastReturnTime: '2024-01-28', lastReturnMile: '15600.00', scrapDate: '2037-07-31', contractNo: '-', location: '北京市昌平区回龙观西大街100号', gpsTime: '2026-05-28 12:30', fuelType: '电' },
];
const vmFormatStatus = (val) => (val === '无' || val === '-' ? '—' : (val || '—'));
const vmHasCustomer = (name) => vmFormatStatus(name) !== '—';
const VM_LONG_TEXT_LABELS = new Set(['客户名称', '登记所有权', '运营公司', '租赁公司', '当前位置', '型号', '车辆类型']);
const vmOperateBadgeClass = (status) => {
if (status === '租赁') return 'xll-vm-badge';
if (status === '自营') return 'xll-vm-badge warn';
if (status === '退出运营') return 'xll-vm-badge danger';
return 'xll-vm-badge neutral';
};
const VmCustomerBar = ({ name, variant = 'list', onExpand }) => {
const display = vmFormatStatus(name);
if (!vmHasCustomer(name)) return null;
const isLong = display.length > 14;
const handleExpand = (e) => {
if (!isLong || !onExpand) return;
e.stopPropagation();
onExpand(display);
};
return (
<div
className={`xll-vm-customer-bar xll-vm-customer-bar--${variant}${isLong && onExpand ? ' is-long' : ''}`}
onClick={handleExpand}
onKeyDown={(e) => e.key === 'Enter' && handleExpand(e)}
role={isLong && onExpand ? 'button' : undefined}
tabIndex={isLong && onExpand ? 0 : undefined}
>
<span className="xll-vm-customer-icon" aria-hidden="true"></span>
<div className="xll-vm-customer-body">
<span className="xll-vm-customer-label">客户名称</span>
<span className="xll-vm-customer-text" title={display}>{display}</span>
{isLong && onExpand && variant === 'list' ? <span className="xll-vm-customer-hint">点击查看全称</span> : null}
</div>
</div>
);
};
const VmInfoRow = ({ label, value, onClick, longText }) => (
<div className={`xll-mod-form-row${longText ? ' xll-mod-form-row--stack' : ''}`}>
<span className="xll-mod-form-label">{label}</span>
{onClick ? (
<button type="button" className="xll-mod-form-value" style={{ border: 'none', background: 'transparent', color: XLL_GREEN, textAlign: longText ? 'left' : 'right', cursor: 'pointer', padding: 0 }} onClick={onClick}>{value}</button>
) : (
<span className="xll-mod-form-value">{value}</span>
)}
</div>
);
const VehicleManagementModule = ({ onRegisterBack }) => {
const vehicles = VM_MOCK_VEHICLES;
const [searchKey, setSearchKey] = useState('');
const [operateFilter, setOperateFilter] = useState('');
const [vehicleStatusFilter, setVehicleStatusFilter] = useState('');
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [detailVehicle, setDetailVehicle] = useState(null);
const [detailTab, setDetailTab] = useState('detail');
const [licenseNav, setLicenseNav] = useState('driverLicense');
const [customerNamePreview, setCustomerNamePreview] = useState(null);
const filteredList = useMemo(() => {
const q = searchKey.trim().toLowerCase();
return vehicles.filter((v) => {
if (operateFilter && v.operateStatus !== operateFilter) return false;
if (vehicleStatusFilter && v.vehicleStatus !== vehicleStatusFilter) return false;
if (!q) return true;
return (
(v.plateNo && v.plateNo.toLowerCase().includes(q))
|| (v.vin && v.vin.toLowerCase().includes(q))
|| (v.brand && v.brand.toLowerCase().includes(q))
|| (v.model && v.model.toLowerCase().includes(q))
|| (v.customer && v.customer.toLowerCase().includes(q))
|| (v.region && v.region.toLowerCase().includes(q))
);
});
}, [vehicles, searchKey, operateFilter, vehicleStatusFilter]);
const activeFilterCount = (operateFilter ? 1 : 0) + (vehicleStatusFilter ? 1 : 0);
const openDetail = useCallback((v) => {
setDetailTab('detail');
setLicenseNav('driverLicense');
setDetailVehicle(v);
}, []);
useEffect(() => {
if (!onRegisterBack) return undefined;
onRegisterBack(() => {
if (customerNamePreview) { setCustomerNamePreview(null); return true; }
if (detailVehicle) { setDetailVehicle(null); return true; }
return false;
});
return () => onRegisterBack(null);
}, [detailVehicle, customerNamePreview, onRegisterBack]);
const renderDetailTab = (v) => (
<div className="xll-mod-section" style={{ margin: '12px 14px 0' }}>
<div className="xll-mod-section-title">运营信息</div>
<div className="xll-vm-detail-grid">
<VmDetailKv label="运营城市" value={v.region ? v.region.replace(/\//g, ' · ') : '—'} />
<VmDetailKv label="运营状态" value={v.operateStatus} />
<VmDetailKv label="车辆状态" value={vmFormatStatus(v.vehicleStatus)} />
<VmDetailKv label="出库状态" value={vmFormatStatus(v.outStatus)} />
<VmDetailKv label="证照状态" value={vmFormatStatus(v.licenseStatus)} />
<VmDetailKv label="保险状态" value={vmFormatStatus(v.insuranceStatus)} />
<VmDetailKv label="在线状态" value={v.onlineStatus} />
<VmDetailKv label="客户名称" value={vmFormatStatus(v.customer)} full />
<VmDetailKv label="业务部门" value={vmFormatStatus(v.department)} />
<VmDetailKv label="业务负责人" value={vmFormatStatus(v.manager)} />
<VmDetailKv label="合同编号" value={v.contractNo && v.contractNo !== '-' ? v.contractNo : '—'} full />
</div>
<div className="xll-mod-section-title" style={{ marginTop: 16 }}>车辆档案</div>
<div className="xll-vm-detail-grid">
<VmDetailKv label="车辆识别代号" value={v.vin} full />
<VmDetailKv label="车牌号" value={v.plateNo} />
<VmDetailKv label="车辆编号" value={vmFormatStatus(v.vehicleNo)} />
<VmDetailKv label="品牌" value={v.brand} />
<VmDetailKv label="型号" value={v.model} full />
<VmDetailKv label="车辆类型" value={v.vehicleType} />
<VmDetailKv label="车身颜色" value={v.color} />
<VmDetailKv label="燃料种类" value={v.fuelType || '—'} />
<VmDetailKv label="出厂年份" value={v.year} />
<VmDetailKv label="行驶公里数" value={v.mileage ? `${v.mileage} KM` : '—'} />
</div>
<div className="xll-mod-section-title" style={{ marginTop: 16 }}>权属与停放</div>
<div className="xll-vm-detail-grid">
<VmDetailKv label="登记所有权" value={v.ownership || '—'} full />
<VmDetailKv label="运营公司" value={v.operateCompany} full />
<VmDetailKv label="车辆来源" value={v.vehicleSource} />
<VmDetailKv label="租赁公司" value={vmFormatStatus(v.leaseCompany)} full />
<VmDetailKv label="归属停车场" value={vmFormatStatus(v.parking)} />
<VmDetailKv label="采购入库时间" value={v.purchaseDate} />
<VmDetailKv label="行驶证注册" value={v.regDate} />
<VmDetailKv label="检验有效期" value={v.inspectExpire} />
<VmDetailKv label="强制报废日期" value={v.scrapDate} />
</div>
<div className="xll-mod-section-title" style={{ marginTop: 16 }}>交还车与定位</div>
<div className="xll-vm-detail-grid">
<VmDetailKv label="上次交车时间" value={v.lastDeliveryTime || '—'} />
<VmDetailKv label="上次交车里程" value={v.lastDeliveryMile ? `${v.lastDeliveryMile} KM` : '—'} />
<VmDetailKv label="上次还车时间" value={v.lastReturnTime || '—'} />
<VmDetailKv label="上次还车里程" value={v.lastReturnMile ? `${v.lastReturnMile} KM` : '—'} />
<VmDetailKv label="GPS上传时间" value={v.gpsTime || '—'} full />
<VmDetailKv label="当前位置" value={v.location || '—'} full />
</div>
</div>
);
const renderCertFields = (navKey, data) => {
const d = data[navKey] || {};
const photos = d.photos || [];
const rows = [];
if (navKey === 'driverLicense') {
rows.push(['注册日期', d.regDate], ['发证日期', d.issueDate], ['强制报废期', d.scrapDate], ['检验有效期', d.expireDate], ['上海下次等评', d.shNextEvaluation], ['更新人', d.updateUser]);
} else if (navKey === 'transportLicense') {
rows.push(['证件编号', d.licenseNo], ['发证日期', d.issueDate], ['证件有效期', d.expireDate], ['审验有效期', d.inspectValidUntil], ['更新人', d.updateUser]);
} else if (navKey === 'hydrogenCard') {
rows.push(['卡号', d.cardNo], ['卡类型', d.cardType], ['余额(¥)', d.balance != null && d.balance !== '' ? formatMoneySymbol(d.balance) : ''], ['发卡时间', d.issueDate], ['发卡人', d.issueUser]);
} else if (navKey === 'safetyValve' || navKey === 'pressureGauge') {
rows.push(['检验日期', d.inspectDate], ['下次检验日期', d.nextInspectDate]);
} else if (navKey === 'specialEquipDecal') {
rows.push(['下次检验日期', d.nextInspectDate], ['更新人', d.updateUser]);
}
return (
<>
{navKey !== 'hydrogenCard' && (
photos.length > 0 ? (
<div className="xll-vm-photo-grid">
{photos.map((url, i) => (
<div className="xll-vm-photo" key={i}><img src={url} alt="" /></div>
))}
</div>
) : (
<div className="xll-vm-photo-empty">暂无证照影像</div>
)
)}
{rows.map(([label, val]) => (
<VmInfoRow key={label} label={label} value={val || '—'} longText={label.includes('编号') || label.includes('卡号')} />
))}
</>
);
};
const renderLicenseTab = (v) => {
const bundle = vmGetLicenseBundle(v.plateNo);
const navLabel = VM_CERT_NAV.find((n) => n.key === licenseNav)?.label || '';
return (
<div className="xll-mod-section" style={{ margin: '12px 14px 0' }}>
<div className="xll-mod-section-title">证照档案 · {v.plateNo}</div>
<div className="xll-vm-cert-nav" role="tablist">
{VM_CERT_NAV.map((item) => (
<button key={item.key} type="button" role="tab" aria-selected={licenseNav === item.key} className={`xll-vm-cert-nav-btn${licenseNav === item.key ? ' active' : ''}`} onClick={() => setLicenseNav(item.key)}>{item.label}</button>
))}
</div>
<div className="xll-mod-section-title" style={{ fontSize: 13, marginBottom: 10 }}>{navLabel}</div>
{renderCertFields(licenseNav, bundle)}
</div>
);
};
const renderInsuranceTab = (v) => {
const policies = vmGetInsurancePolicies(v);
return (
<div style={{ margin: '12px 14px 0' }}>
{policies.map((p) => {
const st = vmInsuranceStatus(p.endDate);
return (
<div className="xll-vm-ins-card" key={p.type}>
<div className="xll-vm-ins-head">
<span className="xll-vm-ins-type">{p.type}</span>
<span className={`xll-vm-ins-status ${st.cls}`}>{st.label}</span>
</div>
{p.startDate ? (
<>
<div className="xll-vm-ins-row"><span>保险期间</span><span>{p.startDate} {p.endDate}</span></div>
<div className="xll-vm-ins-row"><span>承保公司</span><span style={{ textAlign: 'right', maxWidth: '58%' }}>{p.company}</span></div>
<div className="xll-vm-ins-row"><span>保单号</span><span>{p.policyNo}</span></div>
<div className="xll-vm-ins-row"><span>保费</span><span>{p.premium ? formatMoneySymbol(p.premium) : ''}</span></div>
{p.hasPdf && (
<button type="button" className="xll-vm-ins-pdf" onClick={() => message.success(`${p.type} PDF 保单下载已开始(原型)`)}>下载 PDF 保单</button>
)}
</>
) : (
<div className="xll-vm-ins-row"><span>暂无有效保单</span></div>
)}
</div>
);
})}
</div>
);
};
const renderEventsTab = (v) => {
const events = vmGetVehicleEvents(v);
return (
<div className="xll-mod-section" style={{ margin: '12px 14px 0' }}>
<div className="xll-mod-section-title">全生命周期事件</div>
<div className="xll-vm-event-list">
{events.map((ev, i) => (
<div className="xll-vm-event-item" key={`${ev.type}-${i}`}>
<span className="xll-vm-event-dot" aria-hidden="true" />
<div className="xll-vm-event-type">{ev.type}</div>
<div className="xll-vm-event-meta">{ev.time} · {ev.operator}</div>
<div className="xll-vm-event-summary">{ev.summary}</div>
{ev.extra ? <div className="xll-vm-event-meta" style={{ marginTop: 4 }}>{ev.extra}</div> : null}
</div>
))}
</div>
</div>
);
};
const renderNamePreviewModal = () => (Modal ? (
<Modal
title="客户名称"
open={!!customerNamePreview}
centered
footer={null}
onCancel={() => setCustomerNamePreview(null)}
>
<div className="xll-vm-name-modal-text">{customerNamePreview}</div>
<button
type="button"
className="xll-mod-btn-primary"
style={{ width: '100%', minHeight: 44, borderRadius: 12, marginTop: 16 }}
onClick={() => { message.success('已复制(原型)'); setCustomerNamePreview(null); }}
>
复制名称
</button>
</Modal>
) : null);
if (detailVehicle) {
const v = detailVehicle;
return (
<div className="xll-mod-root">
<div className="xll-mod-scroll" style={{ paddingBottom: 24 }}>
<div className="xll-vm-detail-hero">
<div className="xll-vm-plate-row">
<span className="xll-vm-detail-plate">{v.plateNo}</span>
<span className={vmOperateBadgeClass(v.operateStatus)}>{v.operateStatus}</span>
<span className={`xll-vm-online${v.onlineStatus === '在线' ? '' : ''}`}>
<span className={`xll-vm-dot ${v.onlineStatus === '在线' ? 'on' : 'off'}`} />
{v.onlineStatus}
</span>
</div>
<div className="xll-vm-detail-sub">{v.brand} · {v.model}<br />{v.vin}</div>
<div className="xll-vm-detail-tags">
<span className="xll-vm-badge neutral">{vmFormatStatus(v.vehicleStatus)}</span>
{v.licenseStatus === '异常' && <span className="xll-vm-badge danger">证照异常</span>}
{v.insuranceStatus === '异常' && <span className="xll-vm-badge danger">保险异常</span>}
</div>
</div>
<div className="xll-vm-subtabs" role="tablist">
{VM_DETAIL_TABS.map((tab) => (
<button key={tab.key} type="button" role="tab" aria-selected={detailTab === tab.key} className={`xll-vm-subtab${detailTab === tab.key ? ' active' : ''}`} onClick={() => setDetailTab(tab.key)}>{tab.label}</button>
))}
</div>
{detailTab === 'detail' && renderDetailTab(v)}
{detailTab === 'license' && renderLicenseTab(v)}
{detailTab === 'insurance' && renderInsuranceTab(v)}
{detailTab === 'events' && renderEventsTab(v)}
</div>
{renderNamePreviewModal()}
</div>
);
}
return (
<div className="xll-mod-root">
<div className="xll-mod-toolbar">
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="xll-mod-search" style={{ flex: 1 }}>
<IconSearch />
<input type="search" placeholder="搜索车牌、VIN、品牌、客户" value={searchKey} onChange={(e) => setSearchKey(e.target.value)} aria-label="搜索车辆" />
</div>
<button type="button" className={`xll-vm-filter-btn${activeFilterCount ? ' active' : ''}`} onClick={() => setFilterDrawerOpen(true)} aria-label="筛选">
<IconFilter />
</button>
</div>
{(operateFilter || vehicleStatusFilter) && (
<div className="xll-mod-chips" style={{ marginTop: 10 }}>
{operateFilter && (
<button type="button" className="xll-mod-chip active" onClick={() => setOperateFilter('')}>运营{operateFilter} ×</button>
)}
{vehicleStatusFilter && (
<button type="button" className="xll-mod-chip active" onClick={() => setVehicleStatusFilter('')}>状态{vehicleStatusFilter} ×</button>
)}
</div>
)}
</div>
<div className="xll-mod-list-head"><span>全部车辆</span><span> {filteredList.length} </span></div>
<div className="xll-mod-list">
{filteredList.length === 0 ? (
<div className="xll-mod-empty">暂无匹配车辆<br />试试清空搜索或筛选条件</div>
) : (
filteredList.map((v) => (
<div
key={v.id}
className="xll-mod-card"
style={{ '--mod-accent': XLL_GREEN, '--mod-soft': XLL_GREEN_SOFT }}
role="button"
tabIndex={0}
onClick={() => openDetail(v)}
onKeyDown={(e) => e.key === 'Enter' && openDetail(v)}
>
<div className="xll-mod-card-head">
<span className="xll-mod-ar-plate">{v.plateNo}</span>
<span className={`xll-vm-online${v.onlineStatus === '在线' ? '' : ''}`}>
<span className={`xll-vm-dot ${v.onlineStatus === '在线' ? 'on' : 'off'}`} />
{v.onlineStatus}
</span>
</div>
<div className="xll-mod-card-sub">{v.brand} · {v.model}</div>
<VmCustomerBar name={v.customer} variant="list" onExpand={setCustomerNamePreview} />
<div className="xll-mod-meta">
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">运营城市</span><span className="xll-mod-meta-val">{v.region ? v.region.replace(/\//g, ' · ') : '—'}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">运营状态</span><span className="xll-mod-meta-val">{v.operateStatus}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">车辆状态</span><span className="xll-mod-meta-val">{vmFormatStatus(v.vehicleStatus)}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">保险状态</span><span className="xll-mod-meta-val" style={{ color: v.insuranceStatus === '' ? COLOR_DANGER : COLOR_TEXT_SEC }}>{v.insuranceStatus}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">证照状态</span><span className="xll-mod-meta-val" style={{ color: v.licenseStatus === '' ? COLOR_DANGER : COLOR_TEXT_SEC }}>{vmFormatStatus(v.licenseStatus)}</span></div>
<div className="xll-vm-meta-cell"><span className="xll-mod-meta-label">车辆来源</span><span className="xll-mod-meta-val">{v.vehicleSource || ''}</span></div>
</div>
<div className="xll-mod-card-foot">
<div className="xll-vm-card-vin-wrap">
<span className="xll-vm-card-vin-label">车辆识别代码</span>
<span className="xll-vm-card-vin" title={v.vin}>{v.vin}</span>
</div>
<button type="button" className="xll-mod-card-btn" onClick={(e) => { e.stopPropagation(); openDetail(v); }}>查看</button>
</div>
</div>
))
)}
</div>
{Drawer ? (
<Drawer title="筛选车辆" placement="bottom" height={420} open={filterDrawerOpen} onClose={() => setFilterDrawerOpen(false)} styles={{ body: { padding: '12px 16px 24px' } }}>
<div style={{ fontSize: 13, fontWeight: 600, color: COLOR_TEXT, marginBottom: 8 }}>运营状态</div>
<div className="xll-mod-drawer-types" style={{ marginBottom: 16 }}>
<button type="button" className={`xll-mod-drawer-type-btn${!operateFilter ? ' active' : ''}`} onClick={() => setOperateFilter('')}>全部</button>
{VM_OPERATE_STATUSES.map((s) => (
<button key={s} type="button" className={`xll-mod-drawer-type-btn${operateFilter === s ? ' active' : ''}`} onClick={() => setOperateFilter(s)}>{s}</button>
))}
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: COLOR_TEXT, marginBottom: 8 }}>车辆状态</div>
<div className="xll-mod-drawer-types">
<button type="button" className={`xll-mod-drawer-type-btn${!vehicleStatusFilter ? ' active' : ''}`} onClick={() => setVehicleStatusFilter('')}>全部</button>
{VM_VEHICLE_STATUSES.map((s) => (
<button key={s} type="button" className={`xll-mod-drawer-type-btn${vehicleStatusFilter === s ? ' active' : ''}`} onClick={() => setVehicleStatusFilter(s)}>{s}</button>
))}
</div>
<div style={{ marginTop: 20, display: 'flex', gap: 10 }}>
<button type="button" className="xll-mod-btn-ghost" style={{ flex: 1, minHeight: 44, borderRadius: 12 }} onClick={() => { setOperateFilter(''); setVehicleStatusFilter(''); }}>重置</button>
<button type="button" className="xll-mod-btn-primary" style={{ flex: 1, minHeight: 44, borderRadius: 12 }} onClick={() => { setFilterDrawerOpen(false); message.success('筛选已应用'); }}>确定</button>
</div>
</Drawer>
) : null}
{renderNamePreviewModal()}
</div>
);
};
const IconBack = () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
);
const IconSearch = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
const IconFilter = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
);
const IconGlobe = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
const IconTruck = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<rect x="1" y="3" width="15" height="13" rx="1" /><path d="M16 8h4l3 3v5h-7V8z" /><circle cx="5.5" cy="18.5" r="2.5" /><circle cx="18.5" cy="18.5" r="2.5" />
</svg>
);
const IconStation = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M12 2v4" /><path d="M12 18v4" /><path d="M4.93 4.93l2.83 2.83" /><path d="M16.24 16.24l2.83 2.83" /><path d="M2 12h4" /><path d="M18 12h4" /><path d="M4.93 19.07l2.83-2.83" /><path d="M16.24 7.76l2.83-2.83" /><circle cx="12" cy="12" r="4" />
</svg>
);
const IconStatusSignal = () => (
<svg width="18" height="12" viewBox="0 0 18 12" fill="currentColor" aria-hidden="true">
<rect x="0" y="7" width="3" height="5" rx="0.5" /><rect x="5" y="5" width="3" height="7" rx="0.5" /><rect x="10" y="2" width="3" height="10" rx="0.5" /><rect x="15" y="0" width="3" height="12" rx="0.5" />
</svg>
);
const IconStatusWifi = () => (
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<path d="M8 10.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" fill="currentColor" stroke="none" />
<path d="M4.5 7.2a4.8 4.8 0 0 1 7 0" strokeLinecap="round" /><path d="M1.5 4.2a8.5 8.5 0 0 1 13 0" strokeLinecap="round" />
</svg>
);
const IconStatusBattery = () => (
<svg width="26" height="12" viewBox="0 0 26 12" fill="none" aria-hidden="true">
<rect x="0.5" y="0.5" width="21" height="11" rx="2.5" stroke="currentColor" strokeOpacity="0.45" />
<rect x="2.5" y="2.5" width="16" height="7" rx="1.5" fill="currentColor" />
<path d="M23 4.5v3c.8-.45 1.3-1.15 1.3-1.5S23.8 4.95 23 4.5Z" fill="currentColor" fillOpacity="0.45" />
</svg>
);
const IconTabTodo = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill={active ? XLL_GREEN : 'none'} stroke={active ? XLL_GREEN : COLOR_MUTED} strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
);
const IconTabBusiness = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={active ? XLL_GREEN : COLOR_MUTED} strokeWidth="2" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
);
const IconTabMap = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={active ? XLL_GREEN : COLOR_MUTED} strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" /><line x1="8" y1="2" x2="8" y2="18" /><line x1="16" y1="6" x2="16" y2="22" />
</svg>
);
const IconTabMine = ({ active }) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke={active ? XLL_GREEN : COLOR_MUTED} strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
</svg>
);
const IconWarn = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true">
<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>
);
const TASK_ICONS = {
delivery: () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M5 17h14v-5H5v5z" /><path d="M5 12V7h10l3 5" /><circle cx="7.5" cy="17" r="1.5" /><circle cx="16.5" cy="17" r="1.5" />
</svg>
),
return: () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
),
inspection: () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<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>
),
transfer: () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
</svg>
),
move: () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
),
};
const BIZ_ICONS = {
vehicle: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><rect x="1" y="3" width="15" height="13" rx="1" /><path d="M16 8h4l3 3v5h-7V8z" /><circle cx="5.5" cy="18.5" r="2.5" /><circle cx="18.5" cy="18.5" r="2.5" /></svg>),
prepare: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></svg>),
delivery: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M5 17h14v-5H5v5z" /><path d="M5 12V7h10l3 5" /><circle cx="7.5" cy="17" r="1.5" /><circle cx="16.5" cy="17" r="1.5" /></svg>),
return: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" /></svg>),
replace: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><polyline points="17 1 21 5 17 9" /><path d="M3 11V9a4 4 0 0 1 4-4h14" /><polyline points="7 23 3 19 7 15" /><path d="M21 13v2a4 4 0 0 1-4 4H3" /></svg>),
move: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></svg>),
transfer: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" /></svg>),
inspection: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><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" /></svg>),
fault: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><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>),
training: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /></svg>),
audit: () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" /></svg>),
'stat-vehicle': () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="20" x2="18" y2="10" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="6" y1="20" x2="6" y2="14" /></svg>),
'stat-h2-fee': () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="12" y1="1" x2="12" y2="23" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></svg>),
'stat-h2-qty': () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" /></svg>),
'stat-electric': () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg>),
'mileage-query': () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>),
'mileage-assess': () => (<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>),
};
const TAB_ICON_MAP = {
todo: IconTabTodo,
business: IconTabBusiness,
map: IconTabMap,
mine: IconTabMine,
};
const PRD_DOCS = {
login: {
title: '登录页 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">小羚羚小程序</span>
<div className="xll-prd-meta">版本 V1.0 · 模块账号登录 · 适用全体企业微信/小程序用户</div>
<div className="xll-prd-h2">目标</div>
<p className="xll-prd-p">提供企业微信授权或账号密码登录入口登录成功后进入工作台默认 Tab并拉取待办数量与消息未读数</p>
<div className="xll-prd-highlight">登录按钮点击后应展示 Loading 状态防止重复提交成功后 Toast 提示并跳转工作台</div>
<div className="xll-prd-h2">页面元素</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">品牌 Logo + 产品名小羚羚</li>
<li className="xll-prd-li">主按钮企业微信一键登录原型模拟</li>
<li className="xll-prd-li">右上角需求说明随时可查看本文档</li>
</ul>
<div className="xll-prd-h2">交互</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">登录成功 进入主框架默认展示工作台待办列表</li>
<li className="xll-prd-li">登录态本地缓存下次打开免登原型省略</li>
</ul>
</div>
),
},
todo: {
title: '待办 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">工作台 / 待办</span>
<div className="xll-prd-meta">版本 V1.0 · 参照设计图1 · 聚合运维待办任务</div>
<div className="xll-prd-h2">目标</div>
<p className="xll-prd-p">一线人员登录后首屏聚合<strong>交车还车年审调拨异动</strong></p>
<div className="xll-prd-h2">列表卡片</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">左侧色条 + 类型图标区分任务类别</li>
<li className="xll-prd-li">右上角黄色数字角标同类型待办条数可选</li>
<li className="xll-prd-li">年审到期显示红色警告标签不仅靠颜色</li>
<li className="xll-prd-li">右侧绿色去处理按钮触控区域 44px</li>
</ul>
<div className="xll-prd-h2">导航</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">左上返回子模块内先退子页再退回工作台</li>
<li className="xll-prd-li">底部 Tab图标 + 文字标签当前页高亮</li>
</ul>
</div>
),
},
'todo-detail': {
title: '待办办理 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">待办子页</span>
<div className="xll-prd-h2">通用规则</div>
<p className="xll-prd-p">从待办卡片去处理进入顶部 Hero 展示任务类型与标题正文只读展示任务详情底部确认办理提交</p>
<div className="xll-prd-h2">分类型差异</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li"><strong>交车</strong> + </li>
<li className="xll-prd-li"><strong>还车</strong></li>
<li className="xll-prd-li"><strong>年审</strong>:进入内嵌年审管理(待处理 / 办理 / 历史记录</li>
<li className="xll-prd-li"><strong>调拨/异动</strong>线</li>
</ul>
</div>
),
},
business: {
title: '业务 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">业务入口</span>
<div className="xll-prd-meta">版本 V1.0 · 参照设计图2-3 · 功能宫格导航</div>
<div className="xll-prd-h2">信息架构</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li"><strong>运维管理</strong>//</li>
<li className="xll-prd-li"><strong>审批管理</strong> ONE-OS </li>
<li className="xll-prd-li"><strong>数据可视化</strong>///</li>
</ul>
<div className="xll-prd-h2">角标规则</div>
<p className="xll-prd-p">绿色圆形角标表示待处理数量99+ 时显示 99无待办则不展示角标</p>
<div className="xll-prd-h2">交互</div>
<p className="xll-prd-p">点击审批中心年审交车车辆管理在小羚羚壳内嵌打开完整模块绿色主题统一其余宫格 Toast 提示原型</p>
</div>
),
},
delivery: {
title: '交车 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">运维管理 / 交车</span>
<div className="xll-prd-meta">版本 V1.0 · 参照 web 交车管理 + Axhub交车完成原型 · 默认区域权限浙江省-嘉兴市</div>
<div className="xll-prd-h2">列表页</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">KPI全部 / 进行中 / 已完成点击切换筛选</li>
<li className="xll-prd-li">搜索车牌项目客户名称</li>
<li className="xll-prd-li">卡片车牌未选车显示车辆待选交车状态 Tag未开始 / 已保存 / 待客户签章 / 待重新签章 / 客户已签章项目客户交车区域</li>
<li className="xll-prd-li">进行中 Tab 下提供状态筛选 Chip全部状态未开始已保存待客户签章待重新签章</li>
<li className="xll-prd-li">进行中任务展示去办理 / 继续办理按钮</li>
</ul>
<div className="xll-prd-h2">分步表单顶部步骤条</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">顶部步骤条<strong>车辆情况</strong> <strong></strong> <strong></strong></li>
<li className="xll-prd-li"><strong>车辆情况</strong>Hero </li>
<li className="xll-prd-li"><strong>识别车牌号</strong> OCR <strong>=</strong><strong>=</strong><strong>=</strong> readiness <strong></strong><strong></strong></li>
<li className="xll-prd-li"><strong>相册上传</strong><strong></strong>广//</li>
<li className="xll-prd-li"><strong>车辆配置</strong>广/</li>
<li className="xll-prd-li"><strong>驾驶培训</strong>(必填):支持电子培训 / 手动记录两种模式电子培训展示培训二维码微信扫码观看视频完成后识别提车码手动记录运维端先录入司机手机号姓名身份证号并上传身份证正反面驾驶证正反面司机正面照片从业资格证重卡必填点击底部生成培训码培训码与录入信息绑定展示二维码供司机微信扫码生成后交车页标记待司机确认查看培训码刷新培训状态司机签字完成并同步后标记已完成驾驶培训</li>
<li className="xll-prd-li"><strong>手动记录 · 司机微信端</strong> 10 </li>
<li className="xll-prd-li"><strong>交车数据</strong> 2 %/MPa 2 </li>
<li className="xll-prd-li"><strong>交车位置</strong> GPS 线 GPS GPS 线GPS 线</li>
<li className="xll-prd-li"><strong>交车检查项</strong> 3 (mm)</li>
<li className="xll-prd-li"><strong>实际交车时间</strong> E</li>
<li className="xll-prd-li"><strong>拍摄照片</strong>////退<strong></strong>/<strong></strong> +/- 1.0×3.0× 广/</li>
<li className="xll-prd-li"><strong>备胎照片</strong></li>
<li className="xll-prd-li"><strong>分步操作条</strong></li>
<li className="xll-prd-li"><strong>照片上传</strong>/</li>
<li className="xll-prd-li"><strong>胎纹 OCR</strong></li>
<li className="xll-prd-li"><strong>选择被授权人</strong> E </li>
<li className="xll-prd-li"><strong>待客户签章</strong></li>
<li className="xll-prd-li"><strong>待重新签章</strong> E </li>
<li className="xll-prd-li"><strong>客户签章</strong></li>
</ul>
<div className="xll-prd-h2">查看态</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">客户已签章 / 待客户签章只读浏览各步骤不可编辑</li>
<li className="xll-prd-li">待重新签章各步骤只读选择被授权人可改</li>
<li className="xll-prd-li">已完成可下载 E签宝签章 PDF展示是否归还</li>
</ul>
<div className="xll-prd-h2">导航</div>
<p className="xll-prd-p">表单内左上返回先退出办理页再次返回退出交车模块步骤条可点击跳转</p>
</div>
),
},
map: {
title: '地图 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">地图</span>
<div className="xll-prd-meta">版本 V1.0 · 参照设计图4 · 腾讯地图</div>
<div className="xll-prd-h2">Tab 切换</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li"><strong>氢能车</strong> Marker</li>
<li className="xll-prd-li"><strong>加氢站</strong> POI</li>
</ul>
<div className="xll-prd-h2">工具栏</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">车牌号搜索带搜索图标聚焦时高亮边框</li>
<li className="xll-prd-li">筛选按区域运营状态过滤</li>
<li className="xll-prd-li">全图恢复全国视野</li>
</ul>
</div>
),
},
mine: {
title: '我的 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">我的</span>
<div className="xll-prd-meta">版本 V1.0 · 参照设计图5 · 个人中心</div>
<div className="xll-prd-h2">展示字段</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">顶部 Hero 展示头像姓名与岗位</li>
<li className="xll-prd-li">姓名账号手机号脱敏展示</li>
</ul>
<div className="xll-prd-h2">退出登录</div>
<p className="xll-prd-p">点击退出登录需二次确认弹窗确认后清除 Token 返回登录页</p>
</div>
),
},
audit: {
title: '审批中心 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">审批管理 / 审批中心</span>
<div className="xll-prd-meta">版本 V1.0 · 对接 ONE-OS 审批中心 · 适用有审批权限的业务/财务/管理人员</div>
<div className="xll-prd-h2">目标</div>
<p className="xll-prd-p">在小程序内聚合我发起的 / 我的待办 / 我的已办 / 抄送我的四类审批任务支持按流程类型筛选与搜索点击进入对应流程的办理或查看页</p>
<div className="xll-prd-h2">列表 Tab</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li"><strong>我发起的</strong></li>
<li className="xll-prd-li"><strong>我的待办</strong></li>
<li className="xll-prd-li"><strong>我的已办</strong></li>
<li className="xll-prd-li"><strong>抄送我的</strong></li>
</ul>
<div className="xll-prd-h2">列表卡片字段</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">流程类型色条 + 类型名称合同审批提车应收款租赁账单还车应结款车辆调拨替换车申请等</li>
<li className="xll-prd-li">单据号摘要发起人到达/完成时间</li>
<li className="xll-prd-li">客户类流程额外展示客户名称项目名称还车展示应退/应补金额</li>
<li className="xll-prd-li">状态 Tag审批中 / 已通过 / 已驳回 / 已终止</li>
</ul>
<div className="xll-prd-h2">筛选与搜索</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">快捷 Chip全部合同审批提车应收款租赁账单车辆调拨更多类型抽屉展示全部流程类型</li>
<li className="xll-prd-li">搜索范围单据号摘要流程类型发起人客户名项目名车牌号</li>
</ul>
<div className="xll-prd-h2">跳转规则</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">已接入详情页提车应收款还车应结款租赁账单车辆调拨申请/运维替换车申请</li>
<li className="xll-prd-li">待办 Tab 进入办理态底部展示通过/驳回/终止/评论其余 Tab 为只读查看态</li>
<li className="xll-prd-li">左上返回详情页先退回列表再退回业务入口</li>
</ul>
</div>
),
},
'audit-pickup': {
title: '提车应收款审批 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">审批中心 / 提车应收款</span>
<div className="xll-prd-meta">版本 V1.0 · 数据源自提车应收款业务单 · 审批节点业管 财务</div>
<div className="xll-prd-h2">页面目标</div>
<p className="xll-prd-p">审批人核对本次提车实收金额是否与合同约定一致确认车辆明细氢费预付款如有及开票信息后方可通过</p>
<div className="xll-prd-h2">Hero </div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">主金额<strong>实收款总额</strong></li>
<li className="xll-prd-li">副信息客户名称合同编码项目名称提车台数</li>
<li className="xll-prd-li">对比行应收款总额较应收减免差额</li>
<li className="xll-prd-li">实收明细底部抽屉分项展示月租金保证金服务费减免及氢费预收如有</li>
</ul>
<div className="xll-prd-h2">正文区块</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li"><strong>车辆明细</strong>/</li>
<li className="xll-prd-li"><strong>氢费预付款</strong></li>
<li className="xll-prd-li"><strong>开票信息</strong></li>
<li className="xll-prd-li"><strong>审批情况</strong></li>
</ul>
<div className="xll-prd-h2">底部操作待办态</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">评论填写意见不改变流程状态</li>
<li className="xll-prd-li">终止 / 驳回需填写原因流程结束并通知发起人</li>
<li className="xll-prd-li">通过确认后流转下一节点末节点通过后单据完结</li>
</ul>
</div>
),
},
'audit-return': {
title: '还车应结款审批 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">审批中心 / 还车应结款</span>
<div className="xll-prd-meta">版本 V1.0 · 数据源自还车结算单 · 多部门分组提交后汇总审批</div>
<div className="xll-prd-h2">页面目标</div>
<p className="xll-prd-p">还车完成后客户服务组能源采购组运维部安全部分别填报费用审批人核对分组明细与汇总金额判定保证金抵扣后应退或应补</p>
<div className="xll-prd-highlight">当保证金 待结算总额时Hero 展示应退还总额否则展示应补缴总额</div>
<div className="xll-prd-h2">Hero 紫色主题</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">主金额应退还总额 / 应补缴总额</li>
<li className="xll-prd-li">车辆信息车牌合同编码项目名称三项保险状态易损/轮胎/养护</li>
<li className="xll-prd-li">租期交车时间还车时间</li>
<li className="xll-prd-li">摘要保证金待结算总额车辆实际租金结算明细抽屉展示分项汇总</li>
</ul>
<div className="xll-prd-h2">分组结算卡片</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li"><strong>客户服务组</strong>ETC </li>
<li className="xll-prd-li"><strong>能源采购组</strong>/退退</li>
<li className="xll-prd-li"><strong>运维部</strong>//广</li>
<li className="xll-prd-li"><strong>安全部</strong>/</li>
</ul>
<div className="xll-prd-h2">审批与操作</div>
<p className="xll-prd-p">审批时间轴 + 底部评论/终止/驳回/通过规则同提车应收款查看态隐藏操作栏仅浏览</p>
</div>
),
},
'audit-lease-bill': {
title: '租赁账单审批 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">审批中心 / 租赁账单</span>
<div className="xll-prd-meta">版本 V1.0 · 数据源自月度租赁账单 · 审批节点业务服务组 业管主管 财务</div>
<div className="xll-prd-h2">页面目标</div>
<p className="xll-prd-p">审批人核对本期账单实收金额逐车租金与服务费减免是否合理首期账单需额外核对氢费预付款</p>
<div className="xll-prd-h2">Hero 蓝色主题</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">主金额实收款总额</li>
<li className="xll-prd-li">客户合同编码项目名称</li>
<li className="xll-prd-li">账单周期起止日期期数 Tag N </li>
<li className="xll-prd-li">应收款总额开票总额应收明细实收明细两个底部抽屉</li>
</ul>
<div className="xll-prd-h2">正文区块</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li"><strong>车辆列表</strong>/</li>
<li className="xll-prd-li"><strong>氢费预付款</strong> 1 </li>
<li className="xll-prd-li"><strong>审批情况</strong></li>
</ul>
<div className="xll-prd-h2">金额计算口径开发</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">应收总额 = 月租金 + 保证金首期+ 服务费 + 氢费预收首期</li>
<li className="xll-prd-li">实收总额 = 实收租金 + 保证金 + 实收服务费 租金减免 服务费减免 + 氢费实收 氢费减免</li>
<li className="xll-prd-li">开票总额不含保证金</li>
</ul>
</div>
),
},
'audit-transfer-create': {
title: '车辆调拨审批(申请方) · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">审批中心 / 车辆调拨 · 申请方</span>
<div className="xll-prd-meta">版本 V1.0 · transferStage=create · 区域间车辆调配申请</div>
<div className="xll-prd-h2">页面目标</div>
<p className="xll-prd-p">发起区域申请将车辆调拨至目标区域审批人核对路线运输方式车辆清单及出发时车况数据</p>
<div className="xll-prd-h2">Hero 绿色主题</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">调拨车辆台数出发区域 接收区域路线展示</li>
<li className="xll-prd-li">调拨日期Tag 标识调拨申请</li>
</ul>
<div className="xll-prd-h2">正文区块</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li"><strong>调拨情况</strong>/</li>
<li className="xll-prd-li"><strong>运输信息</strong>/</li>
<li className="xll-prd-li"><strong>车辆清单</strong>//</li>
<li className="xll-prd-li"><strong>审批情况</strong></li>
</ul>
<div className="xll-prd-h2">与运维方审批的差异</div>
<p className="xll-prd-p">申请方侧重为何调拨调哪些车运维方ops侧重接收区域验收与到达车况Hero Tag 运维调拨方</p>
</div>
),
},
'audit-transfer-ops': {
title: '车辆调拨审批(运维方) · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">审批中心 / 车辆调拨 · 运维方</span>
<div className="xll-prd-meta">版本 V1.0 · transferStage=ops · 接收区域运维确认调拨到达</div>
<div className="xll-prd-h2">页面目标</div>
<p className="xll-prd-p">接收区域运维负责人在车辆到达后审批确认核对运输过程信息与车辆到达时里程氢量电量是否与申请一致</p>
<div className="xll-prd-h2">页面结构</div>
<p className="xll-prd-p">布局与申请方审批页一致Hero Tag 改为运维调拨方车辆卡片除出发数据外需展示到达里程/氢量/电量如有</p>
<div className="xll-prd-h2">审批要点</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">确认车辆已全部到达接收区域</li>
<li className="xll-prd-li">核对车况数据异常是否已在备注中说明</li>
<li className="xll-prd-li">通过后更新车辆归属区域流程完结</li>
</ul>
</div>
),
},
'audit-replace': {
title: '替换车申请审批 · 产品需求说明',
body: (
<div className="xll-prd-doc">
<span className="xll-prd-tag">审批中心 / 替换车申请</span>
<div className="xll-prd-meta">版本 V1.0 · 数据源自替换车业务单 · 审批节点业务主管 事业部主管 运维主管</div>
<div className="xll-prd-h2">页面目标</div>
<p className="xll-prd-p">客户用车期间需更换车辆时审批人核对替换类型/替换车辆信息及替换原因客户原因替换需确认费用</p>
<div className="xll-prd-h2">Hero 玫红主题</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">替换车辆台数客户名称</li>
<li className="xll-prd-li">合同编码项目名称交车区域项目类型 Tag</li>
</ul>
<div className="xll-prd-h2">替换对卡片</div>
<ul className="xll-prd-ul">
<li className="xll-prd-li">左右对照被替换车原车牌/品牌型号 替换为新车牌/品牌型号</li>
<li className="xll-prd-li">替换类型 Tag永久替换 / 临时替换</li>
<li className="xll-prd-li">替换原因原因说明原因为客户原因时展示替换费用</li>
</ul>
<div className="xll-prd-h2">审批与操作</div>
<p className="xll-prd-p">支持多组替换对逐条展示底部操作栏规则与其他审批页一致通过后由运维安排交/还车衔接</p>
</div>
),
},
};
const IphoneStatusBar = () => (
<div className="xll-status">
<span className="xll-status-time">9:41</span>
<div className="xll-status-icons">
<IconStatusSignal />
<IconStatusWifi />
<IconStatusBattery />
</div>
</div>
);
const MpCapsule = () => (
<div className="xll-capsule" role="group" aria-label="小程序菜单">
<button type="button" className="xll-capsule-btn" aria-label="更多" onClick={() => message.info('更多菜单(原型)')}>···</button>
<span className="xll-capsule-divider" aria-hidden="true" />
<button type="button" className="xll-capsule-btn" aria-label="关闭" onClick={() => message.warning('退出小程序(原型)')}>×</button>
</div>
);
const PrdModal = ({ prdKey, onClose }) => {
const doc = PRD_DOCS[prdKey];
if (!doc || !prdKey) return null;
return (
<div className="tc-mini-sheet" role="dialog" aria-modal="true" aria-label={doc.title}>
<button type="button" className="tc-mini-sheet-mask" onClick={onClose} aria-label="关闭" />
<div className="tc-mini-sheet-panel">
<div className="tc-mini-sheet-handle" aria-hidden="true" />
<div className="tc-mini-sheet-head">
<span className="tc-mini-sheet-title">{doc.title}</span>
<button type="button" className="tc-mini-sheet-close" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="tc-mini-sheet-body">{doc.body}</div>
<div className="tc-mini-sheet-foot">
<button type="button" className="tc-mini-sheet-ok" onClick={onClose}>知道了</button>
</div>
</div>
</div>
);
};
const LoginPage = ({ onLogin, onOpenPrd }) => {
const [loading, setLoading] = useState(false);
const handleLogin = () => {
if (loading) return;
setLoading(true);
setTimeout(() => {
setLoading(false);
onLogin();
}, 800);
};
return (
<div className="xll-login-wrap">
<div className="xll-login-logo" aria-hidden="true"></div>
<div className="xll-login-name">小羚羚</div>
<div className="xll-login-desc">氢能车辆运营移动端<br />企业微信授权后即可使用</div>
<button type="button" className="xll-login-btn" onClick={handleLogin} disabled={loading} aria-busy={loading}>
{loading ? '登录中…' : '企业微信一键登录'}
</button>
<button type="button" className="xll-prd-link" style={{ marginTop: 24 }} onClick={() => onOpenPrd('login')}>需求说明</button>
</div>
);
};
const TaskCard = ({ task, index, onProcess }) => {
const theme = TASK_THEME[task.type] || TASK_THEME.delivery;
const TaskIcon = TASK_ICONS[task.type] || TASK_ICONS.delivery;
return (
<div
className="xll-task-card"
style={{ '--xll-accent': theme.accent, '--xll-soft': theme.soft, animationDelay: `${index * 40}ms` }}
>
{task.badge ? <span className="xll-task-badge" aria-label={`${task.badge}条待办`}>{task.badge}</span> : null}
<div className="xll-task-head">
<div className="xll-task-title-row">
<span className="xll-task-icon"><TaskIcon /></span>
<div className="xll-task-title-wrap">
<span className="xll-task-type-tag">{theme.label}</span>
<div className="xll-task-title">{task.title}</div>
</div>
</div>
<button type="button" className="xll-task-action" onClick={() => onProcess(task)} aria-label={`处理${task.title}`}>去处理</button>
</div>
<div className="xll-task-body">
{task.fields.map((f) => (
<div className="xll-kv" key={f.label}>
<span className="xll-kv-label">{f.label}</span>
<span className={`xll-kv-value${f.warn ? ' warn' : ''}`}>
{f.warn ? f.value.replace('(已到期)', '') : f.value}
{f.warn && <span className="xll-warn-tag"><IconWarn />已到期</span>}
</span>
</div>
))}
</div>
</div>
);
};
const TodoPage = ({ onProcess }) => (
<>
<div className="xll-list-head">
<span>待办任务</span>
<span className="xll-list-count"> {TODO_TASKS.length} </span>
</div>
{TODO_TASKS.map((task, i) => (
<TaskCard key={task.id} task={task} index={i} onProcess={onProcess} />
))}
</>
);
const BusinessPage = ({ onEntry }) => (
<>
{BUSINESS_SECTIONS.map((sec) => (
<div className="xll-biz-section" key={sec.title}>
<div className="xll-biz-section-title">{sec.title}</div>
<div className="xll-biz-grid">
{sec.items.map((item) => {
const BizIcon = BIZ_ICONS[item.key] || BIZ_ICONS.vehicle;
return (
<button key={item.key} type="button" className="xll-biz-item" onClick={() => onEntry(item)} aria-label={item.label}>
<span className="xll-biz-icon-wrap">
<span className="xll-biz-icon"><BizIcon /></span>
{item.badge > 0 ? <span className="xll-biz-badge" aria-hidden="true">{item.badge > 99 ? 99 : item.badge}</span> : null}
</span>
<span className="xll-biz-label">{item.label}</span>
</button>
);
})}
</div>
</div>
))}
</>
);
const MapPage = () => {
const [mapTab, setMapTab] = useState('vehicle');
const [search, setSearch] = useState('');
return (
<div className="xll-map-page">
<div className="xll-map-tabs" role="tablist" aria-label="地图类型">
<button type="button" role="tab" aria-selected={mapTab === 'vehicle'} className={`xll-map-tab${mapTab === 'vehicle' ? ' active' : ''}`} onClick={() => setMapTab('vehicle')}>氢能车</button>
<button type="button" role="tab" aria-selected={mapTab === 'station'} className={`xll-map-tab${mapTab === 'station' ? ' active' : ''}`} onClick={() => setMapTab('station')}>加氢站</button>
</div>
<div className="xll-map-toolbar">
<div className="xll-map-search-wrap">
<IconSearch />
<input className="xll-map-search" placeholder="请输入车牌号查询" value={search} onChange={(e) => setSearch(e.target.value)} aria-label="车牌号搜索" />
</div>
<button type="button" className="xll-map-filter" aria-label="筛选" onClick={() => message.info('打开地图筛选(原型)')}><IconFilter /></button>
</div>
<div className="xll-map-area" role="img" aria-label={mapTab === 'vehicle' ? '氢能车实时位置地图' : '加氢站分布地图'}>
<button type="button" className="xll-map-full" onClick={() => message.info('恢复全图视野(原型)')} aria-label="恢复全图视野">
<IconGlobe />
全图
</button>
{mapTab === 'vehicle' ? (
<>
<span className="xll-map-marker" style={{ top: '28%', left: '22%' }} title="粤B58888F"><IconTruck /></span>
<span className="xll-map-marker" style={{ top: '35%', left: '48%' }} title="粤AGP5368"><IconTruck /></span>
<span className="xll-map-marker" style={{ top: '52%', left: '65%' }} title="浙F06900F"><IconTruck /></span>
</>
) : (
<>
<span className="xll-map-marker xll-map-marker--station" style={{ top: '40%', left: '55%' }} title="平湖加氢站"><IconStation /></span>
<span className="xll-map-marker xll-map-marker--station" style={{ top: '30%', left: '30%' }} title="嘉兴港区加氢站"><IconStation /></span>
</>
)}
<div className="xll-map-placeholder">
<span>{mapTab === 'vehicle' ? '氢能车实时位置(腾讯地图)' : '加氢站 POI腾讯地图'}</span>
{search && <span>搜索{search}</span>}
</div>
<span className="xll-map-brand">腾讯地图</span>
</div>
</div>
);
};
const MinePage = ({ onLogout }) => (
<>
<div className="xll-mine-hero">
<div className="xll-mine-avatar" aria-hidden="true"></div>
<div className="xll-mine-hero-info">
<div className="xll-mine-hero-name">张明辉</div>
<div className="xll-mine-hero-role">运维主管 · ONE-OS</div>
</div>
</div>
<div className="xll-mine-card">
<div className="xll-mine-row"><span className="xll-mine-label">姓名</span><span className="xll-mine-value"></span></div>
<div className="xll-mine-row"><span className="xll-mine-label">账号</span><span className="xll-mine-value">zhangmh@one-os.com</span></div>
<div className="xll-mine-row"><span className="xll-mine-label">手机号</span><span className="xll-mine-value">138****6688</span></div>
</div>
<button type="button" className="xll-logout" onClick={onLogout}>退出登录</button>
</>
);
const TodoDetailPage = ({ task, onBack, onDone }) => {
const theme = TASK_THEME[task.type] || TASK_THEME.delivery;
return (
<div className="xll-sub-page">
<div className="xll-sub-hero" style={{ '--xll-accent': theme.accent }}>
<span className="xll-sub-hero-tag">{theme.label}任务</span>
<div className="xll-sub-hero-title">{task.title}</div>
</div>
<div className="xll-sub-card">
<div className="xll-sub-section-title">任务详情</div>
{task.fields.map((f) => (
<div className="xll-kv" key={f.label} style={{ marginBottom: 10 }}>
<span className="xll-kv-label">{f.label}</span>
<span className={`xll-kv-value${f.warn ? ' warn' : ''}`}>
{f.warn ? f.value.replace('(已到期)', '') : f.value}
{f.warn && <span className="xll-warn-tag"><IconWarn />已到期</span>}
</span>
</div>
))}
<p style={{ fontSize: 13, color: COLOR_MUTED, marginTop: 12, lineHeight: 1.55 }}>
请核对以上信息并完成现场办理提交后将同步至 ONE-OS 后台对应模块
</p>
<div className="xll-sub-foot">
<button type="button" className="xll-sub-btn xll-sub-btn-ghost" onClick={onBack}>返回</button>
<button type="button" className="xll-sub-btn xll-sub-btn-primary" onClick={onDone}>确认办理</button>
</div>
</div>
</div>
);
};
const Component = function XiaoLingLingMiniApp() {
const [loggedIn, setLoggedIn] = useState(false);
const [mainTab, setMainTab] = useState('todo');
const [todoDetail, setTodoDetail] = useState(null);
const [bizModule, setBizModule] = useState(null);
const [prdKey, setPrdKey] = useState(null);
const [auditPrdKey, setAuditPrdKey] = useState(null);
const handleAuditPrdKeyChange = useCallback((key) => {
setAuditPrdKey(key || null);
}, []);
const navTitle = useMemo(() => {
if (!loggedIn) return '登录';
if (bizModule === 'audit') return '审批中心';
if (bizModule === 'inspection') return '年审';
if (bizModule === 'vehicle') return '车辆管理';
if (bizModule === 'delivery') return '交车';
if (bizModule === 'replace') return '替换车';
if (todoDetail) return '任务办理';
const tab = MAIN_TABS.find((t) => t.key === mainTab);
return tab?.navLabel || '小羚羚';
}, [loggedIn, mainTab, todoDetail, bizModule]);
const currentPrdKey = useMemo(() => {
if (!loggedIn) return 'login';
if (bizModule === 'delivery') return 'delivery';
if (bizModule === 'replace') return 'replace';
if (bizModule === 'audit') return auditPrdKey || 'audit';
if (bizModule === 'inspection' || bizModule === 'vehicle') return 'business';
if (todoDetail) return 'todo-detail';
if (mainTab === 'todo') return 'todo';
if (mainTab === 'business') return 'business';
if (mainTab === 'map') return 'map';
if (mainTab === 'mine') return 'mine';
return 'todo';
}, [loggedIn, mainTab, todoDetail, bizModule, auditPrdKey]);
const handleOpenPrd = useCallback((key) => setPrdKey(key || currentPrdKey), [currentPrdKey]);
const moduleBackRef = useRef(null);
const registerModuleBack = useCallback((handler) => {
moduleBackRef.current = handler;
}, []);
const handleNavBack = useCallback(() => {
if (bizModule && typeof moduleBackRef.current === 'function' && moduleBackRef.current()) return;
if (bizModule) {
setBizModule(null);
return;
}
if (todoDetail) setTodoDetail(null);
}, [bizModule, todoDetail]);
const handleProcessTask = useCallback((task) => {
if (task.type === 'inspection') {
setBizModule('inspection');
return;
}
if (task.type === 'delivery') {
setBizModule('delivery');
return;
}
setTodoDetail(task);
}, []);
const handleBusinessEntry = useCallback((item) => {
if (item.key === 'audit') {
setBizModule('audit');
return;
}
if (item.key === 'inspection') {
setBizModule('inspection');
return;
}
if (item.key === 'vehicle') {
setBizModule('vehicle');
return;
}
if (item.key === 'delivery') {
setBizModule('delivery');
return;
}
if (item.key === 'replace') {
setBizModule('replace');
return;
}
message.info(`进入「${item.label}」模块(原型)`);
}, []);
const handleLogout = useCallback(() => {
if (Modal && Modal.confirm) {
Modal.confirm({
title: '确认退出登录?',
content: '退出后需重新授权登录',
okText: '退出',
cancelText: '取消',
okButtonProps: { danger: true },
centered: true,
onOk: () => {
setLoggedIn(false);
setMainTab('todo');
setTodoDetail(null);
setBizModule(null);
message.info('已退出登录');
},
});
return;
}
setLoggedIn(false);
setMainTab('todo');
setBizModule(null);
message.info('已退出登录');
}, []);
const showTabBar = loggedIn && !todoDetail && !bizModule;
const showBack = !!todoDetail || !!bizModule;
const bodyClass = useMemo(() => {
const parts = ['xll-body'];
if (!loggedIn) parts.push('xll-body--login');
else if (bizModule) parts.push('xll-body--module');
else if (mainTab === 'map' && !todoDetail) parts.push('xll-body--map');
return parts.join(' ');
}, [loggedIn, mainTab, todoDetail, bizModule]);
return (
<div className="xll-root">
<style>{PAGE_STYLE}</style>
<div className="xll-phone">
<div className="xll-chrome">
<IphoneStatusBar />
<div className="xll-navbar">
<div className="xll-nav-left">
{showBack ? (
<button type="button" className="xll-back" onClick={handleNavBack} aria-label="返回"><IconBack /></button>
) : null}
</div>
<span className="xll-nav-title">{navTitle}</span>
<div className="xll-nav-right">
<button type="button" className="xll-prd-link" onClick={() => handleOpenPrd(currentPrdKey)}>需求说明</button>
<MpCapsule />
</div>
</div>
</div>
<div className={bodyClass}>
{!loggedIn ? (
<LoginPage onLogin={() => { setLoggedIn(true); message.success('登录成功'); }} onOpenPrd={handleOpenPrd} />
) : bizModule ? (
bizModule === 'audit' ? (
<ApprovalCenterModule onRegisterBack={registerModuleBack} onPrdKeyChange={handleAuditPrdKeyChange} />
) : bizModule === 'inspection' ? (
<AnnualReviewModule onRegisterBack={registerModuleBack} />
) : bizModule === 'vehicle' ? (
<VehicleManagementModule onRegisterBack={registerModuleBack} />
) : bizModule === 'delivery' ? (
<DeliveryModule onRegisterBack={registerModuleBack} />
) : bizModule === 'replace' ? (
<ReplaceModule onRegisterBack={registerModuleBack} />
) : null
) : todoDetail ? (
<TodoDetailPage
task={todoDetail}
onBack={() => setTodoDetail(null)}
onDone={() => { message.success('办理完成(原型)'); setTodoDetail(null); }}
/>
) : mainTab === 'todo' ? (
<TodoPage onProcess={handleProcessTask} />
) : mainTab === 'business' ? (
<BusinessPage onEntry={handleBusinessEntry} />
) : mainTab === 'map' ? (
<MapPage />
) : (
<MinePage onLogout={handleLogout} />
)}
</div>
{showTabBar && (
<div className="xll-tabbar" role="tablist" aria-label="主导航">
{MAIN_TABS.map((tab) => {
const TabIcon = TAB_ICON_MAP[tab.key];
const active = mainTab === tab.key;
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={active}
aria-label={tab.label}
className={`xll-tabbar-btn${active ? ' active' : ''}`}
onClick={() => setMainTab(tab.key)}
>
<span className="xll-tabbar-icon"><TabIcon active={active} /></span>
{tab.label}
</button>
);
})}
</div>
)}
<PrdModal prdKey={prdKey} onClose={() => setPrdKey(null)} />
</div>
</div>
);
};
if (typeof window !== 'undefined') window.Component = Component;
export default Component;