Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/layout/AppShell.tsx
2026-07-05 19:33:06 +08:00

355 lines
14 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 /> }
]
}
];
const customerPrimaryPath = [
{ label: '车辆地图', page: 'map' as const },
{ label: '轨迹回放', page: 'history' as const },
{ label: '统计查询', page: 'mileage' as const },
{ label: '数据导出', page: 'history-query' as const },
{ label: '告警通知', page: 'alert-events' as const }
];
const customerTaskPath = [
{ label: '地图看车', question: '车辆在哪里', page: 'map' as const },
{ label: '轨迹回放', question: '这段时间怎么跑', page: 'history' as const },
{ label: '统计查询', question: '里程怎么算', page: 'mileage' as const },
{ label: '数据导出', question: '证据怎么给客户', page: 'history-query' as const },
{ label: '告警通知', question: '异常谁处理', page: 'alert-events' as const }
];
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);
}
function customerStatusTitle(title?: string) {
if (!title) {
return '';
}
return title === '来源不完整' ? '数据通道不完整' : title;
}
function dateText(offsetDays = 0) {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offsetDays);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function AppShell({
activePage,
linkIssueCount,
activeAlertRuleCount,
p0AlertRuleCount,
platformRelease,
currentVehicleStatus,
currentVehicleLabel,
currentVehicleConsistency,
customerTimeWindow,
onCustomerTimeWindowChange,
onCopyCustomerScope,
onChange,
onCustomerTask,
onVehicleSearch,
children
}: {
activePage: PageKey;
linkIssueCount: number | null;
activeAlertRuleCount?: number | null;
p0AlertRuleCount?: number | null;
platformRelease?: string;
currentVehicleStatus?: VehicleServiceStatus;
currentVehicleLabel?: string;
currentVehicleConsistency?: VehicleSourceConsistency;
customerTimeWindow?: Record<string, string>;
onCustomerTimeWindowChange?: (filters: Record<string, string>) => void;
onCopyCustomerScope?: () => void;
onChange: (page: PageKey) => void;
onCustomerTask?: (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 dateFrom = customerTimeWindow?.dateFrom ?? '';
const dateTo = customerTimeWindow?.dateTo ?? '';
const applyTimeWindowPreset = (days: number) => {
onCustomerTimeWindowChange?.({
dateFrom: dateText(-(days - 1)),
dateTo: dateText()
});
};
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-customer-primary-path" aria-label="客户主线">
<div className="vp-customer-primary-path-head">
<strong>线</strong>
<span></span>
</div>
<div className="vp-customer-primary-path-actions">
{customerPrimaryPath.map((item) => (
<button
key={item.page}
type="button"
className={selectedPage === item.page ? 'vp-customer-primary-path-action vp-customer-primary-path-action-active' : 'vp-customer-primary-path-action'}
onClick={() => onChange(item.page)}
aria-label={`客户主线 ${item.label}`}
>
{item.label}
</button>
))}
</div>
<div className="vp-customer-task-dispatch" aria-label="客户任务分发台">
<div className="vp-customer-task-dispatch-head">
<strong></strong>
<span> VIN / / </span>
</div>
<div className="vp-customer-task-dispatch-actions">
{customerTaskPath.map((item) => (
<button
key={item.page}
type="button"
className={selectedPage === item.page ? 'vp-customer-task-dispatch-action vp-customer-task-dispatch-action-active' : 'vp-customer-task-dispatch-action'}
onClick={() => (onCustomerTask ?? onChange)(item.page)}
aria-label={`客户任务 ${item.label} ${item.question}`}
>
<strong>{item.label}</strong>
<span>{item.question}</span>
</button>
))}
</div>
</div>
</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">
<div className="vp-topbar-time-window" aria-label="客户时间窗">
<label>
<span></span>
<input
aria-label="顶部时间窗开始"
type="date"
value={dateFrom}
onChange={(event) => onCustomerTimeWindowChange?.({ dateFrom: event.currentTarget.value, dateTo })}
/>
</label>
<label>
<span></span>
<input
aria-label="顶部时间窗结束"
type="date"
value={dateTo}
onChange={(event) => onCustomerTimeWindowChange?.({ dateFrom, dateTo: event.currentTarget.value })}
/>
</label>
<div className="vp-topbar-time-window-presets">
<button type="button" aria-label="顶部时间窗 今天" onClick={() => applyTimeWindowPreset(1)}></button>
<button type="button" aria-label="顶部时间窗 近7天" onClick={() => applyTimeWindowPreset(7)}>7</button>
<button type="button" aria-label="顶部时间窗 近30天" onClick={() => applyTimeWindowPreset(30)}>30</button>
<button type="button" aria-label="顶部时间窗 清空" onClick={() => onCustomerTimeWindowChange?.({})}></button>
</div>
</div>
{currentVehicleLabel ? <Tag color="grey">{currentVehicleLabel}</Tag> : null}
{currentVehicleStatus ? (
<Tag color={currentVehicleStatus.severity === 'ok' ? 'green' : currentVehicleStatus.severity === 'error' ? 'red' : 'orange'}>
{customerStatusTitle(currentVehicleStatus.title)}
</Tag>
) : null}
{currentVehicleConsistency ? (
<Tag color={currentVehicleConsistency.severity === 'ok' ? 'green' : currentVehicleConsistency.severity === 'error' ? 'red' : 'orange'}>
{customerStatusTitle(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={() => (onCustomerTask ?? onChange)(item.page)}>
{item.label}
</Button>
))}
</div>
<Button
size="small"
aria-label="顶部复制客户服务范围"
onClick={onCopyCustomerScope}
>
</Button>
<Button
size="small"
aria-label={`顶部${alertLabel}`}
className={alertClassName}
onClick={() => (onCustomerTask ?? onChange)('alert-events')}
>
{alertLabel}
</Button>
{platformRelease ? <span className="vp-topbar-release"> {platformRelease}</span> : null}
</div>
</header>
{children}
</main>
</div>
</div>
);
}