Files
ONE-OS/ONE-OS小程序/审批中心.jsx

1865 lines
88 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 【重要】必须使用 const Component 作为组件变量名
// ONE-OS 小程序 - 审批中心(我发起的 / 我的待办 / 我的已办 / 我的抄送)
const { useState, useMemo, useCallback, useRef, useEffect } = React;
const moment = window.moment || window.dayjs;
const COLOR_PRIMARY = '#16D1A1';
const COLOR_PRIMARY_DEEP = '#00BFA5';
const COLOR_PRIMARY_SOFT = 'rgba(22, 209, 161, 0.12)';
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_WARN = '#FF7D00';
const COLOR_DANGER = '#F53F3F';
const COLOR_SUCCESS = '#00B42A';
const FONT_FAMILY =
'-apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", STHeiti, sans-serif';
const resolveAntdBundle = () => {
const raw = window.antd;
if (!raw) return {};
if (raw.default && typeof raw.default === 'object') return { ...raw, ...raw.default };
return raw;
};
const antd = resolveAntdBundle();
const message = antd.message || { info: () => {}, success: () => {}, warning: () => {} };
const Drawer = antd.Drawer;
const Modal = antd.Modal;
const FallbackTag = ({ children, className, style, color }) => {
const palette = {
error: { c: COLOR_DANGER, bg: 'rgba(245, 63, 63, 0.1)' },
warning: { c: COLOR_WARN, bg: 'rgba(255, 125, 0, 0.1)' },
success: { c: COLOR_SUCCESS, bg: 'rgba(0, 180, 42, 0.1)' },
default: { c: COLOR_MUTED, bg: 'rgba(134, 144, 156, 0.12)' },
processing: { c: COLOR_PRIMARY_DEEP, bg: COLOR_PRIMARY_SOFT },
};
const p = palette[color] || palette.processing;
return (
<span
className={className}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
lineHeight: '18px',
padding: '2px 8px',
borderRadius: 999,
fontWeight: 600,
color: p.c,
background: p.bg,
...style,
}}
>
{children}
</span>
);
};
const Tag = antd.Tag || FallbackTag;
const MOCK_CURRENT_USER = '张明辉';
const APPROVAL_FLOW_TYPES = [
'合同审批',
'提车应收款',
'租赁账单',
'还车应结款',
'氢费对账单(对站)',
'氢费对账单(对客)',
'车辆调拨',
'车辆异动',
];
const TAB_ITEMS = [
{ key: 'initiated', label: '我发起的', short: '发起' },
{ key: 'todo', label: '我的待办', short: '待办' },
{ key: 'done', label: '我的已办', short: '已办' },
{ key: 'cc', label: '我的抄送', short: '抄送' },
];
/** 流程类型主题色(左侧色条 + 图标底) */
const 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: COLOR_PRIMARY_DEEP, soft: COLOR_PRIMARY_SOFT },
车辆异动: { accent: '#14B8A6', soft: 'rgba(20, 184, 166, 0.12)' },
};
const buildMockTasks = () => [
{ 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 车', initiator: '陈高伟', initiateTime: '2026-06-01 08:40', arriveTime: '2026-06-01 09:10', finishTime: '', currentNode: '业管主管', currentAssignee: '张明辉', 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: '车辆调拨', bizNo: 'DB-2026-018', summary: '粤B58888F · 深圳 → 杭州', initiator: '王东东', initiateTime: '2026-06-01 08:00', arriveTime: '2026-06-01 08:45', 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月 · 批量租赁账单', 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-13', flowType: '车辆调拨', bizNo: 'DB-2026-003', summary: '苏E33333 · 苏州 → 南京', initiator: '王东东', initiateTime: '2026-03-15 10:30', arriveTime: '2026-03-15 11:00', finishTime: '', 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-16', flowType: '氢费对账单(对站)', bizNo: 'H2-ST-202604', summary: '嘉兴港区加氢站 · 4月对账', initiator: '能源部-周工', initiateTime: '2026-04-20 10:00', arriveTime: '2026-04-21 09:00', finishTime: '2026-04-22 11:00', currentNode: '—', currentAssignee: '', status: '已通过', ccUsers: ['张明辉', '陈高伟'], handledBy: ['能源部-周工'], ccTime: '2026-04-21 09:05' },
{ id: 'ap-17', flowType: '车辆异动', bizNo: 'YD-2026-028', summary: '沪A03561F · 年审至检测站', initiator: '王东东', initiateTime: '2026-02-24 08:00', arriveTime: '2026-02-24 08:30', finishTime: '2026-02-24 12:00', currentNode: '—', currentAssignee: '', status: '已通过', ccUsers: ['张明辉'], handledBy: ['张明辉'], ccTime: '2026-02-24 08:35' },
];
const PAGE_STYLE = `
.ac-mini-root {
min-height: 100dvh;
background: linear-gradient(165deg, #e8ebef 0%, ${COLOR_PAGE} 40%);
display: flex;
justify-content: center;
padding: 16px 12px 32px;
box-sizing: border-box;
font-family: ${FONT_FAMILY};
-webkit-font-smoothing: antialiased;
}
.ac-phone {
width: 100%;
max-width: 390px;
min-height: 844px;
background: ${COLOR_PAGE};
border-radius: 28px;
overflow: hidden;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(15, 23, 42, 0.05);
display: flex;
flex-direction: column;
}
.ac-chrome { flex-shrink: 0; background: ${COLOR_BG}; }
.ac-status-bar {
height: 44px;
padding: 14px 24px 0;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
}
.ac-status-time { font-size: 15px; font-weight: 600; color: ${COLOR_TEXT}; letter-spacing: -0.02em; }
.ac-mp-navbar {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
border-bottom: 1px solid rgba(0,0,0,.05);
position: relative;
background: ${COLOR_BG};
}
.ac-mp-navbar-title { font-size: 17px; font-weight: 700; color: ${COLOR_TEXT}; }
.ac-tab-seg {
display: flex;
gap: 6px;
padding: 10px 14px 8px;
background: ${COLOR_BG};
flex-shrink: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.ac-tab-seg::-webkit-scrollbar { display: none; }
.ac-tab-seg-btn {
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;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, transform 0.15s ease;
position: relative;
}
.ac-tab-seg-btn:active { transform: scale(0.97); }
.ac-tab-seg-btn.active {
background: ${COLOR_BG};
color: ${COLOR_PRIMARY_DEEP};
font-weight: 700;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
.ac-tab-seg-btn:focus-visible {
outline: 2px solid ${COLOR_PRIMARY};
outline-offset: 2px;
}
.ac-tab-count {
display: block;
font-size: 11px;
font-weight: 600;
margin-top: 2px;
opacity: 0.85;
font-variant-numeric: tabular-nums;
}
.ac-toolbar {
padding: 0 14px 10px;
flex-shrink: 0;
background: ${COLOR_BG};
border-bottom: 1px solid ${COLOR_LINE};
}
.ac-search-wrap {
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;
}
.ac-search-wrap:focus-within {
border-color: rgba(22, 209, 161, 0.45);
box-shadow: 0 0 0 3px rgba(22, 209, 161, 0.12);
background: ${COLOR_BG};
}
.ac-search-wrap svg { flex-shrink: 0; color: ${COLOR_MUTED}; }
.ac-search-input {
flex: 1;
border: none;
background: transparent;
font-size: 15px;
color: ${COLOR_TEXT};
outline: none;
min-width: 0;
}
.ac-search-input::placeholder { color: ${COLOR_MUTED}; }
.ac-filter-scroll {
display: flex;
gap: 8px;
margin-top: 10px;
overflow-x: auto;
padding-bottom: 2px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.ac-filter-scroll::-webkit-scrollbar { display: none; }
.ac-filter-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;
touch-action: manipulation;
transition: all 0.2s ease;
white-space: nowrap;
}
.ac-filter-chip:active { transform: scale(0.96); }
.ac-filter-chip.active {
border-color: ${COLOR_PRIMARY};
color: ${COLOR_PRIMARY_DEEP};
background: ${COLOR_PRIMARY_SOFT};
font-weight: 600;
}
.ac-filter-chip:focus-visible {
outline: 2px solid ${COLOR_PRIMARY};
outline-offset: 2px;
}
.ac-filter-more {
border-style: dashed;
color: ${COLOR_PRIMARY_DEEP};
font-weight: 600;
}
.ac-list-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px 4px;
font-size: 12px;
color: ${COLOR_MUTED};
}
.ac-list {
flex: 1;
overflow-y: auto;
padding: 4px 14px 28px;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.ac-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, 0.05);
border: 1px solid rgba(0,0,0,.04);
cursor: pointer;
touch-action: manipulation;
transition: transform 0.18s ease, box-shadow 0.18s ease;
animation: ac-card-in 0.35s ease both;
}
.ac-card::before {
content: '';
position: absolute;
left: 0;
top: 12px;
bottom: 12px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--ac-accent, ${COLOR_PRIMARY});
}
.ac-card:active { transform: scale(0.985); box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06); }
.ac-card:focus-visible {
outline: 2px solid ${COLOR_PRIMARY};
outline-offset: 2px;
}
@keyframes ac-card-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.ac-card { animation: none; transition: none; }
.ac-card:active { transform: none; }
.ac-tab-seg-btn:active { transform: none; }
.ac-filter-chip:active { transform: none; }
}
.ac-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.ac-card-title-row { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
.ac-card-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--ac-icon-bg, ${COLOR_PRIMARY_SOFT});
color: var(--ac-accent, ${COLOR_PRIMARY_DEEP});
}
.ac-card-title { font-size: 16px; font-weight: 700; color: ${COLOR_TEXT}; line-height: 1.25; }
.ac-card-bizno { font-size: 12px; color: ${COLOR_MUTED}; margin-top: 3px; font-variant-numeric: tabular-nums; }
.ac-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.ac-summary {
font-size: 14px;
color: ${COLOR_TEXT_SEC};
margin-bottom: 10px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ac-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
padding-top: 8px;
border-top: 1px dashed ${COLOR_LINE};
}
.ac-meta-item { min-width: 0; }
.ac-meta-label { font-size: 11px; color: ${COLOR_MUTED}; margin-bottom: 2px; }
.ac-meta-value {
font-size: 13px;
color: ${COLOR_TEXT};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
}
.ac-biz-grid {
margin-bottom: 10px;
padding-top: 0;
border-top: none;
}
.ac-biz-grid .ac-meta-value.ac-amount {
color: #F97316;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ac-card-foot {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid ${COLOR_LINE};
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ac-card-action {
min-height: 36px;
padding: 0 16px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%);
color: #fff;
font-size: 13px;
font-weight: 700;
cursor: pointer;
touch-action: manipulation;
box-shadow: 0 4px 12px rgba(0, 191, 165, 0.25);
}
.ac-card-action:active { opacity: 0.92; transform: scale(0.98); }
.ac-empty {
text-align: center;
padding: 56px 24px 32px;
}
.ac-empty-icon {
width: 64px;
height: 64px;
margin: 0 auto 16px;
border-radius: 50%;
background: ${COLOR_PAGE};
display: flex;
align-items: center;
justify-content: center;
color: ${COLOR_MUTED};
}
.ac-empty-title { font-size: 15px; font-weight: 600; color: ${COLOR_TEXT}; margin-bottom: 6px; }
.ac-empty-desc { font-size: 13px; color: ${COLOR_MUTED}; line-height: 1.55; }
.ac-drawer-types {
display: flex;
flex-direction: column;
gap: 8px;
}
.ac-drawer-type-btn {
min-height: 48px;
padding: 0 16px;
border: 1px solid ${COLOR_LINE};
border-radius: 12px;
background: ${COLOR_BG};
text-align: left;
font-size: 14px;
color: ${COLOR_TEXT};
cursor: pointer;
touch-action: manipulation;
}
.ac-drawer-type-btn.active {
border-color: ${COLOR_PRIMARY};
background: ${COLOR_PRIMARY_SOFT};
color: ${COLOR_PRIMARY_DEEP};
font-weight: 600;
}
.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, 0.35); }
.tc-hero-label { font-size: 13px; opacity: 0.92; margin-bottom: 6px; }
.tc-hero-amount { font-size: 36px; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; letter-spacing: -0.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: 0.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: 0.88; }
.tc-section { margin: 12px 14px 0; background: ${COLOR_BG}; border-radius: 14px; overflow: hidden; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.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, 0.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, 0.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: ${COLOR_PRIMARY_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, 0.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, 0.3); }
.tc-btn-reject { background: ${COLOR_PAGE}; color: ${COLOR_DANGER}; border: 1px solid rgba(245, 63, 63, 0.25); }
.tc-btn-approve { background: linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%); color: #fff; box-shadow: 0 4px 14px rgba(0, 191, 165, 0.3); }
.tc-btn:active { opacity: 0.92; transform: scale(0.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: ${COLOR_PRIMARY}; }
.tc-notify-item.disabled { opacity: 0.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: ${COLOR_PRIMARY_SOFT}; color: ${COLOR_PRIMARY_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(22, 209, 161, 0.55); box-shadow: 0 0 0 3px rgba(22, 209, 161, 0.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: ${COLOR_PRIMARY}; background: ${COLOR_PRIMARY_SOFT}; color: ${COLOR_PRIMARY_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; }
.ac-biz-grid .ac-meta-value.ac-amount-hc { color: #8B5CF6; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
.tc-hero--return { background: linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%); box-shadow: 0 10px 28px rgba(139, 92, 246, 0.35); }
.tc-section-badge--purple { color: #8B5CF6; background: rgba(139, 92, 246, 0.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-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-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; }
.ac-mp-back { position: absolute; left: 8px; width: 40px; height: 40px; border: none; background: transparent; color: ${COLOR_TEXT}; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; z-index: 2; }
.ac-mp-back:active { background: rgba(0,0,0,.05); }
.ac-phone--detail { position: relative; }
`;
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 IconChevron = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
);
const IconBack = () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
);
const IconEmpty = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" 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="9" y1="13" x2="15" y2="13" />
</svg>
);
const FlowTypeIcon = ({ flowType }) => {
const common = { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
const map = {
合同审批: <svg {...common}><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" /></svg>,
提车应收款: <svg {...common}><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>,
租赁账单: <svg {...common}><rect x="3" y="4" width="18" height="16" rx="2" /><line x1="7" y1="9" x2="17" y2="9" /><line x1="7" y1="13" x2="13" y2="13" /></svg>,
还车应结款: <svg {...common}><rect x="1" y="3" width="15" height="13" /><polygon points="16 8 20 8 23 11 23 16 16 16 16 8" /><circle cx="5.5" cy="18.5" r="2.5" /><circle cx="18.5" cy="18.5" r="2.5" /></svg>,
'氢费对账单(对站)': <svg {...common}><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" /></svg>,
'氢费对账单(对客)': <svg {...common}><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></svg>,
车辆调拨: <svg {...common}><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>,
车辆异动: <svg {...common}><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" /><circle cx="12" cy="10" r="3" /></svg>,
};
return map[flowType] || map['合同审批'];
};
const IphoneStatusBar = () => {
const time = moment ? moment().format('HH:mm') : '9:41';
return (
<div className="ac-status-bar">
<span className="ac-status-time">{time}</span>
<span style={{ display: 'flex', gap: 4, alignItems: 'center' }} aria-hidden="true">
<svg width="16" height="12" viewBox="0 0 16 12"><path fill="currentColor" d="M1 4h2v8H1V4zm4-2h2v10H5V2zm4 3h2v7H9V5zm4-5h2v12h-2V0z" /></svg>
<svg width="22" height="11" viewBox="0 0 22 11"><rect x="0.5" y="0.5" width="18" height="10" rx="2" stroke="currentColor" fill="none" /><rect x="2" y="2" width="13" height="7" rx="1" fill="currentColor" /></svg>
</span>
</div>
);
};
const formatMoney = (val) => {
const n = parseFloat(val);
if (Number.isNaN(n)) return val || '—';
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatYuan = (val) => {
const n = parseFloat(val);
if (Number.isNaN(n)) return '—';
return `${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`;
};
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 statusMeta = (status) => {
if (status === '已通过') return { color: 'success', label: status };
if (status === '已驳回') return { color: 'error', label: status };
if (status === '已撤回') return { color: 'default', label: status };
return { color: 'warning', label: status };
};
const isPendingStatus = (status) => status === '审批中' || status === '待审批';
const MiniProgramChrome = ({ title, showBack, onBack }) => (
<div className="ac-chrome">
<IphoneStatusBar />
<div className="ac-mp-navbar">
{showBack ? (
<button type="button" className="ac-mp-back" onClick={onBack} aria-label="返回">
<IconBack />
</button>
) : null}
<span className="ac-mp-navbar-title">{title}</span>
</div>
</div>
);
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">{formatMoney(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">{formatMoney(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 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">{formatMoney(displayActualTotal)}</div>
<div className="tc-hero-meta">
<span>{projectInfo.customerName}</span>
<span>{vehicles.length} 台车</span>
</div>
<div className="tc-hero-sub">
<div className="tc-hero-compare">
应收款总额 {formatMoney(totals.receivableTotal)}
<br />
较应收减免 {formatMoney(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></div>
<div className="tc-kv-grid">
<TcInfoRow label="提车收款单编码" value={projectInfo.collectCode} full />
<TcInfoRow label="合同编码" value={projectInfo.contractCode} />
<TcInfoRow label="项目名称" value={projectInfo.projectName} full />
<TcInfoRow label="客户企业名称" value={projectInfo.customerName} full />
<TcInfoRow label="付款方式" value={projectInfo.paymentMethod} />
<TcInfoRow label="付款周期" value={projectInfo.paymentCycle} />
<TcInfoRow label="合同生效" value={projectInfo.contractStart} />
<TcInfoRow label="合同结束" value={projectInfo.contractEnd} />
<TcInfoRow label="业务部门" value={projectInfo.businessDept} />
<TcInfoRow label="业务负责人" value={projectInfo.businessPerson} />
</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}
/>
{Drawer ? (
<Drawer title="实收款明细" placement="bottom" height="auto" open={actualDrawerOpen} onClose={() => setActualDrawerOpen(false)} styles={{ body: { padding: '8px 20px 24px' } }}>
{actualRows.map((r) => (
<div className="tc-drawer-row" key={r.label}>
<span style={{ color: COLOR_TEXT_SEC }}>{r.label}</span>
<span className={`tc-drawer-row-val${r.highlight ? ' highlight' : ''}`} style={{ fontWeight: 700 }}>{r.value}</span>
</div>
))}
<div className="tc-drawer-total"><span>实收款总额</span><span>{formatMoney(displayActualTotal)}</span></div>
</Drawer>
) : null}
</>
);
};
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 = [
{ key: 'bs-0', feeItem: '违章处理违约金', amount: '0.00' },
{ key: 'bs-1', feeItem: '保险上浮', amount: '0.00' },
{ key: 'bs-2', feeItem: 'ETC-客户未缴费用', amount: '100.00' },
{ key: 'bs-3', feeItem: 'ETC卡缺损费', amount: '0.00' },
{ key: 'bs-4', feeItem: 'ETC设备缺损费', amount: '0.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 = [
{ key: 'op-0', feeItem: '清洗费', amount: '0.00' },
{ key: 'op-1', feeItem: '未结算保养', amount: '372.50' },
{ key: 'op-2', feeItem: '未结算维修', amount: '0.00' },
{ key: 'op-3', feeItem: '车损费用', amount: '0.00' },
{ key: 'op-4', feeItem: '接车服务费', amount: '0.00' },
];
const violations = [{
code: 'WZ202602010001', plateNo: vehicle.plateNo, violationBehavior: '闯红灯',
violationTime: '2026-02-01', penaltyAmount: '100.00', paymentStatus: '未缴费', handleStatus: '未处理',
}];
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 = businessServiceRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toFixed(2);
const operationTotal = operationRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toFixed(2);
const energyTotal = ((parseFloat(energy.hydrogenSupplement) || 0) + (parseFloat(energy.hydrogenFee) || 0) + (parseFloat(energy.electricFee) || 0)).toFixed(2);
const depositAmount = task?.depositAmount || '5000.00';
const pendingSettle = task?.pendingSettle || (
parseFloat(businessServiceTotal) + parseFloat(billInfo.shouldRefundRent)
+ parseFloat(energy.hydrogenSupplement) + parseFloat(energy.hydrogenFee) + parseFloat(energy.electricFee)
- parseFloat(energy.prepayRefund) + parseFloat(operationTotal)
).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: formatYuan(businessServiceTotal) },
{ label: '车辆应退租金', value: formatYuan(billInfo.shouldRefundRent) },
{ label: '氢量差补缴金额', value: formatYuan(energy.hydrogenSupplement), highlight: true },
{ label: '氢费补缴金额', value: formatYuan(energy.hydrogenFee) },
{ label: '电费补缴金额', value: formatYuan(energy.electricFee) },
{ label: '预付款退费(减)', value: `- ${formatYuan(energy.prepayRefund)}` },
{ label: '运维部费用总额', value: formatYuan(operationTotal) },
];
return {
vehicle, businessServiceRows, billInfo, energy, operationRows, violations,
approvalSteps, businessServiceTotal, operationTotal, energyTotal,
depositAmount, pendingSettle, refundTotal, payTotal, settleBreakdown,
};
};
const HcFeeGroup = ({ title, total, submitter, defaultOpen, children }) => {
const [open, setOpen] = useState(defaultOpen !== false);
return (
<div className="hc-group-block">
<div className="hc-group-head" role="button" tabIndex={0} onClick={() => setOpen((o) => !o)} onKeyDown={(e) => e.key === 'Enter' && setOpen((o) => !o)}>
<div>
<div className="hc-group-title">{title}</div>
<div className="hc-group-meta">总金额 {formatYuan(total)} · {submitter} · 已提交</div>
</div>
<span style={{ color: COLOR_MUTED, fontSize: 12 }}>{open ? '收起' : '展开'}</span>
</div>
{open && children}
</div>
);
};
const ReturnSettlementApprovePage = ({ task, mode, onBack }) => {
const detail = useMemo(() => buildReturnSettlementDetail(task), [task]);
const {
vehicle, businessServiceRows, billInfo, energy, operationRows, violations, approvalSteps,
businessServiceTotal, operationTotal, energyTotal, 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 heroSub = parseFloat(payTotal) > 0
? `应补缴 ${formatMoney(payTotal)}`
: `应退还 ${formatMoney(refundTotal)}`;
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">待结算总额</div>
<div className="tc-hero-amount">{formatMoney(displayPending)}</div>
<div className="tc-hero-meta">
<span>{vehicle.plateNo}</span>
<span>{vehicle.customerName}</span>
</div>
<div className="tc-hero-sub">
<div className="tc-hero-compare">
保证金 {formatMoney(depositAmount)}
<br />
{heroSub}
<br />
车辆实际租金 {formatYuan(billInfo.actualRent)}
</div>
<button type="button" className="tc-hero-detail-btn" onClick={() => setSettleDrawerOpen(true)}>结算明细</button>
</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={vehicle.plateNo} />
<TcInfoRow label="合同编码" value={vehicle.contractCode} />
<TcInfoRow label="项目名称" value={vehicle.projectName} full />
<TcInfoRow label="客户企业名称" value={vehicle.customerName} full />
<TcInfoRow label="交车时间" value={vehicle.deliveryTime} />
<TcInfoRow label="还车时间" value={vehicle.returnTime} />
<TcInfoRow label="易损保" value={vehicle.fragileInsurance} />
<TcInfoRow label="轮胎保" value={vehicle.tireInsurance} />
<TcInfoRow label="养护保" value={vehicle.maintenanceInsurance} />
</div>
</div>
<div className="tc-section">
<div className="tc-section-head"><span className="tc-section-title">结算概览</span></div>
<div className="hc-stat-grid">
<div className="hc-stat-card"><div className="hc-stat-label">保证金总额</div><div className="hc-stat-val">{formatMoney(depositAmount)}</div></div>
<div className="hc-stat-card"><div className="hc-stat-label">待结算总额</div><div className="hc-stat-val warn">{formatMoney(displayPending)}</div></div>
<div className="hc-stat-card"><div className="hc-stat-label">应退还总额</div><div className="hc-stat-val success">{formatMoney(refundTotal)}</div></div>
<div className="hc-stat-card"><div className="hc-stat-label">应补缴总额</div><div className="hc-stat-val danger">{formatMoney(payTotal)}</div></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--purple">4 </span>
</div>
<HcFeeGroup title="业务服务组" total={businessServiceTotal} submitter="业务服务组-张三">
{businessServiceRows.filter((r) => parseFloat(r.amount) > 0).map((r) => (
<div className="hc-fee-row" key={r.key}><span className="hc-fee-row-name">{r.feeItem}</span><span className="hc-fee-row-amt">{formatYuan(r.amount)}</span></div>
))}
<div className="hc-sub-block">
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: COLOR_TEXT }}>车辆租金</div>
<div className="tc-kv-grid" style={{ padding: 0 }}>
<TcInfoRow label="本期已收租金" value={formatYuan(billInfo.receivedRent)} />
<TcInfoRow label="车辆实际租金" value={formatYuan(billInfo.actualRent)} />
<TcInfoRow label="车辆应退租金" value={formatYuan(billInfo.shouldRefundRent)} />
</div>
</div>
</HcFeeGroup>
<HcFeeGroup title="能源采购组" total={energyTotal} submitter="能源采购组-李四" defaultOpen={false}>
<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.hydrogenFee)} />
<TcInfoRow label="电费补缴" value={formatYuan(energy.electricFee)} />
<TcInfoRow label="预付款退费" value={formatYuan(energy.prepayRefund)} />
<TcInfoRow label="预充值余额" value={formatYuan(energy.userBalance)} />
</div>
</HcFeeGroup>
<HcFeeGroup title="运维部" total={operationTotal} submitter="运维部-王五" defaultOpen={false}>
{operationRows.filter((r) => parseFloat(r.amount) > 0).map((r) => (
<div className="hc-fee-row" key={r.key}><span className="hc-fee-row-name">{r.feeItem}</span><span className="hc-fee-row-amt">{formatYuan(r.amount)}</span></div>
))}
</HcFeeGroup>
<HcFeeGroup title="安全组" total="0.00" submitter="安全组-赵六" defaultOpen={false}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: COLOR_TEXT }}>违章清单</div>
{violations.map((v) => (
<div className="hc-violation-card" key={v.code}>
<div><strong>{v.violationBehavior}</strong> · {v.penaltyAmount} </div>
<div>{v.code} · {v.violationTime} · {v.paymentStatus}</div>
</div>
))}
</HcFeeGroup>
</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}</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('评论已添加(原型)')} />
{Drawer ? (
<Drawer title="待结算明细" placement="bottom" height="auto" open={settleDrawerOpen} onClose={() => setSettleDrawerOpen(false)} zIndex={1000} styles={{ body: { padding: '8px 20px 24px' } }}>
{settleBreakdown.map((r) => (
<div className="tc-drawer-row" key={r.label}>
<span style={{ color: COLOR_TEXT_SEC }}>{r.label}</span>
<span className={`tc-drawer-row-val${r.highlight ? ' highlight' : ''}`} style={{ fontWeight: 700, color: r.highlight ? '#8B5CF6' : undefined }}>{r.value}</span>
</div>
))}
<div className="tc-drawer-total"><span>待结算总额</span><span style={{ color: '#8B5CF6' }}>{formatMoney(displayPending)}</span></div>
</Drawer>
) : null}
</>
);
};
const filterByTab = (task, tabKey, user) => {
if (tabKey === 'initiated') return task.initiator === user;
if (tabKey === 'todo') return task.currentAssignee === user && isPendingStatus(task.status);
if (tabKey === 'done') return (task.handledBy || []).includes(user);
if (tabKey === 'cc') return (task.ccUsers || []).includes(user);
return false;
};
const QUICK_FILTER_TYPES = ['合同审批', '提车应收款', '租赁账单', '车辆调拨'];
const XLL_GREEN = '#7AB929';
const XLL_GREEN_DEEP = '#6AA322';
const XLL_GREEN_SOFT = 'rgba(122, 185, 41, 0.14)';
const EMBED_STYLE = `
.ac-embed-root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: ${COLOR_PAGE};
}
.ac-embed-root .ac-tab-seg,
.ac-embed-root .ac-toolbar,
.ac-embed-root .ac-list-head { flex-shrink: 0; }
.ac-embed-root .ac-list { flex: 1; min-height: 0; overflow-y: auto; }
.ac-phone--embed-detail {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
background: ${COLOR_PAGE};
}
`;
const XLL_THEME_PATCH = `
.xll-module-theme .ac-tab-seg-btn.active { color: ${XLL_GREEN_DEEP}; }
.xll-module-theme .ac-tab-seg-btn:focus-visible { outline-color: ${XLL_GREEN}; }
.xll-module-theme .ac-search-wrap:focus-within {
border-color: rgba(122, 185, 41, 0.45);
box-shadow: 0 0 0 3px ${XLL_GREEN_SOFT};
}
.xll-module-theme .ac-filter-chip.active {
border-color: ${XLL_GREEN};
color: ${XLL_GREEN_DEEP};
background: ${XLL_GREEN_SOFT};
}
.xll-module-theme .ac-filter-chip:focus-visible { outline-color: ${XLL_GREEN}; }
.xll-module-theme .ac-filter-more { color: ${XLL_GREEN_DEEP}; }
.xll-module-theme .ac-card:focus-visible { outline-color: ${XLL_GREEN}; }
.xll-module-theme .ac-card-action { color: ${XLL_GREEN}; border-color: rgba(122,185,41,.35); background: ${XLL_GREEN_SOFT}; }
.xll-module-theme .ac-drawer-type-btn.active { border-color: ${XLL_GREEN}; color: ${XLL_GREEN_DEEP}; background: ${XLL_GREEN_SOFT}; }
`;
const ApprovalCenterPanel = function ApprovalCenterPanel({ embedded = false, theme = 'default', onBack, onOpenPrd }) {
const [mainTab, setMainTab] = useState('todo');
const [flowFilter, setFlowFilter] = useState('');
const [searchKey, setSearchKey] = useState('');
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [detailApproveTask, setDetailApproveTask] = useState(null);
const currentUser = MOCK_CURRENT_USER;
const allTasks = useMemo(() => buildMockTasks(), []);
const tabCounts = useMemo(() => {
const counts = { initiated: 0, todo: 0, done: 0, cc: 0 };
allTasks.forEach((t) => {
TAB_ITEMS.forEach((tab) => {
if (filterByTab(t, tab.key, currentUser)) counts[tab.key] += 1;
});
});
return counts;
}, [allTasks, currentUser]);
const filteredList = useMemo(() => {
const q = searchKey.trim().toLowerCase();
let list = allTasks.filter((t) => filterByTab(t, mainTab, currentUser));
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) => {
const ta = String(b.arriveTime || b.initiateTime || b.ccTime || '');
const tb = String(a.arriveTime || a.initiateTime || a.ccTime || '');
return ta.localeCompare(tb);
});
}, [allTasks, mainTab, flowFilter, searchKey, currentUser]);
const handleCardClick = useCallback(
(task) => {
if (task.flowType === '提车应收款' || task.flowType === '还车应结款') {
setDetailApproveTask(task);
return;
}
if (mainTab === 'todo') {
message.info(`打开「${task.flowType}」审批办理页(原型)\n单据:${task.bizNo}`);
return;
}
message.info(`查看「${task.flowType}」详情(原型)\n单据:${task.bizNo}`);
},
[mainTab]
);
const closeDetailApprove = useCallback(() => setDetailApproveTask(null), []);
const detailApproveMode = mainTab === 'todo' ? 'approve' : 'view';
const themeClass = theme === 'xll' ? ' xll-module-theme' : '';
const embedStyles = `${PAGE_STYLE}${EMBED_STYLE}${theme === 'xll' ? XLL_THEME_PATCH : ''}`;
useEffect(() => {
if (!embedded || typeof window === 'undefined') return undefined;
window.__xllAuditBack = () => {
if (detailApproveTask) {
setDetailApproveTask(null);
return true;
}
return false;
};
return () => { delete window.__xllAuditBack; };
}, [embedded, detailApproveTask]);
if (detailApproveTask) {
const isReturn = detailApproveTask.flowType === '还车应结款';
const DetailPage = isReturn ? ReturnSettlementApprovePage : PickupReceivableApprovePage;
if (embedded) {
return (
<div className={`ac-embed-root ac-phone--embed-detail${themeClass}`}>
<style>{embedStyles}</style>
<DetailPage task={detailApproveTask} mode={detailApproveMode} onBack={closeDetailApprove} />
</div>
);
}
return (
<div className="ac-mini-root">
<style>{PAGE_STYLE}</style>
<div className="ac-phone ac-phone--detail">
<MiniProgramChrome title={isReturn ? '还车应结款审批' : '提车应收款审批'} showBack onBack={closeDetailApprove} />
<DetailPage task={detailApproveTask} mode={detailApproveMode} onBack={closeDetailApprove} />
</div>
</div>
);
}
const renderMeta = (label, value) => (
<div className="ac-meta-item" key={label}>
<div className="ac-meta-label">{label}</div>
<div className="ac-meta-value" title={value}>{value || '—'}</div>
</div>
);
const renderCard = (task, index) => {
const theme = FLOW_THEME[task.flowType] || { accent: COLOR_PRIMARY_DEEP, soft: COLOR_PRIMARY_SOFT };
const st = statusMeta(task.status);
const showApprove = mainTab === 'todo';
const metaItems = [
renderMeta('发起人', task.initiator),
renderMeta('发起时间', task.initiateTime),
];
if (mainTab === 'todo') {
metaItems.push(renderMeta('到达时间', task.arriveTime));
if (task.currentNode && task.currentNode !== '—') metaItems.push(renderMeta('当前节点', task.currentNode));
} else if (mainTab === 'done') {
metaItems.push(renderMeta('办理时间', task.finishTime || task.arriveTime));
} else if (mainTab === 'cc') {
metaItems.push(renderMeta('抄送时间', task.ccTime || task.arriveTime || task.initiateTime));
}
if ((mainTab === 'initiated' || mainTab === 'cc') && isPendingStatus(task.status) && task.currentNode && task.currentNode !== '—') {
metaItems.push(renderMeta('当前节点', task.currentNode));
}
const isPickupReceivable = task.flowType === '提车应收款';
const isReturnSettlement = task.flowType === '还车应结款';
const cardSummary = isPickupReceivable || isReturnSettlement
? `${task.customerName || ''} · ${task.projectName || task.plateNo || ''}`
: task.summary;
return (
<div
key={task.id}
className="ac-card"
style={{
'--ac-accent': theme.accent,
'--ac-icon-bg': theme.soft,
animationDelay: `${Math.min(index, 8) * 40}ms`,
}}
role="button"
tabIndex={0}
aria-label={`${task.flowType}${task.status}${cardSummary}`}
onClick={() => handleCardClick(task)}
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(task)}
>
<div className="ac-card-head">
<div className="ac-card-title-row">
<span className="ac-card-icon" aria-hidden="true">
<FlowTypeIcon flowType={task.flowType} />
</span>
<div style={{ minWidth: 0 }}>
<div className="ac-card-title">{task.flowType}</div>
<div className="ac-card-bizno">{task.bizNo}</div>
</div>
</div>
<Tag color={st.color}>
<span className="ac-status-dot" aria-hidden="true" />
{st.label}
</Tag>
</div>
{isPickupReceivable ? (
<div className="ac-meta-grid ac-biz-grid">
{renderMeta('客户企业名称', task.customerName)}
{renderMeta('项目名称', task.projectName)}
{renderMeta('车辆数', task.vehicleCount != null ? `${task.vehicleCount}` : '—')}
<div className="ac-meta-item" key="实收金额">
<div className="ac-meta-label">实收金额</div>
<div className="ac-meta-value ac-amount" title={formatMoney(task.actualAmount)}>
{formatMoney(task.actualAmount)}
</div>
</div>
</div>
) : isReturnSettlement ? (
<div className="ac-meta-grid ac-biz-grid">
{renderMeta('车牌号', task.plateNo)}
{renderMeta('客户企业名称', task.customerName)}
{renderMeta('项目名称', task.projectName)}
<div className="ac-meta-item" key="待结算总额">
<div className="ac-meta-label">待结算总额</div>
<div className="ac-meta-value ac-amount-hc" title={formatMoney(task.pendingSettle)}>
{formatMoney(task.pendingSettle)}
</div>
</div>
</div>
) : (
<div className="ac-summary">{task.summary}</div>
)}
<div className="ac-meta-grid">{metaItems}</div>
{showApprove && (
<div className="ac-card-foot">
<span style={{ fontSize: 12, color: COLOR_MUTED }}>待您审批</span>
<button
type="button"
className="ac-card-action"
onClick={(e) => {
e.stopPropagation();
handleCardClick(task);
}}
>
去审批
</button>
</div>
)}
{!showApprove && (
<div className="ac-card-foot" style={{ justifyContent: 'flex-end', borderTop: 'none', paddingTop: 4, marginTop: 4 }}>
<span style={{ fontSize: 12, color: COLOR_MUTED, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
查看详情 <IconChevron />
</span>
</div>
)}
</div>
);
};
const activeTabLabel = TAB_ITEMS.find((t) => t.key === mainTab)?.label || '';
const listContent = (
<>
<div className="ac-tab-seg" role="tablist" aria-label="审批列表分类">
{TAB_ITEMS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={mainTab === tab.key}
className={`ac-tab-seg-btn${mainTab === tab.key ? ' active' : ''}`}
onClick={() => setMainTab(tab.key)}
>
{tab.short}
<span className="ac-tab-count">{tabCounts[tab.key]} </span>
</button>
))}
</div>
<div className="ac-toolbar">
<div className="ac-search-wrap">
<IconSearch />
<input
className="ac-search-input"
type="search"
placeholder="搜索单据号、摘要、发起人"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
aria-label="搜索审批任务"
/>
</div>
<div className="ac-filter-scroll" role="group" aria-label="流程类型筛选">
<button
type="button"
className={`ac-filter-chip${!flowFilter ? ' active' : ''}`}
onClick={() => setFlowFilter('')}
>
全部
</button>
{QUICK_FILTER_TYPES.map((type) => (
<button
key={type}
type="button"
className={`ac-filter-chip${flowFilter === type ? ' active' : ''}`}
onClick={() => setFlowFilter(flowFilter === type ? '' : type)}
>
{type}
</button>
))}
<button type="button" className="ac-filter-chip ac-filter-more" onClick={() => setFilterDrawerOpen(true)}>
更多类型
</button>
</div>
</div>
<div className="ac-list-head">
<span>{activeTabLabel}</span>
<span> {filteredList.length} </span>
</div>
<div className="ac-list">
{filteredList.length === 0 ? (
<div className="ac-empty">
<div className="ac-empty-icon"><IconEmpty /></div>
<div className="ac-empty-title">暂无{activeTabLabel}任务</div>
<div className="ac-empty-desc">
{searchKey || flowFilter
? '试试清空搜索或切换流程类型'
: '新的审批任务到达后将在此展示'}
</div>
</div>
) : (
filteredList.map(renderCard)
)}
</div>
{Drawer ? (
<Drawer
title="选择流程类型"
placement="bottom"
height={420}
open={filterDrawerOpen}
onClose={() => setFilterDrawerOpen(false)}
styles={{ body: { padding: '12px 16px 24px' } }}
>
<div className="ac-drawer-types">
<button
type="button"
className={`ac-drawer-type-btn${!flowFilter ? ' active' : ''}`}
onClick={() => { setFlowFilter(''); setFilterDrawerOpen(false); }}
>
全部类型
</button>
{APPROVAL_FLOW_TYPES.map((type) => (
<button
key={type}
type="button"
className={`ac-drawer-type-btn${flowFilter === type ? ' active' : ''}`}
onClick={() => { setFlowFilter(type); setFilterDrawerOpen(false); }}
>
{type}
</button>
))}
</div>
</Drawer>
) : null}
</>
);
if (embedded) {
return (
<div className={`ac-embed-root${themeClass}`}>
<style>{embedStyles}</style>
{listContent}
</div>
);
}
return (
<div className="ac-mini-root">
<style>{PAGE_STYLE}</style>
<div className="ac-phone">
<MiniProgramChrome title="审批中心" />
{listContent}
</div>
</div>
);
};
const Component = function ApprovalCenterMiniApp() {
return <ApprovalCenterPanel embedded={false} />;
};
if (typeof window !== 'undefined') {
window.Component = Component;
window.ONEOS_MP_EMBED = window.ONEOS_MP_EMBED || {};
window.ONEOS_MP_EMBED.ApprovalCenterPanel = ApprovalCenterPanel;
}
export default Component;