Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx
2026-07-18 06:19:32 +08:00

461 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconChevronRight, IconDelete, IconPlus, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, Select, 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 { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
const customerMenus = [
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
{ key: 'vehicles', label: '车辆查询', description: '查看车辆档案与最新遥测' },
{ key: 'tracks', label: '轨迹回放', description: '查询授权车辆的历史轨迹' },
{ key: 'statistics', label: '里程查询', description: '查询授权车辆的每日与区间里程' }
];
type Draft = {
username: string;
displayName: string;
password: string;
status: 'enabled' | 'disabled';
customerRef: string;
tenantRef: string;
menuKeys: string[];
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
});
function dateTimeInput(value?: string) {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return shanghaiDateTimeFormatter.format(parsed).replace(' ', 'T');
}
const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleGrants: [] };
function draftFromUser(user?: AdminUser): Draft {
if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleGrants: [] };
return {
username: user.username, displayName: user.displayName, password: '', status: user.status,
customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys],
vehicleGrants: user.vehicles.map((vehicle) => ({ vin: vehicle.vin, validFrom: dateTimeInput(vehicle.validFrom), validTo: dateTimeInput(vehicle.validTo) }))
};
}
function formatTime(value?: string) {
if (!value) return '尚未登录';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
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 deferredCustomerKeyword = useDeferredValue(customerKeyword.trim());
const [customerStatus, setCustomerStatus] = useState<'all' | Draft['status']>('all');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const visibleCustomers = useMemo(() => {
const keyword = deferredCustomerKeyword.toLocaleLowerCase('zh-CN');
return customers.filter((user) => {
if (customerStatus !== 'all' && user.status !== customerStatus) return false;
if (!keyword) return true;
return [user.displayName, user.username, user.customerRef, user.tenantRef]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword));
});
}, [customerStatus, customers, deferredCustomerKeyword]);
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(() => visibleCustomers.filter((user) => user.status === 'enabled').length, [visibleCustomers]);
const grantedVehicles = useMemo(() => visibleCustomers.reduce((total, user) => total + user.vehicles.length, 0), [visibleCustomers]);
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)));
const editorVisible = creating || Boolean(selected);
useSideSheetA11y(editorVisible, '.v2-user-editor-sidesheet', 'v2-user-editor-sheet', '客户账号详情', '关闭账号详情');
useSideSheetA11y(Boolean(editingGrant), '.v2-user-grant-sidesheet', 'v2-user-grant-window', '车辆授权有效期', '关闭车辆授权有效期');
useEffect(() => {
if (!creating && selected) {
setDraft(draftFromUser(selected));
setVehicleLabels(Object.fromEntries(selected.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate])));
}
}, [creating, selected]);
const candidates = useQuery({
queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword],
queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal),
enabled: deferredVehicleKeyword.length >= 1,
staleTime: 30_000
});
const assigned = useMemo(() => new Set(draft.vehicleGrants.map((grant) => grant.vin)), [draft.vehicleGrants]);
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) => {
const next = { ...current };
for (const vehicle of candidateVehicles) next[vehicle.vin] = vehicle.plate;
return next;
});
}, [candidateVehicles]);
const save = useMutation({
mutationFn: async () => {
const input: CustomerUserInput = {
displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status,
customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys,
vehicleVins: draft.vehicleGrants.map((grant) => grant.vin), vehicleGrants: draft.vehicleGrants
};
if (creating) return api.createCustomerUser({ ...input, username: draft.username.trim(), password: draft.password });
if (!selected) throw new Error('请先选择客户账号');
return api.updateCustomerUser(selected.id, input);
},
onSuccess: async (result) => {
setFeedback(creating ? '客户账号已创建' : '账号与权限已更新');
setCreating(false);
setSelectedID(result.id);
setDraft((value) => ({ ...value, password: '' }));
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
},
onError: (error) => setFeedback(error instanceof Error ? error.message : '保存失败')
});
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 updateGrant = (vin: string, patch: Partial<CustomerVehicleGrantInput>) => setDraft((value) => ({
...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant)
}));
const addBulkVINs = () => {
const next = bulkVINs.split(/[\s,;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean);
setDraft((value) => {
const existing = new Set(value.vehicleGrants.map((grant) => grant.vin));
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 persistDraft = () => {
if (save.isPending) return;
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();
};
const submit = (event: FormEvent) => {
event.preventDefault();
persistDraft();
};
return <div className="v2-user-admin">
<WorkspaceCommandBar
className="v2-user-command-bar"
ariaLabel="账号管理操作"
title="客户访问治理"
description="菜单与车辆按最小权限开放,变更最多 30 秒生效"
status={`${customers.length} 个客户账号`}
meta={<Typography.Text type="tertiary"> · </Typography.Text>}
actions={<Button theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}></Button>}
/>
<WorkspaceFilterPanel
className="v2-user-filter-panel"
title="账号范围"
description="按客户名称、登录账号、客户标识与启停状态查找"
mobileSummary={`${customerStatus === 'all' ? '全部状态' : customerStatus === 'enabled' ? '启用账号' : '停用账号'}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
expanded={!filtersCollapsed}
status={users.isPending ? '正在读取账号' : `${visibleCustomers.length} / ${customers.length} 个账号`}
statusColor={customerKeyword.trim() || customerStatus !== 'all' ? 'blue' : 'grey'}
collapsedLabel="修改"
onToggle={() => setFiltersCollapsed((value) => !value)}
>
<div className={`v2-user-filter-form${filtersCollapsed ? ' is-mobile-collapsed' : ''}`}>
<label className="v2-user-filter-keyword">
<span></span>
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={setCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
</label>
<label>
<span id="v2-user-status-filter-label"></span>
<Select
aria-labelledby="v2-user-status-filter-label"
value={customerStatus}
onChange={(value) => setCustomerStatus(String(value) as typeof customerStatus)}
optionList={[
{ value: 'all', label: '全部状态' },
{ value: 'enabled', label: '启用账号' },
{ value: 'disabled', label: '停用账号' }
]}
/>
</label>
<Button
className="v2-user-filter-reset"
theme="light"
type="tertiary"
icon={<IconRefresh />}
aria-label="清空账号筛选"
disabled={!customerKeyword.trim() && customerStatus === 'all'}
onClick={() => {
setCustomerKeyword('');
setCustomerStatus('all');
}}
>
</Button>
</div>
</WorkspaceFilterPanel>
<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={visibleCustomers.length === customers.length ? 'grey' : 'blue'} type="light" size="small">{visibleCustomers.length} / {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 ? <PanelLoading className="v2-user-list-loading" title="正在加载客户账号" description="账号目录和授权摘要就绪后会自动显示。" /> : customers.length === 0 ? <PanelEmpty className="v2-user-list-empty" title="还没有客户账号" description="创建客户账号后,可在这里配置菜单和车辆范围。" action={<Button theme="solid" onClick={startCreate}></Button>} /> : <>
<div className="v2-customer-list-scroll" role="region" aria-label="客户账号目录,可上下滚动" tabIndex={0}>
<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>
</>}
</div>
</Card>
</div>
<SideSheet
className="v2-user-editor-sidesheet"
visible={editorVisible}
width={mobileLayout ? '100%' : 'min(840px, 100vw)'}
aria-label="客户账号详情"
title={<div className="v2-user-editor-sheet-title">
<span><strong>{creating ? '创建客户账号' : selected?.displayName}</strong><small>{creating ? '设置登录身份和最小必要权限' : `@${selected?.username} · ${draft.menuKeys.length} 个菜单 · ${draft.vehicleGrants.length} 辆车 · 最近登录 ${formatTime(selected?.lastLoginAt)}`}</small></span>
<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>
</div>}
onCancel={closeEditor}
footer={<div className="v2-user-editor-sheet-footer">
<Typography.Text className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} role="status" type={save.isError ? 'danger' : 'tertiary'}>{feedback || '权限变更最多 30 秒生效,保存前请核对车辆有效期'}</Typography.Text>
<Button theme="solid" htmlType="submit" form="v2-user-editor-form" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</Button>
</div>}
>
{editorVisible ? <form id="v2-user-editor-form" className="v2-user-editor-form" onSubmit={submit}>
<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>
</form> : null}
</SideSheet>
<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>;
}