feat(platform): add mileage delivery navigation
This commit is contained in:
@@ -898,6 +898,44 @@ export function Mileage({
|
|||||||
onClick: () => exportMileage()
|
onClick: () => exportMileage()
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
const mileageDeliveryNavigation = [
|
||||||
|
{
|
||||||
|
title: '确认统计口径',
|
||||||
|
value: `${formatKm(summary.totalMileageKm)} km`,
|
||||||
|
detail: `范围 ${mileageRangeText},闭合状态 ${closureStatus},发布判断 ${publishAuditConclusion}。`,
|
||||||
|
action: '复制统计摘要',
|
||||||
|
color: publishAuditColor,
|
||||||
|
disabled: false,
|
||||||
|
onClick: () => copyStatisticsSummary()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '回放里程轨迹',
|
||||||
|
value: currentVehicleKeyword || '请选择车辆',
|
||||||
|
detail: '用同一车辆和时间窗回看位置、速度、停驶和里程断点。',
|
||||||
|
action: '轨迹复核',
|
||||||
|
color: currentVehicleKeyword ? 'blue' as const : 'grey' as const,
|
||||||
|
disabled: !currentVehicleKeyword || !onOpenHistory,
|
||||||
|
onClick: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '导出明细证据',
|
||||||
|
value: `${rows.length.toLocaleString()} 条当前页`,
|
||||||
|
detail: `覆盖 ${formatPercent(pageCoverageRate)},用于客户复盘、BI核对和运营日报附件。`,
|
||||||
|
action: '导出CSV',
|
||||||
|
color: rows.length > 0 ? 'blue' as const : 'grey' as const,
|
||||||
|
disabled: rows.length === 0,
|
||||||
|
onClick: () => exportMileage()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '发送交付结论',
|
||||||
|
value: mileageDeliveryState,
|
||||||
|
detail: mileageDeliveryState === '可交付' ? '复制交付包后可直接发给客户或BI使用。' : '交付前需要补齐闭合、异常、覆盖或在线影响说明。',
|
||||||
|
action: '复制交付包',
|
||||||
|
color: mileageDeliveryColor,
|
||||||
|
disabled: false,
|
||||||
|
onClick: () => copyMileageDeliveryPackage()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const loadSummary = (values: Record<string, string> = filters) => {
|
const loadSummary = (values: Record<string, string> = filters) => {
|
||||||
setSummaryLoading(true);
|
setSummaryLoading(true);
|
||||||
@@ -1331,6 +1369,38 @@ export function Mileage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card bordered title="客户里程交付导航" style={{ marginTop: 16 }}>
|
||||||
|
<div className="vp-mileage-delivery-nav">
|
||||||
|
<div className="vp-mileage-delivery-nav-summary">
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color={mileageDeliveryColor}>{mileageDeliveryState}</Tag>
|
||||||
|
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
|
||||||
|
<Tag color={closureStatusColor}>{closureStatus}</Tag>
|
||||||
|
</Space>
|
||||||
|
<Typography.Title heading={5} style={{ margin: 0 }}>把统计结果变成客户可交付的里程说明</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
把里程统计变成客户能看懂的交付路径:先确认口径,再回放轨迹,最后导出明细和结论。
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div className="vp-mileage-delivery-nav-grid">
|
||||||
|
{mileageDeliveryNavigation.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.title}
|
||||||
|
type="button"
|
||||||
|
className="vp-mileage-delivery-nav-item"
|
||||||
|
disabled={item.disabled}
|
||||||
|
onClick={item.onClick}
|
||||||
|
aria-label={`客户里程交付导航 ${item.title} ${item.action}`}
|
||||||
|
>
|
||||||
|
<Tag color={item.color}>{item.title}</Tag>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
<span>{item.detail}</span>
|
||||||
|
<em>{item.action}</em>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
<Card bordered title="客户里程问题" style={{ marginTop: 16 }}>
|
<Card bordered title="客户里程问题" style={{ marginTop: 16 }}>
|
||||||
<div className="vp-mileage-question-grid">
|
<div className="vp-mileage-question-grid">
|
||||||
{mileageCustomerQuestions.map((item) => (
|
{mileageCustomerQuestions.map((item) => (
|
||||||
|
|||||||
@@ -4449,6 +4449,73 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vp-mileage-delivery-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-mileage-delivery-nav-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-mileage-delivery-nav-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-mileage-delivery-nav-item {
|
||||||
|
min-height: 142px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(22, 100, 255, 0.16);
|
||||||
|
border-radius: var(--vp-radius);
|
||||||
|
background: #fbfcff;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-mileage-delivery-nav-item:hover,
|
||||||
|
.vp-mileage-delivery-nav-item:focus-visible {
|
||||||
|
border-color: rgba(22, 100, 255, 0.42);
|
||||||
|
background: #f5f9ff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-mileage-delivery-nav-item:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-mileage-delivery-nav-item strong {
|
||||||
|
color: var(--vp-text);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 23px;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-mileage-delivery-nav-item span {
|
||||||
|
color: var(--vp-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-mileage-delivery-nav-item em {
|
||||||
|
color: var(--vp-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.vp-mileage-delivery-board {
|
.vp-mileage-delivery-board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 0.64fr) minmax(0, 1.36fr);
|
grid-template-columns: minmax(280px, 0.64fr) minmax(0, 1.36fr);
|
||||||
@@ -7250,6 +7317,7 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
.vp-mileage-next-actions,
|
.vp-mileage-next-actions,
|
||||||
.vp-mileage-next-grid,
|
.vp-mileage-next-grid,
|
||||||
.vp-mileage-question-grid,
|
.vp-mileage-question-grid,
|
||||||
|
.vp-mileage-delivery-nav-grid,
|
||||||
.vp-mileage-decision-board,
|
.vp-mileage-decision-board,
|
||||||
.vp-mileage-decision-grid,
|
.vp-mileage-decision-grid,
|
||||||
.vp-stat-definition-grid,
|
.vp-stat-definition-grid,
|
||||||
|
|||||||
@@ -7695,7 +7695,7 @@ test('opens vehicle service from mileage row with row source evidence', async ()
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-MILEAGE-ROW')).toBeInTheDocument();
|
expect((await screen.findAllByText('VIN-MILEAGE-ROW')).length).toBeGreaterThan(0);
|
||||||
const serviceButtons = screen.getAllByRole('button', { name: '车辆服务' });
|
const serviceButtons = screen.getAllByRole('button', { name: '车辆服务' });
|
||||||
fireEvent.click(serviceButtons[serviceButtons.length - 1]);
|
fireEvent.click(serviceButtons[serviceButtons.length - 1]);
|
||||||
|
|
||||||
@@ -7764,7 +7764,7 @@ test('opens same-day trajectory playback from mileage row for evidence review',
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-MILEAGE-TRACK')).toBeInTheDocument();
|
expect((await screen.findAllByText('VIN-MILEAGE-TRACK')).length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '核对轨迹' }));
|
fireEvent.click(screen.getByRole('button', { name: '核对轨迹' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -7842,7 +7842,7 @@ test('opens same-day raw frame evidence from mileage row', async () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-MILEAGE-RAW')).toBeInTheDocument();
|
expect((await screen.findAllByText('VIN-MILEAGE-RAW')).length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '核对原始' }));
|
fireEvent.click(screen.getByRole('button', { name: '核对原始' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -7922,7 +7922,7 @@ test('shows mileage anomaly action guidance', async () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-MILEAGE-ANOMALY')).toBeInTheDocument();
|
expect((await screen.findAllByText('VIN-MILEAGE-ANOMALY')).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('核对里程来源')).toBeInTheDocument();
|
expect(screen.getByText('核对里程来源')).toBeInTheDocument();
|
||||||
expect(screen.getByText('日里程异常,优先核对当天首末总里程、来源断链和补传数据。')).toBeInTheDocument();
|
expect(screen.getByText('日里程异常,优先核对当天首末总里程、来源断链和补传数据。')).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: '复制证据' })[0]);
|
fireEvent.click(screen.getAllByRole('button', { name: '复制证据' })[0]);
|
||||||
@@ -8156,6 +8156,12 @@ test('shows vehicle-first statistics domains for one vehicle service', async ()
|
|||||||
expect(screen.getByRole('button', { name: '客户统计查询 发布判断 发布审计' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '客户统计查询 发布判断 发布审计' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '客户统计查询 复核证据 轨迹复核' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '客户统计查询 复核证据 轨迹复核' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: '客户统计查询 明细导出 导出明细' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: '客户统计查询 明细导出 导出明细' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('客户里程交付导航')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('把里程统计变成客户能看懂的交付路径:先确认口径,再回放轨迹,最后导出明细和结论。')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '客户里程交付导航 确认统计口径 复制统计摘要' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '客户里程交付导航 回放里程轨迹 轨迹复核' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '客户里程交付导航 导出明细证据 导出CSV' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '客户里程交付导航 发送交付结论 复制交付包' })).toBeInTheDocument();
|
||||||
expect(await screen.findByText('车辆统计服务总览')).toBeInTheDocument();
|
expect(await screen.findByText('车辆统计服务总览')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('里程统计').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('里程统计').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText('在线率与离线时长')).toBeInTheDocument();
|
expect(screen.getByText('在线率与离线时长')).toBeInTheDocument();
|
||||||
@@ -8407,7 +8413,7 @@ test('exports current mileage page as CSV', async () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('VIN-MILEAGE-EXPORT')).toBeInTheDocument();
|
expect((await screen.findAllByText('VIN-MILEAGE-EXPORT')).length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '导出里程当前页 CSV' }));
|
fireEvent.click(screen.getByRole('button', { name: '导出里程当前页 CSV' }));
|
||||||
|
|
||||||
expect(createObjectURL).toHaveBeenCalled();
|
expect(createObjectURL).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
- Azuga 的车队跟踪资料强调实时跟踪、车辆资产保护、维护告警和洞察报表。这对应本项目车辆中心的作业栏:最近上报、待关注车辆、身份维护和报表导出。参考:https://www.azuga.com/
|
- Azuga 的车队跟踪资料强调实时跟踪、车辆资产保护、维护告警和洞察报表。这对应本项目车辆中心的作业栏:最近上报、待关注车辆、身份维护和报表导出。参考:https://www.azuga.com/
|
||||||
- Verizon Connect 把 live map 与 historic replays 放在同一个 GPS tracking 叙事里,Geotab Trips History 支持查看车辆实时位置和历史行程,Samsara GPS tracking 也把 Trip history 作为分析车辆行程效率的核心入口。因此历史页要先呈现“车辆 + 时间窗 + 轨迹回放 + 证据导出”的客户复盘路径,而不是先暴露 RAW/字段表。参考:https://www.verizonconnect.com/solutions/gps-fleet-tracking-software/、https://support.geotab.com/help/mygeotab/fleet-activity/trips/trips-history、https://www.samsara.com/products/telematics/gps-fleet-tracking
|
- Verizon Connect 把 live map 与 historic replays 放在同一个 GPS tracking 叙事里,Geotab Trips History 支持查看车辆实时位置和历史行程,Samsara GPS tracking 也把 Trip history 作为分析车辆行程效率的核心入口。因此历史页要先呈现“车辆 + 时间窗 + 轨迹回放 + 证据导出”的客户复盘路径,而不是先暴露 RAW/字段表。参考:https://www.verizonconnect.com/solutions/gps-fleet-tracking-software/、https://support.geotab.com/help/mygeotab/fleet-activity/trips/trips-history、https://www.samsara.com/products/telematics/gps-fleet-tracking
|
||||||
- Samsara、Geotab、Verizon Connect 的公开页面都把客户问题翻译成任务入口:车辆在哪里、这一趟怎么跑、哪些风险要通知、报表如何导出。因此首页右侧需要问题式入口,先回答客户语言,再下钻到地图、轨迹、统计、历史证据和告警。
|
- Samsara、Geotab、Verizon Connect 的公开页面都把客户问题翻译成任务入口:车辆在哪里、这一趟怎么跑、哪些风险要通知、报表如何导出。因此首页右侧需要问题式入口,先回答客户语言,再下钻到地图、轨迹、统计、历史证据和告警。
|
||||||
|
- Geotab、Fleetistics、FleetRabbit 等资料都把 Trip History、Mileage、Reports、Excel/PDF 导出放在一起:客户不是只看一张里程表,而是需要“统计口径 + 轨迹复核 + 明细导出 + 可发送结论”的报告路径。
|
||||||
|
|
||||||
## 对标后的产品改造规则
|
## 对标后的产品改造规则
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
- 自定义时间窗必须成为跨页面共享的查询意图:轨迹、里程、历史数据、告警复盘使用同一组参数。
|
- 自定义时间窗必须成为跨页面共享的查询意图:轨迹、里程、历史数据、告警复盘使用同一组参数。
|
||||||
- 车辆中心要像资产系统一样把车辆清单变成作业入口:最近上报、待关注、身份维护、报表导出,而不是只提供表格筛选。
|
- 车辆中心要像资产系统一样把车辆清单变成作业入口:最近上报、待关注、身份维护、报表导出,而不是只提供表格筛选。
|
||||||
- 轨迹/历史页要像 Trips History:先锁定车辆和时间,随后回放路线、核对里程、导出证据;RAW 和字段裁剪只作为复盘依据。
|
- 轨迹/历史页要像 Trips History:先锁定车辆和时间,随后回放路线、核对里程、导出证据;RAW 和字段裁剪只作为复盘依据。
|
||||||
|
- 里程统计页要像客户报告工作台:先确认统计口径和闭合状态,再进入轨迹复核、历史明细、CSV 导出和交付结论。
|
||||||
- 内部可观测性继续保留,但默认折叠在运维/依据层,避免客户把系统理解为“协议接入平台”。
|
- 内部可观测性继续保留,但默认折叠在运维/依据层,避免客户把系统理解为“协议接入平台”。
|
||||||
|
|
||||||
## 本项目采用的产品原则
|
## 本项目采用的产品原则
|
||||||
|
|||||||
Reference in New Issue
Block a user