feat(platform): add vehicle service task board

This commit is contained in:
lingniu
2026-07-05 01:55:54 +08:00
parent 7642f9c9eb
commit 2c881dae7c
3 changed files with 159 additions and 1 deletions

View File

@@ -445,6 +445,67 @@ export function Dashboard({
onClick: () => onOpenHistory()
}
];
const taskTimeMonitorSummary = (() => {
const vehicle = String(timeMonitorFilters.keyword ?? '').trim() || '全部车辆';
const source = String(timeMonitorFilters.protocol ?? '').trim() || '全部数据通道';
const dateFrom = String(timeMonitorFilters.dateFrom ?? '').trim();
const dateTo = String(timeMonitorFilters.dateTo ?? '').trim();
const range = dateFrom || dateTo ? `${dateFrom || '-'}${dateTo || '-'}` : '全部时间';
return `${vehicle} / ${source} / ${range}`;
})();
const customerTaskBoard = [
{
title: '先看全域在线',
value: `${formatCount(serviceSummary?.onlineVehicles ?? summary?.onlineVehicles)} 在线`,
detail: `地图有效定位 ${commandLocatedCount.toLocaleString()} 辆,优先确认车辆是否仍在上报。`,
color: 'green' as const,
primaryAction: '打开实时地图',
secondaryAction: '查看在线车辆',
onPrimary: () => onOpenMap({ online: 'online' }),
onSecondary: () => onOpenRealtime({ online: 'online' })
},
{
title: '再看重点车辆',
value: focusVehicle?.label ?? '暂无重点车辆',
detail: focusVehicle ? `${focusVehicle.reason}${focusVehicle.realtimeEvidence}` : '当前没有高优先级车辆,保持日常巡检。',
color: focusVehicle?.statusColor ?? 'green' as const,
primaryAction: '车辆服务',
secondaryAction: '复制处置卡',
onPrimary: () => focusVehicle?.lookupKey && onOpenVehicle(focusVehicle.lookupKey, focusVehicle.protocol),
onSecondary: () => copyFocusVehicleService(),
disabled: !focusVehicle?.lookupKey
},
{
title: '按时间窗复盘',
value: taskTimeMonitorSummary,
detail: '同一时间窗贯穿轨迹、里程、RAW 和告警,不需要跨页面重复输入。',
color: 'blue' as const,
primaryAction: '轨迹回放',
secondaryAction: '里程统计',
onPrimary: () => openTimeMonitorHistory(),
onSecondary: () => openTimeMonitorMileage()
},
{
title: '导出证据数据',
value: `${formatCount(summary?.frameToday)} 今日数据`,
detail: '面向 BI、客户复盘和异常定位按车辆、时间、字段裁剪导出。',
color: 'blue' as const,
primaryAction: '历史导出',
secondaryAction: '导出概览',
onPrimary: () => openTimeMonitorRaw(),
onSecondary: () => exportDashboardSnapshot()
},
{
title: '闭环告警通知',
value: `${formatCount(summary?.issueVehicles)} 告警车辆`,
detail: highPriorityIssue ? `${qualityIssueLabel(highPriorityIssue.issueType)} / ${priorityIssueVehicleLabel(highPriorityIssue)}` : '暂无高优先级告警。',
color: (summary?.issueVehicles ?? 0) > 0 ? 'orange' as const : 'green' as const,
primaryAction: '告警事件',
secondaryAction: '复制交接',
onPrimary: () => onOpenQuality(highPriorityIssue?.issueType ? { issueType: highPriorityIssue.issueType } : {}),
onSecondary: () => copyOperationsHandoff()
}
];
const capabilities = [
{
title: '实时地图',
@@ -1039,6 +1100,41 @@ export function Dashboard({
))}
</div>
</Card>
<Card bordered title="今日车辆服务任务板" style={{ marginBottom: 16 }}>
<div className="vp-customer-task-grid">
{customerTaskBoard.map((item) => (
<div key={item.title} className="vp-customer-task-item">
<div className="vp-customer-task-head">
<Tag color={item.color}>{item.title}</Tag>
<strong>{item.value}</strong>
</div>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
<Space wrap>
<Button
size="small"
theme="solid"
type="primary"
disabled={item.disabled}
aria-label={`今日任务 ${item.title} ${item.primaryAction}`}
onClick={item.onPrimary}
>
{item.primaryAction}
</Button>
<Button
size="small"
theme="light"
type="primary"
disabled={item.disabled && item.secondaryAction !== '复制处置卡'}
aria-label={`今日任务 ${item.title} ${item.secondaryAction}`}
onClick={item.onSecondary}
>
{item.secondaryAction}
</Button>
</Space>
</div>
))}
</div>
</Card>
<div className="vp-kpi-grid">
{kpis.map((item) => (
<Card key={item.label} bordered className="vp-kpi-card" bodyStyle={{ padding: 0 }}>

View File

@@ -410,6 +410,41 @@ body {
line-height: 26px;
}
.vp-customer-task-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.vp-customer-task-item {
min-height: 172px;
padding: 14px;
border: 1px solid var(--vp-border);
border-radius: 8px;
background: #fbfcff;
display: grid;
gap: 12px;
align-content: space-between;
}
.vp-customer-task-head {
min-width: 0;
display: grid;
gap: 8px;
}
.vp-customer-task-head strong {
color: var(--vp-text);
font-size: 18px;
line-height: 24px;
word-break: break-word;
}
.vp-customer-task-item .semi-typography {
font-size: 13px;
line-height: 20px;
}
.vp-time-monitor-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -3105,6 +3140,7 @@ button.vp-realtime-command-item:focus-visible {
.vp-map-ops-readiness,
.vp-map-ops-work,
.vp-time-monitor-grid,
.vp-customer-task-grid,
.vp-current-service-board,
.vp-current-service-grid,
.vp-vehicle-service-desk,

View File

@@ -791,6 +791,12 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
expect(screen.getByText('客户常用工作流')).toBeInTheDocument();
expect(screen.getByText('实时车辆地图')).toBeInTheDocument();
expect(screen.getByText('历史数据导出')).toBeInTheDocument();
expect(screen.getByText('今日车辆服务任务板')).toBeInTheDocument();
expect(screen.getByText('先看全域在线')).toBeInTheDocument();
expect(screen.getByText('再看重点车辆')).toBeInTheDocument();
expect(screen.getByText('按时间窗复盘')).toBeInTheDocument();
expect(screen.getByText('导出证据数据')).toBeInTheDocument();
expect(screen.getByText('闭环告警通知')).toBeInTheDocument();
expect(screen.getByText('车辆服务入口')).toBeInTheDocument();
expect(screen.getByText('自定义时间监控')).toBeInTheDocument();
expect(screen.getByText('应用时间窗')).toBeInTheDocument();
@@ -844,6 +850,23 @@ test('dashboard exposes vehicle data center capability matrix', async () => {
expect(window.location.hash).toBe('#/map?online=online');
cleanup();
await renderDashboard();
fireEvent.click(screen.getByRole('button', { name: '今日任务 先看全域在线 打开实时地图' }));
expect(window.location.hash).toBe('#/map?online=online');
cleanup();
await renderDashboard();
fireEvent.click(screen.getByRole('button', { name: '今日任务 按时间窗复盘 里程统计' }));
expect(window.location.hash.startsWith('#/mileage')).toBe(true);
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('dateFrom')).toBeTruthy();
cleanup();
await renderDashboard();
fireEvent.click(screen.getByRole('button', { name: '今日任务 导出证据数据 历史导出' }));
expect(window.location.hash.startsWith('#/history-query')).toBe(true);
expect(new URLSearchParams(window.location.hash.split('?')[1] ?? '').get('includeFields')).toBe('true');
cleanup();
await renderDashboard();
fireEvent.click(screen.getByRole('button', { name: '客户工作流 历史数据导出' }));
expect(window.location.hash.startsWith('#/history-query')).toBe(true);
@@ -5528,7 +5551,10 @@ test('drills into quality issues by issue type', async () => {
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: /主要问题 暂无数据来源 1/ }));
const issueButtons = await screen.findAllByRole('button', { name: /主要问题 暂无数据来源 1/ });
const issueSummaryButton = issueButtons[issueButtons.length - 1];
expect(issueButtons.length).toBeGreaterThanOrEqual(1);
fireEvent.click(issueSummaryButton as HTMLElement);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?issueType=NO_SOURCE&limit=20&offset=0'), undefined);