Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx
T

1406 lines
86 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, IconDownload, IconPlus, IconRefresh, IconSearch, IconUserGroup } from '@douyinfe/semi-icons';
import { Avatar, Button, Card, Checkbox, Collapse, Input, List, Select, Switch, Tabs, Tag, Typography, Upload } from '@douyinfe/semi-ui';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AdminUser, CustomerUserBatchResult, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
import { accountBatchImportCSVHeader, accountBatchImportCSVTemplate, parseAccountBatchImportCSV, type AccountBatchImportItem } from '../domain/accountBatchImport';
import { downloadBlob } from '../domain/download';
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';
import '../styles/users.css';
const customerMenus = [
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
{ key: 'vehicles', label: '车辆查询', description: '查看车辆档案与最新遥测' },
{ key: 'tracks', label: '轨迹回放', description: '查询授权车辆的历史轨迹' },
{ key: 'statistics', label: '里程查询', description: '查询授权车辆的每日与区间里程' }
];
export type CustomerAccessDraft = {
username: string;
displayName: string;
password: string;
status: 'enabled' | 'disabled';
customerRef: string;
tenantRef: string;
menuKeys: string[];
vehicleGrants: CustomerVehicleGrantInput[];
};
type Draft = CustomerAccessDraft;
type EditorSection = 'identity' | 'menus' | 'vehicles';
type CustomerScopeFilter = 'all' | Draft['status'] | 'attention';
type UserDirectoryRoute = {
keyword: string;
status: CustomerScopeFilter;
page: number;
limit: number;
userID: number | null;
mode: 'create' | '';
importOpen: boolean;
section: EditorSection;
};
type CustomerAccessState = {
key: 'ready' | 'missing-menus' | 'missing-vehicles' | 'identity-required' | '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 userDirectoryPageSizes = [10, 20, 50];
export function userDirectoryRouteFromParams(params: URLSearchParams, defaultLimit = 20): UserDirectoryRoute {
const statusParam = params.get('userStatus');
const sectionParam = params.get('userSection');
const userID = Number(params.get('userId'));
const limit = Number(params.get('userLimit'));
const page = Number(params.get('userPage'));
return {
keyword: (params.get('userSearch') || '').slice(0, 160),
status: statusParam === 'enabled' || statusParam === 'disabled' || statusParam === 'attention' ? statusParam : 'all',
page: Number.isInteger(page) && page > 0 ? page : 1,
limit: userDirectoryPageSizes.includes(limit) ? limit : defaultLimit,
userID: Number.isInteger(userID) && userID > 0 ? userID : null,
mode: params.get('userMode') === 'create' ? 'create' : '',
importOpen: params.get('userImport') === 'open',
section: sectionParam === 'menus' || sectionParam === 'vehicles' ? sectionParam : 'identity'
};
}
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: [], vehicleGrants: [] };
function passwordMeetsPolicy(value: string) {
const categoryCount = [
/[a-z]/.test(value),
/[A-Z]/.test(value),
/\d/.test(value),
/[^A-Za-z0-9]/.test(value)
].filter(Boolean).length;
return value.length >= 10 && value.length <= 128 && categoryCount >= 3;
}
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))
});
}
export function customerAccessDraftDiff(draft: CustomerAccessDraft, baseline: CustomerAccessDraft) {
const draftMenus = new Set(draft.menuKeys);
const baselineMenus = new Set(baseline.menuKeys);
const draftGrants = new Map(draft.vehicleGrants.map((grant) => [grant.vin, grant]));
const baselineGrants = new Map(baseline.vehicleGrants.map((grant) => [grant.vin, grant]));
const addedMenus = draft.menuKeys.filter((key) => !baselineMenus.has(key));
const removedMenus = baseline.menuKeys.filter((key) => !draftMenus.has(key));
const addedVehicles = draft.vehicleGrants.filter((grant) => !baselineGrants.has(grant.vin));
const removedVehicles = baseline.vehicleGrants.filter((grant) => !draftGrants.has(grant.vin));
const changedVehicles = draft.vehicleGrants.filter((grant) => {
const saved = baselineGrants.get(grant.vin);
return saved && (saved.validFrom !== grant.validFrom || saved.validTo !== grant.validTo);
});
const identityChanged = ['displayName', 'password', 'customerRef', 'tenantRef']
.some((key) => draft[key as keyof Pick<Draft, 'displayName' | 'password' | 'customerRef' | 'tenantRef'>]
!== baseline[key as keyof Pick<Draft, 'displayName' | 'password' | 'customerRef' | 'tenantRef'>]);
const statusChanged = draft.status !== baseline.status;
return {
addedMenus,
removedMenus,
addedVehicles,
removedVehicles,
changedVehicles,
identityChanged,
statusChanged,
disablesAccount: baseline.status === 'enabled' && draft.status === 'disabled',
requiresConfirmation: removedMenus.length > 0 || removedVehicles.length > 0 || (baseline.status === 'enabled' && draft.status === 'disabled')
};
}
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, identityRequired = false): CustomerAccessState {
if (status === 'disabled') return { key: 'disabled', label: '账号已停用', shortLabel: '停用', color: 'grey', attention: false };
if (identityRequired) return { key: 'identity-required', label: '身份待关联', shortLabel: '待身份', color: 'amber', attention: true };
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, externalSubject?: string) {
const normalized = provider?.trim();
if (!normalized || normalized === 'local') return {
label: '本地', color: 'grey' as const, title: '本地账号', external: false, mappingComplete: true,
sourceLabel: '平台本地账号', credentialOwner: '本平台', stateLabel: '凭据可维护'
};
const mappingComplete = Boolean(externalSubject?.trim());
return {
label: normalized, color: mappingComplete ? 'cyan' as const : 'amber' as const,
title: mappingComplete ? `外部身份源:${normalized},映射已连接` : `外部身份源:${normalized},缺少身份映射`,
external: true, mappingComplete, sourceLabel: normalized, credentialOwner: normalized,
stateLabel: mappingComplete ? '映射已连接' : '映射待补全'
};
}
function maskExternalSubject(value?: string) {
const normalized = value?.trim() ?? '';
if (!normalized) return '未建立映射';
if (normalized.length <= 6) return `${normalized.slice(0, 1)}***${normalized.slice(-1)}`;
return `${normalized.slice(0, 3)}••••${normalized.slice(-4)}`;
}
function identityNeedsAttention(user: Pick<AdminUser, 'authProvider' | 'externalSubject'>) {
const provider = authProviderMeta(user.authProvider, user.externalSubject);
return provider.external && !provider.mappingComplete;
}
export default function UsersPage() {
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
const [routeParams, setRouteParams] = useSearchParams();
const defaultDirectoryLimit = mobileLayout ? 10 : 20;
const routeView = useMemo(() => userDirectoryRouteFromParams(routeParams, defaultDirectoryLimit), [defaultDirectoryLimit, routeParams]);
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(routeView.keyword);
const deferredCustomerKeyword = useDeferredValue(customerKeyword.trim());
const [customerStatus, setCustomerStatus] = useState<CustomerScopeFilter>(routeView.status);
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, identityNeedsAttention(user));
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, user.authProvider]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword));
});
}, [customerStatus, customers, deferredCustomerKeyword]);
const directoryTotalPages = Math.max(1, Math.ceil(visibleCustomers.length / routeView.limit));
const safeDirectoryPage = Math.min(routeView.page, directoryTotalPages);
const pagedCustomers = useMemo(() => {
const offset = (safeDirectoryPage - 1) * routeView.limit;
return visibleCustomers.slice(offset, offset + routeView.limit);
}, [routeView.limit, safeDirectoryPage, visibleCustomers]);
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' | 'save-access' | 'switch-customer' | null>(null);
const [pendingCustomerID, setPendingCustomerID] = useState<number | null>(null);
const [importItems, setImportItems] = useState<AccountBatchImportItem[]>([]);
const [importFileName, setImportFileName] = useState('');
const [importParseError, setImportParseError] = useState('');
const [importResult, setImportResult] = useState<CustomerUserBatchResult | null>(null);
const [repairRow, setRepairRow] = useState<number | null>(null);
const attentionCustomerCount = useMemo(() => customers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user)).attention).length, [customers]);
const disabledCustomerCount = useMemo(() => customers.filter((user) => user.status === 'disabled').length, [customers]);
const readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user)).key === 'ready').length, [visibleCustomers]);
const attentionCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user)).attention).length, [visibleCustomers]);
const disabledCustomers = useMemo(() => visibleCustomers.filter((user) => user.status === 'disabled').length, [visibleCustomers]);
const externalCustomers = useMemo(() => visibleCustomers.filter((user) => authProviderMeta(user.authProvider, user.externalSubject).external), [visibleCustomers]);
const externalIdentityPending = useMemo(() => externalCustomers.filter(identityNeedsAttention).length, [externalCustomers]);
const localCustomerCount = customers.length - customers.filter((user) => authProviderMeta(user.authProvider, user.externalSubject).external).length;
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: externalCustomers.length,
note: externalIdentityPending ? `${externalIdentityPending} 个待关联` : '当前范围已连接',
tone: externalIdentityPending ? 'warning' : '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 draftDiff = useMemo(() => customerAccessDraftDiff(draft, baselineDraft), [baselineDraft, draft]);
const selectedIdentityProvider = authProviderMeta(selected?.authProvider, selected?.externalSubject);
const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length, Boolean(!creating && selected && selectedIdentityProvider.external && !selectedIdentityProvider.mappingComplete));
const invalidLinkedUser = users.isSuccess && routeView.userID != null && !customers.some((user) => user.id === routeView.userID);
const linkedUserOutsideScope = selected && !visibleCustomers.some((user) => user.id === selected.id);
const repairingImportItem = importItems.find((item) => item.row === repairRow);
const importReadyCount = importResult?.mode === 'preview' ? importResult.summary.ready : 0;
const importCreatedCount = importResult?.mode === 'create' ? importResult.summary.created : 0;
const importFailedCount = importResult?.summary.failed ?? 0;
const setUserRoute = (patch: Partial<UserDirectoryRoute>, replace = false) => {
setRouteParams((current) => {
const next = new URLSearchParams(current);
const view = { ...userDirectoryRouteFromParams(current, defaultDirectoryLimit), ...patch };
const setOptional = (key: string, value: string, empty: string) => value === empty ? next.delete(key) : next.set(key, value);
setOptional('userSearch', view.keyword.trimStart(), '');
setOptional('userStatus', view.status, 'all');
setOptional('userPage', String(view.page), '1');
setOptional('userLimit', String(view.limit), String(defaultDirectoryLimit));
setOptional('userId', view.userID ? String(view.userID) : '', '');
setOptional('userMode', view.mode, '');
setOptional('userImport', view.importOpen ? 'open' : '', '');
setOptional('userSection', view.section, 'identity');
return next;
}, { replace });
};
const clearEditorRoute = (replace = false) => setUserRoute({ userID: null, mode: '', section: 'identity' }, replace);
useEffect(() => {
if (!hasUnsavedChanges) return;
const protectDraft = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener('beforeunload', protectDraft);
return () => window.removeEventListener('beforeunload', protectDraft);
}, [hasUnsavedChanges]);
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);
setUserRoute({ userID: result.id, mode: '', section: activeSection }, true);
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 batchImport = useMutation<CustomerUserBatchResult, Error, 'preview' | 'create'>({
mutationFn: (mode) => api.batchCustomerUsers(mode, importItems),
onSuccess: async (result) => {
setImportResult(result);
setRepairRow(result.items.find((item) => item.status === 'invalid' || item.status === 'conflict' || item.status === 'failed')?.row ?? null);
if (result.mode === 'create' && result.summary.created) await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
}
});
const resetBatchImport = () => {
batchImport.reset();
setImportItems([]);
setImportFileName('');
setImportParseError('');
setImportResult(null);
setRepairRow(null);
};
const openBatchImport = () => {
if (hasUnsavedChanges) {
setFeedback('请先保存或关闭当前账号草稿,再批量导入');
return;
}
closeEditor(false);
resetBatchImport();
setUserRoute({ userID: null, mode: '', importOpen: true, section: 'identity' });
};
const closeBatchImport = () => {
setUserRoute({ importOpen: false }, true);
resetBatchImport();
};
const readImportFile = async (file?: File) => {
batchImport.reset();
setImportResult(null);
setRepairRow(null);
setImportItems([]);
setImportFileName(file?.name ?? '');
setImportParseError('');
if (!file) return;
try {
const text = typeof file.text === 'function' ? await file.text() : await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error('无法读取 CSV 文件'));
reader.onload = () => resolve(String(reader.result ?? ''));
reader.readAsText(file);
});
setImportItems(parseAccountBatchImportCSV(text));
} catch (error) {
setImportParseError(error instanceof Error ? error.message : 'CSV 解析失败');
}
};
const updateImportItem = (row: number, patch: Partial<AccountBatchImportItem['input']>) => {
setImportItems((current) => current.map((item) => item.row === row ? { ...item, input: { ...item.input, ...patch } } : item));
setImportResult(null);
batchImport.reset();
};
const retryFailedImportItems = () => {
if (!importResult) return;
const failedRows = new Set(importResult.items.filter((item) => item.status !== 'created').map((item) => item.row));
setImportItems((current) => current.filter((item) => failedRows.has(item.row)));
setImportResult(null);
setRepairRow(null);
batchImport.reset();
};
const downloadImportTemplate = () => downloadBlob(new Blob([`\uFEFF${accountBatchImportCSVTemplate}`], { type: 'text/csv;charset=utf-8' }), 'customer-account-import-template.csv');
const startCreate = (syncRoute = true) => {
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);
setPendingCustomerID(null);
if (syncRoute) setUserRoute({ userID: null, mode: 'create', section: 'identity' });
};
const closeEditor = (syncRoute = true) => {
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);
setPendingCustomerID(null);
if (syncRoute) clearEditorRoute();
};
const requestCloseEditor = () => {
if (!hasUnsavedChanges) {
closeEditor();
return;
}
setConfirmation('discard-editor');
};
const loadCustomer = (user: AdminUser, section: EditorSection = 'identity', syncRoute = true) => {
const nextDraft = draftFromUser(user);
setCreating(false);
setSelectedID(user.id);
setActiveSection(section);
setDraft(nextDraft);
setBaselineDraft(nextDraft);
setVehicleKeyword('');
setAssignedKeyword('');
setAssignedPage(1);
setBulkVINs('');
setVehicleComposerOpen(user.vehicles.length === 0);
setFeedback('');
setEditingGrantVIN('');
setGrantEndEnabled(false);
setConfirmation(null);
setPendingCustomerID(null);
if (syncRoute) setUserRoute({ userID: user.id, mode: '', section });
};
const selectCustomer = (user: AdminUser) => {
if (!creating && selectedID === user.id) return;
if (hasUnsavedChanges) {
setPendingCustomerID(user.id);
setConfirmation('switch-customer');
return;
}
loadCustomer(user);
};
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 = (confirmed = false) => {
if (save.isPending) return;
setFeedback('');
if (!draft.displayName.trim() || (creating && (!draft.username.trim() || !draft.password))) {
setActiveSection('identity');
setUserRoute({ section: 'identity' }, true);
setFeedback('请先完善登录身份中的必填信息');
return;
}
const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom));
if (invalidGrant) {
setActiveSection('vehicles');
setUserRoute({ section: 'vehicles' }, true);
openGrantEditor(invalidGrant.vin);
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
return;
}
if (!confirmed && draftDiff.requiresConfirmation) {
setConfirmation('save-access');
return;
}
save.mutate();
};
const creatingIdentityComplete = Boolean(
draft.username.trim()
&& draft.displayName.trim()
&& passwordMeetsPolicy(draft.password)
);
const editorPrimaryLabel = creating
? activeSection === 'identity'
? '下一步:菜单权限'
: activeSection === 'menus'
? '下一步:车辆权限'
: '创建账号'
: '保存权限';
const editorPrimaryDisabled = save.isPending || (creating
? activeSection === 'identity'
? !creatingIdentityComplete
: !hasUnsavedChanges
: !hasUnsavedChanges);
const editorSaveState = save.isError
? { color: 'red' as const, label: '保存失败', copy: feedback }
: creating
? {
color: hasUnsavedChanges ? 'amber' as const : 'blue' as const,
label: '新建草稿',
copy: feedback || (hasUnsavedChanges
? '内容仅保存在当前草稿,完成三步后再创建账号'
: '先填写登录身份,再确认菜单和车辆权限')
}
: hasUnsavedChanges
? { color: 'amber' as const, label: '待保存', copy: feedback || '更改仅保存在当前草稿,保存后最多 30 秒生效' }
: { color: 'green' as const, label: '已同步', copy: feedback || '账号与权限已和服务器同步' };
const handleEditorPrimaryAction = () => {
if (!creating) {
persistDraft();
return;
}
setFeedback('');
if (activeSection === 'identity') {
if (!creatingIdentityComplete) {
setFeedback('请填写用户名、客户名称,并设置符合安全规则的初始密码');
return;
}
setActiveSection('menus');
setUserRoute({ section: 'menus' }, true);
setFeedback('登录身份已完成,请确认客户可以访问的菜单');
return;
}
if (activeSection === 'menus') {
setActiveSection('vehicles');
setUserRoute({ section: 'vehicles' }, true);
setFeedback(draft.menuKeys.length
? `已选择 ${draft.menuKeys.length} 个菜单,请继续分配车辆`
: '当前尚未开放菜单;可继续分配车辆,账号将标记为待完善');
return;
}
persistDraft();
};
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);
setUserRoute({ keyword: draftCustomerKeyword, status: draftCustomerStatus, page: 1 }, true);
setFiltersCollapsed(true);
};
const resetMobileDraft = () => {
setDraftCustomerKeyword('');
setDraftCustomerStatus('all');
};
const submitMobileFilters = (event: FormEvent) => {
event.preventDefault();
applyMobileFilters();
};
useEffect(() => {
if (routeView.keyword !== customerKeyword) setCustomerKeyword(routeView.keyword);
if (routeView.status !== customerStatus) setCustomerStatus(routeView.status);
}, [routeView.keyword, routeView.status]);
useEffect(() => {
if (!users.isSuccess || hasUnsavedChanges) return;
if (routeView.mode === 'create') {
if (!creating) startCreate(false);
if (activeSection !== routeView.section) setActiveSection(routeView.section);
return;
}
if (routeView.userID) {
const routeCustomer = customers.find((user) => user.id === routeView.userID);
if (!routeCustomer) return;
if (creating || selectedID !== routeCustomer.id) loadCustomer(routeCustomer, routeView.section, false);
else if (activeSection !== routeView.section) setActiveSection(routeView.section);
return;
}
if (creating || selectedID) closeEditor(false);
}, [routeView.mode, routeView.section, routeView.userID, users.isSuccess]);
useEffect(() => {
if (!users.isSuccess || routeView.page <= directoryTotalPages) return;
setUserRoute({ page: directoryTotalPages }, true);
}, [directoryTotalPages, routeView.page, users.isSuccess]);
return <div className="v2-user-admin">
<div className="v2-user-discovery-shell">
<WorkspaceCommandBar
className="v2-user-command-bar"
ariaLabel="账号管理操作"
icon={<IconUserGroup />}
tone="authority"
title="客户访问治理"
description="菜单与车辆按最小权限开放,变更最多 30 秒生效"
status={`${customers.length} 个客户账号${attentionCustomerCount ? ` · ${attentionCustomerCount} 待完善` : disabledCustomerCount ? ` · ${disabledCustomerCount} 已停用` : ' · 权限就绪'}`}
meta={<Typography.Text type="tertiary">{localCustomerCount} 个本地账号 · {customers.length - localCustomerCount} 个外部身份</Typography.Text>}
actions={<><Button className="v2-workspace-mobile-tool-button" theme="light" aria-label="批量导入客户账号" onClick={openBatchImport}>批量导入</Button><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={(value) => {
setCustomerKeyword(value);
setUserRoute({ keyword: value, page: 1 }, true);
}} placeholder="名称、账号、客户标识或身份源" />
</label>
<label>
<span id="v2-user-status-filter-label">访问状态</span>
<Select
aria-labelledby="v2-user-status-filter-label"
value={customerStatus}
onChange={(value) => {
const status = String(value) as typeof customerStatus;
setCustomerStatus(status);
setUserRoute({ status, page: 1 }, true);
}}
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');
setUserRoute({ keyword: '', status: 'all', page: 1 }, true);
}}
>
清空
</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="当前范围内的账号、菜单与车辆授权摘要"
meta={<span className="v2-user-directory-glance" aria-label={`共 ${visibleCustomers.length} 个账号,${readyCustomers} 个权限就绪,${disabledCustomers} 个已停用`}>
<b>{visibleCustomers.length}</b> 个账号
<i aria-hidden="true" />
<span className={attentionCustomers ? 'has-attention' : ''}>{attentionCustomers ? `${attentionCustomers} 个待完善` : disabledCustomers ? `${readyCustomers} 就绪 · ${disabledCustomers} 停用` : '全部权限就绪'}</span>
</span>}
/>
<WorkspaceMetricRail
variant="queue"
className="v2-user-directory-metric-rail"
ariaLabel="客户权限目录统计"
items={directoryMetrics}
/>
<div className="v2-user-directory-body">
{invalidLinkedUser ? <div className="v2-user-route-notice is-warning" role="alert">
<span><strong>账号链接已失效</strong><small>账号 #{routeView.userID} 可能已被删除,目录筛选仍可继续使用。</small></span>
<Button theme="light" type="warning" size="small" onClick={() => clearEditorRoute(true)}>清除失效链接</Button>
</div> : linkedUserOutsideScope ? <div className="v2-user-route-notice" role="status">
<span><strong>正在配置筛选范围外的账号</strong><small>{selected.displayName} 不在当前目录结果中,详情仍保持打开。</small></span>
<Button theme="light" type="primary" size="small" onClick={() => {
setCustomerKeyword('');
setCustomerStatus('all');
setUserRoute({ keyword: '', status: 'all', page: 1 }, true);
}}>显示所在目录</Button>
</div> : null}
{users.isPending ? <PanelLoading className="v2-user-list-loading" title="正在加载客户账号" description="账号目录和授权摘要就绪后会自动显示。" /> : users.isError ? <PanelEmpty
className="v2-user-list-empty"
tone="warning"
icon={<IconUserGroup />}
title="账号目录暂时不可用"
description="这不是空目录。保留当前筛选,恢复连接后可原位重试。"
action={<Button theme="light" type="warning" icon={<IconRefresh />} onClick={() => users.refetch()}>重新读取账号</Button>}
/> : customers.length === 0 ? <PanelEmpty
className="v2-user-list-empty"
tone="primary"
icon={<IconUserGroup />}
title="还没有客户账号"
description="创建客户账号后,可在这里配置菜单和车辆范围。"
action={<Button theme="solid" icon={<IconPlus />} aria-label="创建第一个客户账号" onClick={() => startCreate()}>创建第一个客户账号</Button>}
/> : <>
{visibleCustomers.length ? <div className="v2-user-directory-columns" aria-hidden="true">
<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={pagedCustomers}
emptyContent={<PanelEmpty
className="v2-user-filter-empty"
tone="primary"
icon={<IconSearch />}
title="没有匹配账号"
description={`当前“${customerScopeLabel(customerStatus)}”范围没有匹配结果。`}
action={<Button
theme="light"
type="primary"
icon={<IconRefresh />}
aria-label="清除筛选"
onClick={() => {
setCustomerKeyword('');
setCustomerStatus('all');
setUserRoute({ keyword: '', status: 'all', page: 1 }, true);
}}
>清除筛选</Button>}
/>}
renderItem={(user) => {
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user));
const authProvider = authProviderMeta(user.authProvider, user.externalSubject);
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}${authProvider.title}${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 title={user.displayName}>{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>
<span className="v2-user-list-secondary">
<small>@{user.username}{user.customerRef ? ` · ${user.customerRef}` : ''}</small>
<span className="v2-user-list-access-summary" aria-hidden="true"><b>{user.menuKeys.length}</b> 菜单 · <b>{user.vehicles.length}</b> 辆车</span>
</span>
</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><span className="v2-user-config-label">配置</span><IconChevronRight /></span>
</Button>
</List.Item>;
}}
/>
</div>
{visibleCustomers.length ? <footer className="v2-user-directory-pagination">
<TablePagination
page={safeDirectoryPage}
totalPages={directoryTotalPages}
info={<> {(safeDirectoryPage - 1) * routeView.limit + 1}{Math.min(safeDirectoryPage * routeView.limit, visibleCustomers.length)} 个,共 {visibleCustomers.length} 个账号</>}
onPageChange={(page) => setUserRoute({ page })}
pageSize={mobileLayout ? undefined : routeView.limit}
pageSizeLabel="每页账号数"
onPageSizeChange={(limit) => setUserRoute({ limit, page: 1 })}
pageSizeOptions={mobileLayout ? undefined : userDirectoryPageSizes.map((value) => ({ value, label: `${value} 个/页` }))}
/>
</footer> : null}
</>}
</div>
</Card>
</div>
<WorkspaceSideSheet
className="v2-user-import-sidesheet"
variant="task"
visible={routeView.importOpen}
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 'min(900px, 100vw)'}
height={mobileLayout ? 'min(94dvh, 860px)' : undefined}
ariaLabel="批量导入客户账号"
closeLabel="关闭批量导入客户账号"
title="批量导入客户账号"
description="先预检账号、菜单和车辆,再只创建校验通过项"
icon={<IconUserGroup />}
badge={importResult?.mode === 'create' ? `${importCreatedCount} 个已创建` : importResult?.mode === 'preview' ? `${importReadyCount} 个可创建` : importItems.length ? `${importItems.length} 行待预检` : 'CSV 导入'}
badgeColor={importFailedCount ? 'orange' : importCreatedCount ? 'green' : 'blue'}
summaryItems={[
{ label: '已读取', value: importItems.length || '—', detail: importFileName || '等待选择 CSV', tone: 'primary' },
{ label: '可创建', value: importReadyCount || '—', detail: importResult?.mode === 'preview' ? '服务端校验通过' : '完成预检后显示', tone: importReadyCount ? 'success' : 'neutral' },
{ label: '失败项', value: importFailedCount || '—', detail: importFailedCount ? '可在当前窗口修复并重试' : '暂无待处理错误', tone: importFailedCount ? 'warning' : 'neutral' }
]}
onCancel={closeBatchImport}
footerNote={batchImport.isError ? batchImport.error.message : importResult?.mode === 'create' ? `已创建 ${importCreatedCount} 个;${importFailedCount} 个未创建` : '预检不会写入账号,确认创建后逐项提交'}
secondaryActions={[{ label: '下载模板', icon: <IconDownload />, onClick: downloadImportTemplate }]}
primaryAction={importResult?.mode === 'create' && !importFailedCount
? { label: '完成', onClick: closeBatchImport }
: importResult?.mode === 'create' && importFailedCount
? { label: `仅保留 ${importFailedCount} 个失败项`, onClick: retryFailedImportItems }
: importResult?.mode === 'preview'
? { label: `创建 ${importReadyCount} 个账号`, onClick: () => batchImport.mutate('create'), loading: batchImport.isPending, disabled: !importReadyCount }
: { label: '开始预检', onClick: () => batchImport.mutate('preview'), loading: batchImport.isPending, disabled: !importItems.length || Boolean(importParseError) }}
>
<div className="v2-user-import-workspace">
<section className="v2-user-import-source" aria-labelledby="v2-user-import-source-title">
<header><span><strong id="v2-user-import-source-title">1. 选择账号清单</strong><small>每次最多 50 个;支持带引号的标准 CSV</small></span><Tag color={importItems.length ? 'green' : 'blue'} type="light" size="small">{importItems.length ? '已读取' : '未选择'}</Tag></header>
<div className="v2-user-import-upload">
<Upload action="" accept=".csv,text/csv" limit={1} uploadTrigger="custom" showUploadList={false} onFileChange={(files) => { const selectedFile = files[0] as File & { fileInstance?: File }; void readImportFile(selectedFile?.fileInstance ?? selectedFile); }}>
<Button theme="light" type="primary">{importFileName ? '重新选择 CSV' : '选择 CSV 文件'}</Button>
</Upload>
<span><strong>{importFileName || '尚未选择文件'}</strong><small>{importItems.length ? `已读取 ${importItems.length} 个客户账号` : '密码、菜单和车辆范围会在浏览器中解析后送往服务端预检'}</small></span>
</div>
<p className="v2-user-import-format"><span>表头</span><code>{accountBatchImportCSVHeader}</code></p>
{importParseError ? <p className="v2-user-import-error" role="alert">{importParseError}</p> : null}
</section>
{importItems.length ? <section className="v2-user-import-review" aria-labelledby="v2-user-import-review-title">
<header><span><strong id="v2-user-import-review-title">2. 预检与修复</strong><small>{importResult ? '按 CSV 行号定位问题;修复后需重新预检' : '服务端将校验用户名冲突、密码策略、菜单和车辆接入状态'}</small></span><Tag color={importResult ? (importFailedCount ? 'orange' : 'green') : 'grey'} type="light" size="small">{importResult ? (importFailedCount ? `${importFailedCount} 项待处理` : '全部通过') : '等待预检'}</Tag></header>
<div className="v2-user-import-list" role="list" aria-label="批量账号预检结果">
{importItems.map((item) => {
const result = importResult?.items.find((candidate) => candidate.row === item.row);
const failed = result && result.status !== 'ready' && result.status !== 'created';
return <article key={item.row} role="listitem" className={`v2-user-import-row${failed ? ' has-error' : result?.status === 'created' ? ' is-created' : result?.status === 'ready' ? ' is-ready' : ''}`}>
<span className="v2-user-import-row-number">{item.row}</span>
<span className="v2-user-import-row-copy"><strong>{item.input.displayName || '未填写客户名称'}</strong><small>@{item.input.username || '未填写用户名'} · {item.input.menuKeys.length} 菜单 · {item.input.vehicleVins?.length ?? 0} 辆车</small></span>
<Tag color={result?.status === 'created' || result?.status === 'ready' ? 'green' : failed ? 'red' : 'grey'} type="light" size="small">{result?.status === 'created' ? '已创建' : result?.status === 'ready' ? '可创建' : result?.status === 'conflict' ? '用户名冲突' : result?.status === 'invalid' ? '配置无效' : result?.status === 'failed' ? '创建失败' : '待预检'}</Tag>
<span className="v2-user-import-row-result"><small>{result?.message || '等待服务端预检'}</small>{failed ? <Button theme="borderless" type="primary" size="small" aria-label={`修复第 ${item.row} 行`} onClick={() => setRepairRow(repairRow === item.row ? null : item.row)}>{repairRow === item.row ? '收起' : '修复'}</Button> : null}</span>
</article>;
})}
</div>
</section> : null}
{repairingImportItem ? <section className="v2-user-import-repair" aria-labelledby="v2-user-import-repair-title">
<header><span><strong id="v2-user-import-repair-title">修复第 {repairingImportItem.row} </strong><small>修改会保留在当前导入清单;完成后重新预检</small></span><Button theme="borderless" type="tertiary" size="small" onClick={() => setRepairRow(null)}>收起</Button></header>
<div className="v2-user-import-repair-fields">
<label><span>用户名</span><Input value={repairingImportItem.input.username} onChange={(username) => updateImportItem(repairingImportItem.row, { username })} /></label>
<label><span>客户名称</span><Input value={repairingImportItem.input.displayName} onChange={(displayName) => updateImportItem(repairingImportItem.row, { displayName })} /></label>
<label><span>初始密码</span><Input mode="password" autoComplete="new-password" value={repairingImportItem.input.password} onChange={(password) => updateImportItem(repairingImportItem.row, { password })} /></label>
<label><span>账号状态</span><Select value={repairingImportItem.input.status} optionList={[{ value: 'enabled', label: '启用' }, { value: 'disabled', label: '停用' }]} onChange={(status) => updateImportItem(repairingImportItem.row, { status: String(status) as 'enabled' | 'disabled' })} /></label>
<label className="is-wide"><span>菜单权限(用 | 分隔)</span><Input value={repairingImportItem.input.menuKeys.join('|')} onChange={(value) => updateImportItem(repairingImportItem.row, { menuKeys: value.split('|').map((part) => part.trim().toLowerCase()).filter(Boolean) })} /></label>
<label className="is-wide"><span>车辆 VIN(用 | 分隔)</span><Input value={(repairingImportItem.input.vehicleVins ?? []).join('|')} onChange={(value) => updateImportItem(repairingImportItem.row, { vehicleVins: value.split('|').map((part) => part.trim().toUpperCase()).filter(Boolean) })} /></label>
</div>
</section> : null}
</div>
</WorkspaceSideSheet>
<WorkspaceSideSheet
className="v2-user-editor-sidesheet"
variant="editor"
visible={editorVisible}
width={mobileLayout ? '100%' : 'min(820px, 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 ? '创建后不可修改 · 本地身份' : `${selectedIdentityProvider.sourceLabel} · ${selectedIdentityProvider.stateLabel}`, tone: selectedIdentityProvider.mappingComplete ? 'primary' : 'warning' },
{ 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={editorSaveState.color} type="light" size="small">{editorSaveState.label}</Tag>
<Typography.Text className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} type={save.isError ? 'danger' : 'tertiary'}>{editorSaveState.copy}</Typography.Text>
</span>}
primaryAction={{ label: editorPrimaryLabel, onClick: handleEditorPrimaryAction, disabled: editorPrimaryDisabled, loading: save.isPending }}
>
{editorVisible ? <form id="v2-user-editor-form" className="v2-user-editor-form" onSubmit={submit}>
{!creating && selectedID !== null && customers.length > 1 ? <section className="v2-user-editor-switcher" aria-label="客户账号切换">
<span><strong id="v2-user-editor-switch-label">切换客户</strong><small>未保存草稿会先确认</small></span>
<Select
aria-labelledby="v2-user-editor-switch-label"
value={selectedID}
onChange={(value) => {
const nextCustomer = customers.find((user) => user.id === Number(value));
if (nextCustomer) selectCustomer(nextCustomer);
}}
optionList={customers.map((user) => ({ value: user.id, label: `${user.displayName} · @${user.username}` }))}
/>
</section> : null}
{hasUnsavedChanges ? <section className="v2-user-change-preview" aria-label="待保存变更">
<span><strong>待保存变更</strong><small>仅影响当前草稿</small></span>
<div>
{draftDiff.identityChanged ? <Tag color="blue" type="light" size="small">账号资料已修改</Tag> : null}
{draftDiff.statusChanged ? <Tag color={draftDiff.disablesAccount ? 'red' : 'green'} type="light" size="small">账号将{draft.status === 'enabled' ? '启用' : '停用'}</Tag> : null}
{draftDiff.addedMenus.length ? <Tag color="green" type="light" size="small">开放菜单 +{draftDiff.addedMenus.length}</Tag> : null}
{draftDiff.removedMenus.length ? <Tag color="red" type="light" size="small">收回菜单 -{draftDiff.removedMenus.length}</Tag> : null}
{draftDiff.addedVehicles.length ? <Tag color="green" type="light" size="small">新增车辆 +{draftDiff.addedVehicles.length}</Tag> : null}
{draftDiff.removedVehicles.length ? <Tag color="red" type="light" size="small">移除车辆 -{draftDiff.removedVehicles.length}</Tag> : null}
{draftDiff.changedVehicles.length ? <Tag color="orange" type="light" size="small">调整有效期 {draftDiff.changedVehicles.length}</Tag> : null}
</div>
</section> : null}
<Tabs className="v2-user-editor-tabs" activeKey={activeSection} onChange={(key) => {
const section = String(key) as EditorSection;
setActiveSection(section);
setUserRoute({ section }, true);
}}>
<Tabs.TabPane tab={<span className="v2-user-tab-label"><i aria-hidden="true">1</i>登录身份</span>} 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>{creating ? '平台本地账号' : selectedIdentityProvider.sourceLabel}</strong></span>
<Tag color={selectedIdentityProvider.mappingComplete ? (selectedIdentityProvider.external ? 'cyan' : 'grey') : 'amber'} type="light" size="small">{creating ? '创建本地凭据' : selectedIdentityProvider.stateLabel}</Tag>
</div>
</div>
<section
className={`v2-user-identity-source${selectedIdentityProvider.external ? ' is-external' : ' is-local'}${!selectedIdentityProvider.mappingComplete ? ' has-warning' : ''}`}
role={!selectedIdentityProvider.mappingComplete ? 'alert' : 'status'}
aria-label="身份来源状态"
>
<header>
<span><strong>{creating ? '平台本地身份' : selectedIdentityProvider.sourceLabel}</strong><small>{selectedIdentityProvider.external ? '外部身份只负责登录,平台权限仍在此维护' : '登录凭据和账号状态由本平台维护'}</small></span>
<Tag color={!selectedIdentityProvider.mappingComplete ? 'amber' : selectedIdentityProvider.external ? 'cyan' : 'green'} type="light" size="small">{creating ? '待创建' : selectedIdentityProvider.stateLabel}</Tag>
</header>
<div>
<span><small>身份源</small><strong>{creating ? '本平台' : selectedIdentityProvider.sourceLabel}</strong></span>
<span><small>身份映射</small><strong>{creating ? '创建后生成' : selectedIdentityProvider.external ? maskExternalSubject(selected?.externalSubject) : `@${draft.username || '待设置'}`}</strong></span>
<span><small>凭据管理</small><strong>{creating ? '本平台' : selectedIdentityProvider.credentialOwner}</strong></span>
</div>
<p>{!selectedIdentityProvider.mappingComplete
? `该账号缺少 ${selectedIdentityProvider.sourceLabel} 身份映射,当前无法通过外部身份源登录。请在上游目录或受信任身份适配器中补齐映射;菜单与车辆权限可继续维护。`
: selectedIdentityProvider.external
? `登录密码、锁定与多因素认证由 ${selectedIdentityProvider.sourceLabel} 管理;本平台只维护启停状态、菜单和车辆数据范围。`
: creating ? '新账号将使用平台本地密码登录。' : '管理员可以在下方重置本地密码;重置或停用会撤销现有会话。'}</p>
</section>
<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 ? '设置初始密码,并按需关联外部客户标识' : selectedIdentityProvider.external ? `登录凭据由 ${selectedIdentityProvider.sourceLabel} 管理,平台保留业务映射` : '密码留空则保持不变,客户标识用于后续系统对接'}</small></span>
<Tag color={selectedIdentityProvider.external ? 'cyan' : 'grey'} type="light" size="small">{creating ? '密码必填' : selectedIdentityProvider.external ? '外部凭据只读' : '按需修改'}</Tag>
</header>
<div className="v2-user-fields is-secondary">
<label><span>{creating ? '初始密码' : selectedIdentityProvider.external ? '外部登录凭据' : '重置密码(可选)'}</span><Input required={creating} disabled={!creating && selectedIdentityProvider.external} mode="password" autoComplete="new-password" value={draft.password} onChange={(value) => setDraft((current) => ({ ...current, password: value }))} placeholder={selectedIdentityProvider.external ? `由 ${selectedIdentityProvider.sourceLabel} 管理` : '至少 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"><i aria-hidden="true">2</i>菜单权限<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"><i aria-hidden="true">3</i>车辆权限<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">
<header className="v2-user-vehicle-composer-heading">
<span><strong>添加授权车辆</strong><small>搜索选择车辆,或批量粘贴 VIN</small></span>
<Tag color="blue" type="light" size="small">按需添加</Tag>
</header>
<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> : <PanelEmpty
className="v2-user-assigned-filter-empty"
compact
tone="primary"
icon={<IconSearch />}
title="没有匹配的授权车辆"
description="请更换车牌或 VIN 关键词。"
action={<Button theme="light" type="primary" icon={<IconRefresh />} aria-label="清除车辆筛选" onClick={() => setAssignedKeyword('')}>清除车辆筛选</Button>}
/>}
<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>
</> : <PanelEmpty
className="v2-user-vehicle-empty"
tone="warning"
icon={<IconPlus />}
title="尚未分配车辆"
description={mobileLayout ? '添加至少一辆车后,客户才能看到车辆数据。' : '请在上方按车牌或 VIN 搜索并添加授权车辆。'}
action={mobileLayout ? <Button theme="solid" icon={<IconPlus />} aria-label="添加授权车辆" onClick={() => setVehicleComposerOpen(true)}>添加授权车辆</Button> : undefined}
/>}
{!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()}
/>
<WorkspaceConfirmDialog
visible={confirmation === 'switch-customer'}
ariaLabel="确认切换客户账号"
className="v2-user-confirm-dialog"
title="切换客户并放弃当前草稿?"
description="当前账号的登录身份、菜单或车辆权限尚未保存,切换后将载入另一客户的已保存配置。"
summaryItems={[
{ label: '当前草稿', value: selected?.displayName || draft.displayName || '新建账号', detail: `${draft.menuKeys.length} 菜单 · ${draft.vehicleGrants.length} 辆`, tone: 'warning' },
{ label: '即将打开', value: customers.find((user) => user.id === pendingCustomerID)?.displayName || '另一客户', detail: '读取已保存权限', tone: 'primary' },
{ label: '服务器数据', value: '不受影响', detail: '不会发送保存请求', tone: 'success' }
]}
cancelLabel="继续编辑"
confirmLabel="放弃并切换"
note="未保存的当前草稿无法在切换后恢复。"
onCancel={() => {
setConfirmation(null);
setPendingCustomerID(null);
}}
onConfirm={() => {
const nextCustomer = customers.find((user) => user.id === pendingCustomerID);
if (nextCustomer) loadCustomer(nextCustomer);
else {
setConfirmation(null);
setPendingCustomerID(null);
}
}}
/>
<WorkspaceConfirmDialog
visible={confirmation === 'save-access'}
ariaLabel="确认保存权限收回"
className="v2-user-confirm-dialog"
title="确认保存权限收回?"
description="此次保存包含访问范围缩减。生效后,客户将无法继续访问被收回的菜单或车辆。"
summaryItems={[
{ label: '账号状态', value: draftDiff.disablesAccount ? '将停用' : '保持启用', detail: draftDiff.disablesAccount ? '全部登录访问将停止' : '仅应用下方权限差异', tone: draftDiff.disablesAccount ? 'danger' : 'neutral' },
{ label: '菜单权限', value: draftDiff.removedMenus.length ? `收回 ${draftDiff.removedMenus.length} 个` : '不收回', detail: draftDiff.addedMenus.length ? `同时开放 ${draftDiff.addedMenus.length} 个` : '没有新增菜单', tone: draftDiff.removedMenus.length ? 'danger' : 'success' },
{ label: '车辆权限', value: draftDiff.removedVehicles.length ? `移除 ${draftDiff.removedVehicles.length} 辆` : '不移除', detail: draftDiff.addedVehicles.length ? `同时新增 ${draftDiff.addedVehicles.length} 辆` : '没有新增车辆', tone: draftDiff.removedVehicles.length ? 'danger' : 'success' }
]}
cancelLabel="返回核对"
confirmLabel="确认并保存"
note="保存后最多 30 秒生效;账号详情会保持打开并显示同步结果。"
onCancel={() => setConfirmation(null)}
onConfirm={() => {
setConfirmation(null);
persistDraft(true);
}}
/>
</div>;
}