feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBarChartVStroked,
|
||||
IconBox,
|
||||
IconCalendar,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconClose,
|
||||
IconDownload,
|
||||
IconFilter,
|
||||
IconHelpCircle,
|
||||
IconHome,
|
||||
IconList,
|
||||
IconMapPin,
|
||||
IconMore,
|
||||
IconPause,
|
||||
IconPlay,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconRoute,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
IconTickCircle,
|
||||
IconUser
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type PageKey = 'monitor' | 'tracks' | 'history' | 'statistics' | 'alerts' | 'access' | 'users';
|
||||
type Tone = 'blue' | 'green' | 'amber' | 'red' | 'neutral';
|
||||
|
||||
type NavItem = {
|
||||
key: PageKey | 'vehicles';
|
||||
label: string;
|
||||
icon: typeof IconHome;
|
||||
};
|
||||
|
||||
const workspaceNavigation: NavItem[] = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'history', label: '历史数据', icon: IconClock },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked }
|
||||
];
|
||||
|
||||
const governanceNavigation: NavItem[] = [
|
||||
{ key: 'alerts', label: '事件中心', icon: IconAlertCircle },
|
||||
{ key: 'access', label: '接入管理', icon: IconBox },
|
||||
{ key: 'users', label: '账号管理', icon: IconUser }
|
||||
];
|
||||
|
||||
const pageMeta: Record<PageKey, { title: string; subtitle: string }> = {
|
||||
monitor: { title: '全局监控', subtitle: '实时掌握授权车辆的位置、状态与数据活跃度。' },
|
||||
tracks: { title: '轨迹回放', subtitle: '按时间窗还原车辆行程、停留点与轨迹事件。' },
|
||||
history: { title: '历史数据', subtitle: '查询原始上报、协议字段和可追溯数据证据。' },
|
||||
statistics: { title: '里程查询', subtitle: '比较车辆每日里程、区间汇总与来源覆盖。' },
|
||||
alerts: { title: '事件中心', subtitle: '集中处置车辆告警、自动化规则与证据链。' },
|
||||
access: { title: '接入管理', subtitle: '管理协议来源、终端接入和数据链路健康度。' },
|
||||
users: { title: '账号管理', subtitle: '管理客户账号、菜单权限与车辆授权范围。' }
|
||||
};
|
||||
|
||||
const liveVehicles = [
|
||||
{ plate: '粤AG18312', vin: 'LB9A32A24R0LS1426', brand: 'G7s', speed: 62, status: '行驶中', time: '20:12:12', protocol: 'JT/T 808', tone: 'green' as Tone },
|
||||
{ plate: '川AHTW01', vin: 'LNXNEGRR7SR318212', brand: 'Hyundai', speed: 55, status: '行驶中', time: '20:12:12', protocol: 'GB/T 32960', tone: 'green' as Tone },
|
||||
{ plate: '豫A88888', vin: 'LMRKH9AC2R1004087', brand: '宇通', speed: 48, status: '行驶中', time: '20:11:11', protocol: '宇通 MQTT', tone: 'green' as Tone },
|
||||
{ plate: '粤AFF7936', vin: 'LB9A32A24P0LS1230', brand: '广安车联', speed: 0, status: '离线', time: '19:58:58', protocol: 'JT/T 808', tone: 'neutral' as Tone }
|
||||
];
|
||||
|
||||
const accountRows = [
|
||||
{ name: '羚牛示范车队', account: 'demo.team@lingniu.com', source: '平台本地', menus: 18, vehicles: 128, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '广州运营中心', account: 'gz.ops@lingniu.com', source: '平台本地', menus: 16, vehicles: 86, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '深圳车队', account: 'sz.fleet@lingniu.com', source: '平台本地', menus: 15, vehicles: 72, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '上海物流车队', account: 'sh.logistics@lingniu.com', source: 'LDAP', menus: 14, vehicles: 64, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '北京测试车队', account: 'bj.test@lingniu.com', source: '平台本地', menus: 8, vehicles: 22, state: '待完善', tone: 'amber' as Tone },
|
||||
{ name: '西安车队', account: 'xa.fleet@lingniu.com', source: '钉钉', menus: 10, vehicles: 18, state: '停用', tone: 'neutral' as Tone }
|
||||
];
|
||||
|
||||
const eventRows = [
|
||||
{ id: 'EVT-240703-031', plate: '粤AG18312', title: '急加速事件', level: '一般', time: '20:12:08', state: '待确认', tone: 'amber' as Tone },
|
||||
{ id: 'EVT-240703-030', plate: '豫A88888', title: '离线超过 10 分钟', level: '重要', time: '20:06:22', state: '处理中', tone: 'red' as Tone },
|
||||
{ id: 'EVT-240703-029', plate: '川AHTW01', title: '进入电子围栏', level: '提示', time: '19:58:41', state: '已归档', tone: 'blue' as Tone },
|
||||
{ id: 'EVT-240703-028', plate: '粤AFF7936', title: '终端电压偏低', level: '一般', time: '19:47:16', state: '已确认', tone: 'green' as Tone },
|
||||
{ id: 'EVT-240703-027', plate: '湘B56789', title: '超速持续 3 分钟', level: '重要', time: '19:38:02', state: '已通知', tone: 'red' as Tone }
|
||||
];
|
||||
|
||||
const sourceRows = [
|
||||
{ source: 'JT/T 808', endpoint: 'tcp://ingest-808.lingniu.local:6808', vehicles: 82, today: '1.86M', latency: '42 ms', state: '运行正常', tone: 'green' as Tone },
|
||||
{ source: 'GB/T 32960', endpoint: 'tcp://ingest-32960.lingniu.local:32960', vehicles: 38, today: '962K', latency: '58 ms', state: '运行正常', tone: 'green' as Tone },
|
||||
{ source: '宇通 MQTT', endpoint: 'mqtt://bus-gateway.lingniu.local', vehicles: 8, today: '118K', latency: '126 ms', state: '轻微延迟', tone: 'amber' as Tone },
|
||||
{ source: '补录 CSV', endpoint: '对象存储 / 手工导入', vehicles: 14, today: '3', latency: '—', state: '按需运行', tone: 'blue' as Tone }
|
||||
];
|
||||
|
||||
const historyRows = [
|
||||
{ time: '20:12:12.486', source: 'JT/T 808', event: '位置上报', speed: '62 km/h', mileage: '128,642.8 km', result: '已入库' },
|
||||
{ time: '20:12:02.212', source: 'GB/T 32960', event: '整车数据', speed: '61 km/h', mileage: '128,642.6 km', result: '已融合' },
|
||||
{ time: '20:11:42.936', source: 'JT/T 808', event: '位置上报', speed: '58 km/h', mileage: '128,642.1 km', result: '已入库' },
|
||||
{ time: '20:11:32.501', source: 'GB/T 32960', event: '驱动电机', speed: '57 km/h', mileage: '128,641.9 km', result: '已融合' },
|
||||
{ time: '20:11:12.184', source: 'JT/T 808', event: '位置上报', speed: '51 km/h', mileage: '128,641.4 km', result: '已入库' }
|
||||
];
|
||||
|
||||
const mileageRows = [
|
||||
{ plate: '粤AG18312', vin: 'LB9A32A24R0LS1426', days: [118, 142, 96, 165, 128, 152, 136], total: 937, source: 'JT/T 808' },
|
||||
{ plate: '川AHTW01', vin: 'LNXNEGRR7SR318212', days: [86, 103, 74, 112, 97, 126, 109], total: 707, source: 'GB/T 32960' },
|
||||
{ plate: '豫A88888', vin: 'LMRKH9AC2R1004087', days: [172, 184, 165, 198, 176, 208, 191], total: 1294, source: '宇通 MQTT' },
|
||||
{ plate: '粤AFF7936', vin: 'LB9A32A24P0LS1230', days: [34, 61, 0, 48, 72, 65, 0], total: 280, source: 'JT/T 808' }
|
||||
];
|
||||
|
||||
function pageFromHash(): PageKey {
|
||||
const value = window.location.hash.replace(/^#\/?/, '') as PageKey;
|
||||
return value in pageMeta ? value : 'monitor';
|
||||
}
|
||||
|
||||
function StatusPill({ children, tone = 'neutral' }: { children: ReactNode; tone?: Tone }) {
|
||||
return <span className={`platform-status is-${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function Sidebar({ page, onNavigate }: { page: PageKey; onNavigate: (page: PageKey | 'vehicles') => void }) {
|
||||
const renderGroup = (items: NavItem[]) => items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className={item.key === page ? 'is-active' : ''}
|
||||
type="button"
|
||||
key={item.key}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="lab-sidebar" aria-label="车辆数据中台导航">
|
||||
<div className="lab-brand"><img src="/brand-logo.svg" alt="羚牛智能" /></div>
|
||||
<nav>
|
||||
<p>车辆工作台</p>
|
||||
{renderGroup(workspaceNavigation)}
|
||||
<p>平台治理</p>
|
||||
{renderGroup(governanceNavigation)}
|
||||
</nav>
|
||||
<button className="lab-collapse" type="button"><IconChevronLeft aria-hidden="true" /><span>收起</span></button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ page, onOpenMenu }: { page: PageKey; onOpenMenu: () => void }) {
|
||||
const meta = pageMeta[page];
|
||||
return (
|
||||
<header className="lab-topbar">
|
||||
<div className="lab-topbar-title">
|
||||
<span><strong>{meta.title}</strong><small>{meta.subtitle}</small></span>
|
||||
</div>
|
||||
<div className="lab-topbar-actions">
|
||||
<button type="button" className="lab-help-button"><IconHelpCircle aria-hidden="true" /><span>页面帮助</span></button>
|
||||
<button type="button" className="platform-mobile-menu-trigger" onClick={onOpenMenu} aria-label="打开页面导航"><IconMore aria-hidden="true" /></button>
|
||||
<button type="button" className="lab-account-button" aria-label="账号菜单">
|
||||
<span className="lab-avatar">I</span><b>local-developer</b><em>管理员</em><IconChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricStrip({ items }: { items: Array<{ label: string; value: string | number; unit?: string; tone?: Tone; note?: string }> }) {
|
||||
return (
|
||||
<section className="platform-metrics" aria-label="页面摘要">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong className={`is-${item.tone ?? 'blue'}`}>{item.value}<small>{item.unit}</small></strong>
|
||||
{item.note ? <em>{item.note}</em> : null}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandRail({
|
||||
children,
|
||||
onSubmit,
|
||||
primaryLabel,
|
||||
onPrimary
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSubmit?: (event: FormEvent) => void;
|
||||
primaryLabel?: string;
|
||||
onPrimary?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<form className="platform-command-rail" onSubmit={onSubmit}>
|
||||
{children}
|
||||
{primaryLabel ? <button className="platform-primary-button" type={onSubmit ? 'submit' : 'button'} onClick={onPrimary}>{primaryLabel}<IconChevronRight aria-hidden="true" /></button> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchField({ value, onChange, placeholder, label }: { value: string; onChange: (value: string) => void; placeholder: string; label: string }) {
|
||||
return (
|
||||
<label className="platform-search-field">
|
||||
<IconSearch aria-hidden="true" />
|
||||
<input value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} aria-label={label} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function MapCanvas({
|
||||
selected,
|
||||
onSelect,
|
||||
route = false,
|
||||
children
|
||||
}: {
|
||||
selected: string;
|
||||
onSelect: (plate: string) => void;
|
||||
route?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const markers = [
|
||||
{ plate: '粤AG18312', x: 47, y: 46 },
|
||||
{ plate: '川AHTW01', x: 67, y: 34 },
|
||||
{ plate: '豫A88888', x: 72, y: 61 },
|
||||
{ plate: '粤AFF7936', x: 29, y: 66 },
|
||||
{ plate: '湘B56789', x: 55, y: 76 }
|
||||
];
|
||||
return (
|
||||
<div className="platform-map" aria-label="车辆地图">
|
||||
{route ? <div className="platform-route" aria-hidden="true"><i /><i /><i /><i /><i /></div> : null}
|
||||
{markers.map((marker) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`platform-map-marker${selected === marker.plate ? ' is-selected' : ''}`}
|
||||
style={{ left: `${marker.x}%`, top: `${marker.y}%` }}
|
||||
aria-label={`选择地图车辆 ${marker.plate}`}
|
||||
onClick={() => onSelect(marker.plate)}
|
||||
key={marker.plate}
|
||||
>
|
||||
<IconRoute aria-hidden="true" /><span>{marker.plate}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="platform-map-tools" aria-label="地图工具">
|
||||
<button type="button" aria-label="回到车辆范围"><IconMapPin aria-hidden="true" /></button>
|
||||
<button type="button" aria-label="刷新地图"><IconRefresh aria-hidden="true" /></button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState(liveVehicles[0].plate);
|
||||
const [view, setView] = useState<'map' | 'list'>('map');
|
||||
const selectedVehicle = liveVehicles.find((vehicle) => vehicle.plate === selected) ?? liveVehicles[0];
|
||||
const rows = liveVehicles.filter((vehicle) => `${vehicle.plate}${vehicle.vin}${vehicle.brand}`.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<main className="platform-page platform-monitor-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()}>
|
||||
<SearchField value={query} onChange={setQuery} placeholder="输入车牌 / VIN / 终端手机号" label="搜索实时车辆" />
|
||||
<label className="platform-select-field"><span>通信协议</span><select aria-label="通信协议"><option>全部协议</option><option>JT/T 808</option><option>GB/T 32960</option></select></label>
|
||||
<label className="platform-select-field"><span>车辆状态</span><select aria-label="车辆状态"><option>全部状态</option><option>在线</option><option>离线</option></select></label>
|
||||
<button type="button" className="platform-refresh"><IconRefresh aria-hidden="true" />刷新</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '车辆总数', value: 128, unit: '辆' },
|
||||
{ label: '当前在线', value: 96, unit: '辆', tone: 'green' },
|
||||
{ label: '行驶车辆', value: 34, unit: '辆' },
|
||||
{ label: '当前离线', value: 32, unit: '辆', tone: 'neutral' },
|
||||
{ label: '告警车辆', value: 3, unit: '辆', tone: 'red' }
|
||||
]} />
|
||||
<section className="platform-map-workspace">
|
||||
<aside className={`platform-live-list${view === 'list' ? ' is-mobile-visible' : ''}`} aria-label="实时车辆列表">
|
||||
<header>
|
||||
<span><strong>实时车辆</strong><small>{rows.length} 辆示例车辆</small></span>
|
||||
<div className="platform-view-toggle" role="group" aria-label="地图或列表视图">
|
||||
<button type="button" className={view === 'map' ? 'is-active' : ''} onClick={() => setView('map')} aria-label="地图视图"><IconMapPin aria-hidden="true" /></button>
|
||||
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')} aria-label="列表视图"><IconList aria-hidden="true" /></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="platform-live-list-body">
|
||||
{rows.map((vehicle) => (
|
||||
<button type="button" className={selected === vehicle.plate ? 'is-selected' : ''} onClick={() => { setSelected(vehicle.plate); setView('map'); }} key={vehicle.vin}>
|
||||
<i className={`is-${vehicle.tone}`} />
|
||||
<span><strong>{vehicle.plate}</strong><small>{vehicle.brand} · {vehicle.vin}</small><em>{vehicle.time}</em></span>
|
||||
<b>{vehicle.speed} km/h</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<footer><button type="button">查看全部车辆<IconChevronRight aria-hidden="true" /></button></footer>
|
||||
</aside>
|
||||
<MapCanvas selected={selected} onSelect={setSelected}>
|
||||
<div className="platform-mobile-map-tabs" role="group" aria-label="监控视图">
|
||||
<button type="button" className={view === 'map' ? 'is-active' : ''} onClick={() => setView('map')}>地图</button>
|
||||
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')}>列表</button>
|
||||
</div>
|
||||
</MapCanvas>
|
||||
<aside className="platform-map-inspector" aria-label={`${selectedVehicle.plate} 实时详情`}>
|
||||
<header><span><strong>{selectedVehicle.plate}</strong><StatusPill tone={selectedVehicle.tone}>{selectedVehicle.status}</StatusPill></span><small>{selectedVehicle.brand} · {selectedVehicle.vin}</small></header>
|
||||
<dl>
|
||||
<div><dt>实时速度</dt><dd className="is-emphasis">{selectedVehicle.speed} <small>km/h</small></dd></div>
|
||||
<div><dt>最后上报时间</dt><dd>07-03 {selectedVehicle.time}</dd></div>
|
||||
<div><dt>通信协议</dt><dd><StatusPill tone="blue">{selectedVehicle.protocol}</StatusPill></dd></div>
|
||||
<div><dt>定位方式</dt><dd>北斗 / GPS</dd></div>
|
||||
<div><dt>当前位置</dt><dd>广东省广州市天河区科韵路附近</dd></div>
|
||||
<div><dt>今日里程</dt><dd>128.6 km</dd></div>
|
||||
</dl>
|
||||
<footer><button type="button">车辆档案</button><button type="button" className="is-primary">轨迹回放<IconChevronRight aria-hidden="true" /></button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackPage() {
|
||||
const [selected, setSelected] = useState('粤AG18312');
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(36);
|
||||
const [railOpen, setRailOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const timer = window.setInterval(() => setProgress((value) => value >= 100 ? 0 : value + 1), 240);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [playing]);
|
||||
|
||||
return (
|
||||
<main className="platform-page platform-track-page">
|
||||
<section className="platform-track-stage">
|
||||
<aside className={`platform-track-rail${railOpen ? ' is-open' : ''}`}>
|
||||
<header><span><strong>轨迹查询</strong><small>最长支持连续 7 天</small></span><button type="button" onClick={() => setRailOpen(false)} aria-label="收起轨迹查询"><IconChevronLeft /></button></header>
|
||||
<SearchField value={selected} onChange={setSelected} placeholder="输入车牌 / VIN" label="搜索轨迹车辆" />
|
||||
<div className="platform-date-presets" role="group" aria-label="轨迹时间范围">
|
||||
<button className="is-active" type="button">今天</button><button type="button">昨天</button><button type="button">近 3 天</button>
|
||||
</div>
|
||||
<label className="platform-field-stack"><span>时间范围</span><div><IconCalendar /><input defaultValue="2026-07-27 00:00 — 23:59" aria-label="轨迹时间范围" /></div></label>
|
||||
<label className="platform-field-stack"><span>里程来源</span><select aria-label="里程来源"><option>自动选择最佳来源</option><option>JT/T 808 · GPS 里程</option><option>GB/T 32960 · 仪表盘里程</option></select></label>
|
||||
<button type="button" className="platform-query-button">生成轨迹</button>
|
||||
<MetricStrip items={[
|
||||
{ label: '行程里程', value: '128.6', unit: 'km' },
|
||||
{ label: '停留点', value: 4, unit: '个', tone: 'green' },
|
||||
{ label: '事件点', value: 3, unit: '个', tone: 'amber' }
|
||||
]} />
|
||||
<div className="platform-track-evidence">
|
||||
<header><strong>行程明细</strong><StatusPill tone="green">完整点集</StatusPill></header>
|
||||
{['08:42 科韵路出发', '10:16 琶洲停留 18 分钟', '14:08 急加速事件', '20:12 天河软件园'].map((item, index) => <button type="button" key={item}><i>{index + 1}</i><span>{item}</span><IconChevronRight /></button>)}
|
||||
</div>
|
||||
</aside>
|
||||
<MapCanvas route selected={selected} onSelect={setSelected}>
|
||||
{!railOpen ? <button type="button" className="platform-open-rail" onClick={() => setRailOpen(true)}><IconList />查询与明细</button> : null}
|
||||
<div className="platform-track-float-card">
|
||||
<span><StatusPill tone="green">查询完整</StatusPill><small>07-27 14:08:36</small></span>
|
||||
<strong>{selected}</strong>
|
||||
<p>当前 58 km/h · 累计里程 128,642.1 km</p>
|
||||
</div>
|
||||
</MapCanvas>
|
||||
<section className="platform-playback" aria-label="轨迹播放控制">
|
||||
<button type="button" onClick={() => setPlaying((value) => !value)} aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'}>{playing ? <IconPause /> : <IconPlay />}</button>
|
||||
<span><b>08:42</b><input type="range" min="0" max="100" value={progress} onChange={(event) => setProgress(Number(event.target.value))} aria-label="轨迹播放进度" /><b>20:12</b></span>
|
||||
<em>{progress}%</em>
|
||||
<select aria-label="回放倍速"><option>1×</option><option>2×</option><option>4×</option></select>
|
||||
<button type="button" aria-label="导出轨迹"><IconDownload /></button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryPage() {
|
||||
const [keyword, setKeyword] = useState('粤AG18312');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selected = historyRows[selectedIndex];
|
||||
return (
|
||||
<main className="platform-page platform-analytic-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()} primaryLabel="查询历史">
|
||||
<SearchField value={keyword} onChange={setKeyword} placeholder="车牌 / VIN" label="搜索历史数据车辆" />
|
||||
<label className="platform-select-field"><span>日期</span><input type="date" defaultValue="2026-07-27" aria-label="历史日期" /></label>
|
||||
<label className="platform-select-field"><span>协议来源</span><select aria-label="历史协议来源"><option>全部来源</option><option>JT/T 808</option><option>GB/T 32960</option></select></label>
|
||||
<label className="platform-select-field"><span>数据类型</span><select aria-label="历史数据类型"><option>全部类型</option><option>位置上报</option><option>整车数据</option></select></label>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '上报记录', value: '8,642', unit: '条' },
|
||||
{ label: '协议来源', value: 2, unit: '个', tone: 'green' },
|
||||
{ label: '有效覆盖', value: '99.8', unit: '%', tone: 'green' },
|
||||
{ label: '异常间隔', value: 3, unit: '处', tone: 'amber' }
|
||||
]} />
|
||||
<section className="platform-split-workspace">
|
||||
<div className="platform-table-panel">
|
||||
<header className="platform-panel-header"><span><strong>历史上报记录</strong><small>按设备时间倒序排列,可追溯原始协议证据</small></span><button type="button"><IconDownload />导出当前结果</button></header>
|
||||
<div className="platform-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>设备时间</th><th>来源</th><th>数据类型</th><th>速度</th><th>累计里程</th><th>处理结果</th></tr></thead>
|
||||
<tbody>{historyRows.map((row, index) => (
|
||||
<tr className={selectedIndex === index ? 'is-selected' : ''} onClick={() => setSelectedIndex(index)} tabIndex={0} key={`${row.time}-${row.source}`}>
|
||||
<td><strong>{row.time}</strong><small>2026-07-27</small></td><td><StatusPill tone="blue">{row.source}</StatusPill></td><td>{row.event}</td><td>{row.speed}</td><td>{row.mileage}</td><td><StatusPill tone="green">{row.result}</StatusPill></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer className="platform-pagination"><span>共 8,642 条 · 当前 1–5</span><div><button disabled type="button"><IconChevronLeft /></button><b>1</b><button type="button"><IconChevronRight /></button></div></footer>
|
||||
</div>
|
||||
<aside className="platform-evidence-panel" aria-label="原始数据证据">
|
||||
<header><span><small>原始数据证据</small><strong>{selected.time}</strong></span><button type="button" aria-label="刷新证据"><IconRefresh /></button></header>
|
||||
<dl>
|
||||
<div><dt>协议来源</dt><dd>{selected.source}</dd></div>
|
||||
<div><dt>消息类型</dt><dd>{selected.event}</dd></div>
|
||||
<div><dt>处理结果</dt><dd><StatusPill tone="green">{selected.result}</StatusPill></dd></div>
|
||||
<div><dt>数据质量</dt><dd>字段完整 · 时序正常</dd></div>
|
||||
</dl>
|
||||
<section className="platform-code-block" aria-label="协议字段">
|
||||
<header><strong>协议字段</strong><button type="button">复制 JSON</button></header>
|
||||
<pre>{`{\n "latitude": 23.12908,\n "longitude": 113.35512,\n "speedKmh": ${selected.speed.split(' ')[0]},\n "mileageKm": 128642.8,\n "alarm": 0\n}`}</pre>
|
||||
</section>
|
||||
<button type="button" className="platform-inspector-action">打开完整证据链<IconChevronRight /></button>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StatisticsPage() {
|
||||
const [mode, setMode] = useState<'daily' | 'vehicle'>('daily');
|
||||
const maxMileage = Math.max(...mileageRows.flatMap((row) => row.days));
|
||||
return (
|
||||
<main className="platform-page platform-analytic-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()} primaryLabel="计算里程">
|
||||
<SearchField value="" onChange={() => undefined} placeholder="车牌 / VIN,可留空查询授权范围" label="搜索里程车辆" />
|
||||
<label className="platform-select-field"><span>开始日期</span><input type="date" defaultValue="2026-07-21" aria-label="里程开始日期" /></label>
|
||||
<label className="platform-select-field"><span>结束日期</span><input type="date" defaultValue="2026-07-27" aria-label="里程结束日期" /></label>
|
||||
<label className="platform-select-field"><span>里程来源</span><select aria-label="里程来源"><option>自动优选</option><option>JT/T 808</option><option>GB/T 32960</option></select></label>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '车辆数量', value: 4, unit: '辆' },
|
||||
{ label: '区间总里程', value: '3,218', unit: 'km', tone: 'green' },
|
||||
{ label: '日均里程', value: '114.9', unit: 'km' },
|
||||
{ label: '完整覆盖', value: 3, unit: '辆', tone: 'green' },
|
||||
{ label: '缺失天数', value: 2, unit: '天', tone: 'amber' }
|
||||
]} />
|
||||
<section className="platform-mileage-workspace">
|
||||
<header className="platform-panel-header">
|
||||
<span><strong>每日与区间里程</strong><small>2026-07-21 — 2026-07-27 · 自动选择每辆车最佳来源</small></span>
|
||||
<div className="platform-segmented" role="tablist" aria-label="里程视图">
|
||||
<button type="button" role="tab" aria-selected={mode === 'daily'} className={mode === 'daily' ? 'is-active' : ''} onClick={() => setMode('daily')}>每日趋势</button>
|
||||
<button type="button" role="tab" aria-selected={mode === 'vehicle'} className={mode === 'vehicle' ? 'is-active' : ''} onClick={() => setMode('vehicle')}>车辆汇总</button>
|
||||
</div>
|
||||
<button type="button"><IconDownload />导出 Excel</button>
|
||||
</header>
|
||||
<div className={`platform-mileage-chart is-${mode}`} aria-label="里程图表">
|
||||
{mileageRows.map((row) => (
|
||||
<div className="platform-mileage-series" key={row.vin}>
|
||||
<header><span><strong>{row.plate}</strong><small>{row.source}</small></span><b>{row.total} km</b></header>
|
||||
<div>{row.days.map((value, index) => <span style={{ height: mode === 'daily' ? `${Math.max(8, value / maxMileage * 100)}%` : `${row.total / 13}%` }} title={`07-${21 + index}:${value} km`} key={`${row.vin}-${index}`}><i>{value || '—'}</i></span>)}</div>
|
||||
</div>
|
||||
))}
|
||||
<footer>{['07-21', '07-22', '07-23', '07-24', '07-25', '07-26', '07-27'].map((day) => <span key={day}>{day}</span>)}</footer>
|
||||
</div>
|
||||
<div className="platform-mileage-summary">
|
||||
{mileageRows.map((row) => <button type="button" key={row.vin}><span><strong>{row.plate}</strong><small>{row.vin}</small></span><StatusPill tone="blue">{row.source}</StatusPill><b>{row.total}<small> km</small></b><IconChevronRight /></button>)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsPage() {
|
||||
const [scope, setScope] = useState('全部事件');
|
||||
const [selectedId, setSelectedId] = useState(eventRows[0].id);
|
||||
const selected = eventRows.find((row) => row.id === selectedId) ?? eventRows[0];
|
||||
const rows = scope === '重要事件' ? eventRows.filter((row) => row.level === '重要') : eventRows;
|
||||
return (
|
||||
<main className="platform-page platform-governance-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()}>
|
||||
<SearchField value="" onChange={() => undefined} placeholder="事件编号 / 车牌 / VIN / 规则名称" label="搜索事件" />
|
||||
<div className="platform-tabs" role="tablist" aria-label="事件范围">{['全部事件', '重要事件', '待处理', '已归档'].map((item) => <button type="button" role="tab" aria-selected={scope === item} className={scope === item ? 'is-active' : ''} onClick={() => setScope(item)} key={item}>{item}</button>)}</div>
|
||||
<button type="button" className="platform-secondary-button"><IconSetting />自动化规则</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '今日事件', value: 38, unit: '条' },
|
||||
{ label: '待确认', value: 6, unit: '条', tone: 'amber' },
|
||||
{ label: '处理中', value: 2, unit: '条', tone: 'red' },
|
||||
{ label: '自动归档', value: 27, unit: '条', tone: 'green' }
|
||||
]} />
|
||||
<section className="platform-directory-workspace">
|
||||
<div className="platform-directory">
|
||||
<header className="platform-directory-columns"><span>事件 / 车辆</span><span>等级</span><span>发生时间</span><span>处置状态</span></header>
|
||||
{rows.map((event) => (
|
||||
<button type="button" className={selectedId === event.id ? 'is-selected' : ''} onClick={() => setSelectedId(event.id)} key={event.id}>
|
||||
<span><strong>{event.title}</strong><small>{event.plate} · {event.id}</small></span>
|
||||
<StatusPill tone={event.tone}>{event.level}</StatusPill><time>{event.time}</time><StatusPill tone={event.state === '待确认' ? 'amber' : event.state === '处理中' ? 'red' : 'green'}>{event.state}</StatusPill><IconChevronRight />
|
||||
</button>
|
||||
))}
|
||||
<footer className="platform-pagination"><span>共 {rows.length} 条示例事件</span><div><button disabled type="button"><IconChevronLeft /></button><b>1</b><button disabled type="button"><IconChevronRight /></button></div></footer>
|
||||
</div>
|
||||
<aside className="platform-governance-inspector" aria-label={`${selected.id} 事件详情`}>
|
||||
<header><span><small>{selected.id}</small><strong>{selected.title}</strong></span><StatusPill tone={selected.tone}>{selected.level}</StatusPill></header>
|
||||
<section className="platform-inspector-summary"><div><small>关联车辆</small><strong>{selected.plate}</strong></div><div><small>发生时间</small><strong>07-03 {selected.time}</strong></div></section>
|
||||
<dl><div><dt>当前状态</dt><dd>{selected.state}</dd></div><div><dt>触发规则</dt><dd>{selected.title} · 生产版本 v4</dd></div><div><dt>证据来源</dt><dd>JT/T 808 · 位置与速度</dd></div><div><dt>通知结果</dt><dd>运营负责人已送达</dd></div></dl>
|
||||
<section className="platform-timeline"><strong>处置记录</strong><p><i />20:12 系统识别并创建事件</p><p><i />20:13 自动通知运营负责人</p><p><i />等待人工确认</p></section>
|
||||
<footer><button type="button">标记误报</button><button type="button" className="is-primary">确认并归档</button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessPage() {
|
||||
const [selectedSource, setSelectedSource] = useState(sourceRows[0].source);
|
||||
const selected = sourceRows.find((row) => row.source === selectedSource) ?? sourceRows[0];
|
||||
return (
|
||||
<main className="platform-page platform-governance-page">
|
||||
<CommandRail>
|
||||
<SearchField value="" onChange={() => undefined} placeholder="协议、端点或终端标识" label="搜索接入来源" />
|
||||
<div className="platform-tabs" role="tablist" aria-label="接入范围"><button type="button" className="is-active" role="tab" aria-selected="true">全部来源</button><button type="button" role="tab">运行中</button><button type="button" role="tab">需关注</button></div>
|
||||
<button type="button" className="platform-primary-button"><IconPlus />新建接入</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '协议来源', value: 4, unit: '个' },
|
||||
{ label: '在线终端', value: 128, unit: '个', tone: 'green' },
|
||||
{ label: '今日上报', value: '2.94M', unit: '条' },
|
||||
{ label: '需关注来源', value: 1, unit: '个', tone: 'amber' }
|
||||
]} />
|
||||
<section className="platform-directory-workspace">
|
||||
<div className="platform-directory">
|
||||
<header className="platform-directory-columns platform-access-columns"><span>协议来源 / 接入端点</span><span>车辆</span><span>今日上报</span><span>延迟</span><span>状态</span></header>
|
||||
{sourceRows.map((source) => (
|
||||
<button type="button" className={`${selectedSource === source.source ? 'is-selected' : ''} platform-access-row`} onClick={() => setSelectedSource(source.source)} key={source.source}>
|
||||
<span><strong>{source.source}</strong><small>{source.endpoint}</small></span><b>{source.vehicles}</b><b>{source.today}</b><time>{source.latency}</time><StatusPill tone={source.tone}>{source.state}</StatusPill><IconChevronRight />
|
||||
</button>
|
||||
))}
|
||||
<section className="platform-health-band">
|
||||
<header><span><strong>数据链路健康</strong><small>最近 15 分钟 · 端到端处理快照</small></span><StatusPill tone="green">整体正常</StatusPill></header>
|
||||
<div>{['连接接收', '协议解析', '去重融合', '时序入库'].map((item, index) => <span key={item}><IconTickCircle /><b>{item}</b><small>{[99.99, 99.98, 99.97, 99.99][index]}%</small></span>)}</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside className="platform-governance-inspector" aria-label={`${selected.source} 接入详情`}>
|
||||
<header><span><small>接入来源</small><strong>{selected.source}</strong></span><StatusPill tone={selected.tone}>{selected.state}</StatusPill></header>
|
||||
<section className="platform-inspector-summary"><div><small>车辆覆盖</small><strong>{selected.vehicles} 辆</strong></div><div><small>当前延迟</small><strong>{selected.latency}</strong></div></section>
|
||||
<dl><div><dt>接入端点</dt><dd>{selected.endpoint}</dd></div><div><dt>鉴权方式</dt><dd>终端白名单 + 密钥</dd></div><div><dt>最后接收</dt><dd>07-03 20:12:12</dd></div><div><dt>今日异常帧</dt><dd>3 条 · 0.0002%</dd></div></dl>
|
||||
<section className="platform-timeline"><strong>最近活动</strong><p><i />20:12 接收 1,284 个实时帧</p><p><i />20:10 配置健康检查通过</p><p><i />18:30 密钥轮换完成</p></section>
|
||||
<footer><button type="button">查看原始帧</button><button type="button" className="is-primary">编辑接入</button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
const [scope, setScope] = useState('全部状态');
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedName, setSelectedName] = useState(accountRows[0].name);
|
||||
const [notice, setNotice] = useState('');
|
||||
const selected = accountRows.find((row) => row.name === selectedName) ?? accountRows[0];
|
||||
const rows = accountRows.filter((row) => {
|
||||
const matchesQuery = `${row.name}${row.account}${row.source}`.toLowerCase().includes(query.toLowerCase());
|
||||
const matchesScope = scope === '全部状态' || (scope === '启用账号' && row.state === '启用') || (scope === '停用账号' && row.state === '停用') || (scope === '待完善权限' && row.state === '待完善');
|
||||
return matchesQuery && matchesScope;
|
||||
});
|
||||
return (
|
||||
<main className="platform-page platform-governance-page">
|
||||
<CommandRail>
|
||||
<SearchField value={query} onChange={setQuery} placeholder="名称、账号、客户标识或身份源" label="搜索客户账号" />
|
||||
<div className="platform-tabs" role="tablist" aria-label="账号范围">{['全部状态', '启用账号', '停用账号', '待完善权限'].map((item) => <button type="button" role="tab" aria-selected={scope === item} className={scope === item ? 'is-active' : ''} onClick={() => setScope(item)} key={item}>{item}</button>)}</div>
|
||||
<button type="button" className="platform-primary-button" onClick={() => setNotice('已打开新建客户账号草稿')}><IconPlus />新建客户账号</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '当前账号', value: 18, unit: '个' },
|
||||
{ label: '待完善', value: 3, unit: '个', tone: 'amber', note: '待补全权限设置' },
|
||||
{ label: '权限就绪', value: 14, unit: '个', tone: 'green', note: '可正常使用' },
|
||||
{ label: '外部身份', value: 5, unit: '个', note: '通过外部身份源' }
|
||||
]} />
|
||||
<section className="platform-directory-workspace">
|
||||
<div className="platform-directory">
|
||||
<header className="platform-directory-columns platform-account-columns"><span>客户名称</span><span>登录账号</span><span>身份源</span><span>菜单</span><span>车辆</span><span>状态</span></header>
|
||||
{rows.map((account) => (
|
||||
<button type="button" className={`${selectedName === account.name ? 'is-selected' : ''} platform-account-row`} onClick={() => setSelectedName(account.name)} key={account.account}>
|
||||
<strong>{account.name}</strong><span>{account.account}</span><span>{account.source}</span><b>{account.menus}</b><b>{account.vehicles}</b><StatusPill tone={account.tone}>{account.state}</StatusPill><IconChevronRight />
|
||||
</button>
|
||||
))}
|
||||
<footer className="platform-pagination"><span>共 {rows.length} 个示例账号</span><div><button disabled type="button"><IconChevronLeft /></button><b>1</b><button type="button"><IconChevronRight /></button></div></footer>
|
||||
</div>
|
||||
<aside className="platform-governance-inspector platform-account-inspector" aria-label={`${selected.name} 账号详情`}>
|
||||
<header><span><small>客户账号</small><strong>{selected.name}</strong></span><StatusPill tone={selected.tone}>{selected.state}</StatusPill></header>
|
||||
<dl><div><dt>登录账号</dt><dd>{selected.account}</dd></div><div><dt>身份来源</dt><dd>{selected.source}</dd></div><div><dt>最近登录</dt><dd>2026-07-27 19:48</dd></div></dl>
|
||||
<section className="platform-permission-section"><header><strong>菜单权限</strong><button type="button">编辑</button></header><div><b>{selected.menus} 个菜单</b><small>车辆工作台与平台治理</small></div></section>
|
||||
<section className="platform-permission-section"><header><strong>车辆权限与有效期</strong><button type="button">编辑</button></header><div><b>{selected.vehicles} 辆授权车</b><small>持续有效 · 账号范围</small></div></section>
|
||||
<footer><button type="button">停用账号</button><button type="button" className="is-primary" onClick={() => setNotice(`${selected.name} 权限已保存`)}>保存权限</button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
{notice ? <div className="platform-notice" role="status">{notice}<button type="button" onClick={() => setNotice('')} aria-label="关闭提示"><IconClose /></button></div> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomNavigation({ page, onNavigate, onMore }: { page: PageKey; onNavigate: (page: PageKey | 'vehicles') => void; onMore: () => void }) {
|
||||
const items: Array<NavItem | { key: 'more'; label: string; icon: typeof IconMore }> = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked },
|
||||
{ key: 'more', label: '更多', icon: IconMore }
|
||||
];
|
||||
return (
|
||||
<nav className="lab-bottom-nav" aria-label="移动端主导航">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = item.key === page || (item.key === 'more' && ['history', 'alerts', 'access', 'users'].includes(page));
|
||||
return <button type="button" className={active ? 'is-active' : ''} onClick={() => item.key === 'more' ? onMore() : onNavigate(item.key)} key={item.key}><Icon /><span>{item.label}</span></button>;
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileMenu({ page, onNavigate, onClose }: { page: PageKey; onNavigate: (page: PageKey | 'vehicles') => void; onClose: () => void }) {
|
||||
return (
|
||||
<div className="lab-sheet-backdrop platform-mobile-menu-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<section className="platform-mobile-menu" role="dialog" aria-modal="true" aria-label="页面导航">
|
||||
<header><span><strong>切换页面</strong><small>车辆工作台与平台治理</small></span><button type="button" onClick={onClose} aria-label="关闭页面导航"><IconClose /></button></header>
|
||||
<div>{[...workspaceNavigation, ...governanceNavigation].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return <button type="button" className={item.key === page ? 'is-active' : ''} onClick={() => onNavigate(item.key)} key={item.key}><Icon /><span><strong>{item.label}</strong><small>{item.key === 'vehicles' ? '车辆目录与数字档案' : item.key in pageMeta ? pageMeta[item.key as PageKey].subtitle : ''}</small></span><IconChevronRight /></button>;
|
||||
})}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPage(page: PageKey) {
|
||||
switch (page) {
|
||||
case 'monitor': return <MonitorPage />;
|
||||
case 'tracks': return <TrackPage />;
|
||||
case 'history': return <HistoryPage />;
|
||||
case 'statistics': return <StatisticsPage />;
|
||||
case 'alerts': return <AlertsPage />;
|
||||
case 'access': return <AccessPage />;
|
||||
case 'users': return <UsersPage />;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlatformDesignLab() {
|
||||
const [page, setPage] = useState<PageKey>(() => pageFromHash());
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => setPage(pageFromHash());
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
const navigate = (target: PageKey | 'vehicles') => {
|
||||
setMobileMenuOpen(false);
|
||||
if (target === 'vehicles') {
|
||||
window.location.href = '/vehicle-design-lab.html';
|
||||
return;
|
||||
}
|
||||
window.location.hash = target;
|
||||
setPage(target);
|
||||
};
|
||||
|
||||
const content = useMemo(() => renderPage(page), [page]);
|
||||
|
||||
return (
|
||||
<div className="vehicle-design-lab platform-design-lab">
|
||||
<Sidebar page={page} onNavigate={navigate} />
|
||||
<div className="lab-stage">
|
||||
<TopBar page={page} onOpenMenu={() => setMobileMenuOpen(true)} />
|
||||
{content}
|
||||
<BottomNavigation page={page} onNavigate={navigate} onMore={() => setMobileMenuOpen(true)} />
|
||||
{mobileMenuOpen ? <MobileMenu page={page} onNavigate={navigate} onClose={() => setMobileMenuOpen(false)} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
import {
|
||||
IconBarChartVStroked,
|
||||
IconBox,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconClose,
|
||||
IconCopy,
|
||||
IconFilter,
|
||||
IconHelpCircle,
|
||||
IconHome,
|
||||
IconMore,
|
||||
IconRefresh,
|
||||
IconRoute,
|
||||
IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
type DirectoryView = 'all' | 'online' | 'offline' | 'multi';
|
||||
type VehicleStatus = 'online' | 'offline';
|
||||
type ProtocolKind = 'JT/T 808' | 'GB/T 32960' | '宇通 MQTT';
|
||||
|
||||
type VehicleRecord = {
|
||||
plate: string;
|
||||
vin: string;
|
||||
brand: string;
|
||||
terminal: string;
|
||||
status: VehicleStatus;
|
||||
updatedAt: string;
|
||||
protocols: ProtocolKind[];
|
||||
department: string;
|
||||
owner: string;
|
||||
customer: string;
|
||||
};
|
||||
|
||||
const vehicles: VehicleRecord[] = [
|
||||
{
|
||||
plate: '粤AG18312',
|
||||
vin: 'LB9A32A24R0LS1426',
|
||||
brand: 'G7s',
|
||||
terminal: '13307795425',
|
||||
status: 'online',
|
||||
updatedAt: '07-03 20:12',
|
||||
protocols: ['JT/T 808', 'GB/T 32960'],
|
||||
department: '华南运营部',
|
||||
owner: '陈思远',
|
||||
customer: '羚牛示范车队'
|
||||
},
|
||||
{
|
||||
plate: '川AHTW01',
|
||||
vin: 'LNXNEGRR7SR318212',
|
||||
brand: 'Hyundai',
|
||||
terminal: '暂无终端手机号',
|
||||
status: 'online',
|
||||
updatedAt: '07-03 20:12',
|
||||
protocols: ['GB/T 32960'],
|
||||
department: '西南交付部',
|
||||
owner: '王凌',
|
||||
customer: '川渝联合车队'
|
||||
},
|
||||
{
|
||||
plate: '豫A88888',
|
||||
vin: 'LMRKH9AC2R1004087',
|
||||
brand: '宇通',
|
||||
terminal: '暂无终端手机号',
|
||||
status: 'online',
|
||||
updatedAt: '07-03 20:11',
|
||||
protocols: ['宇通 MQTT'],
|
||||
department: '中原运营部',
|
||||
owner: '刘真',
|
||||
customer: '宇通测试车队'
|
||||
},
|
||||
{
|
||||
plate: '粤AFF7936',
|
||||
vin: 'LB9A32A24P0LS1230',
|
||||
brand: '广安车联',
|
||||
terminal: '13307795426',
|
||||
status: 'offline',
|
||||
updatedAt: '07-03 19:58',
|
||||
protocols: ['JT/T 808'],
|
||||
department: '华南运营部',
|
||||
owner: '陈思远',
|
||||
customer: '羚牛示范车队'
|
||||
}
|
||||
];
|
||||
|
||||
const navigation = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch, active: true },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'history', label: '历史数据', icon: IconClock },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked }
|
||||
];
|
||||
|
||||
const governanceNavigation = [
|
||||
{ key: 'alerts', label: '事件中心', icon: IconClock },
|
||||
{ key: 'access', label: '接入管理', icon: IconBox },
|
||||
{ key: 'users', label: '账号管理', icon: IconMore }
|
||||
];
|
||||
|
||||
const viewOptions: Array<{ key: DirectoryView; label: string }> = [
|
||||
{ key: 'all', label: '全部车辆' },
|
||||
{ key: 'online', label: '当前在线' },
|
||||
{ key: 'offline', label: '当前离线' },
|
||||
{ key: 'multi', label: '多源车辆' }
|
||||
];
|
||||
|
||||
function ProtocolTag({ protocol }: { protocol: ProtocolKind }) {
|
||||
const tone = protocol === 'JT/T 808' ? 'cyan' : protocol === 'GB/T 32960' ? 'blue' : 'violet';
|
||||
return <span className={`lab-protocol-tag is-${tone}`}>{protocol}</span>;
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: VehicleStatus }) {
|
||||
return <span className={`lab-status-tag is-${status}`}>{status === 'online' ? '在线' : '离线'}</span>;
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const navigate = (key: string) => {
|
||||
if (key !== 'vehicles') window.location.href = `/platform-design-lab.html#${key}`;
|
||||
};
|
||||
return (
|
||||
<aside className="lab-sidebar" aria-label="车辆数据中台导航">
|
||||
<div className="lab-brand">
|
||||
<img src="/brand-logo.svg" alt="羚牛智能" />
|
||||
</div>
|
||||
<nav>
|
||||
<p>车辆工作台</p>
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button className={item.active ? 'is-active' : ''} type="button" key={item.label} onClick={() => navigate(item.key)}>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<p>平台治理</p>
|
||||
{governanceNavigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button type="button" key={item.label} onClick={() => navigate(item.key)}>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button className="lab-collapse" type="button">
|
||||
<IconChevronLeft aria-hidden="true" />
|
||||
<span>收起</span>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ onHelp }: { onHelp: () => void }) {
|
||||
return (
|
||||
<header className="lab-topbar">
|
||||
<div className="lab-topbar-title">
|
||||
<img src="/brand-mark.svg" alt="" aria-hidden="true" />
|
||||
<span>
|
||||
<strong>车辆查询</strong>
|
||||
<small>按车牌或 VIN 查询车辆档案和实时遥测。</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="lab-topbar-actions">
|
||||
<button type="button" className="lab-help-button" onClick={onHelp}>
|
||||
<IconHelpCircle aria-hidden="true" />
|
||||
<span>页面帮助</span>
|
||||
</button>
|
||||
<button type="button" className="lab-account-button" aria-label="账号菜单">
|
||||
<span className="lab-avatar">I</span>
|
||||
<b>local-developer</b>
|
||||
<em>管理员</em>
|
||||
<IconChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
type CommandSurfaceProps = {
|
||||
keyword: string;
|
||||
view: DirectoryView;
|
||||
department: string;
|
||||
owner: string;
|
||||
customer: string;
|
||||
status: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onViewChange: (view: DirectoryView) => void;
|
||||
onDepartmentChange: (value: string) => void;
|
||||
onOwnerChange: (value: string) => void;
|
||||
onCustomerChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
};
|
||||
|
||||
function CommandSurface({
|
||||
keyword,
|
||||
view,
|
||||
department,
|
||||
owner,
|
||||
customer,
|
||||
status,
|
||||
onKeywordChange,
|
||||
onViewChange,
|
||||
onDepartmentChange,
|
||||
onOwnerChange,
|
||||
onCustomerChange,
|
||||
onStatusChange,
|
||||
onSubmit
|
||||
}: CommandSurfaceProps) {
|
||||
return (
|
||||
<section className="lab-command-surface" aria-label="车辆查询命令栏">
|
||||
<div className="lab-command-primary">
|
||||
<form className="lab-search-form" onSubmit={onSubmit}>
|
||||
<IconSearch aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
placeholder="输入车牌 / VIN / 终端手机号"
|
||||
aria-label="输入车牌、VIN 或终端手机号"
|
||||
/>
|
||||
<button type="submit">
|
||||
<span>查询车辆</span>
|
||||
<IconChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="lab-view-tabs" role="tablist" aria-label="车辆目录视图">
|
||||
{viewOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === option.key}
|
||||
className={view === option.key ? 'is-active' : ''}
|
||||
onClick={() => onViewChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="lab-batch-button" type="button">
|
||||
<IconBox aria-hidden="true" />
|
||||
<span>批量同步主档</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="lab-filter-row">
|
||||
<label>
|
||||
<span>部门</span>
|
||||
<select value={department} onChange={(event) => onDepartmentChange(event.target.value)}>
|
||||
<option value="">全部部门</option>
|
||||
<option>华南运营部</option>
|
||||
<option>西南交付部</option>
|
||||
<option>中原运营部</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>业务负责人</span>
|
||||
<select value={owner} onChange={(event) => onOwnerChange(event.target.value)}>
|
||||
<option value="">全部负责人</option>
|
||||
<option>陈思远</option>
|
||||
<option>王凌</option>
|
||||
<option>刘真</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>客户</span>
|
||||
<select value={customer} onChange={(event) => onCustomerChange(event.target.value)}>
|
||||
<option value="">全部客户</option>
|
||||
<option>羚牛示范车队</option>
|
||||
<option>川渝联合车队</option>
|
||||
<option>宇通测试车队</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select value={status} onChange={(event) => onStatusChange(event.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCommandSurface({
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
onSubmit,
|
||||
activeFilterCount,
|
||||
onOpenFilters,
|
||||
onBatch
|
||||
}: {
|
||||
keyword: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
activeFilterCount: number;
|
||||
onOpenFilters: () => void;
|
||||
onBatch: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<form className="lab-mobile-search" onSubmit={onSubmit}>
|
||||
<IconSearch aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
placeholder="车牌 / VIN / 终端手机号"
|
||||
aria-label="输入车牌、VIN 或终端手机号"
|
||||
/>
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
<section className="lab-mobile-scope" aria-label="车辆范围">
|
||||
<button className="lab-mobile-filter" type="button" onClick={onOpenFilters} aria-label="打开车辆筛选">
|
||||
<IconFilter aria-hidden="true" />
|
||||
{activeFilterCount > 0 ? <b>{activeFilterCount}</b> : null}
|
||||
</button>
|
||||
<span>
|
||||
<strong>车辆范围</strong>
|
||||
<small>4 辆授权车辆</small>
|
||||
</span>
|
||||
<button className="lab-mobile-modify" type="button" onClick={onOpenFilters}>
|
||||
修改
|
||||
<IconChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
<button className="lab-mobile-batch" type="button" onClick={onBatch}>
|
||||
<IconBox aria-hidden="true" />
|
||||
<span>批量同步</span>
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRail({ records }: { records: VehicleRecord[] }) {
|
||||
const online = records.filter((vehicle) => vehicle.status === 'online').length;
|
||||
const multi = records.filter((vehicle) => vehicle.protocols.length > 1).length;
|
||||
const metrics = [
|
||||
{ label: '授权车辆', value: records.length, tone: 'primary' },
|
||||
{ label: '本页在线', value: online, tone: 'success' },
|
||||
{ label: '本页多源', value: multi, tone: 'primary' }
|
||||
];
|
||||
return (
|
||||
<section className="lab-metric-rail" aria-label="车辆目录摘要">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong className={`is-${metric.tone}`}>{metric.value}<small>辆</small></strong>
|
||||
</div>
|
||||
))}
|
||||
<aside>
|
||||
<span>
|
||||
<strong>授权车辆目录</strong>
|
||||
<small>按照最新上报时间排序,分页浏览全部授权车辆</small>
|
||||
</span>
|
||||
<button type="button">第 1 / 1 页</button>
|
||||
<button type="button" className="lab-refresh-button" aria-label="刷新车辆目录">
|
||||
<IconRefresh aria-hidden="true" />
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopDirectory({
|
||||
records,
|
||||
selectedVin,
|
||||
onSelect
|
||||
}: {
|
||||
records: VehicleRecord[];
|
||||
selectedVin?: string;
|
||||
onSelect: (vehicle: VehicleRecord) => void;
|
||||
}) {
|
||||
const selectByKeyboard = (event: KeyboardEvent<HTMLTableRowElement>, vehicle: VehicleRecord) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onSelect(vehicle);
|
||||
};
|
||||
return (
|
||||
<table className="lab-vehicle-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>车辆</th>
|
||||
<th>品牌 / 终端</th>
|
||||
<th>实时状态</th>
|
||||
<th>协议来源</th>
|
||||
<th>最后上报时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((vehicle) => (
|
||||
<tr
|
||||
key={vehicle.vin}
|
||||
tabIndex={0}
|
||||
aria-selected={vehicle.vin === selectedVin}
|
||||
className={vehicle.vin === selectedVin ? 'is-selected' : ''}
|
||||
onClick={() => onSelect(vehicle)}
|
||||
onKeyDown={(event) => selectByKeyboard(event, vehicle)}
|
||||
>
|
||||
<td>
|
||||
<strong>{vehicle.plate}</strong>
|
||||
<small>{vehicle.vin}</small>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{vehicle.brand}</strong>
|
||||
<small>{vehicle.terminal}</small>
|
||||
</td>
|
||||
<td><StatusTag status={vehicle.status} /></td>
|
||||
<td>
|
||||
<span className="lab-protocol-list">
|
||||
{vehicle.protocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} />)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{vehicle.updatedAt}:12</td>
|
||||
<td>
|
||||
<button type="button" onClick={(event) => { event.stopPropagation(); onSelect(vehicle); }}>
|
||||
查看档案
|
||||
<IconChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDirectory({
|
||||
records,
|
||||
selectedVin,
|
||||
onSelect
|
||||
}: {
|
||||
records: VehicleRecord[];
|
||||
selectedVin?: string;
|
||||
onSelect: (vehicle: VehicleRecord) => void;
|
||||
}) {
|
||||
return (
|
||||
<ul className="lab-mobile-directory" aria-label="车辆目录">
|
||||
{records.map((vehicle) => (
|
||||
<li key={vehicle.vin}>
|
||||
<button
|
||||
type="button"
|
||||
className={vehicle.vin === selectedVin ? 'is-selected' : ''}
|
||||
aria-pressed={vehicle.vin === selectedVin}
|
||||
onClick={() => onSelect(vehicle)}
|
||||
>
|
||||
<span className={`lab-status-dot is-${vehicle.status}`} aria-hidden="true" />
|
||||
<span className="lab-mobile-identity">
|
||||
<strong>{vehicle.plate}</strong>
|
||||
<small>{vehicle.vin}</small>
|
||||
<span className="lab-protocol-list">
|
||||
{vehicle.protocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} />)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="lab-mobile-state">
|
||||
<StatusTag status={vehicle.status} />
|
||||
<small>{vehicle.updatedAt} 更新</small>
|
||||
</span>
|
||||
<IconChevronRight className="lab-mobile-row-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function VehicleInspector({
|
||||
vehicle,
|
||||
mobile,
|
||||
sheetOffset,
|
||||
onClose,
|
||||
onCopyVin,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDragEnd
|
||||
}: {
|
||||
vehicle: VehicleRecord;
|
||||
mobile?: boolean;
|
||||
sheetOffset?: number;
|
||||
onClose: () => void;
|
||||
onCopyVin: () => void;
|
||||
onDragStart?: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
onDragMove?: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
onDragEnd?: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const style = mobile
|
||||
? ({ '--lab-sheet-offset': `${sheetOffset ?? 0}px` } as CSSProperties)
|
||||
: undefined;
|
||||
return (
|
||||
<aside
|
||||
className={mobile ? 'lab-mobile-inspector' : 'lab-desktop-inspector'}
|
||||
aria-label={`${vehicle.plate} 车辆档案`}
|
||||
style={style}
|
||||
>
|
||||
{mobile ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lab-sheet-handle"
|
||||
aria-label="拖动或关闭车辆详情"
|
||||
onPointerDown={onDragStart}
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={onDragEnd}
|
||||
onPointerCancel={onDragEnd}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
) : null}
|
||||
<header>
|
||||
<span>
|
||||
<small>{mobile ? '车辆详情' : '车辆档案'}</small>
|
||||
<strong>{vehicle.plate}</strong>
|
||||
</span>
|
||||
<button type="button" onClick={onClose} aria-label="关闭车辆档案">
|
||||
<IconClose aria-hidden="true" />
|
||||
{mobile ? <span>关闭</span> : null}
|
||||
</button>
|
||||
</header>
|
||||
<div className="lab-inspector-status">
|
||||
<StatusTag status={vehicle.status} />
|
||||
<small>最近更新 {vehicle.updatedAt}</small>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>VIN</dt>
|
||||
<dd>
|
||||
<span>{vehicle.vin}</span>
|
||||
<button type="button" onClick={onCopyVin} aria-label="复制 VIN"><IconCopy aria-hidden="true" /></button>
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>品牌 / 车型</dt><dd>{vehicle.brand}</dd></div>
|
||||
<div><dt>终端编号</dt><dd>{vehicle.terminal}</dd></div>
|
||||
<div><dt>业务负责人</dt><dd>{vehicle.owner}</dd></div>
|
||||
<div><dt>所属部门</dt><dd>{vehicle.department}</dd></div>
|
||||
<div><dt>授权状态</dt><dd>已授权</dd></div>
|
||||
</dl>
|
||||
<section>
|
||||
<strong>协议来源</strong>
|
||||
{vehicle.protocols.map((protocol) => (
|
||||
<div className="lab-inspector-protocol" key={protocol}>
|
||||
<span className="lab-protocol-dot" aria-hidden="true" />
|
||||
<span>
|
||||
<b>{protocol}</b>
|
||||
<small>最后上报 {vehicle.updatedAt}:12</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<button type="button" className="lab-inspector-primary">查看完整档案<IconChevronRight aria-hidden="true" /></button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileFilterSheet({
|
||||
view,
|
||||
status,
|
||||
department,
|
||||
onViewChange,
|
||||
onStatusChange,
|
||||
onDepartmentChange,
|
||||
onReset,
|
||||
onClose
|
||||
}: {
|
||||
view: DirectoryView;
|
||||
status: string;
|
||||
department: string;
|
||||
onViewChange: (view: DirectoryView) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onDepartmentChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="lab-sheet-backdrop" role="presentation" onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}>
|
||||
<section className="lab-filter-sheet" role="dialog" aria-modal="true" aria-labelledby="lab-filter-title">
|
||||
<header>
|
||||
<span><strong id="lab-filter-title">筛选车辆</strong><small>范围、状态与业务归属</small></span>
|
||||
<button type="button" onClick={onClose} aria-label="关闭筛选"><IconClose aria-hidden="true" /></button>
|
||||
</header>
|
||||
<fieldset>
|
||||
<legend>车辆范围</legend>
|
||||
<div className="lab-filter-options">
|
||||
{viewOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={view === option.key ? 'is-active' : ''}
|
||||
onClick={() => onViewChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label>
|
||||
<span>在线状态</span>
|
||||
<select value={status} onChange={(event) => onStatusChange(event.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>所属部门</span>
|
||||
<select value={department} onChange={(event) => onDepartmentChange(event.target.value)}>
|
||||
<option value="">全部部门</option>
|
||||
<option>华南运营部</option>
|
||||
<option>西南交付部</option>
|
||||
<option>中原运营部</option>
|
||||
</select>
|
||||
</label>
|
||||
<footer>
|
||||
<button type="button" onClick={onReset}>重置</button>
|
||||
<button type="button" className="is-primary" onClick={onClose}>查看结果</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomNavigation() {
|
||||
const items = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch, active: true },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked },
|
||||
{ key: 'alerts', label: '更多', icon: IconMore }
|
||||
];
|
||||
return (
|
||||
<nav className="lab-bottom-nav" aria-label="移动端主导航">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={item.active ? 'is-active' : ''}
|
||||
key={item.label}
|
||||
onClick={() => item.key !== 'vehicles' && (window.location.href = `/platform-design-lab.html#${item.key}`)}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function VehicleDesignLab() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim().toLowerCase());
|
||||
const [view, setView] = useState<DirectoryView>('all');
|
||||
const [department, setDepartment] = useState('');
|
||||
const [owner, setOwner] = useState('');
|
||||
const [customer, setCustomer] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [selectedVin, setSelectedVin] = useState(vehicles[0].vin);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [sheetOffset, setSheetOffset] = useState(0);
|
||||
const dragStartRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const filteredVehicles = useMemo(() => vehicles.filter((vehicle) => {
|
||||
const matchesKeyword = !deferredKeyword || [
|
||||
vehicle.plate,
|
||||
vehicle.vin,
|
||||
vehicle.brand,
|
||||
vehicle.terminal
|
||||
].some((value) => value.toLowerCase().includes(deferredKeyword));
|
||||
const matchesView = view === 'all'
|
||||
|| view === 'online' && vehicle.status === 'online'
|
||||
|| view === 'offline' && vehicle.status === 'offline'
|
||||
|| view === 'multi' && vehicle.protocols.length > 1;
|
||||
return matchesKeyword
|
||||
&& matchesView
|
||||
&& (!department || vehicle.department === department)
|
||||
&& (!owner || vehicle.owner === owner)
|
||||
&& (!customer || vehicle.customer === customer)
|
||||
&& (!status || vehicle.status === status);
|
||||
}), [customer, deferredKeyword, department, owner, status, view]);
|
||||
|
||||
const selectedVehicle = vehicles.find((vehicle) => vehicle.vin === selectedVin);
|
||||
const activeFilterCount = [view !== 'all', !!department, !!owner, !!customer, !!status].filter(Boolean).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timeout = window.setTimeout(() => setNotice(''), 2400);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [notice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterOpen) return;
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setFilterOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [filterOpen]);
|
||||
|
||||
const submitSearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setNotice(`已找到 ${filteredVehicles.length} 辆车辆`);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setView('all');
|
||||
setDepartment('');
|
||||
setOwner('');
|
||||
setCustomer('');
|
||||
setStatus('');
|
||||
};
|
||||
|
||||
const copySelectedVin = async () => {
|
||||
if (!selectedVehicle) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedVehicle.vin);
|
||||
setNotice('VIN 已复制');
|
||||
} catch {
|
||||
setNotice('当前浏览器未开放剪贴板权限');
|
||||
}
|
||||
};
|
||||
|
||||
const onDragStart = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
dragStartRef.current = event.clientY;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onDragMove = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (dragStartRef.current === undefined) return;
|
||||
setSheetOffset(Math.max(0, event.clientY - dragStartRef.current));
|
||||
};
|
||||
|
||||
const onDragEnd = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
dragStartRef.current = undefined;
|
||||
if (sheetOffset > 110) setSelectedVin('');
|
||||
setSheetOffset(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`vehicle-design-lab${selectedVehicle ? ' has-inspector' : ''}`}>
|
||||
<Sidebar />
|
||||
<div className="lab-stage">
|
||||
<TopBar onHelp={() => setNotice('帮助:搜索车辆后选择一行查看档案')} />
|
||||
<main>
|
||||
<CommandSurface
|
||||
keyword={keyword}
|
||||
view={view}
|
||||
department={department}
|
||||
owner={owner}
|
||||
customer={customer}
|
||||
status={status}
|
||||
onKeywordChange={setKeyword}
|
||||
onViewChange={setView}
|
||||
onDepartmentChange={setDepartment}
|
||||
onOwnerChange={setOwner}
|
||||
onCustomerChange={setCustomer}
|
||||
onStatusChange={setStatus}
|
||||
onSubmit={submitSearch}
|
||||
/>
|
||||
<MobileCommandSurface
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
onSubmit={submitSearch}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onOpenFilters={() => setFilterOpen(true)}
|
||||
onBatch={() => setNotice('已创建 4 辆车辆的主档同步预览')}
|
||||
/>
|
||||
<MetricRail records={vehicles} />
|
||||
<div className="lab-directory-layout">
|
||||
<section className="lab-directory-surface" aria-label="授权车辆目录">
|
||||
<header className="lab-directory-header">
|
||||
<span>
|
||||
<strong>授权车辆目录</strong>
|
||||
<small>按照最新上报时间排序</small>
|
||||
</span>
|
||||
<em>{filteredVehicles.length} 辆</em>
|
||||
</header>
|
||||
{filteredVehicles.length > 0 ? (
|
||||
<>
|
||||
<DesktopDirectory records={filteredVehicles} selectedVin={selectedVin} onSelect={(vehicle) => setSelectedVin(vehicle.vin)} />
|
||||
<MobileDirectory records={filteredVehicles} selectedVin={selectedVin} onSelect={(vehicle) => setSelectedVin(vehicle.vin)} />
|
||||
</>
|
||||
) : (
|
||||
<div className="lab-empty-state">
|
||||
<IconSearch aria-hidden="true" />
|
||||
<strong>没有匹配车辆</strong>
|
||||
<span>调整关键词或清除筛选后重试。</span>
|
||||
<button type="button" onClick={resetFilters}>清除筛选</button>
|
||||
</div>
|
||||
)}
|
||||
<footer className="lab-directory-footer">
|
||||
<span>共 {filteredVehicles.length} 辆 · 本页 {filteredVehicles.length} 辆</span>
|
||||
<div>
|
||||
<button type="button" disabled aria-label="上一页"><IconChevronLeft aria-hidden="true" /></button>
|
||||
<span>1 / 1</span>
|
||||
<button type="button" disabled aria-label="下一页"><IconChevronRight aria-hidden="true" /></button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
{selectedVehicle ? (
|
||||
<VehicleInspector
|
||||
vehicle={selectedVehicle}
|
||||
onClose={() => setSelectedVin('')}
|
||||
onCopyVin={copySelectedVin}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
<BottomNavigation />
|
||||
{selectedVehicle ? (
|
||||
<VehicleInspector
|
||||
mobile
|
||||
vehicle={selectedVehicle}
|
||||
sheetOffset={sheetOffset}
|
||||
onClose={() => setSelectedVin('')}
|
||||
onCopyVin={copySelectedVin}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
) : null}
|
||||
{filterOpen ? (
|
||||
<MobileFilterSheet
|
||||
view={view}
|
||||
status={status}
|
||||
department={department}
|
||||
onViewChange={setView}
|
||||
onStatusChange={setStatus}
|
||||
onDepartmentChange={setDepartment}
|
||||
onReset={resetFilters}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <div className="lab-notice" role="status">{notice}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { VehicleDesignLab } from './VehicleDesignLab';
|
||||
import './vehicle-design-lab.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('vehicle-design-lab-root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<VehicleDesignLab />
|
||||
</React.StrictMode>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { PlatformDesignLab } from './PlatformDesignLab';
|
||||
import './vehicle-design-lab.css';
|
||||
import './platform-design-lab.css';
|
||||
|
||||
createRoot(document.getElementById('platform-design-lab-root')!).render(
|
||||
<StrictMode>
|
||||
<PlatformDesignLab />
|
||||
</StrictMode>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user