355 lines
14 KiB
TypeScript
355 lines
14 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' | '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>
|
||
);
|
||
}
|