feat(platform): add customer mileage review console
This commit is contained in:
@@ -616,15 +616,70 @@ export function Mileage({
|
||||
filters.dateFrom?.trim() ? `开始日期:${filters.dateFrom.trim()}` : '',
|
||||
filters.dateTo?.trim() ? `结束日期:${filters.dateTo.trim()}` : ''
|
||||
].filter(Boolean);
|
||||
const mileageRangeText = filters.dateFrom?.trim() || filters.dateTo?.trim()
|
||||
? `${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`
|
||||
: '全部日期';
|
||||
const mileageCustomerKpis = [
|
||||
{
|
||||
label: '区间总里程',
|
||||
value: `${formatKm(summary.totalMileageKm)} km`,
|
||||
color: 'blue' as const,
|
||||
detail: `范围:${mileageRangeText}`
|
||||
},
|
||||
{
|
||||
label: '统计车辆',
|
||||
value: `${summary.vehicleCount.toLocaleString()} 辆`,
|
||||
color: summary.vehicleCount > 0 ? 'green' as const : 'grey' as const,
|
||||
detail: `${summary.recordCount.toLocaleString()} 条日里程记录`
|
||||
},
|
||||
{
|
||||
label: '异常记录',
|
||||
value: anomalyRows.length.toLocaleString(),
|
||||
color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const,
|
||||
detail: anomalyRows.length > 0 ? '建议先核对轨迹和原始记录' : '当前页未发现异常'
|
||||
},
|
||||
{
|
||||
label: '在线影响',
|
||||
value: formatPercent(onlineRatePercent),
|
||||
color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const,
|
||||
detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`
|
||||
}
|
||||
];
|
||||
const mileageCustomerActions = [
|
||||
{
|
||||
label: '导出当前明细',
|
||||
disabled: rows.length === 0,
|
||||
action: () => exportMileage()
|
||||
},
|
||||
{
|
||||
label: '查看轨迹',
|
||||
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||||
action: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||||
},
|
||||
{
|
||||
label: '查看原始记录',
|
||||
disabled: !currentVehicleKeyword || !onOpenRaw,
|
||||
action: () => onOpenRaw?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' })
|
||||
},
|
||||
{
|
||||
label: '车辆档案',
|
||||
disabled: !currentVehicleKeyword,
|
||||
action: () => onOpenVehicle(currentVehicleKeyword, currentProtocol)
|
||||
}
|
||||
];
|
||||
|
||||
const loadSummary = (values: Record<string, string> = filters) => {
|
||||
setSummaryLoading(true);
|
||||
api.mileageSummary(mileageParams(values))
|
||||
.then(setSummary)
|
||||
.then((nextSummary) => setSummary(nextSummary ?? emptySummary))
|
||||
.catch((error: Error) => Toast.error(error.message))
|
||||
.finally(() => setSummaryLoading(false));
|
||||
api.onlineStatisticsSummary(mileageParams(values))
|
||||
.then(setOnlineSummary)
|
||||
.then((nextSummary) => setOnlineSummary({
|
||||
...emptyOnlineSummary,
|
||||
...(nextSummary ?? {}),
|
||||
protocolStats: Array.isArray(nextSummary?.protocolStats) ? nextSummary.protocolStats : []
|
||||
}))
|
||||
.catch((error: Error) => Toast.error(error.message));
|
||||
};
|
||||
|
||||
@@ -922,6 +977,96 @@ export function Mileage({
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
<Card bordered loading={summaryLoading || loading} title="区间里程核对台" style={{ marginTop: 16 }}>
|
||||
<div className="vp-mileage-console">
|
||||
<div className="vp-mileage-console-main">
|
||||
<div className="vp-mileage-console-head">
|
||||
<div>
|
||||
<Typography.Title heading={5} style={{ margin: 0 }}>车辆里程核对</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
面向车辆运营查看区间里程、异常记录、在线影响和复核动作。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||||
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="vp-mileage-kpi-grid">
|
||||
{mileageCustomerKpis.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="vp-mileage-kpi-item"
|
||||
onClick={() => {
|
||||
if (item.label === '异常记录' && anomalyRows[0]) copyMileageEvidence(anomalyRows[0]);
|
||||
}}
|
||||
>
|
||||
<Tag color={item.color}>{item.label}</Tag>
|
||||
<strong>{item.value}</strong>
|
||||
<span>{item.detail}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="vp-mileage-mini-charts">
|
||||
<div>
|
||||
<div className="vp-mileage-mini-title">日里程趋势</div>
|
||||
<div className="vp-mileage-mini-chart">
|
||||
{dateSeries.length > 0 ? dateSeries.slice(0, 10).map((item) => (
|
||||
<div key={item.date} className="vp-mileage-mini-row">
|
||||
<span>{item.date}</span>
|
||||
<div><i style={{ width: `${maxDateMileage > 0 ? Math.max(4, (item.value / maxDateMileage) * 100) : 0}%` }} /></div>
|
||||
<strong>{formatKm(item.value)} km</strong>
|
||||
</div>
|
||||
)) : <Typography.Text type="tertiary">暂无日里程趋势。</Typography.Text>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="vp-mileage-mini-title">来源贡献</div>
|
||||
<div className="vp-mileage-mini-chart">
|
||||
{sourceSeries.length > 0 ? sourceSeries.slice(0, 5).map((item) => (
|
||||
<div key={item.source} className="vp-mileage-mini-row">
|
||||
<span>{item.source}</span>
|
||||
<div><i className="vp-mileage-mini-row-green" style={{ width: `${maxSourceMileage > 0 ? Math.max(4, (item.value / maxSourceMileage) * 100) : 0}%` }} /></div>
|
||||
<strong>{formatKm(item.value)} km</strong>
|
||||
</div>
|
||||
)) : <Typography.Text type="tertiary">暂无来源贡献。</Typography.Text>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="vp-mileage-console-side">
|
||||
<div className="vp-mileage-side-card">
|
||||
<div className="vp-mileage-side-title">核对动作</div>
|
||||
{mileageCustomerActions.map((item) => (
|
||||
<Button key={item.label} disabled={item.disabled} onClick={item.action}>{item.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="vp-mileage-side-card">
|
||||
<div className="vp-mileage-side-title">最高单日</div>
|
||||
{peakMileage ? (
|
||||
<>
|
||||
<Typography.Text strong>{peakMileage.plate || peakMileage.vin}</Typography.Text>
|
||||
<Typography.Text type="secondary">{peakMileage.date} / {peakMileage.source}</Typography.Text>
|
||||
<div className="vp-monitor-metric-value">{formatKm(peakMileage.dailyMileageKm)} km</div>
|
||||
<Space wrap>
|
||||
<Button size="small" onClick={() => openMileageEvidence(peakMileage)}>轨迹复核</Button>
|
||||
<Button size="small" onClick={() => copyMileageEvidence(peakMileage)}>复制证据</Button>
|
||||
</Space>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text type="tertiary">当前筛选下暂无最高单日记录。</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="vp-mileage-side-card">
|
||||
<div className="vp-mileage-side-title">统计口径</div>
|
||||
<Typography.Text type="secondary">
|
||||
区间里程以每日首末总里程差值汇总,目标是区间总值等于每日里程之和。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="vp-kpi-grid" style={{ marginTop: 16 }}>
|
||||
{[
|
||||
{ label: '车辆数', value: summary.vehicleCount.toLocaleString() },
|
||||
|
||||
@@ -1496,6 +1496,160 @@ button.vp-realtime-command-item:focus-visible {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.vp-mileage-console {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vp-mileage-console-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.vp-mileage-console-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-mileage-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-mileage-kpi-item {
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vp-mileage-kpi-item:hover,
|
||||
.vp-mileage-kpi-item:focus-visible {
|
||||
border-color: rgba(22, 100, 255, 0.42);
|
||||
background: #f5f9ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vp-mileage-kpi-item strong {
|
||||
color: var(--vp-text);
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
font-weight: 700;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.vp-mileage-kpi-item span {
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.vp-mileage-mini-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-mileage-mini-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--vp-text);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vp-mileage-mini-chart {
|
||||
min-height: 170px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-mileage-mini-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px minmax(0, 1fr) 86px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.vp-mileage-mini-row span,
|
||||
.vp-mileage-mini-row strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vp-mileage-mini-row strong {
|
||||
color: var(--vp-text);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vp-mileage-mini-row div {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(22, 100, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vp-mileage-mini-row i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--vp-primary);
|
||||
}
|
||||
|
||||
.vp-mileage-mini-row-green {
|
||||
background: var(--vp-success) !important;
|
||||
}
|
||||
|
||||
.vp-mileage-console-side {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-mileage-side-card {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-mileage-side-card .semi-button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.vp-mileage-side-title {
|
||||
color: var(--vp-text);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vp-online-ops-board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.72fr) minmax(0, 1.28fr);
|
||||
@@ -2810,12 +2964,16 @@ button.vp-realtime-command-item:focus-visible {
|
||||
.vp-source-fusion-grid,
|
||||
.vp-source-fusion-facts,
|
||||
.vp-vehicle-map-layout,
|
||||
.vp-mileage-console,
|
||||
.vp-mileage-kpi-grid,
|
||||
.vp-mileage-mini-charts,
|
||||
.vp-playback-layout,
|
||||
.vp-playback-timeline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vp-map-commandbar,
|
||||
.vp-mileage-console-head,
|
||||
.vp-map-canvas-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -7571,7 +7571,7 @@ test('shows mileage anomaly action guidance', async () => {
|
||||
expect(await screen.findByText('VIN-MILEAGE-ANOMALY')).toBeInTheDocument();
|
||||
expect(screen.getByText('核对里程来源')).toBeInTheDocument();
|
||||
expect(screen.getByText('日里程异常,优先核对当天首末总里程、来源断链和补传数据。')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制证据' }));
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '复制证据' })[0]);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【里程异常证据包】'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆:粤A异常1 / VIN-MILEAGE-ANOMALY'));
|
||||
@@ -7642,7 +7642,7 @@ test('shows mileage statistics workspace with trend source and definition', asyn
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('区间趋势')).toBeInTheDocument();
|
||||
expect(screen.getByText('来源贡献')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('来源贡献').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('2026-07-01').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('2026-07-02').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('JT808').length).toBeGreaterThanOrEqual(1);
|
||||
@@ -7668,7 +7668,7 @@ test('shows mileage statistics workspace with trend source and definition', asyn
|
||||
expect(screen.getAllByText('异常记录').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('最大单日')).toBeInTheDocument();
|
||||
expect(screen.getByText('60 km')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('60 km').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('统计可信度')).toBeInTheDocument();
|
||||
expect(screen.getByText('异常率')).toBeInTheDocument();
|
||||
expect(screen.getByText('25%')).toBeInTheDocument();
|
||||
@@ -7678,7 +7678,7 @@ test('shows mileage statistics workspace with trend source and definition', asyn
|
||||
expect(screen.getAllByText('100%').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('闭合校验')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('0 km').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('统计口径')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('统计口径').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText((content) => content.includes('区间总值应等于各日里程之和'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -7820,7 +7820,7 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
|
||||
expect(screen.getByText('当前页离线')).toBeInTheDocument();
|
||||
expect(screen.getByText('最长离线')).toBeInTheDocument();
|
||||
expect(screen.getByText('统计证据')).toBeInTheDocument();
|
||||
expect(await screen.findByText('75%')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText('75%')).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText((content) => content.includes('Redis 在线 92 key')).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算')).length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
Reference in New Issue
Block a user