504 lines
22 KiB
TypeScript
504 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|