feat(platform): reshape telematics operations console
This commit is contained in:
@@ -16,13 +16,13 @@ import type { VehicleSourceConsistency, VehicleServiceStatus } from '../api/type
|
||||
export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality';
|
||||
|
||||
const navItems = [
|
||||
{ itemKey: 'dashboard', text: '总览工作台', icon: <IconHome /> },
|
||||
{ itemKey: 'vehicles', text: '车辆服务', icon: <IconServer /> },
|
||||
{ itemKey: 'realtime', text: '实时车辆', icon: <IconActivity /> },
|
||||
{ itemKey: 'detail', text: '车辆详情', icon: <IconSetting /> },
|
||||
{ itemKey: 'history', text: '历史数据', icon: <IconMapPin /> },
|
||||
{ itemKey: 'mileage', text: '里程统计', icon: <IconBarChartHStroked /> },
|
||||
{ itemKey: 'quality', text: '质量治理', icon: <IconHistogram /> }
|
||||
{ itemKey: 'dashboard', text: '运营总览', icon: <IconHome /> },
|
||||
{ itemKey: 'vehicles', text: '车辆中心', icon: <IconServer /> },
|
||||
{ itemKey: 'realtime', text: '实时监控', icon: <IconActivity /> },
|
||||
{ itemKey: 'detail', text: '车辆档案', icon: <IconSetting /> },
|
||||
{ itemKey: 'history', text: '轨迹回放', icon: <IconMapPin /> },
|
||||
{ itemKey: 'mileage', text: '统计分析', icon: <IconBarChartHStroked /> },
|
||||
{ itemKey: 'quality', text: '告警通知', icon: <IconHistogram /> }
|
||||
];
|
||||
|
||||
function linkHealthClassName(count: number | null) {
|
||||
|
||||
@@ -190,7 +190,7 @@ export function Quality({
|
||||
|
||||
return (
|
||||
<div className="vp-page">
|
||||
<PageHeader title="质量治理" description="围绕车辆服务排查断链、VIN 缺失、字段缺失和链路健康" />
|
||||
<PageHeader title="告警通知" description="围绕车辆服务沉淀断链、VIN 缺失、字段缺失和链路健康告警,并形成通知闭环" />
|
||||
<div className="vp-kpi-grid">
|
||||
{[
|
||||
{ label: '问题车辆', value: summary.issueVehicleCount.toLocaleString() },
|
||||
@@ -234,6 +234,20 @@ export function Quality({
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Card bordered title="告警通知闭环" style={{ marginTop: 16 }}>
|
||||
<div className="vp-alert-flow">
|
||||
{[
|
||||
{ label: '事件触发', detail: '断链、无来源、VIN 缺失、字段缺失和容量异常进入告警池。' },
|
||||
{ label: '分级通知', detail: '按错误、警告、关注分层推送给平台、运维和业务责任人。' },
|
||||
{ label: '处置回执', detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' }
|
||||
].map((item) => (
|
||||
<div key={item.label} className="vp-alert-flow-item">
|
||||
<Tag color="blue">{item.label}</Tag>
|
||||
<div>{item.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
{health?.capacityFindings?.length ? (
|
||||
<Card bordered title="容量检查发现" style={{ marginTop: 16 }}>
|
||||
<Space wrap>
|
||||
|
||||
@@ -32,6 +32,19 @@ function sourceEvidenceText(row: VehicleRealtimeRow) {
|
||||
return `${row.onlineSourceCount}/${row.sourceCount} 来源在线`;
|
||||
}
|
||||
|
||||
function isValidCoordinate(row: VehicleRealtimeRow) {
|
||||
return Number.isFinite(row.longitude) && Number.isFinite(row.latitude) && row.longitude !== 0 && row.latitude !== 0;
|
||||
}
|
||||
|
||||
function mapPointStyle(row: VehicleRealtimeRow, index: number) {
|
||||
if (!isValidCoordinate(row)) {
|
||||
return { left: `${18 + (index % 4) * 18}%`, top: `${28 + (index % 3) * 17}%` };
|
||||
}
|
||||
const left = Math.min(88, Math.max(10, ((row.longitude + 180) / 360) * 100));
|
||||
const top = Math.min(82, Math.max(12, ((90 - row.latitude) / 180) * 100));
|
||||
return { left: `${left}%`, top: `${top}%` };
|
||||
}
|
||||
|
||||
const onlineLabel: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线'
|
||||
@@ -57,6 +70,7 @@ export function Realtime({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 50, total: 0 });
|
||||
const amapConfigured = Boolean(String(import.meta.env.VITE_AMAP_WEB_JS_KEY ?? '').trim());
|
||||
|
||||
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||||
setLoading(true);
|
||||
@@ -90,10 +104,14 @@ export function Realtime({
|
||||
filters.online ? `在线:${onlineLabel[filters.online] ?? filters.online}` : '',
|
||||
filters.serviceStatus ? `服务状态:${serviceStatusLabel[filters.serviceStatus] ?? filters.serviceStatus}` : ''
|
||||
].filter(Boolean);
|
||||
const onlineCount = rows.filter((row) => row.online).length;
|
||||
const locatedCount = rows.filter(isValidCoordinate).length;
|
||||
const degradedCount = rows.filter((row) => row.onlineSourceCount < row.sourceCount).length;
|
||||
const primaryProtocols = new Set(rows.map((row) => row.primaryProtocol).filter(Boolean));
|
||||
|
||||
return (
|
||||
<div className="vp-page">
|
||||
<PageHeader title="车辆服务实时态" description="以车辆为主对象查看最新位置、在线来源和核心实时数据,协议只作为来源证据" />
|
||||
<PageHeader title="实时监控" description="以车辆为主对象查看最新位置、在线来源、核心实时数据和地图作业状态" />
|
||||
<Card bordered>
|
||||
<Form key={JSON.stringify(filters)} initValues={filters} layout="horizontal" onSubmit={(values) => {
|
||||
const nextFilters = values as Record<string, string>;
|
||||
@@ -132,6 +150,46 @@ export function Realtime({
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
<div className="vp-monitor-layout">
|
||||
<div className="vp-monitor-map">
|
||||
<div className="vp-monitor-map-header">
|
||||
<Space wrap>
|
||||
<Tag color={amapConfigured ? 'green' : 'orange'}>
|
||||
{amapConfigured ? '高德地图配置就绪' : '高德地图待配置'}
|
||||
</Tag>
|
||||
<Tag color="blue">{locatedCount.toLocaleString()} 辆有定位</Tag>
|
||||
<Tag color="green">{onlineCount.toLocaleString()} 辆在线</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="vp-map vp-map-monitor">
|
||||
{rows.slice(0, 80).map((row, index) => (
|
||||
<span
|
||||
key={`${row.vin}-${row.primaryProtocol}-${index}`}
|
||||
className={`vp-map-dot ${row.online ? 'vp-map-dot-online' : 'vp-map-dot-offline'}`}
|
||||
title={`${row.plate || row.vin} ${row.primaryProtocol || ''}`}
|
||||
style={mapPointStyle(row, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vp-monitor-side">
|
||||
{[
|
||||
{ label: '当前车辆', value: pagination.total.toLocaleString(), color: 'blue' as const },
|
||||
{ label: '在线车辆', value: onlineCount.toLocaleString(), color: 'green' as const },
|
||||
{ label: '定位有效', value: locatedCount.toLocaleString(), color: 'green' as const },
|
||||
{ label: '降级服务', value: degradedCount.toLocaleString(), color: degradedCount > 0 ? 'orange' as const : 'green' as const },
|
||||
{ label: '来源类型', value: primaryProtocols.size.toLocaleString(), color: 'blue' as const }
|
||||
].map((item) => (
|
||||
<div key={item.label} className="vp-monitor-metric">
|
||||
<Tag color={item.color}>{item.label}</Tag>
|
||||
<div className="vp-monitor-metric-value">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
<Typography.Text type="secondary">
|
||||
高德 Web JS Key 和安全密钥通过运行环境配置,前端只读取公开 Key;安全密钥后续走服务端代理,避免明文下发。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs type="line">
|
||||
<Tabs.TabPane tab="表格视图" itemKey="table">
|
||||
{rows.length === 0 && !loading ? (
|
||||
@@ -199,7 +257,7 @@ export function Realtime({
|
||||
key={row.vin}
|
||||
className="vp-map-dot"
|
||||
title={`${row.plate} ${row.primaryProtocol}`}
|
||||
style={{ left: `${18 + index * 24}%`, top: `${26 + (index % 3) * 18}%` }}
|
||||
style={mapPointStyle(row, index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -252,6 +252,32 @@ body {
|
||||
background-size: 36px 36px;
|
||||
}
|
||||
|
||||
.vp-monitor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vp-monitor-map {
|
||||
min-height: 430px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
overflow: hidden;
|
||||
background: var(--vp-surface);
|
||||
}
|
||||
|
||||
.vp-monitor-map-header {
|
||||
min-height: 50px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--vp-border);
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.vp-map-monitor {
|
||||
height: 380px;
|
||||
}
|
||||
|
||||
.vp-map-dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
@@ -261,6 +287,55 @@ body {
|
||||
box-shadow: 0 0 0 5px rgba(22, 100, 255, 0.16);
|
||||
}
|
||||
|
||||
.vp-map-dot-online {
|
||||
background: var(--vp-success);
|
||||
box-shadow: 0 0 0 5px rgba(18, 183, 106, 0.16);
|
||||
}
|
||||
|
||||
.vp-map-dot-offline {
|
||||
background: var(--vp-warning);
|
||||
box-shadow: 0 0 0 5px rgba(247, 144, 9, 0.16);
|
||||
}
|
||||
|
||||
.vp-monitor-side {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-monitor-metric {
|
||||
min-height: 72px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.vp-monitor-metric-value {
|
||||
margin-top: 8px;
|
||||
color: var(--vp-text);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.vp-alert-flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-alert-flow-item {
|
||||
min-height: 96px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-json {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
@@ -395,7 +470,9 @@ body {
|
||||
@media (max-width: 900px) {
|
||||
.vp-evidence-grid,
|
||||
.vp-action-grid,
|
||||
.vp-conclusion-grid {
|
||||
.vp-conclusion-grid,
|
||||
.vp-monitor-layout,
|
||||
.vp-alert-flow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2747,7 +2747,7 @@ test('shows vehicle context when opening detail from sidebar navigation', async
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getAllByText('车辆详情')[0]);
|
||||
fireEvent.click(screen.getAllByText('车辆档案')[0]);
|
||||
|
||||
expect(await screen.findByText('粤AGQ8398 / LB9A32A24R0LS1426')).toBeInTheDocument();
|
||||
expect(screen.getByText('当前车辆:车辆离线')).toBeInTheDocument();
|
||||
@@ -3807,7 +3807,9 @@ test('frames realtime page as one vehicle service with source evidence', async (
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '车辆服务实时态' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: '实时监控' })).toBeInTheDocument();
|
||||
expect(screen.getByText('高德地图待配置')).toBeInTheDocument();
|
||||
expect(screen.getByText('定位有效')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆核心数据')).toBeInTheDocument();
|
||||
expect(screen.getByText('来源证据')).toBeInTheDocument();
|
||||
expect(screen.getByText('GB32960 在线')).toBeInTheDocument();
|
||||
@@ -3933,7 +3935,7 @@ test('updates realtime hash when source filters are submitted', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
await screen.findByRole('heading', { name: '车辆服务实时态' });
|
||||
await screen.findByRole('heading', { name: '实时监控' });
|
||||
fireEvent.change(screen.getByPlaceholderText('VIN / 车牌 / 手机号 / OEM'), { target: { value: '粤ART002' } });
|
||||
fireEvent.click(screen.getByText('全部来源'));
|
||||
fireEvent.click(await screen.findByText('JT808'));
|
||||
@@ -4613,7 +4615,9 @@ test('opens quality governance from vehicle detail overview', async () => {
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/quality?keyword=VIN001');
|
||||
});
|
||||
expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: '告警通知' })).toBeInTheDocument();
|
||||
expect(screen.getByText('告警通知闭环')).toBeInTheDocument();
|
||||
expect(screen.getByText('事件触发')).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?keyword=VIN001&limit=20&offset=0'), undefined);
|
||||
});
|
||||
|
||||
@@ -4638,7 +4642,7 @@ test('quality health storage card stays pending before ops health loads', async
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: '告警通知' })).toBeInTheDocument();
|
||||
expect(screen.getByText('存储读取')).toBeInTheDocument();
|
||||
expect(screen.getByText('检测中')).toBeInTheDocument();
|
||||
expect(screen.queryByText('异常')).not.toBeInTheDocument();
|
||||
@@ -4664,7 +4668,7 @@ test('quality health shows active connection capacity metric', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: '告警通知' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('活跃连接')).toBeInTheDocument();
|
||||
expect(screen.getByText('120,000')).toBeInTheDocument();
|
||||
});
|
||||
@@ -4689,7 +4693,7 @@ test('quality health shows structured capacity findings', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: '告警通知' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('容量检查发现')).toBeInTheDocument();
|
||||
expect(screen.getByText('kafka lag 42')).toBeInTheDocument();
|
||||
});
|
||||
@@ -5615,7 +5619,7 @@ test('shows canonical vehicle archive on detail', async () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('车辆档案')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText('车辆档案')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('车辆主键')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('VIN-ARCHIVE-001').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('档案完整度')).toBeInTheDocument();
|
||||
|
||||
9
vehicle-data-platform/apps/web/src/vite-env.d.ts
vendored
Normal file
9
vehicle-data-platform/apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_AMAP_WEB_JS_KEY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user