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

504 lines
22 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' | 'time-monitor' | 'alert-events' | 'quality' | 'notification-rules' | 'ops-quality';
export type CustomerView = {
id: string;
label: string;
keyword: string;
protocol?: string;
dateFrom?: string;
dateTo?: string;
};
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: 'time-monitor', text: '时间窗监控', icon: <IconPulse /> },
{ 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 customerNavGroups = navGroups.filter((group) => group.audience !== '内部');
const internalNavGroups = navGroups.filter((group) => group.audience === '内部');
const customerPrimaryPath = [
{ label: '车辆地图', page: 'map' as const },
{ label: '轨迹回放', page: 'history' as const },
{ label: '里程统计', page: 'mileage' as const },
{ label: '时间窗监控', page: 'time-monitor' 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: 'time-monitor' 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,
customerViews,
onCustomerTimeWindowChange,
onSaveCustomerView,
onOpenCustomerView,
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>;
customerViews?: CustomerView[];
onCustomerTimeWindowChange?: (filters: Record<string, string>) => void;
onSaveCustomerView?: () => void;
onOpenCustomerView?: (view: CustomerView) => 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 [internalEvidenceExpanded, setInternalEvidenceExpanded] = 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 savedViews = customerViews ?? [];
const serviceObjectText = currentVehicleLabel ? `车辆:${currentVehicleLabel}` : '未选择车辆';
const serviceTimeText = dateFrom || dateTo ? `${dateFrom || '未指定'}${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: 'time-monitor' as const },
{ label: '导出', ariaLabel: '顶部数据导出', page: 'history-query' as const },
{ label: '告警', ariaLabel: '顶部告警事件', page: 'alert-events' as const }
];
const deliveryActions = quickActions.filter((item) => item.page !== 'realtime');
return (
<div className="vp-app">
<div className="vp-shell" data-testid="vehicle-platform-shell" data-mobile-layout="content-first">
<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">
{customerNavGroups.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 className="vp-internal-evidence-zone" aria-label="内部证据区">
<div className="vp-internal-evidence-zone-head">
<div>
<strong></strong>
<span></span>
</div>
<button
type="button"
aria-label={internalEvidenceExpanded ? '收起内部证据区' : '展开内部证据区'}
onClick={() => setInternalEvidenceExpanded((value) => !value)}
>
{internalEvidenceExpanded ? '收起' : '展开'}
</button>
</div>
<div className={internalEvidenceExpanded ? 'vp-internal-evidence-zone-body vp-internal-evidence-zone-body-open' : 'vp-internal-evidence-zone-body'}>
{internalNavGroups.map((group) => (
<div key={group.title} className="vp-nav-group vp-nav-group-internal">
<div className="vp-nav-section-title">
<span className="vp-nav-section-title-main">
<span>{group.title}</span>
<Tag size="small" color="grey">{group.audience}</Tag>
</span>
<small>{group.description}</small>
</div>
<div className="vp-internal-evidence-actions">
{group.items.map((item) => (
<button
key={item.itemKey}
type="button"
className={selectedPage === item.itemKey ? 'vp-internal-evidence-action vp-internal-evidence-action-active' : 'vp-internal-evidence-action'}
aria-label={`内部证据区 ${item.text}`}
onClick={() => onChange(item.itemKey as PageKey)}
>
<span>{item.icon}</span>
<strong>{item.text}</strong>
</button>
))}
</div>
</div>
))}
</div>
</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: 280 }}
/>
<Button theme="solid" type="primary" loading={searching} onClick={search}></Button>
</Space>
<nav className="vp-mobile-service-dock" aria-label="移动端车辆服务快捷入口">
{deliveryActions.map((item) => (
<button
key={item.page}
type="button"
aria-label={`移动端车辆服务快捷入口 ${item.label}`}
onClick={() => (onCustomerTask ?? onChange)(item.page)}
>
{item.label}
</button>
))}
</nav>
<div className="vp-topbar-context">
<div className="vp-service-delivery-strip" aria-label="车辆服务交付条">
<div className="vp-service-delivery-strip-head">
<strong></strong>
<span></span>
</div>
<div className="vp-service-delivery-strip-fact">
<span></span>
<strong>{serviceObjectText}</strong>
</div>
<div className="vp-service-delivery-strip-fact">
<span></span>
<strong>{serviceTimeText}</strong>
</div>
<div className="vp-service-delivery-strip-actions">
<span></span>
<div>
{deliveryActions.map((item) => (
<button
key={item.page}
type="button"
aria-label={`车辆服务交付条 ${item.label}`}
onClick={() => (onCustomerTask ?? onChange)(item.page)}
>
{item.label}
</button>
))}
</div>
</div>
</div>
<div className="vp-topbar-time-window" aria-label="客户时间窗">
<div className="vp-topbar-time-window-copy">
<strong></strong>
<span></span>
</div>
<label htmlFor="vp-customer-time-window-start">
<span></span>
<input
id="vp-customer-time-window-start"
aria-label="客户时间窗开始日期"
type="text"
inputMode="numeric"
pattern="\d{4}-\d{2}-\d{2}"
placeholder="YYYY-MM-DD"
maxLength={10}
value={dateFrom}
onChange={(event) => onCustomerTimeWindowChange?.({ dateFrom: event.currentTarget.value, dateTo })}
/>
</label>
<label className="vp-sr-only" htmlFor="vp-customer-time-window-start"></label>
<label htmlFor="vp-customer-time-window-end">
<span></span>
<input
id="vp-customer-time-window-end"
aria-label="客户时间窗结束日期"
type="text"
inputMode="numeric"
pattern="\d{4}-\d{2}-\d{2}"
placeholder="YYYY-MM-DD"
maxLength={10}
value={dateTo}
onChange={(event) => onCustomerTimeWindowChange?.({ dateFrom, dateTo: event.currentTarget.value })}
/>
</label>
<label className="vp-sr-only" htmlFor="vp-customer-time-window-end"></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="顶部保存客户视图"
onClick={onSaveCustomerView}
>
</Button>
{savedViews.length > 0 ? (
<div className="vp-topbar-saved-views" aria-label="客户视图">
{savedViews.slice(0, 3).map((view) => {
const rangeText = view.dateFrom || view.dateTo ? `${view.dateFrom || '未指定'}${view.dateTo || '未指定'}` : '实时范围';
return (
<button
key={view.id}
type="button"
aria-label={`客户视图 ${view.label} ${rangeText}`}
onClick={() => onOpenCustomerView?.(view)}
>
{view.label}
</button>
);
})}
</div>
) : null}
<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>
);
}