Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/layout/AppShell.tsx

233 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button, Input, Nav, Space, Tag, Toast } from '@douyinfe/semi-ui';
import {
IconActivity,
IconBarChartHStroked,
IconHistogram,
IconHome,
IconMapPin,
IconSearch,
IconServer,
IconSetting,
IconBell,
IconPulse
} from '@douyinfe/semi-icons';
import type { ReactNode } from 'react';
import { useState } from 'react';
import type { VehicleSourceConsistency, VehicleServiceStatus } from '../api/types';
import { isAMapConfigured } from '../config/appConfig';
export type PageKey = 'dashboard' | 'vehicles' | 'map' | 'realtime' | 'detail' | 'history' | 'history-query' | 'mileage' | 'alert-events' | 'quality' | 'notification-rules' | 'ops-quality';
const navGroups = [
{
title: '车辆监控',
audience: '客户',
description: '实时看车在哪里、是否在线、是否异常',
items: [
{ itemKey: 'dashboard', text: '运营总览', icon: <IconHome /> },
{ itemKey: 'map', text: '实时地图', icon: <IconMapPin /> },
{ itemKey: 'realtime', text: '实时监控', icon: <IconActivity /> }
]
},
{
title: '车辆资产',
audience: '客户',
description: '按 VIN、车牌、手机号管理车辆资产',
items: [
{ itemKey: 'vehicles', text: '车辆中心', icon: <IconServer /> },
{ itemKey: 'detail', text: '车辆档案', icon: <IconSetting /> }
]
},
{
title: '轨迹与统计',
audience: '客户',
description: '按时间窗回放轨迹、核对里程、导出数据',
items: [
{ itemKey: 'history', text: '轨迹回放', icon: <IconMapPin /> },
{ itemKey: 'mileage', text: '里程统计', icon: <IconBarChartHStroked /> },
{ itemKey: 'history-query', text: '历史查询/导出', icon: <IconHistogram /> }
]
},
{
title: '告警通知',
audience: '客户',
description: '断链、离线、定位异常和通知升级',
items: [
{ itemKey: 'alert-events', text: '告警事件', icon: <IconHistogram /> },
{ itemKey: 'notification-rules', text: '通知规则', icon: <IconBell /> }
]
},
{
title: '内部运维',
audience: '内部',
description: '客户主流程外查看接入链路和运行质量',
items: [
{ itemKey: 'ops-quality', text: '运维质量', icon: <IconPulse /> }
]
}
];
function linkHealthClassName(count: number | null) {
if (count == null) {
return '';
}
return count > 0 ? 'vp-link-health-button-warning' : 'vp-link-health-button-ok';
}
function alertButtonLabel(linkIssueCount: number | null, activeRuleCount?: number | null, p0RuleCount?: number | null) {
const activeCount = Number.isFinite(Number(activeRuleCount)) ? Number(activeRuleCount) : 0;
const p0Count = Number.isFinite(Number(p0RuleCount)) ? Number(p0RuleCount) : 0;
if (p0Count > 0) {
return `告警事件 P0 ${p0Count.toLocaleString()} / 活跃 ${activeCount.toLocaleString()}`;
}
if (activeCount > 0) {
return `告警事件 ${activeCount.toLocaleString()}类规则`;
}
if (linkIssueCount == null) {
return '告警事件';
}
return linkIssueCount > 0 ? `告警事件 ${linkIssueCount}项关注` : '告警事件正常';
}
function alertButtonClassName(linkIssueCount: number | null, activeRuleCount?: number | null, p0RuleCount?: number | null) {
const activeCount = Number.isFinite(Number(activeRuleCount)) ? Number(activeRuleCount) : 0;
const p0Count = Number.isFinite(Number(p0RuleCount)) ? Number(p0RuleCount) : 0;
if (p0Count > 0 || activeCount > 0 || (linkIssueCount ?? 0) > 0) {
return 'vp-link-health-button-warning';
}
return linkHealthClassName(linkIssueCount);
}
export function AppShell({
activePage,
linkIssueCount,
activeAlertRuleCount,
p0AlertRuleCount,
platformRelease,
currentVehicleStatus,
currentVehicleLabel,
currentVehicleConsistency,
onChange,
onVehicleSearch,
children
}: {
activePage: PageKey;
linkIssueCount: number | null;
activeAlertRuleCount?: number | null;
p0AlertRuleCount?: number | null;
platformRelease?: string;
currentVehicleStatus?: VehicleServiceStatus;
currentVehicleLabel?: string;
currentVehicleConsistency?: VehicleSourceConsistency;
onChange: (page: PageKey) => void;
onVehicleSearch: (keyword: string) => void | Promise<void>;
children: ReactNode;
}) {
const [keyword, setKeyword] = useState('');
const [searching, setSearching] = useState(false);
const amapConfigured = isAMapConfigured();
const alertLabel = alertButtonLabel(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount);
const alertClassName = alertButtonClassName(linkIssueCount, activeAlertRuleCount, p0AlertRuleCount);
const selectedPage = activePage === 'quality' ? 'alert-events' : activePage;
const search = () => {
const value = keyword.trim();
if (!value) {
Toast.warning('请输入 VIN / 车牌 / 手机号');
return;
}
setSearching(true);
Promise.resolve(onVehicleSearch(value)).finally(() => setSearching(false));
};
const quickActions = [
{ label: '地图', ariaLabel: '顶部地图态势', page: 'map' as const },
{ label: '监控', ariaLabel: '顶部实时监控', page: 'realtime' as const },
{ label: '轨迹', ariaLabel: '顶部轨迹回放', page: 'history' as const },
{ label: '里程', ariaLabel: '顶部里程统计', page: 'mileage' as const },
{ label: '导出', ariaLabel: '顶部数据导出', page: 'history-query' as const },
{ label: '告警', ariaLabel: '顶部告警事件', page: 'alert-events' as const }
];
return (
<div className="vp-app">
<div className="vp-shell">
<aside className="vp-sidebar">
<div className="vp-brand">
<span className="vp-brand-mark" />
<span className="vp-brand-copy">
<span></span>
<span> / / </span>
</span>
</div>
<div className="vp-nav-groups">
{navGroups.map((group) => (
<div key={group.title} className="vp-nav-group">
<div className="vp-nav-section-title">
<span className="vp-nav-section-title-main">
<span>{group.title}</span>
<Tag size="small" color={group.audience === '内部' ? 'grey' : 'blue'}>{group.audience}</Tag>
</span>
<small>{group.description}</small>
</div>
<Nav
selectedKeys={[selectedPage]}
items={group.items}
onSelect={({ itemKey }) => onChange(itemKey as PageKey)}
style={{ maxWidth: '100%' }}
/>
</div>
))}
</div>
</aside>
<main className="vp-main">
<header className="vp-topbar">
<Space spacing={12} className="vp-topbar-search">
<Input
prefix={<IconSearch />}
placeholder="搜索 VIN / 车牌 / 手机号"
value={keyword}
onChange={setKeyword}
onEnterPress={search}
style={{ width: 320 }}
/>
<Button theme="solid" type="primary" loading={searching} onClick={search}></Button>
</Space>
<div className="vp-topbar-context">
{currentVehicleLabel ? <Tag color="grey">{currentVehicleLabel}</Tag> : null}
{currentVehicleStatus ? (
<Tag color={currentVehicleStatus.severity === 'ok' ? 'green' : currentVehicleStatus.severity === 'error' ? 'red' : 'orange'}>
{currentVehicleStatus.title}
</Tag>
) : null}
{currentVehicleConsistency ? (
<Tag color={currentVehicleConsistency.severity === 'ok' ? 'green' : currentVehicleConsistency.severity === 'error' ? 'red' : 'orange'}>
{currentVehicleConsistency.title || `${currentVehicleConsistency.onlineSourceCount}/${currentVehicleConsistency.sourceCount} 来源`}
</Tag>
) : null}
<Tag color={amapConfigured ? 'green' : 'orange'}>{amapConfigured ? '高德地图就绪' : '高德地图待配置'}</Tag>
<div className="vp-topbar-quick-actions" aria-label="车辆服务快捷入口">
{quickActions.map((item) => (
<Button key={item.page} size="small" aria-label={item.ariaLabel} onClick={() => onChange(item.page)}>
{item.label}
</Button>
))}
</div>
<Button
size="small"
aria-label={`顶部${alertLabel}`}
className={alertClassName}
onClick={() => onChange('alert-events')}
>
{alertLabel}
</Button>
{platformRelease ? <span className="vp-topbar-release"> {platformRelease}</span> : null}
</div>
</header>
{children}
</main>
</div>
</div>
);
}