859 lines
28 KiB
TypeScript
859 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|