feat(platform): harden telemetry pipeline and unify Semi UI workspaces

This commit is contained in:
lingniu
2026-07-18 00:26:36 +08:00
parent 65b4e4f055
commit 159c80b0ae
136 changed files with 21616 additions and 1785 deletions

View File

@@ -1,7 +1,16 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconChevronRight, IconClose, IconDelete, IconPlus, IconSearch } from '@douyinfe/semi-icons';
import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, SideSheet, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import { PageHeader } from '../shared/PageHeader';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
const customerMenus = [
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
@@ -21,6 +30,8 @@ type Draft = {
vehicleGrants: CustomerVehicleGrantInput[];
};
type EditorSection = 'identity' | 'menus' | 'vehicles';
const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
});
@@ -48,30 +59,42 @@ function formatTime(value?: string) {
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function uniqueVehicles<T extends { vin: string; plate: string }>(vehicles: T[]) {
const byVIN = new Map<string, T>();
for (const vehicle of vehicles) {
const vin = vehicle.vin.trim().toUpperCase();
const current = byVIN.get(vin);
if (!vin || (current?.plate && !vehicle.plate)) continue;
byVIN.set(vin, vehicle);
}
return [...byVIN.values()];
function formatGrantTime(value?: string) {
if (!value) return '未设置';
return value.replace('T', ' ').slice(0, 16);
}
export default function UsersPage() {
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 });
const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]);
const [customerKeyword, setCustomerKeyword] = useState('');
const visibleCustomers = useMemo(() => {
const keyword = customerKeyword.trim().toLocaleLowerCase('zh-CN');
if (!keyword) return customers;
return customers.filter((user) => [user.displayName, user.username, user.customerRef, user.tenantRef]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword)));
}, [customerKeyword, customers]);
const [selectedID, setSelectedID] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
const [activeSection, setActiveSection] = useState<EditorSection>('identity');
const selected = useMemo(() => customers.find((user) => user.id === selectedID), [customers, selectedID]);
const [draft, setDraft] = useState<Draft>(() => draftFromUser());
const [vehicleKeyword, setVehicleKeyword] = useState('');
const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim());
const [assignedKeyword, setAssignedKeyword] = useState('');
const [assignedPage, setAssignedPage] = useState(1);
const [bulkVINs, setBulkVINs] = useState('');
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
const [feedback, setFeedback] = useState('');
const [editingGrantVIN, setEditingGrantVIN] = useState('');
const enabledCustomers = useMemo(() => customers.filter((user) => user.status === 'enabled').length, [customers]);
const grantedVehicles = useMemo(() => customers.reduce((total, user) => total + user.vehicles.length, 0), [customers]);
const editingGrant = useMemo(() => draft.vehicleGrants.find((grant) => grant.vin === editingGrantVIN), [draft.vehicleGrants, editingGrantVIN]);
const editingGrantPlate = editingGrant ? vehicleLabels[editingGrant.vin] : '';
const grantWindowInvalid = Boolean(editingGrant && (!editingGrant.validFrom || (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom)));
useSideSheetA11y(Boolean(editingGrant), '.v2-user-grant-sidesheet', 'v2-user-grant-window', '车辆授权有效期', '关闭车辆授权有效期');
useEffect(() => {
if (!creating && selected) {
@@ -87,7 +110,20 @@ export default function UsersPage() {
staleTime: 30_000
});
const assigned = useMemo(() => new Set(draft.vehicleGrants.map((grant) => grant.vin)), [draft.vehicleGrants]);
const candidateVehicles = useMemo(() => uniqueVehicles(candidates.data?.items ?? []), [candidates.data?.items]);
const candidateVehicles = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
const assignedPageSize = mobileLayout ? 4 : 10;
const filteredAssignedGrants = useMemo(() => {
const keyword = assignedKeyword.trim().toLocaleLowerCase('zh-CN');
if (!keyword) return draft.vehicleGrants;
return draft.vehicleGrants.filter((grant) => [grant.vin, vehicleLabels[grant.vin]]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword)));
}, [assignedKeyword, draft.vehicleGrants, vehicleLabels]);
const assignedTotalPages = Math.max(1, Math.ceil(filteredAssignedGrants.length / assignedPageSize));
const safeAssignedPage = Math.min(assignedPage, assignedTotalPages);
const visibleAssignedGrants = useMemo(() => {
const offset = (safeAssignedPage - 1) * assignedPageSize;
return filteredAssignedGrants.slice(offset, offset + assignedPageSize);
}, [assignedPageSize, filteredAssignedGrants, safeAssignedPage]);
useEffect(() => {
if (candidateVehicles.length === 0) return;
setVehicleLabels((current) => {
@@ -121,26 +157,53 @@ export default function UsersPage() {
const startCreate = () => {
setCreating(true);
setSelectedID(null);
setActiveSection('identity');
setDraft(draftFromUser());
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setVehicleLabels({});
setFeedback('');
setEditingGrantVIN('');
};
const closeEditor = () => {
setCreating(false);
setSelectedID(null);
setDraft(draftFromUser());
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setVehicleLabels({});
setFeedback('');
setEditingGrantVIN('');
};
const selectCustomer = (user: AdminUser) => {
setCreating(false);
setSelectedID(user.id);
setActiveSection('vehicles');
setDraft(draftFromUser(user));
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setFeedback('');
setEditingGrantVIN('');
};
const toggleVIN = (vin: string) => {
if (!assigned.has(vin)) {
setAssignedKeyword('');
setAssignedPage(1);
}
setDraft((value) => ({
...value,
vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin)
? value.vehicleGrants.filter((grant) => grant.vin !== vin)
: [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin))
}));
if (editingGrantVIN === vin) setEditingGrantVIN('');
};
const toggleVIN = (vin: string) => setDraft((value) => ({
...value,
vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin)
? value.vehicleGrants.filter((grant) => grant.vin !== vin)
: [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin))
}));
const updateGrant = (vin: string, patch: Partial<CustomerVehicleGrantInput>) => setDraft((value) => ({
...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant)
}));
@@ -151,47 +214,174 @@ export default function UsersPage() {
const added = next.filter((vin) => !existing.has(vin)).map((vin) => ({ vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }));
return { ...value, vehicleGrants: [...value.vehicleGrants, ...added].sort((left, right) => left.vin.localeCompare(right.vin)) };
});
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
};
const submit = (event: FormEvent) => { event.preventDefault(); setFeedback(''); save.mutate(); };
const submit = (event: FormEvent) => {
event.preventDefault();
setFeedback('');
if (!draft.displayName.trim() || (creating && (!draft.username.trim() || !draft.password))) {
setActiveSection('identity');
setFeedback('请先完善登录身份中的必填信息');
return;
}
const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom));
if (invalidGrant) {
setActiveSection('vehicles');
setEditingGrantVIN(invalidGrant.vin);
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
return;
}
save.mutate();
};
return <div className="v2-user-admin">
<header className="v2-user-admin-heading">
<div><h2></h2><p> 30 </p></div>
<button type="button" onClick={startCreate}></button>
</header>
<div className="v2-user-admin-grid">
<aside className="v2-user-list">
<div className="v2-user-list-summary"><strong>{customers.length}</strong><span></span></div>
{users.isPending ? <p className="v2-user-empty"></p> : customers.length === 0 ? <p className="v2-user-empty"></p> : customers.map((user) => <button key={user.id} type="button" className={selectedID === user.id && !creating ? 'is-active' : ''} onClick={() => selectCustomer(user)}>
<span className="v2-user-avatar">{user.displayName.slice(0, 1)}</span>
<span><b>{user.displayName}</b><small>@{user.username} · {user.vehicles.length} </small></span>
<i className={user.status === 'enabled' ? 'is-enabled' : ''}>{user.status === 'enabled' ? '启用' : '停用'}</i>
</button>)}
</aside>
<main className="v2-user-editor">
{!creating && !selected ? <div className="v2-user-editor-empty"><strong></strong><p></p><button type="button" onClick={startCreate}></button></div> : <form onSubmit={submit}>
<div className="v2-user-editor-title"><div><h3>{creating ? '创建客户账号' : selected?.displayName}</h3><p>{creating ? '设置登录身份和最小必要权限' : `最近登录:${formatTime(selected?.lastLoginAt)}`}</p></div><label className="v2-user-status"><input type="checkbox" checked={draft.status === 'enabled'} onChange={(event) => setDraft((value) => ({ ...value, status: event.target.checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label></div>
<section><h4></h4><div className="v2-user-fields">
<label><span></span><input required disabled={!creating} value={draft.username} onChange={(event) => setDraft((value) => ({ ...value, username: event.target.value }))} placeholder="例如 customer-huadong" /></label>
<label><span></span><input required value={draft.displayName} onChange={(event) => setDraft((value) => ({ ...value, displayName: event.target.value }))} placeholder="显示在平台右上角" /></label>
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><input required={creating} type="password" autoComplete="new-password" value={draft.password} onChange={(event) => setDraft((value) => ({ ...value, password: event.target.value }))} placeholder="至少 10 位,包含三类字符" /></label>
<label><span></span><input value={draft.customerRef} onChange={(event) => setDraft((value) => ({ ...value, customerRef: event.target.value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
</div></section>
<section><h4> <small></small></h4><div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><input type="checkbox" checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div></section>
<section><div className="v2-vehicle-permission-heading"><h4> <small> {draft.vehicleGrants.length} </small></h4>{draft.vehicleGrants.length ? <button type="button" onClick={() => setDraft((value) => ({ ...value, vehicleGrants: [] }))}></button> : null}</div>
<div className="v2-vehicle-permission-tools"><label><span> VIN </span><input value={vehicleKeyword} onChange={(event) => setVehicleKeyword(event.target.value)} placeholder="输入后显示候选车辆" /></label><label><span> VIN</span><span className="v2-bulk-vin"><input value={bulkVINs} onChange={(event) => setBulkVINs(event.target.value)} placeholder="空格、逗号或换行分隔" /><button type="button" disabled={!bulkVINs.trim()} onClick={addBulkVINs}></button></span></label></div>
{deferredVehicleKeyword ? <div className="v2-vehicle-candidates">{candidates.isPending ? <p></p> : candidateVehicles.length === 0 ? <p></p> : candidateVehicles.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={vehicle.vin} onClick={() => toggleVIN(vehicle.vin)}><span><b>{vehicle.plate || '未登记车牌'}</b><small>{vehicle.vin}</small></span><i>{assigned.has(vehicle.vin) ? '已分配' : '选择'}</i></button>)}</div> : null}
{draft.vehicleGrants.length ? <div className="v2-assigned-vins">{draft.vehicleGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return <article key={grant.vin}>
<header><span><b>{plate || '未登记车牌'}</b><small>{grant.vin}</small></span><button type="button" aria-label={`移除 ${plate || grant.vin}`} onClick={() => toggleVIN(grant.vin)}>×</button></header>
<div><label><span></span><input aria-label={`${plate || grant.vin} 启用时间`} type="datetime-local" required value={grant.validFrom} onChange={(event) => updateGrant(grant.vin, { validFrom: event.target.value })} /></label><label><span></span><input aria-label={`${plate || grant.vin} 停用时间`} type="datetime-local" min={grant.validFrom} value={grant.validTo} onChange={(event) => updateGrant(grant.vin, { validTo: event.target.value })} /></label></div>
</article>; })}</div> : <p className="v2-user-empty"></p>}
{!creating && selected?.grantHistory?.length ? <details className="v2-grant-history"><summary> · {selected.grantHistory.length} </summary><div>{selected.grantHistory.map((item) => <article key={item.id}><header><strong>{item.plate || '未登记车牌'}</strong><span>{item.vin}</span></header><p>{formatTime(item.validFrom)} {item.validTo ? formatTime(item.validTo) : '持续有效'}</p><small>{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}</small></article>)}</div></details> : null}
</section>
<PageHeader
title="客户账号与数据权限"
description="客户只会看到已分配的菜单和车辆,权限变更最多在 30 秒内对现有会话生效。"
status={`${customers.length} 个客户账号`}
meta={<Typography.Text type="tertiary"></Typography.Text>}
actions={<Button theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}></Button>}
/>
<div className={`v2-user-admin-grid${creating || selected ? ' is-editor-open' : ''}`}>
<Card className="v2-user-list" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
className="v2-user-directory-header"
title="客户权限目录"
description="按客户查看账号状态、菜单和车辆授权范围"
meta={<Tag color="blue" type="light" size="small">{customers.length} </Tag>}
/>
<div className="v2-user-directory-body">
<div className="v2-user-directory-metrics" aria-label="客户账号概览">
<span className="is-primary"><small></small><strong>{enabledCustomers}</strong></span>
<span><small></small><strong>{customers.length - enabledCustomers}</strong></span>
<span><small></small><strong>{grantedVehicles}</strong></span>
</div>
{users.isPending ? <div className="v2-user-list-loading" role="status"><Spin size="middle" tip="正在加载客户账号" /></div> : customers.length === 0 ? <Empty className="v2-user-list-empty" title="还没有客户账号" description="创建客户账号后,可在这里配置菜单和车辆范围。"><Button theme="solid" onClick={startCreate}></Button></Empty> : <>
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={setCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
<List
className="v2-customer-list"
dataSource={visibleCustomers}
emptyContent={<Empty className="v2-user-filter-empty" title="没有匹配账号" description="请更换名称或账号关键词。" />}
renderItem={(user) => <List.Item key={user.id} className={`v2-user-list-row${selectedID === user.id && !creating ? ' is-active' : ''}`}>
<Button
className="v2-user-list-item"
theme="borderless"
type="tertiary"
aria-label={`选择客户 ${user.displayName},账号 ${user.username}${user.vehicles.length} 辆授权车`}
aria-pressed={selectedID === user.id && !creating}
aria-expanded={selectedID === user.id && !creating}
onClick={() => selectCustomer(user)}
>
<Avatar className="v2-user-avatar" color={user.status === 'enabled' ? 'light-blue' : 'grey'} shape="square" size="small">{user.displayName.slice(0, 1)}</Avatar>
<span className="v2-user-list-identity"><b>{user.displayName}</b><small>@{user.username}</small></span>
<span className="v2-user-list-facts"><small></small><b>{user.menuKeys.length} </b></span>
<span className="v2-user-list-facts"><small></small><b>{user.vehicles.length} </b></span>
<span className="v2-user-list-facts is-login"><small></small><b>{formatTime(user.lastLoginAt)}</b></span>
<span className="v2-user-list-trailing"><Tag className={`v2-user-status-tag is-${user.status}`} color={user.status === 'enabled' ? 'green' : 'grey'} type="light" size="small">{user.status === 'enabled' ? '启用' : '停用'}</Tag><IconChevronRight /></span>
</Button>
</List.Item>}
/>
</>}
</div>
</Card>
{creating || selected ? <Card className="v2-user-editor" bodyStyle={{ padding: 0 }} aria-label="客户账号详情">
<form onSubmit={submit}>
<div className="v2-user-editor-title"><div><h3>{creating ? '创建客户账号' : selected?.displayName}</h3><p>{creating ? '设置登录身份和最小必要权限' : `@${selected?.username} · ${draft.menuKeys.length} 个菜单 · ${draft.vehicleGrants.length} 辆车 · 最近登录 ${formatTime(selected?.lastLoginAt)}`}</p></div><label className="v2-user-status"><Switch aria-label={draft.status === 'enabled' ? '账号启用' : '账号停用'} checked={draft.status === 'enabled'} onChange={(checked) => setDraft((value) => ({ ...value, status: checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label><Button className="v2-user-editor-close" theme="borderless" type="tertiary" icon={<IconClose />} aria-label="关闭账号详情" onClick={closeEditor} /></div>
<Tabs className="v2-user-editor-tabs" activeKey={activeSection} onChange={(key) => setActiveSection(String(key) as EditorSection)}>
<Tabs.TabPane tab="登录身份" itemKey="identity">
<Card className="v2-user-editor-section v2-user-identity-section" title="登录身份" headerLine>
<div className="v2-user-fields">
<label><span></span><Input required disabled={!creating} value={draft.username} onChange={(value) => setDraft((current) => ({ ...current, username: value }))} placeholder="例如 customer-huadong" /></label>
<label><span></span><Input required value={draft.displayName} onChange={(value) => setDraft((current) => ({ ...current, displayName: value }))} placeholder="显示在平台右上角" /></label>
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><Input required={creating} mode="password" autoComplete="new-password" value={draft.password} onChange={(value) => setDraft((current) => ({ ...current, password: value }))} placeholder="至少 10 位,包含三类字符" /></label>
<label><span></span><Input value={draft.customerRef} onChange={(value) => setDraft((current) => ({ ...current, customerRef: value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
</div>
</Card>
</Tabs.TabPane>
<Tabs.TabPane tab={<span className="v2-user-tab-label"><Tag color="blue" type="light" size="small">{draft.menuKeys.length}</Tag></span>} itemKey="menus">
<Card className="v2-user-editor-section v2-user-menu-section" title={<span className="v2-user-section-title"> <Tag color="blue" type="light" size="small"></Tag></span>} headerLine>
<div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><Checkbox checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div>
</Card>
</Tabs.TabPane>
<Tabs.TabPane tab={<span className="v2-user-tab-label"><Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length}</Tag></span>} itemKey="vehicles">
<Card className="v2-user-editor-section v2-user-vehicle-section" title={<span className="v2-user-section-title"> <Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length} </Tag></span>} headerLine headerExtraContent={draft.vehicleGrants.length ? <Button theme="borderless" type="tertiary" size="small" onClick={() => setDraft((value) => ({ ...value, vehicleGrants: [] }))}></Button> : null}>
<div className="v2-vehicle-permission-tools"><label><span> VIN </span><Input aria-label="按车牌或 VIN 搜索" value={vehicleKeyword} onChange={setVehicleKeyword} placeholder="输入后显示候选车辆" /></label><label><span> VIN</span><span className="v2-bulk-vin"><Input value={bulkVINs} onChange={setBulkVINs} placeholder="空格、逗号或换行分隔" /><Button theme="light" disabled={!bulkVINs.trim()} onClick={addBulkVINs}></Button></span></label></div>
{deferredVehicleKeyword ? <VehicleCandidateList
className="v2-vehicle-candidates"
layout="grid"
items={candidateVehicles}
loading={candidates.isPending}
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
onRetry={() => candidates.refetch()}
selectedVins={assigned}
selectedLabel="已分配"
onSelect={(vehicle) => toggleVIN(vehicle.vin)}
/> : null}
{draft.vehicleGrants.length ? <>
<div className="v2-assigned-toolbar">
<span><strong></strong><small>{assignedKeyword ? `${filteredAssignedGrants.length} 条匹配结果` : `${draft.vehicleGrants.length} 辆车拥有当前访问权限`}</small></span>
<Input
aria-label="筛选已授权车辆"
prefix={<IconSearch />}
showClear
value={assignedKeyword}
onChange={(value) => {
setAssignedKeyword(value);
setAssignedPage(1);
}}
placeholder="筛选已授权车牌或 VIN"
/>
</div>
{visibleAssignedGrants.length ? <div className="v2-assigned-vins">{visibleAssignedGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return <Card key={grant.vin} className="v2-assigned-vehicle-card" bodyStyle={{ padding: 0 }}>
<header><span><b>{plate || '未登记车牌'}</b><small>{grant.vin}</small></span><span className="v2-assigned-vehicle-actions"><Button theme="light" type="tertiary" size="small" aria-haspopup="dialog" aria-controls="v2-user-grant-window" aria-label={`调整 ${plate || grant.vin} 有效期`} onClick={() => setEditingGrantVIN(grant.vin)}></Button><Button theme="borderless" type="tertiary" size="small" aria-label={`移除 ${plate || grant.vin}`} icon={<IconDelete />} onClick={() => toggleVIN(grant.vin)} /></span></header>
<div className="v2-assigned-vehicle-interval"><span><small></small><strong>{formatGrantTime(grant.validFrom)}</strong></span><i aria-hidden="true"></i><span><small></small><strong>{grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}</strong></span></div>
</Card>; })}</div> : <Empty className="v2-user-assigned-filter-empty" title="没有匹配的授权车辆" description="请更换车牌或 VIN 关键词。" />}
<div className="v2-assigned-pagination">
<TablePagination
page={safeAssignedPage}
totalPages={assignedTotalPages}
info={<> {(safeAssignedPage - 1) * assignedPageSize + (filteredAssignedGrants.length ? 1 : 0)}{Math.min(safeAssignedPage * assignedPageSize, filteredAssignedGrants.length)} {filteredAssignedGrants.length} </>}
onPageChange={setAssignedPage}
/>
</div>
</> : <Empty className="v2-user-vehicle-empty" title="尚未分配车辆" description="客户将无法看到任何车辆数据。" />}
{!creating && selected?.grantHistory?.length ? <Collapse className="v2-grant-history">
<Collapse.Panel itemKey="grant-history" header={<span className="v2-grant-history-title"> <Tag color="blue" type="light" size="small">{selected.grantHistory.length} </Tag></span>}>
<div className="v2-grant-history-list">{selected.grantHistory.map((item) => <Card key={item.id} className="v2-grant-history-card" bodyStyle={{ padding: 0 }}>
<header><span><strong>{item.plate || '未登记车牌'}</strong><small>{item.vin}</small></span><Tag color={item.validTo ? 'grey' : 'green'} type="light" size="small">{item.validTo ? '已结束' : '有效中'}</Tag></header>
<p>{formatTime(item.validFrom)} <i></i> {item.validTo ? formatTime(item.validTo) : '持续有效'}</p>
<small>{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}</small>
</Card>)}</div>
</Collapse.Panel>
</Collapse> : null}
</Card>
</Tabs.TabPane>
</Tabs>
{feedback ? <p className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} role="status">{feedback}</p> : null}
<footer><button type="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</button></footer>
</form>}
</main>
<footer><Button theme="solid" htmlType="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</Button></footer>
</form>
</Card> : null}
</div>
<SideSheet
className="v2-user-grant-sidesheet"
visible={Boolean(editingGrant)}
width={420}
aria-label="车辆授权有效期"
title={<div className="v2-user-grant-sheet-title"><strong></strong><span>{editingGrant ? `${editingGrantPlate || '未登记车牌'} · ${editingGrant.vin}` : '车辆授权时间窗口'}</span></div>}
onCancel={() => setEditingGrantVIN('')}
footer={<div className="v2-user-grant-sheet-footer"><Typography.Text type="tertiary"></Typography.Text><Button theme="solid" disabled={grantWindowInvalid} onClick={() => setEditingGrantVIN('')}></Button></div>}
>
{editingGrant ? <div className="v2-user-grant-form">
<Card className="v2-user-grant-summary" bodyStyle={{ padding: 0 }}>
<span><small></small><strong>{editingGrantPlate || '未登记车牌'}</strong></span>
<span><small>VIN</small><strong>{editingGrant.vin}</strong></span>
</Card>
<label><span></span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 启用时间`} type="datetime-local" required value={editingGrant.validFrom} onChange={(value) => updateGrant(editingGrant.vin, { validFrom: value })} /></label>
<label><span></span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 停用时间`} type="datetime-local" min={editingGrant.validFrom} value={editingGrant.validTo} onChange={(value) => updateGrant(editingGrant.vin, { validTo: value })} /></label>
{grantWindowInvalid ? <p role="alert"></p> : <p></p>}
</div> : null}
</SideSheet>
</div>;
}