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

225 lines
8.7 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: 'vehicles', text: '车辆中心', icon: <IconServer /> },
{ itemKey: 'map', text: '实时地图', icon: <IconMapPin /> },
{ itemKey: 'realtime', text: '实时监控', icon: <IconActivity /> },
{ 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 }
];
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>
);
}