merge: 合并远程 main 审批中心迭代,保留本地加氢链路与导航注册表。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-18 16:46:32 +08:00
8 changed files with 4777 additions and 3319 deletions

View File

@@ -21,7 +21,7 @@ function statusClassName(status: ApprovalCardItem['status']): string {
export function ApprovalCard({ item, selected = false, onClick }: ApprovalCardProps) { export function ApprovalCard({ item, selected = false, onClick }: ApprovalCardProps) {
const typeColor = getApprovalTypeColor(item.type); const typeColor = getApprovalTypeColor(item.type);
const statusLabel = formatStatusLabel(item); const statusLabel = item.statusLabel ?? formatStatusLabel(item);
return ( return (
<article <article
@@ -75,11 +75,23 @@ export function ApprovalCard({ item, selected = false, onClick }: ApprovalCardPr
))} ))}
</div> </div>
) : null} ) : null}
{item.extraTags && item.extraTags.length > 0 ? (
<div className="ap-card__extra-tags">
{item.extraTags.map((tag) => (
<span key={`${item.id}-${tag}`} className="ap-card__extra-tag">
{tag}
</span>
))}
</div>
) : null}
</div> </div>
<div className="ap-card__footer"> <div className="ap-card__footer">
<div className="ap-card__footer-meta"> <div className="ap-card__footer-meta">
{item.showInitiatorInFooter ? ( {item.initiatorSuffixInFooter ? (
<span className="ap-card__footer-text">{item.initiatedBy}</span>
) : item.showInitiatorInFooter ? (
<span className="ap-card__footer-text"> {item.initiatedBy}</span> <span className="ap-card__footer-text"> {item.initiatedBy}</span>
) : null} ) : null}
<span className="ap-card__footer-text"> <span className="ap-card__footer-text">

View File

@@ -21,6 +21,7 @@ import {
getApprovalTypeLabel, getApprovalTypeLabel,
} from '../config/approvalTypeConfig'; } from '../config/approvalTypeConfig';
import { InsuranceComparisonDetail } from './InsuranceComparisonDetail'; import { InsuranceComparisonDetail } from './InsuranceComparisonDetail';
import { LeaseBillingDetail } from './LeaseBillingDetail';
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -56,6 +57,10 @@ export function ApprovalDetailPlaceholder({ item, onRefresh }: ApprovalDetailPla
return <InsuranceComparisonDetail item={item} onRefresh={onRefresh} />; return <InsuranceComparisonDetail item={item} onRefresh={onRefresh} />;
} }
if (item.type === 'billing') {
return <LeaseBillingDetail item={item} onRefresh={onRefresh} />;
}
const statusLabel = formatStatusLabel(item); const statusLabel = formatStatusLabel(item);
const typeLabel = getApprovalTypeLabel(item.type); const typeLabel = getApprovalTypeLabel(item.type);

View File

