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

825 lines
48 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, 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 { MobileFilterToggle } from '../shared/MobileFilterToggle';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { TablePagination } from '../shared/TablePagination';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceConfirmDialog } from '../shared/WorkspaceDialog';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
import { WorkspaceDateTimeField } from '../shared/WorkspaceDateTimeField';
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';
type CustomerScopeFilter = 'all' | Draft['status'] | 'attention';
type CustomerAccessState = {
key: 'ready' | 'missing-menus' | 'missing-vehicles' | 'disabled';
label: string;
shortLabel: string;
color: 'green' | 'amber' | 'grey';
attention: boolean;
};
const customerScopeOptions: Array<{ value: CustomerScopeFilter; label: string }> = [
{ value: 'all', label: '全部状态' },
{ value: 'enabled', label: '启用账号' },
{ value: 'disabled', label: '停用账号' },
{ value: 'attention', label: '待完善权限' }
];
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 draftSignature(draft: Draft) {
return JSON.stringify({
...draft,
menuKeys: [...draft.menuKeys].sort(),
vehicleGrants: [...draft.vehicleGrants].sort((left, right) => left.vin.localeCompare(right.vin))
});
}
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);
}
function customerAccessState(status: Draft['status'], menuCount: number, vehicleCount: number): CustomerAccessState {
if (status === 'disabled') return { key: 'disabled', label: '账号已停用', shortLabel: '停用', color: 'grey', attention: false };
if (menuCount === 0) return { key: 'missing-menus', label: '待开放菜单', shortLabel: '待菜单', color: 'amber', attention: true };
if (vehicleCount === 0) return { key: 'missing-vehicles', label: '待分配车辆', shortLabel: '待车辆', color: 'amber', attention: true };
return { key: 'ready', label: '权限就绪', shortLabel: '就绪', color: 'green', attention: false };
}
function customerScopeLabel(scope: CustomerScopeFilter) {
if (scope === 'enabled') return '启用账号';
if (scope === 'disabled') return '停用账号';
if (scope === 'attention') return '待完善权限';
return '全部状态';
}
function accessBadgeColor(color: CustomerAccessState['color']): WorkspaceSideSheetBadgeColor {
if (color === 'amber') return 'orange';
return color;
}
function authProviderMeta(provider?: string) {
const normalized = provider?.trim();
if (!normalized || normalized === 'local') return { label: '本地', color: 'grey' as const, title: '本地账号' };
return { label: normalized, color: 'cyan' as const, title: `外部身份源:${normalized}` };
}
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<CustomerScopeFilter>('all');
const [draftCustomerKeyword, setDraftCustomerKeyword] = useState('');
const [draftCustomerStatus, setDraftCustomerStatus] = useState<CustomerScopeFilter>('all');
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
const visibleCustomers = useMemo(() => {
const keyword = deferredCustomerKeyword.toLocaleLowerCase('zh-CN');
return customers.filter((user) => {
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length);
if (customerStatus === 'attention' && !accessState.attention) return false;
if (customerStatus !== 'all' && customerStatus !== 'attention' && 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 [baselineDraft, setBaselineDraft] = 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 [vehicleComposerOpen, setVehicleComposerOpen] = useState(false);
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
const [feedback, setFeedback] = useState('');
const [editingGrantVIN, setEditingGrantVIN] = useState('');
const [grantEndEnabled, setGrantEndEnabled] = useState(false);
const [confirmation, setConfirmation] = useState<'clear-vehicles' | 'discard-editor' | null>(null);
const attentionCustomerCount = useMemo(() => customers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [customers]);
const readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).key === 'ready').length, [visibleCustomers]);
const attentionCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [visibleCustomers]);
const grantedVehicles = useMemo(() => visibleCustomers.reduce((total, user) => total + user.vehicles.length, 0), [visibleCustomers]);
const directoryMetrics: WorkspaceQueueMetricRailItem[] = [
{
label: '当前账号',
value: <>{visibleCustomers.length}<i className="v2-user-directory-metric-unit"> / {customers.length}</i></>,
note: customerKeyword.trim() || customerStatus !== 'all' ? '筛选结果 / 全部客户' : '全部客户账号',
tone: 'primary',
emphasis: 'primary'
},
{
label: '待完善',
value: attentionCustomers,
note: '缺少菜单或车辆',
tone: attentionCustomers ? 'warning' : 'success',
emphasis: 'primary'
},
{
label: '权限就绪',
value: readyCustomers,
note: '启用且权限完整',
tone: 'success',
emphasis: 'secondary'
},
{
label: '车辆授权',
value: grantedVehicles,
note: '当前范围合计',
tone: 'neutral',
emphasis: 'secondary'
}
];
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
|| (grantEndEnabled && !editingGrant.validTo)
|| (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom)
));
const editorVisible = creating || Boolean(selected);
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen;
const hasUnsavedChanges = useMemo(() => draftSignature(draft) !== draftSignature(baselineDraft), [baselineDraft, draft]);
const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length);
useEffect(() => {
if (!creating && selected) {
const nextDraft = draftFromUser(selected);
setDraft(nextDraft);
setBaselineDraft(nextDraft);
setVehicleLabels(Object.fromEntries(selected.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate])));
}
}, [creating, selected?.id, selected?.updatedAt]);
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) => {
const savedDraft = { ...value, password: '' };
setBaselineDraft(savedDraft);
return savedDraft;
});
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
},
onError: (error) => setFeedback(error instanceof Error ? error.message : '保存失败')
});
const startCreate = () => {
const nextDraft = draftFromUser();
setCreating(true);
setSelectedID(null);
setActiveSection('identity');
setDraft(nextDraft);
setBaselineDraft(nextDraft);
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setVehicleComposerOpen(true);
setVehicleLabels({});
setFeedback('');
setEditingGrantVIN('');
setGrantEndEnabled(false);
setConfirmation(null);
};
const closeEditor = () => {
const nextDraft = draftFromUser();
setCreating(false);
setSelectedID(null);
setDraft(nextDraft);
setBaselineDraft(nextDraft);
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setVehicleComposerOpen(false);
setVehicleLabels({});
setFeedback('');
setEditingGrantVIN('');
setGrantEndEnabled(false);
setConfirmation(null);
};
const requestCloseEditor = () => {
if (!hasUnsavedChanges) {
closeEditor();
return;
}
setConfirmation('discard-editor');
};
const selectCustomer = (user: AdminUser) => {
const nextDraft = draftFromUser(user);
setCreating(false);
setSelectedID(user.id);
setActiveSection('vehicles');
setDraft(nextDraft);
setBaselineDraft(nextDraft);
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setVehicleComposerOpen(user.vehicles.length === 0);
setFeedback('');
setEditingGrantVIN('');
setGrantEndEnabled(false);
};
const toggleVIN = (vin: string) => {
const isAssigned = assigned.has(vin);
if (!isAssigned) {
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))
}));
setFeedback(isAssigned
? `${vehicleLabels[vin] || vin} 已从授权草稿移除,保存后生效`
: `${vehicleLabels[vin] || vin} 已加入授权草稿,保存后生效`);
if (editingGrantVIN === vin) {
setEditingGrantVIN('');
setGrantEndEnabled(false);
}
};
const clearVehicleGrants = () => {
const removedCount = draft.vehicleGrants.length;
setDraft((value) => ({ ...value, vehicleGrants: [] }));
setAssignedKeyword('');
setAssignedPage(1);
setVehicleComposerOpen(true);
setEditingGrantVIN('');
setGrantEndEnabled(false);
setFeedback(`已从草稿移除 ${removedCount} 辆车,保存后生效`);
setConfirmation(null);
};
const updateGrant = (vin: string, patch: Partial<CustomerVehicleGrantInput>) => setDraft((value) => ({
...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant)
}));
const openGrantEditor = (vin: string) => {
const grant = draft.vehicleGrants.find((item) => item.vin === vin);
setGrantEndEnabled(Boolean(grant?.validTo));
setEditingGrantVIN(vin);
};
const closeGrantEditor = () => {
setEditingGrantVIN('');
setGrantEndEnabled(false);
};
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');
openGrantEditor(invalidGrant.vin);
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
return;
}
save.mutate();
};
const submit = (event: FormEvent) => {
event.preventDefault();
persistDraft();
};
const openMobileFilters = () => {
setDraftCustomerKeyword(customerKeyword);
setDraftCustomerStatus(customerStatus);
setFiltersCollapsed(false);
};
const closeMobileFilters = () => {
setDraftCustomerKeyword(customerKeyword);
setDraftCustomerStatus(customerStatus);
setFiltersCollapsed(true);
};
const applyMobileFilters = () => {
setCustomerKeyword(draftCustomerKeyword);
setCustomerStatus(draftCustomerStatus);
setFiltersCollapsed(true);
};
const resetMobileDraft = () => {
setDraftCustomerKeyword('');
setDraftCustomerStatus('all');
};
const submitMobileFilters = (event: FormEvent) => {
event.preventDefault();
applyMobileFilters();
};
return <div className="v2-user-admin">
<div className="v2-user-discovery-shell">
<WorkspaceCommandBar
className="v2-user-command-bar"
ariaLabel="账号管理操作"
title="客户访问治理"
description="菜单与车辆按最小权限开放,变更最多 30 秒生效"
status={`${customers.length} 个客户账号${attentionCustomerCount ? ` · ${attentionCustomerCount} 待完善` : ' · 权限就绪'}`}
meta={<Typography.Text type="tertiary"> · </Typography.Text>}
actions={<Button className="v2-workspace-mobile-tool-button is-primary" theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}></Button>}
/>
{mobileLayout ? <div className="v2-user-mobile-discovery">
<MobileFilterToggle
title="账号范围"
summary={`${customerScopeLabel(customerStatus)}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
expanded={mobileFiltersOpen}
controls="v2-user-mobile-filters"
expandedLabel="关闭"
collapsedLabel="修改"
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
/>
<MobileFilterSheet
className="v2-user-mobile-filter-sidesheet"
visible={mobileFiltersOpen}
height="min(62dvh, 480px)"
ariaLabel="客户账号筛选"
dialogId="v2-user-mobile-filters"
closeLabel="关闭客户账号筛选"
title="筛选客户账号"
description="按账号信息与权限状态缩小客户范围"
onCancel={closeMobileFilters}
secondaryAction={{ label: '重置条件', onClick: resetMobileDraft }}
primaryAction={{ label: '应用筛选', onClick: applyMobileFilters }}
>
<form className="v2-user-mobile-filter-form" onSubmit={submitMobileFilters}>
<MobileFilterSheetSection ariaLabel="客户账号筛选条件" title="账号范围" description="筛选仅在应用后更新客户目录,关闭不会丢失当前结果。">
<div>
<label>
<span></span>
<Input aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={draftCustomerKeyword} onChange={setDraftCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
</label>
<label>
<span id="v2-user-mobile-status-filter-label">访</span>
<Select
aria-labelledby="v2-user-mobile-status-filter-label"
value={draftCustomerStatus}
onChange={(value) => setDraftCustomerStatus(String(value) as CustomerScopeFilter)}
optionList={customerScopeOptions}
/>
</label>
</div>
</MobileFilterSheetSection>
</form>
</MobileFilterSheet>
</div> : <WorkspaceFilterPanel
className="v2-user-filter-panel"
title="账号范围"
description="按客户名称、登录账号与权限可用状态查找"
mobileSummary={`${customerScopeLabel(customerStatus)}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
expanded
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">
<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={customerScopeOptions}
/>
</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>
<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="当前范围内的账号、菜单与车辆授权摘要"
/>
<WorkspaceMetricRail
variant="queue"
className="v2-user-directory-metric-rail"
ariaLabel="客户权限目录统计"
items={directoryMetrics}
/>
<div className="v2-user-directory-body">
{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>} /> : <>
{visibleCustomers.length ? <div className="v2-user-directory-columns" role="row" aria-label="客户账号目录列">
<span></span>
<span></span>
<span></span>
<span></span>
<span>访</span>
</div> : null}
<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) => {
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length);
const authProvider = authProviderMeta(user.authProvider);
return <List.Item key={user.id} className={`v2-user-list-row is-access-${accessState.key}${accessState.attention ? ' has-attention' : ''}${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} 辆授权车,${accessState.label}`}
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">
<span className="v2-user-list-name"><b>{user.displayName}</b><span className="v2-user-provider-hint" title={authProvider.title}><Tag className="v2-user-provider-tag" color={authProvider.color} type="light" size="small">{authProvider.label}</Tag></span></span>
<small>@{user.username}{user.customerRef ? ` · ${user.customerRef}` : ''}</small>
</span>
<span className="v2-user-list-facts is-menu"><small></small><b>{user.menuKeys.length}<i> / {customerMenus.length}</i></b></span>
<span className="v2-user-list-facts is-vehicle"><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-${accessState.key}`} color={accessState.color} type="light" size="small"><span className="v2-user-status-tag-desktop">{accessState.label}</span><span className="v2-user-status-tag-mobile">{accessState.shortLabel}</span></Tag><IconChevronRight /></span>
</Button>
</List.Item>;
}}
/>
</div>
</>}
</div>
</Card>
</div>
<WorkspaceSideSheet
className="v2-user-editor-sidesheet"
variant="editor"
visible={editorVisible}
width={mobileLayout ? '100%' : 'min(840px, 100vw)'}
ariaLabel="客户账号详情"
closeLabel="关闭账号详情"
title={creating ? '创建客户账号' : selected?.displayName || '客户账号'}
description={creating ? '设置登录身份和最小必要权限' : `@${selected?.username || '—'} · 最近登录 ${formatTime(selected?.lastLoginAt)}`}
badge={draftAccessState.label}
badgeColor={accessBadgeColor(draftAccessState.color)}
summaryItems={[
{ label: '登录账号', value: draft.username ? `@${draft.username}` : '待设置', detail: creating ? '创建后不可修改' : '登录身份', tone: 'primary' },
{ label: '权限范围', value: `${draft.menuKeys.length} 菜单 · ${draft.vehicleGrants.length}`, detail: '最小必要权限', tone: draftAccessState.attention ? 'warning' : 'success' },
{
label: '账号状态',
value: <span className="v2-user-editor-status-summary"><Switch aria-label={draft.status === 'enabled' ? '账号启用' : '账号停用'} checked={draft.status === 'enabled'} onChange={(checked) => setDraft((value) => ({ ...value, status: checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '启用' : '停用'}</span></span>,
detail: '保存后最多 30 秒生效',
tone: draft.status === 'enabled' ? 'success' : 'neutral'
}
]}
onCancel={requestCloseEditor}
closeOnEsc={!confirmation}
footerNote={<span className="v2-user-save-state" role="status">
<Tag color={save.isError ? 'red' : hasUnsavedChanges ? 'amber' : 'green'} type="light" size="small">{save.isError ? '保存失败' : hasUnsavedChanges ? '待保存' : '已同步'}</Tag>
<Typography.Text className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} type={save.isError ? 'danger' : 'tertiary'}>{feedback || (hasUnsavedChanges ? '更改仅保存在当前草稿,保存后最多 30 秒生效' : '账号与权限已和服务器同步')}</Typography.Text>
</span>}
primaryAction={{ label: creating ? '创建账号' : '保存权限', onClick: persistDraft, disabled: save.isPending || !hasUnsavedChanges, loading: save.isPending }}
>
{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-identity-overview" aria-label="登录身份摘要">
<div>
<span><small></small><strong>{draft.username || '待设置'}</strong></span>
<Tag color={creating ? 'blue' : 'grey'} type="light" size="small">{creating ? '新账号' : '不可修改'}</Tag>
</div>
<div>
<span><small></small><strong>{draft.displayName || '待设置'}</strong></span>
<Tag color={draft.displayName ? 'green' : 'amber'} type="light" size="small">{draft.displayName ? '已设置' : '待完善'}</Tag>
</div>
<div>
<span><small></small><strong>{selected?.authProvider && selected.authProvider !== 'local' ? selected.authProvider : '本地账号'}</strong></span>
<Tag color="blue" type="light" size="small"></Tag>
</div>
</div>
<div className="v2-user-field-group" role="group" aria-labelledby="v2-user-primary-identity">
<header>
<span><strong id="v2-user-primary-identity"></strong><small></small></span>
<Tag color="blue" type="light" size="small"></Tag>
</header>
<div className="v2-user-fields is-primary">
<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>
</div>
</div>
<div className="v2-user-field-group" role="group" aria-labelledby="v2-user-security-mapping">
<header>
<span><strong id="v2-user-security-mapping"></strong><small>{creating ? '设置初始密码,并按需关联外部客户标识' : '密码留空则保持不变,客户标识用于后续系统对接'}</small></span>
<Tag color="grey" type="light" size="small">{creating ? '密码必填' : '按需修改'}</Tag>
</header>
<div className="v2-user-fields is-secondary">
<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>
</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-user-menu-overview" aria-label={`已开放 ${draft.menuKeys.length} 个菜单,共 ${customerMenus.length}`}>
<span><small></small><strong>{draft.menuKeys.length}<i> / {customerMenus.length}</i></strong></span>
<p><b>{draft.menuKeys.length === customerMenus.length ? '客户工作台已完整开放' : '客户工作台按最小权限开放'}</b><small> 30 </small></p>
<Tag color={draft.menuKeys.length ? 'green' : 'amber'} type="light" size="small">{draft.menuKeys.length ? '权限已配置' : '尚未开放'}</Tag>
</div>
<div className="v2-menu-permissions">{customerMenus.map((menu) => {
const selectedMenu = draft.menuKeys.includes(menu.key);
return <label key={menu.key} className={selectedMenu ? 'is-selected' : ''}>
<Checkbox checked={selectedMenu} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} />
<span className="v2-menu-permission-copy"><b>{menu.label}</b><small>{menu.description}</small></span>
<Tag className="v2-menu-permission-state" color={selectedMenu ? 'blue' : 'grey'} type="light" size="small">{selectedMenu ? '已开放' : '未开放'}</Tag>
</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${mobileLayout && !vehicleComposerVisible ? ' is-composer-collapsed' : ''}`}
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 ? <div className="v2-user-section-actions">
{mobileLayout && draft.vehicleGrants.length ? <Button
className="v2-user-vehicle-tools-toggle"
theme="light"
type="primary"
size="small"
icon={!vehicleComposerOpen ? <IconPlus /> : undefined}
aria-controls="v2-user-vehicle-composer"
aria-expanded={vehicleComposerOpen}
aria-label={vehicleComposerOpen ? '收起添加车辆' : '添加授权车辆'}
onClick={() => setVehicleComposerOpen((value) => !value)}
>{vehicleComposerOpen ? '收起' : '添加'}</Button> : null}
{draft.vehicleGrants.length ? <Button theme="borderless" type="tertiary" size="small" aria-haspopup="dialog" aria-controls="v2-user-clear-vehicles" aria-label={`清空全部 ${draft.vehicleGrants.length} 辆车辆权限`} onClick={() => setConfirmation('clear-vehicles')}></Button> : null}
</div> : null}
>
{vehicleComposerVisible ? <div id="v2-user-vehicle-composer" className="v2-user-vehicle-composer">
<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}
</div> : 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${mobileLayout ? ' is-mobile-compact' : ''}`} 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={() => openGrantEditor(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" aria-label={`授权有效期:启用 ${formatGrantTime(grant.validFrom)};停用 ${grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}`}><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}
</WorkspaceSideSheet>
<WorkspaceSideSheet
className="v2-user-grant-sidesheet"
variant="config"
visible={Boolean(editingGrant)}
placement={mobileLayout ? 'bottom' : 'right'}
width={420}
height={mobileLayout ? 'min(82dvh, 680px)' : undefined}
ariaLabel="车辆授权有效期"
closeLabel="关闭车辆授权有效期"
title="车辆授权有效期"
description={editingGrant ? `${editingGrantPlate || '未登记车牌'} · ${editingGrant.vin}` : '设置车辆可访问时间窗口'}
badge={editingGrant?.validTo ? '定时停用' : '持续有效'}
badgeColor={editingGrant?.validTo ? 'orange' : 'green'}
summaryItems={editingGrant ? [
{ label: '车牌', value: editingGrantPlate || '未登记车牌', tone: 'primary' },
{ label: '启用', value: formatGrantTime(editingGrant.validFrom), detail: '授权开始' },
{ label: '停用', value: editingGrant.validTo ? formatGrantTime(editingGrant.validTo) : '持续有效', detail: editingGrant.validTo ? '授权结束' : '未设置结束时间', tone: editingGrant.validTo ? 'warning' : 'success' }
] : []}
onCancel={closeGrantEditor}
footerNote="完成后仍需保存账号权限才会生效"
primaryAction={{ label: '完成', onClick: closeGrantEditor, disabled: grantWindowInvalid }}
>
{editingGrant ? <div className="v2-user-grant-form">
<div className="v2-user-grant-field-group" role="group" aria-labelledby="v2-user-grant-window-heading">
<header><span><strong id="v2-user-grant-window-heading">访</strong><small></small></span><Tag color="blue" type="light" size="small"></Tag></header>
<div>
<label><span></span><WorkspaceDateTimeField ariaLabel={`${editingGrantPlate || editingGrant.vin} 启用时间`} value={editingGrant.validFrom} onChange={(value) => updateGrant(editingGrant.vin, { validFrom: value })} /></label>
<div className="v2-user-grant-policy">
<span><strong></strong><small>{grantEndEnabled ? '关闭后必须指定停用时间' : '保持开启则不自动收回权限'}</small></span>
<Switch
aria-label={`${editingGrantPlate || editingGrant.vin} 持续有效`}
checked={!grantEndEnabled}
onChange={(checked) => {
setGrantEndEnabled(!checked);
if (checked) updateGrant(editingGrant.vin, { validTo: '' });
}}
/>
</div>
{grantEndEnabled ? <label><span></span><WorkspaceDateTimeField ariaLabel={`${editingGrantPlate || editingGrant.vin} 停用时间`} value={editingGrant.validTo} onChange={(value) => updateGrant(editingGrant.vin, { validTo: value })} placeholder="选择停用日期和时间" showClear /></label> : null}
</div>
</div>
{grantWindowInvalid ? <p role="alert"></p> : <p>{grantEndEnabled ? '到达停用时间后,客户将不再看到该车数据。' : '当前授权持续有效,不会自动收回。'}</p>}
</div> : null}
</WorkspaceSideSheet>
<WorkspaceConfirmDialog
visible={confirmation === 'clear-vehicles'}
ariaLabel="确认清空车辆权限"
className="v2-user-confirm-dialog"
title={`清空 ${draft.vehicleGrants.length} 辆车辆权限?`}
description="车辆会先从当前草稿中移除,只有点击账号详情底部的保存权限后才会正式生效。"
summaryItems={[
{ label: '影响对象', value: `${draft.vehicleGrants.length}`, detail: '当前账号全部车辆权限', tone: 'danger' },
{ label: '当前动作', value: '修改草稿', detail: '确认后仍可继续编辑', tone: 'warning' },
{ label: '正式生效', value: '保存权限后', detail: '未保存不会影响服务器', tone: 'success' }
]}
cancelLabel="返回核对"
confirmLabel="确认清空"
note="这是草稿操作,仍需再次保存权限。"
onCancel={() => setConfirmation(null)}
onConfirm={clearVehicleGrants}
/>
<WorkspaceConfirmDialog
visible={confirmation === 'discard-editor'}
ariaLabel="确认放弃未保存的更改"
className="v2-user-confirm-dialog"
title="放弃未保存的更改?"
description="登录身份、菜单或车辆权限的修改尚未保存,关闭后将恢复到上次保存状态。"
summaryItems={[
{ label: '草稿状态', value: '尚未保存', detail: `${draft.menuKeys.length} 菜单 · ${draft.vehicleGrants.length}`, tone: 'warning' },
{ label: '关闭结果', value: creating ? '清空新建草稿' : '恢复已保存状态', detail: creating ? '账号不会被创建' : `账号 @${selected?.username || draft.username}`, tone: 'danger' },
{ label: '服务器数据', value: '不受影响', detail: '不会发送保存请求', tone: 'success' }
]}
cancelLabel="继续编辑"
confirmLabel="放弃更改"
note="放弃后无法恢复当前草稿。"
onCancel={() => setConfirmation(null)}
onConfirm={closeEditor}
/>
</div>;
}