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] !== baseline[key as keyof Pick]); 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) { 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(routeView.status); const [draftCustomerKeyword, setDraftCustomerKeyword] = useState(''); const [draftCustomerStatus, setDraftCustomerStatus] = useState('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(null); const [creating, setCreating] = useState(false); const [activeSection, setActiveSection] = useState('identity'); const selected = useMemo(() => customers.find((user) => user.id === selectedID), [customers, selectedID]); const [draft, setDraft] = useState(() => draftFromUser()); const [baselineDraft, setBaselineDraft] = useState(() => 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>({}); 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(null); const [importItems, setImportItems] = useState([]); const [importFileName, setImportFileName] = useState(''); const [importParseError, setImportParseError] = useState(''); const [importResult, setImportResult] = useState(null); const [repairRow, setRepairRow] = useState(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} / {customers.length}, 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, 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({ 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((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) => { 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) => 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
} tone="authority" title="客户访问治理" description="菜单与车辆按最小权限开放,变更最多 30 秒生效" status={`${customers.length} 个客户账号${attentionCustomerCount ? ` · ${attentionCustomerCount} 待完善` : disabledCustomerCount ? ` · ${disabledCustomerCount} 已停用` : ' · 权限就绪'}`} meta={{localCustomerCount} 个本地账号 · {customers.length - localCustomerCount} 个外部身份} actions={<>} /> {mobileLayout ?
: null}
setDraft((value) => ({ ...value, status: checked ? 'enabled' : 'disabled' }))} />{draft.status === 'enabled' ? '启用' : '停用'}, detail: '保存后最多 30 秒生效', tone: draft.status === 'enabled' ? 'success' : 'neutral' } ]} onCancel={requestCloseEditor} closeOnEsc={!confirmation} footerNote={ {editorSaveState.label} {editorSaveState.copy} } primaryAction={{ label: editorPrimaryLabel, onClick: handleEditorPrimaryAction, disabled: editorPrimaryDisabled, loading: save.isPending }} > {editorVisible ? {!creating && selectedID !== null && customers.length > 1 ?
切换客户未保存草稿会先确认 setDraft((current) => ({ ...current, username: value }))} placeholder="例如 customer-huadong" />
安全与系统映射{creating ? '设置初始密码,并按需关联外部客户标识' : selectedIdentityProvider.external ? `登录凭据由 ${selectedIdentityProvider.sourceLabel} 管理,平台保留业务映射` : '密码留空则保持不变,客户标识用于后续系统对接'} {creating ? '密码必填' : selectedIdentityProvider.external ? '外部凭据只读' : '按需修改'}
菜单权限{draft.menuKeys.length}} itemKey="menus"> 菜单权限 客户开放菜单} headerLine>
当前开放{draft.menuKeys.length} / {customerMenus.length}

{draft.menuKeys.length === customerMenus.length ? '客户工作台已完整开放' : '客户工作台按最小权限开放'}客户登录后只会看到下方已开放菜单,变更保存后最多 30 秒生效。

{draft.menuKeys.length ? '权限已配置' : '尚未开放'}
{customerMenus.map((menu) => { const selectedMenu = draft.menuKeys.includes(menu.key); return ; })}
车辆权限{draft.vehicleGrants.length}} itemKey="vehicles"> 车辆权限与有效期 {draft.vehicleGrants.length} 辆} headerLine headerExtraContent={draft.vehicleGrants.length ?
{mobileLayout && draft.vehicleGrants.length ? : null} {draft.vehicleGrants.length ? : null}
: null} > {vehicleComposerVisible ?
添加授权车辆搜索选择车辆,或批量粘贴 VIN 按需添加
{deferredVehicleKeyword ? candidates.refetch()} selectedVins={assigned} selectedLabel="已分配" onSelect={(vehicle) => toggleVIN(vehicle.vin)} /> : null}
: null} {draft.vehicleGrants.length ? <>
已授权车辆{assignedKeyword ? `${filteredAssignedGrants.length} 条匹配结果` : `${draft.vehicleGrants.length} 辆车拥有当前访问权限`} } showClear value={assignedKeyword} onChange={(value) => { setAssignedKeyword(value); setAssignedPage(1); }} placeholder="筛选已授权车牌或 VIN" />
{visibleAssignedGrants.length ?
{visibleAssignedGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return
{plate || '未登记车牌'}{grant.vin}
启用{formatGrantTime(grant.validFrom)}停用{grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}
; })}
: } title="没有匹配的授权车辆" description="请更换车牌或 VIN 关键词。" action={} />}
第 {(safeAssignedPage - 1) * assignedPageSize + (filteredAssignedGrants.length ? 1 : 0)}–{Math.min(safeAssignedPage * assignedPageSize, filteredAssignedGrants.length)} 条,共 {filteredAssignedGrants.length} 辆} onPageChange={setAssignedPage} />
: } title="尚未分配车辆" description={mobileLayout ? '添加至少一辆车后,客户才能看到车辆数据。' : '请在上方按车牌或 VIN 搜索并添加授权车辆。'} action={mobileLayout ? : undefined} />} {!creating && selected?.grantHistory?.length ? 历次开放记录 {selected.grantHistory.length} 条}>
{selected.grantHistory.map((item) =>
{item.plate || '未登记车牌'}{item.vin}{item.validTo ? '已结束' : '有效中'}

{formatTime(item.validFrom)} {item.validTo ? formatTime(item.validTo) : '持续有效'}

开通:{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}
)}
: null}
: null} {editingGrant ?
访问时间窗口只在授权区间内开放该车数据上海时间
持续有效{grantEndEnabled ? '关闭后必须指定停用时间' : '保持开启则不自动收回权限'} { setGrantEndEnabled(!checked); if (checked) updateGrant(editingGrant.vin, { validTo: '' }); }} />
{grantEndEnabled ? : null}
{grantWindowInvalid ?

请设置完整时间,并确保停用时间晚于启用时间。

:

{grantEndEnabled ? '到达停用时间后,客户将不再看到该车数据。' : '当前授权持续有效,不会自动收回。'}

}
: null}
setConfirmation(null)} onConfirm={clearVehicleGrants} /> setConfirmation(null)} onConfirm={() => closeEditor()} /> 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); } }} /> setConfirmation(null)} onConfirm={() => { setConfirmation(null); persistDraft(true); }} /> ; }