新增审批中心及提车/还车/租赁账单/调拨/替换车等流程需求文档,优化底部抽屉确认样式,并精简还车应结款审批页车辆租金展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
5567 lines
333 KiB
JavaScript
5567 lines
333 KiB
JavaScript
// 【重要】必须使用 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-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; }
|
||
.xll-mod-drawer-type-btn.active { border-color:${XLL_GREEN}; color:${XLL_GREEN_DEEP}; background:${XLL_GREEN_SOFT}; font-weight:600; }
|
||
.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-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: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-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:0 14px 14px; }
|
||
.xll-dv-filter-field { margin-bottom:14px; }
|
||
.xll-dv-filter-label { display:block; font-size:13px; font-weight:600; color:${COLOR_TEXT}; margin-bottom:8px; }
|
||
.xll-dv-filter-input { width:100%; min-height:44px; border:1px solid ${COLOR_LINE}; border-radius:10px; padding:0 12px; font-size:14px; box-sizing:border-box; outline:none; background:${COLOR_BG}; color:${COLOR_TEXT}; }
|
||
.xll-dv-filter-input:focus { border-color:${XLL_GREEN}; box-shadow:0 0 0 2px ${XLL_GREEN_SOFT}; }
|
||
.xll-dv-filter-date-row { display:flex; align-items:center; gap:8px; }
|
||
.xll-dv-filter-date-row .xll-dv-filter-input { flex:1; min-width:0; }
|
||
.xll-dv-filter-hint { font-size:12px; color:${COLOR_MUTED}; line-height:1.55; margin-top:8px; }
|
||
.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; }
|
||
.xll-biz-item:active { background:${COLOR_PAGE}; }
|
||
.xll-biz-item:focus-visible { outline:2px solid ${XLL_GREEN}; outline-offset:2px; }
|
||
.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:2px; right:calc(50% - 32px); 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); }
|
||
.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_RESERVE_PLATES = [
|
||
{ plateNo: '浙F80088', parkingLot: '嘉兴港区氢能停车场', brand: '苏龙', model: '海格牌18吨双飞翼货车', vin: 'LKLG7C4E4NA774701' },
|
||
{ plateNo: '浙F88601', parkingLot: '平湖指定停车场', brand: '现代', model: '帕力安牌4.5吨冷链车', vin: 'LNBSCPKB8RR123401' },
|
||
{ plateNo: '浙F88602', parkingLot: '平湖指定停车场', brand: '现代', model: '帕力安牌4.5吨冷链车', vin: 'LNBSCPKB8RR123402' },
|
||
];
|
||
|
||
const DV_FORM_STEPS = [
|
||
{ key: 'info', label: '交车信息' },
|
||
{ key: 'equip', label: '车辆信息' },
|
||
{ key: 'metrics', label: '交车数据' },
|
||
{ key: 'photos', label: '交车照片' },
|
||
{ key: 'confirm', label: '确认提交' },
|
||
];
|
||
|
||
const DV_PHOTO_SECTIONS = [
|
||
{ key: 'body', label: '车身照片' },
|
||
{ key: 'chassis', label: '底盘照片' },
|
||
{ key: 'tire', label: '轮胎照片' },
|
||
{ key: 'defect', label: '瑕疵照片' },
|
||
{ key: 'other', label: '其他照片' },
|
||
];
|
||
|
||
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, hasAd: '无', hasTailgate: '有', spareTire: '有', driverTraining: '已完成' },
|
||
],
|
||
},
|
||
{
|
||
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: '有', driverTraining: '已完成' },
|
||
{ vehicleKey: 2, seq: 2, vehicleType: '18吨双飞翼货车', brand: '苏龙', model: '海格牌18吨双飞翼货车', vin: 'LKLG7C4E4NA774702', plateNo: '沪A03802F', deliveryTime: '2025-11-20 09:30', deliveryPerson: '何苗苗', deliveryStatus: '已保存', deliveryMileage: null, deliveryH2: null, deliveryH2Unit: '%', deliveryElec: null, hasAd: '无', hasTailgate: '有', spareTire: '有', driverTraining: '已完成' },
|
||
],
|
||
},
|
||
{
|
||
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, hasAd: '有', hasTailgate: '有', spareTire: '有', driverTraining: '已完成', vehicleReturned: true },
|
||
{ 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: '已完成', vehicleReturned: false },
|
||
],
|
||
},
|
||
];
|
||
|
||
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 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 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' };
|
||
return { text: status || '未开始', cls: 'neutral' };
|
||
};
|
||
const dvCardStatusClass = (status) => {
|
||
if (status === '客户已签章') return 'ok';
|
||
if (status === '待客户签章') return 'info';
|
||
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,
|
||
hasAd: v.hasAd || '',
|
||
hasTailgate: v.hasTailgate || '',
|
||
spareTire: v.spareTire || '',
|
||
driverTraining: v.driverTraining || '',
|
||
vehicleReturned: v.vehicleReturned,
|
||
});
|
||
});
|
||
});
|
||
return rows;
|
||
};
|
||
|
||
const dvBuildEmptyForm = (row) => ({
|
||
plateNo: row.plateNo || '',
|
||
brand: row.brand || '',
|
||
model: row.model || '',
|
||
vin: row.vin || '',
|
||
hasAd: row.hasAd || '',
|
||
hasTailgate: row.hasTailgate || '',
|
||
spareTire: row.spareTire || '',
|
||
driverTraining: row.driverTraining || '',
|
||
deliveryMileage: row.deliveryMileage != null ? String(row.deliveryMileage) : '',
|
||
deliveryH2: row.deliveryH2 != null ? String(row.deliveryH2) : '',
|
||
deliveryH2Unit: row.deliveryH2Unit || '%',
|
||
deliveryElec: row.deliveryElec != null ? String(row.deliveryElec) : '',
|
||
deliveryTime: row.deliveryTime ? row.deliveryTime.replace(' ', 'T').slice(0, 16) : '',
|
||
});
|
||
|
||
const dvFormatH2 = (v, unit) => (v == null || v === '' ? '—' : `${v} ${unit || '%'}`);
|
||
const dvFormatMileage = (v) => (v == null || v === '' ? '—' : `${Number(v).toLocaleString()} 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">
|
||
<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" 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 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 [moreFilterDraft, setMoreFilterDraft] = useState(DV_EMPTY_MORE_FILTER);
|
||
const [activeRow, setActiveRow] = useState(null);
|
||
const [formStep, setFormStep] = useState(0);
|
||
const [formDraft, setFormDraft] = useState(null);
|
||
|
||
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 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) => dvDisplayPlate(r.plateNo).toUpperCase().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 readOnly = activeRow && (dvIsHistoryStatus(activeRow.deliveryStatus) || activeRow.deliveryStatus === '待客户签章');
|
||
const plateOptions = useMemo(() => {
|
||
const addr = activeRow?.deliveryAddress || '';
|
||
return DV_RESERVE_PLATES.filter((p) => !addr || p.parkingLot === addr || addr.indexOf(p.parkingLot.slice(0, 2)) >= 0);
|
||
}, [activeRow]);
|
||
|
||
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 openRow = useCallback((row) => {
|
||
setActiveRow(row);
|
||
setFormDraft(dvBuildEmptyForm(row));
|
||
setFormStep(0);
|
||
}, []);
|
||
|
||
const closeForm = useCallback(() => {
|
||
setActiveRow(null);
|
||
setFormDraft(null);
|
||
setFormStep(0);
|
||
}, []);
|
||
|
||
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((plateNo) => {
|
||
const picked = DV_RESERVE_PLATES.find((p) => p.plateNo === plateNo);
|
||
setFormDraft((f) => ({
|
||
...f,
|
||
plateNo,
|
||
brand: picked?.brand || f.brand,
|
||
model: picked?.model || f.model,
|
||
vin: picked?.vin || f.vin,
|
||
}));
|
||
}, []);
|
||
|
||
const handleSave = useCallback(() => {
|
||
if (!activeRow || !formDraft) return;
|
||
patchRow(activeRow.id, {
|
||
...formDraft,
|
||
deliveryMileage: formDraft.deliveryMileage === '' ? null : Number(formDraft.deliveryMileage),
|
||
deliveryH2: formDraft.deliveryH2 === '' ? null : Number(formDraft.deliveryH2),
|
||
deliveryElec: formDraft.deliveryElec === '' ? null : Number(formDraft.deliveryElec),
|
||
deliveryTime: formDraft.deliveryTime ? formDraft.deliveryTime.replace('T', ' ') : '',
|
||
deliveryStatus: '已保存',
|
||
});
|
||
message.success('交车单已保存(原型)');
|
||
}, [activeRow, formDraft, patchRow]);
|
||
|
||
const handleSubmit = useCallback(() => {
|
||
if (!activeRow || !formDraft) return;
|
||
if (!formDraft.plateNo) {
|
||
message.warning('请先选择交车车牌');
|
||
setFormStep(0);
|
||
return;
|
||
}
|
||
if (!formDraft.deliveryMileage || !formDraft.deliveryH2 || !formDraft.deliveryElec) {
|
||
message.warning('请填写交车里程、氢量与电量');
|
||
setFormStep(2);
|
||
return;
|
||
}
|
||
patchRow(activeRow.id, {
|
||
...formDraft,
|
||
deliveryMileage: Number(formDraft.deliveryMileage),
|
||
deliveryH2: Number(formDraft.deliveryH2),
|
||
deliveryElec: Number(formDraft.deliveryElec),
|
||
deliveryTime: formDraft.deliveryTime ? formDraft.deliveryTime.replace('T', ' ') : '2026-06-04 10:00',
|
||
deliveryPerson: MOCK_USER,
|
||
deliveryStatus: '待客户签章',
|
||
});
|
||
message.success('已提交,等待客户签章(原型)');
|
||
closeForm();
|
||
}, [activeRow, formDraft, patchRow, closeForm]);
|
||
|
||
useEffect(() => {
|
||
if (!onRegisterBack) return undefined;
|
||
onRegisterBack(() => {
|
||
if (activeRow) { closeForm(); return true; }
|
||
return false;
|
||
});
|
||
return () => onRegisterBack(null);
|
||
}, [activeRow, closeForm, onRegisterBack]);
|
||
|
||
const renderFormField = (label, value, editor) => (
|
||
<div className="xll-mod-form-row">
|
||
<span className="xll-mod-form-label">{label}</span>
|
||
{readOnly || !editor ? <span className={`xll-mod-form-value${readOnly ? ' xll-dv-view-val' : ''}`}>{value || '—'}</span> : editor}
|
||
</div>
|
||
);
|
||
|
||
const renderStepContent = () => {
|
||
if (!activeRow || !formDraft) return null;
|
||
const r = activeRow;
|
||
const f = formDraft;
|
||
if (formStep === 0) {
|
||
const st = dvStatusTag(r.deliveryStatus);
|
||
return (
|
||
<>
|
||
<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>
|
||
</div>
|
||
<div className="tc-hero-period">
|
||
<span className="tc-hero-period-tag">{r.bizType || '租赁'}</span>
|
||
{r.replaceOldPlate ? <span>替换旧车 {r.replaceOldPlate}</span> : null}
|
||
</div>
|
||
</div>
|
||
<div className="tc-section">
|
||
<div className="tc-section-head">
|
||
<span className="tc-section-title">交车信息</span>
|
||
<span className={`xll-dv-status ${st.cls}`}>{st.text}</span>
|
||
</div>
|
||
<div className="tc-section-hint">车牌下拉仅展示「已备车」且停车场在运维权限范围内的车辆。</div>
|
||
<div className="tc-section-form">
|
||
{renderFormField('业务类型', r.bizType, null)}
|
||
{renderFormField('任务来源', r.taskSource, null)}
|
||
{renderFormField('业务部门', r.businessDept, null)}
|
||
{renderFormField('业务负责人', r.businessOwner, null)}
|
||
{renderFormField('创建时间', r.createTime, null)}
|
||
{renderFormField('车牌号', dvDisplayPlate(f.plateNo), readOnly ? null : (
|
||
<select className="xll-mod-form-input" value={f.plateNo} onChange={(e) => handlePlatePick(e.target.value)} aria-label="选择车牌">
|
||
<option value="">请选择车牌</option>
|
||
{plateOptions.map((p) => (
|
||
<option key={p.plateNo} value={p.plateNo}>{p.plateNo} · {p.parkingLot}</option>
|
||
))}
|
||
</select>
|
||
))}
|
||
{renderFormField('品牌型号', `${f.brand || r.brand} · ${f.model || r.model}`, null)}
|
||
{renderFormField('VIN', f.vin || r.vin, readOnly ? null : (
|
||
<input className="xll-mod-form-input" value={f.vin} onChange={(e) => setFormDraft((d) => ({ ...d, vin: e.target.value }))} placeholder="车架号" />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
if (formStep === 1) {
|
||
const yesNoOpts = ['', '有', '无'];
|
||
const trainOpts = ['', '已完成', '未完成', '无需培训'];
|
||
return (
|
||
<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-form">
|
||
{renderFormField('车身广告', f.hasAd || '—', readOnly ? null : (
|
||
<select className="xll-mod-form-input" value={f.hasAd} onChange={(e) => setFormDraft((d) => ({ ...d, hasAd: e.target.value }))}>
|
||
{yesNoOpts.map((o) => <option key={o || 'empty'} value={o}>{o || '请选择'}</option>)}
|
||
</select>
|
||
))}
|
||
{renderFormField('尾板', f.hasTailgate || '—', readOnly ? null : (
|
||
<select className="xll-mod-form-input" value={f.hasTailgate} onChange={(e) => setFormDraft((d) => ({ ...d, hasTailgate: e.target.value }))}>
|
||
{yesNoOpts.map((o) => <option key={`t-${o || 'e'}`} value={o}>{o || '请选择'}</option>)}
|
||
</select>
|
||
))}
|
||
{renderFormField('备胎', f.spareTire || '—', readOnly ? null : (
|
||
<select className="xll-mod-form-input" value={f.spareTire} onChange={(e) => setFormDraft((d) => ({ ...d, spareTire: e.target.value }))}>
|
||
{yesNoOpts.map((o) => <option key={`s-${o || 'e'}`} value={o}>{o || '请选择'}</option>)}
|
||
</select>
|
||
))}
|
||
{renderFormField('驾驶培训', f.driverTraining || '—', readOnly ? null : (
|
||
<select className="xll-mod-form-input" value={f.driverTraining} onChange={(e) => setFormDraft((d) => ({ ...d, driverTraining: e.target.value }))}>
|
||
{trainOpts.map((o) => <option key={`d-${o || 'e'}`} value={o}>{o || '请选择'}</option>)}
|
||
</select>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (formStep === 2) {
|
||
return (
|
||
<div className="tc-section">
|
||
<div className="tc-section-head"><span className="tc-section-title">交车数据</span></div>
|
||
<div className="tc-section-form">
|
||
{renderFormField('交车时间', f.deliveryTime ? f.deliveryTime.replace('T', ' ') : (r.deliveryTime || '—'), readOnly ? null : (
|
||
<input className="xll-mod-form-input" type="datetime-local" value={f.deliveryTime} onChange={(e) => setFormDraft((d) => ({ ...d, deliveryTime: e.target.value }))} />
|
||
))}
|
||
{renderFormField('交车里程(km)', dvFormatMileage(f.deliveryMileage), readOnly ? null : (
|
||
<input className="xll-mod-form-input" type="number" value={f.deliveryMileage} onChange={(e) => setFormDraft((d) => ({ ...d, deliveryMileage: e.target.value }))} placeholder="0" />
|
||
))}
|
||
{renderFormField('氢量', dvFormatH2(f.deliveryH2, f.deliveryH2Unit), readOnly ? null : (
|
||
<span style={{ display: 'flex', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
|
||
<input className="xll-mod-form-input" type="number" value={f.deliveryH2} onChange={(e) => setFormDraft((d) => ({ ...d, deliveryH2: e.target.value }))} placeholder="0" style={{ maxWidth: 80 }} />
|
||
<select className="xll-mod-form-input" value={f.deliveryH2Unit} onChange={(e) => setFormDraft((d) => ({ ...d, deliveryH2Unit: e.target.value }))} style={{ maxWidth: 72 }}>
|
||
<option value="%">%</option>
|
||
<option value="MPa">MPa</option>
|
||
</select>
|
||
</span>
|
||
))}
|
||
{renderFormField('电量(%)', f.deliveryElec ? `${f.deliveryElec}%` : '—', readOnly ? null : (
|
||
<input className="xll-mod-form-input" type="number" value={f.deliveryElec} onChange={(e) => setFormDraft((d) => ({ ...d, deliveryElec: e.target.value }))} placeholder="0" />
|
||
))}
|
||
{readOnly && r.deliveryPerson ? renderFormField('交车人', r.deliveryPerson, null) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (formStep === 3) {
|
||
return (
|
||
<div className="tc-section">
|
||
<div className="tc-section-head"><span className="tc-section-title">交车照片</span></div>
|
||
<div className="tc-section-hint">请按模块上传交车照片;支持拍照或相册,单张不超过 10MB(原型模拟)。</div>
|
||
{DV_PHOTO_SECTIONS.map((sec) => (
|
||
<div key={sec.key} className="xll-dv-photo-block">
|
||
<div className="xll-dv-photo-title">{sec.label}</div>
|
||
<div className="xll-dv-photo-grid">
|
||
{[0, 1, 2].map((i) => (
|
||
<div
|
||
key={i}
|
||
className="xll-dv-photo-slot"
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => !readOnly && message.info(`上传${sec.label}(原型)`)}
|
||
onKeyDown={(e) => e.key === 'Enter' && !readOnly && message.info(`上传${sec.label}(原型)`)}
|
||
>
|
||
{readOnly && sec.key === 'body' && i === 0 ? '已上传' : '+ 添加'}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
const st = dvStatusTag(r.deliveryStatus);
|
||
return (
|
||
<>
|
||
<div className="tc-hero tc-hero--delivery">
|
||
<div className="tc-hero-label">交车确认</div>
|
||
<div className="tc-hero-amount" style={{ fontSize: 20 }}>{dvDisplayPlate(f.plateNo)}</div>
|
||
<div className="tc-hero-meta"><span>{r.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>{r.projectName || '—'}</div>
|
||
<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><span className={`xll-dv-status ${st.cls}`} style={{ marginLeft: 0 }}>{st.text}</span></div>
|
||
</div>
|
||
</div>
|
||
<div className="tc-section">
|
||
<div className="tc-section-head"><span className="tc-section-title">交车摘要</span></div>
|
||
<div className="tc-section-form">
|
||
<div className="xll-mod-form-row"><span className="xll-mod-form-label">里程/氢/电</span><span className="xll-mod-form-value">{dvFormatMileage(f.deliveryMileage)} · {dvFormatH2(f.deliveryH2, f.deliveryH2Unit)} · {f.deliveryElec ? `${f.deliveryElec}%` : '—'}</span></div>
|
||
<div className="xll-mod-form-row"><span className="xll-mod-form-label">交车时间</span><span className="xll-mod-form-value">{dvDisplayActualTime(f.deliveryTime || r.deliveryTime)}</span></div>
|
||
{r.deliveryPerson ? <div className="xll-mod-form-row"><span className="xll-mod-form-label">交车人</span><span className="xll-mod-form-value">{r.deliveryPerson}</span></div> : null}
|
||
</div>
|
||
</div>
|
||
{dvIsHistoryStatus(r.deliveryStatus) && (
|
||
<div className="tc-section">
|
||
<div className="tc-section-head"><span className="tc-section-title">E签宝签章文件</span></div>
|
||
<div className="tc-section-form">
|
||
<div className="xll-mod-upload" role="button" tabIndex={0} onClick={() => message.success('下载签章 PDF(原型)')} onKeyDown={(e) => e.key === 'Enter' && message.success('下载签章 PDF(原型)')}>
|
||
交车确认单_已签章.pdf<br /><span style={{ fontSize: 12 }}>点击预览或下载</span>
|
||
</div>
|
||
{r.vehicleReturned != null && (
|
||
<div className="xll-mod-form-row"><span className="xll-mod-form-label">是否归还</span><span className="xll-mod-form-value">{r.vehicleReturned ? '已归还' : '未归还'}</span></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div style={{ height: 16 }} />
|
||
</>
|
||
);
|
||
};
|
||
|
||
const activeTabLabel = DV_LIST_TABS.find((t) => t.key === listFilter)?.label || '';
|
||
|
||
if (activeRow) {
|
||
const isLast = formStep >= DV_FORM_STEPS.length - 1;
|
||
const isFirst = formStep <= 0;
|
||
return (
|
||
<div className="xll-mod-root xll-dv-module">
|
||
<div className="xll-mod-detail-wrap">
|
||
<div className="xll-dv-steps-wrap">
|
||
<div className="xll-dv-steps" role="tablist" aria-label="交车办理步骤">
|
||
{DV_FORM_STEPS.map((s, i) => (
|
||
<button
|
||
key={s.key}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={formStep === i}
|
||
className={`xll-dv-step${formStep === i ? ' active' : ''}${i < formStep ? ' done' : ''}`}
|
||
onClick={() => setFormStep(i)}
|
||
>
|
||
{i + 1}.{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="tc-scroll" style={{ paddingBottom: readOnly && isLast ? 24 : undefined }}>
|
||
{renderStepContent()}
|
||
</div>
|
||
{!readOnly && (
|
||
<div className="xll-mod-action-bar">
|
||
{!isFirst && (
|
||
<button type="button" className="xll-mod-btn-ghost" onClick={() => setFormStep((s) => Math.max(0, s - 1))}>上一步</button>
|
||
)}
|
||
{!isLast ? (
|
||
<button type="button" className="xll-mod-btn-primary" style={isFirst ? { flex: 2 } : undefined} onClick={() => setFormStep((s) => Math.min(DV_FORM_STEPS.length - 1, s + 1))}>下一步</button>
|
||
) : (
|
||
<>
|
||
<button type="button" className="xll-mod-btn-ghost" onClick={handleSave}>保存</button>
|
||
<button type="button" className="xll-mod-btn-primary" onClick={handleSubmit}>提交签章</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{readOnly && !isLast && (
|
||
<div className="xll-mod-action-bar">
|
||
{!isFirst && <button type="button" className="xll-mod-btn-ghost" onClick={() => setFormStep((s) => s - 1)}>上一步</button>}
|
||
<button type="button" className="xll-mod-btn-primary" onClick={() => setFormStep((s) => Math.min(DV_FORM_STEPS.length - 1, s + 1))}>下一步</button>
|
||
</div>
|
||
)}
|
||
{readOnly && isLast && !isFirst && (
|
||
<div className="xll-mod-action-bar">
|
||
<button type="button" className="xll-mod-btn-ghost" onClick={() => setFormStep((s) => s - 1)}>上一步</button>
|
||
</div>
|
||
)}
|
||
</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${activeMoreFilterCount ? ' active' : ''}`}
|
||
onClick={() => { setMoreFilterDraft(moreFilter); 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>
|
||
)}
|
||
{activeMoreFilterCount > 0 && (
|
||
<div className="xll-mod-chips" style={{ marginTop: (listFilter === 'all' || listFilter === 'inProgress') ? 0 : 10 }}>
|
||
{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 || activeMoreFilterCount ? '试试清空搜索或切换筛选' : '新的交车任务到达后将在此展示'}
|
||
</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 actionLabel = row.deliveryStatus === '未开始' ? '去办理' : isSignPending ? '查看' : '继续办理';
|
||
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 ? (
|
||
<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>
|
||
{Drawer ? (
|
||
<Drawer title="更多筛选" placement="bottom" height={480} open={filterDrawerOpen} onClose={() => setFilterDrawerOpen(false)} styles={{ body: { padding: '12px 16px 24px' } }}>
|
||
<div className="xll-dv-filter-field">
|
||
<label className="xll-dv-filter-label" htmlFor="dv-filter-customer">客户名称</label>
|
||
<input
|
||
id="dv-filter-customer"
|
||
className="xll-dv-filter-input"
|
||
type="search"
|
||
placeholder="请输入客户名称"
|
||
value={moreFilterDraft.customerName}
|
||
onChange={(e) => setMoreFilterDraft((f) => ({ ...f, customerName: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="xll-dv-filter-field">
|
||
<label className="xll-dv-filter-label" htmlFor="dv-filter-project">项目名称</label>
|
||
<input
|
||
id="dv-filter-project"
|
||
className="xll-dv-filter-input"
|
||
type="search"
|
||
placeholder="请输入项目名称"
|
||
value={moreFilterDraft.projectName}
|
||
onChange={(e) => setMoreFilterDraft((f) => ({ ...f, projectName: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="xll-dv-filter-field">
|
||
<span className="xll-dv-filter-label">交车日期</span>
|
||
<div className="xll-dv-filter-date-row">
|
||
<input
|
||
className="xll-dv-filter-input"
|
||
type="date"
|
||
aria-label="交车开始日期"
|
||
value={moreFilterDraft.dateStart}
|
||
onChange={(e) => setMoreFilterDraft((f) => ({ ...f, dateStart: e.target.value }))}
|
||
/>
|
||
<span style={{ fontSize: 13, color: COLOR_MUTED, flexShrink: 0 }}>至</span>
|
||
<input
|
||
className="xll-dv-filter-input"
|
||
type="date"
|
||
aria-label="交车结束日期"
|
||
value={moreFilterDraft.dateEnd}
|
||
onChange={(e) => setMoreFilterDraft((f) => ({ ...f, dateEnd: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="xll-dv-filter-hint">到达「待客户签章」及之后状态,按实际交车时间筛选;未交车任务不参与日期筛选。</div>
|
||
</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={() => setMoreFilterDraft(DV_EMPTY_MORE_FILTER)}
|
||
>
|
||
重置
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="xll-mod-btn-primary"
|
||
style={{ flex: 1, minHeight: 44, borderRadius: 12 }}
|
||
onClick={() => {
|
||
if (moreFilterDraft.dateStart && moreFilterDraft.dateEnd && moreFilterDraft.dateStart > moreFilterDraft.dateEnd) {
|
||
message.warning('开始日期不能晚于结束日期');
|
||
return;
|
||
}
|
||
setMoreFilter(moreFilterDraft);
|
||
setFilterDrawerOpen(false);
|
||
}}
|
||
>
|
||
确定
|
||
</button>
|
||
</div>
|
||
</Drawer>
|
||
) : null}
|
||
</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">
|
||
<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">二、分步表单(5 步)</div>
|
||
<ul className="xll-prd-ul">
|
||
<li className="xll-prd-li"><strong>交车信息</strong>:Hero 展示客户名称、项目名称与交车区域;同页含合同任务字段及车牌/VIN 选择。</li>
|
||
<li className="xll-prd-li"><strong>车辆信息</strong>:广告、尾板、备胎、驾驶培训。</li>
|
||
<li className="xll-prd-li"><strong>交车数据</strong>:交车时间、里程、氢量(%/MPa)、电量。</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">已完成可下载 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}>
|
||
{item.badge > 0 && <span className="xll-biz-badge" aria-hidden="true">{item.badge > 99 ? 99 : item.badge}</span>}
|
||
<span className="xll-biz-icon"><BizIcon /></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;
|