feat(platform): add customer mileage review console

This commit is contained in:
lingniu
2026-07-05 01:24:56 +08:00
parent 11710ec6fa
commit 72a4761bdb
3 changed files with 310 additions and 7 deletions

View File

@@ -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() },

View File

@@ -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;

View File

@@ -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);