@@ -73,7 +73,7 @@ function buildSummaryRows(item: ApprovalCardItem, comparisonNo: string) {
{ label: '当前审批人', value: item.currentApprover ?? '-' }, { label: '当前审批人', value: item.currentApprover ?? '-' },
{ label: '保险公司数量', value: companyCount }, { label: '保险公司数量', value: companyCount },
{ label: '采购险种', value: insuranceType }, { label: '采购险种', value: insuranceType },
{ label: '中选保险公司', value: item.subtitle?.replace(/^中选:/, '') ?? '-' }, { label: '中选保险公司', value: item.subtitle ?? '-' },
]; ];
} }
@@ -96,7 +96,7 @@ function buildVehicleRows(item: ApprovalCardItem): VehicleRow[] {
} }
function buildQuoteRows(item: ApprovalCardItem): QuoteRow[] { function buildQuoteRows(item: ApprovalCardItem): QuoteRow[] {
const winner = item.subtitle?.replace(/^中选:/, '') ?? '国元农业保险上海分公司'; const winner = item.subtitle ?? '国元农业保险上海分公司';
const finalAmount = getFact(item, '最终报价').replace('¥', ''); const finalAmount = getFact(item, '最终报价').replace('¥', '');
return [ return [

View File

@@ -0,0 +1,290 @@
import React, { useState } from 'react';
import {
Avatar,
Button,
Empty,
Space,
Table,
Tabs,
Tag,
Timeline,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CheckCircleOutlined,
ClockCircleOutlined,
CopyOutlined,
ReloadOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ApprovalCardItem } from '../types';
import { formatStatusLabel, getApprovalTypeLabel } from '../config/approvalTypeConfig';
const { Text, Title } = Typography;
export interface LeaseBillingDetailProps {
item: ApprovalCardItem;
onRefresh?: () => void;
}
function getFact(item: ApprovalCardItem, label: string): string {
return item.keyFacts?.find((fact) => fact.label === label)?.value ?? '-';
}
function parseBillingCycle(text: string) {
const normalized = text.replace('·', ' ').trim();
const periodMatch = normalized.match(/第\d+期/);
const period = periodMatch?.[0] ?? '-';
const fullDateMatch = normalized.match(/(\d{4}-\d{2}-\d{2})至(\d{4}-\d{2}-\d{2})/);
if (fullDateMatch) {
return { period, startDate: fullDateMatch[1], endDate: fullDateMatch[2] };
}
const rangeMatch = normalized.match(/(\d{2}-\d{2})至(\d{2}-\d{2})/);
const startDate = rangeMatch ? `2026-${rangeMatch[1]}` : '-';
const endDate = rangeMatch ? `2026-${rangeMatch[2]}` : '-';
return { period, startDate, endDate };
}
function parseAmount(value: string): string {
return value.replace(/[¥,]/g, '').trim() || '0.00';
}
function buildBillNo(item: ApprovalCardItem): string {
const seq = item.id.replace(/\D/g, '').padStart(16, '0');
return `207${seq}994`;
}
interface BillDetailRow {
key: string;
index: number;
brand: string;
model: string;
plateNo: string;
rentReceivable: string;
rentReceived: string;
rentRemark: string;
waivedAmount: string;
depositReceivable: string;
}
function buildBillDetailRows(item: ApprovalCardItem): BillDetailRow[] {
const vehicleCount = Number(getFact(item, '计费车辆数量').match(/(\d+)/)?.[1] ?? 2);
const amount = parseAmount(getFact(item, '应收款总额'));
const receivedAmount = parseAmount(getFact(item, '实收款总额'));
const perVehicleReceivable = (Number(amount) / vehicleCount).toFixed(2);
const perVehicleReceived = (Number(receivedAmount) / vehicleCount).toFixed(2);
return Array.from({ length: vehicleCount }, (_, index) => ({
key: String(index + 1),
index: index + 1,
brand: '帕力安',
model: '4.5T 厢式货车',
plateNo: index === 0 ? '粤AGR9901' : '粤AGR9902',
rentReceivable: perVehicleReceivable,
rentReceived: perVehicleReceived,
rentRemark: '-',
waivedAmount: '0.00',
depositReceivable: '0.00',
}));
}
export function LeaseBillingDetail({ item, onRefresh }: LeaseBillingDetailProps) {
const [activeTab, setActiveTab] = useState('detail');
const billNo = buildBillNo(item);
const typeLabel = getApprovalTypeLabel(item.type);
const statusLabel = item.statusLabel ?? formatStatusLabel(item);
const cycle = parseBillingCycle(item.subtitle ?? '');
const receivableTotal = parseAmount(getFact(item, '应收款总额'));
const receivedTotal = parseAmount(getFact(item, '实收款总额'));
const invoicedTotal = parseAmount(getFact(item, '开票总额'));
const pendingInvoice = Math.max(Number(receivedTotal) - Number(invoicedTotal), 0).toFixed(2);
const billDetailRows = buildBillDetailRows(item);
const billInfoRows = [
{ label: '合同编码', value: 'AUTO_REG_20260703105722' },
{ label: '合同类型', value: '租赁合同' },
{ label: '项目名称', value: item.projectName ?? '-' },
{ label: '客户名称', value: item.title },
{ label: '交车任务编码', value: '-' },
{ label: '账单编码', value: `RS-ZD-${item.id.replace('ac-', '')}-${billNo.slice(-10)}` },
{ label: '账单期数', value: cycle.period },
{ label: '账单开始日期', value: cycle.startDate },
{ label: '账单结束日期', value: cycle.endDate },
];
const detailColumns: ColumnsType<BillDetailRow> = [
{ title: '序号', dataIndex: 'index', width: 56, fixed: 'left' },
{ title: '品牌', dataIndex: 'brand', width: 80 },
{ title: '型号', dataIndex: 'model', width: 120 },
{ title: '车牌号', dataIndex: 'plateNo', width: 100 },
{ title: '应收车辆月租金', dataIndex: 'rentReceivable', width: 120 },
{ title: '实收车辆月租金', dataIndex: 'rentReceived', width: 120 },
{ title: '车辆租金备注', dataIndex: 'rentRemark', width: 110 },
{ title: '减免金额', dataIndex: 'waivedAmount', width: 90 },
{ title: '应收车辆保证金', dataIndex: 'depositReceivable', width: 120 },
];
return (
<div className="ap-detail ap-detail--billing">
<div className="ap-detail__toolbar">
<Space size={8}>
<Text type="secondary"></Text>
<Text copyable={{ icon: <CopyOutlined />, text: billNo }}>{billNo}</Text>
</Space>
<Button type="text" icon={<ReloadOutlined />} onClick={onRefresh}>
</Button>
</div>
<div className="ap-detail__header">
<div className="ap-detail__header-main">
<Title level={4} className="ap-detail__title">
{typeLabel}
</Title>
<span className="ap-detail__badge ap-detail__badge--pending">{statusLabel}</span>
</div>
<div className="ap-detail__meta">
<Space size={16} wrap>
<Space size={6}>
<Avatar size={24} icon={<UserOutlined />} />
<Text>{item.initiatedBy}</Text>
</Space>
<Space size={6}>
<Text type="secondary"></Text>
<Text>{typeLabel}</Text>
</Space>
<Space size={6}>
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
<Text type="secondary"></Text>
<Text>{item.initiatedAt}</Text>
</Space>
</Space>
</div>
</div>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
className="ap-detail__tabs"
items={[
{
key: 'detail',
label: '审批详情',
children: (
<div className="ap-detail__content">
<Text strong className="ap-detail__section-title">
</Text>
<table className="ap-insurance-summary ap-billing-summary">
<tbody>
{Array.from({ length: Math.ceil(billInfoRows.length / 3) }).map((_, rowIndex) => {
const cells = billInfoRows.slice(rowIndex * 3, rowIndex * 3 + 3);
return (
<tr key={rowIndex}>
{cells.map((cell) => (
<React.Fragment key={cell.label}>
<th>{cell.label}</th>
<td>{cell.value}</td>
</React.Fragment>
))}
{cells.length < 3
? Array.from({ length: 3 - cells.length }).map((__, fillIndex) => (
<React.Fragment key={`fill-${fillIndex}`}>
<th />
<td />
</React.Fragment>
))
: null}
</tr>
);
})}
</tbody>
</table>
<div className="ap-billing-detail-section">
<Text strong className="ap-detail__section-title">
</Text>
<div className="ap-billing-totals">
<span>
<Text className="ap-billing-totals__value">{receivableTotal} </Text>
</span>
<span>
<Text className="ap-billing-totals__value">{receivedTotal} </Text>
</span>
<span>
<Text className="ap-billing-totals__value">{invoicedTotal} </Text>
</span>
<span className="ap-billing-totals__meta">
{pendingInvoice}
</span>
</div>
<Table
className="ap-insurance-table ap-billing-table"
columns={detailColumns}
dataSource={billDetailRows}
pagination={false}
size="small"
scroll={{ x: 1100 }}
/>
</div>
<div className="ap-billing-timeline-section">
<Text strong className="ap-detail__section-title">
</Text>
<Timeline
className="ap-detail__timeline"
items={[
{
color: 'gray',
children: (
<div className="ap-billing-timeline-item">
<div className="ap-billing-timeline-head">
<Text strong></Text>
<Tag></Tag>
</div>
</div>
),
},
{
color: 'green',
dot: <CheckCircleOutlined style={{ fontSize: 14 }} />,
children: (
<div className="ap-billing-timeline-item">
<div className="ap-billing-timeline-head">
<Text strong></Text>
<Tag color="success"></Tag>
</div>
<Text type="secondary">{item.initiatedBy}</Text>
<Text type="secondary" className="ap-detail__timeline-time">
{item.initiatedAt}
</Text>
</div>
),
},
]}
/>
</div>
</div>
),
},
{
key: 'flow',
label: '审批流程图',
children: (
<div className="ap-detail__flow-placeholder">
<Empty description="流程图占位,后续接入 BPM 流程引擎" />
</div>
),
},
]}
/>
<div className="ap-detail__actions">
<Button></Button>
</div>
</div>
);
}

View File

@@ -27,18 +27,55 @@ export const MOCK_APPROVAL_CASES: ApprovalCardItem[] = [
status: 'processing', status: 'processing',
listTab: 'initiated', listTab: 'initiated',
title: '沪A51676F', title: '沪A51676F',
subtitle: '中选:国元农业保险上海分公司', subtitle: '国元农业保险上海分公司',
keyFacts: [ keyFacts: [
{ label: '采购险种', value: '商业险' }, { label: '采购险种', value: '商业险' },
{ label: '最终报价', value: '¥1000.00' }, { label: '最终报价', value: '¥1000.00' },
{ label: '保险公司数量', value: '3家' }, { label: '保险公司数量', value: '3家' },
{ label: '最晚付费日', value: '2026-08-09' }, { label: '最晚付费日', value: '2026-08-09' },
], ],
currentApprover: '财务部-赵总', currentApprover: '赵总',
showInitiatorInFooter: true, showInitiatorInFooter: true,
initiatedBy: CURRENT_USER, initiatedBy: CURRENT_USER,
initiatedAt: '2026-06-10 14:00', initiatedAt: '2026-06-10 14:00',
}, },
{
id: 'ac-24',
type: 'return_settlement',
status: 'processing',
listTab: 'initiated',
title: '粤AGR8536',
subtitle: '环迪新能源(广州)有限公司 · 荣达餐饮试-租赁帕力安4.5T',
keyFacts: [
{ label: '应退还总额', value: '¥11,610.18', emphasis: true },
{ label: '保证金总额', value: '¥10,000.00' },
{ label: '待结算总额', value: '¥-1,610.18' },
{ label: '应补缴总额', value: '¥0' },
],
currentApprover: '赵总',
showInitiatorInFooter: true,
initiatedBy: CURRENT_USER,
initiatedAt: '2026-06-10 14:00',
},
{
id: 'ac-25',
type: 'billing',
status: 'processing',
listTab: 'initiated',
title: '嘉兴市振鑫物流有限公司',
subtitle: '第1期 · 2026-07-03至2026-07-31',
projectName: '浮云测试-0703-试用-1',
keyFacts: [
{ label: '计费车辆数量', value: '2辆' },
{ label: '应收款总额', value: '¥288.00', emphasis: true },
{ label: '实收款总额', value: '¥288.00' },
{ label: '开票总额', value: '¥244.00' },
],
currentApprover: '赵总',
showInitiatorInFooter: true,
initiatedBy: '王雨昊',
initiatedAt: '2026-07-31 16:40',
},
]; ];
export function filterCasesByTab( export function filterCasesByTab(

View File

@@ -272,6 +272,7 @@
font-weight: 500; font-weight: 500;
line-height: 1.45; line-height: 1.45;
word-break: break-word; word-break: break-word;
white-space: pre-line;
} }
.ap-card__fact-value--emphasis { .ap-card__fact-value--emphasis {
@@ -310,6 +311,25 @@
border-color: #fde68a; border-color: #fde68a;
} }
.ap-card__extra-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.ap-card__extra-tag {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: #f1f5f9;
color: var(--ap-muted);
border: 1px solid #e2e8f0;
}
.ap-card__footer { .ap-card__footer {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -571,6 +591,56 @@
background: #f1f5f9; background: #f1f5f9;
} }
.ap-billing-summary th {
width: 10%;
}
.ap-billing-summary td {
width: 23%;
}
.ap-billing-detail-section {
margin-top: 8px;
}
.ap-billing-totals {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 20px;
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 8px;
background: #f8fafc;
font-size: 13px;
color: var(--ap-muted);
}
.ap-billing-totals__value {
color: var(--ap-primary) !important;
font-weight: 600;
}
.ap-billing-totals__meta {
margin-left: auto;
font-size: 12px;
}
.ap-billing-timeline-section {
margin-top: 24px;
}
.ap-billing-timeline-item {
padding-bottom: 4px;
}
.ap-billing-timeline-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
@media (max-width: 960px) { @media (max-width: 960px) {
.ap-page { .ap-page {
height: auto; height: auto;

View File

@@ -50,8 +50,12 @@ export interface ApprovalCardItem {
initiatedBy: string; initiatedBy: string;
initiatedAt: string; initiatedAt: string;
typeLabel?: string; typeLabel?: string;
statusLabel?: string;
projectName?: string;
footerText?: string; footerText?: string;
showInitiatorInFooter?: boolean; showInitiatorInFooter?: boolean;
initiatorSuffixInFooter?: boolean;
extraTags?: string[];
ccUsers?: string[]; ccUsers?: string[];
handledBy?: string; handledBy?: string;
} }

File diff suppressed because it is too large Load Diff