feat: expand vehicle data platform capabilities

This commit is contained in:
lingniu
2026-07-27 16:46:15 +08:00
parent e3a1f80f86
commit 3c4bece72c
650 changed files with 62155 additions and 2552 deletions
@@ -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 · 15</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