import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { IconChevronRight, IconDelete, IconPlus, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, Select, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui'; import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { api } from '../../api/client'; import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types'; import { useMobileLayout } from '../hooks/useMobileLayout'; import { MobileFilterToggle } from '../shared/MobileFilterToggle'; import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet'; import { TablePagination } from '../shared/TablePagination'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceConfirmDialog } from '../shared/WorkspaceDialog'; import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet'; import { WorkspaceDateTimeField } from '../shared/WorkspaceDateTimeField'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; import { PanelEmpty, PanelLoading } from '../shared/AsyncState'; const customerMenus = [ { key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' }, { key: 'vehicles', label: '车辆查询', description: '查看车辆档案与最新遥测' }, { key: 'tracks', label: '轨迹回放', description: '查询授权车辆的历史轨迹' }, { key: 'statistics', label: '里程查询', description: '查询授权车辆的每日与区间里程' } ]; type Draft = { username: string; displayName: string; password: string; status: 'enabled' | 'disabled'; customerRef: string; tenantRef: string; menuKeys: string[]; vehicleGrants: CustomerVehicleGrantInput[]; }; type EditorSection = 'identity' | 'menus' | 'vehicles'; type CustomerScopeFilter = 'all' | Draft['status'] | 'attention'; type CustomerAccessState = { key: 'ready' | 'missing-menus' | 'missing-vehicles' | 'disabled'; label: string; shortLabel: string; color: 'green' | 'amber' | 'grey'; attention: boolean; }; const customerScopeOptions: Array<{ value: CustomerScopeFilter; label: string }> = [ { value: 'all', label: '全部状态' }, { value: 'enabled', label: '启用账号' }, { value: 'disabled', label: '停用账号' }, { value: 'attention', label: '待完善权限' } ]; const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); function dateTimeInput(value?: string) { if (!value) return ''; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return ''; return shanghaiDateTimeFormatter.format(parsed).replace(' ', 'T'); } const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleGrants: [] }; function draftFromUser(user?: AdminUser): Draft { if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleGrants: [] }; return { username: user.username, displayName: user.displayName, password: '', status: user.status, customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys], vehicleGrants: user.vehicles.map((vehicle) => ({ vin: vehicle.vin, validFrom: dateTimeInput(vehicle.validFrom), validTo: dateTimeInput(vehicle.validTo) })) }; } function draftSignature(draft: Draft) { return JSON.stringify({ ...draft, menuKeys: [...draft.menuKeys].sort(), vehicleGrants: [...draft.vehicleGrants].sort((left, right) => left.vin.localeCompare(right.vin)) }); } function formatTime(value?: string) { if (!value) return '尚未登录'; return new Date(value).toLocaleString('zh-CN', { hour12: false }); } function formatGrantTime(value?: string) { if (!value) return '未设置'; return value.replace('T', ' ').slice(0, 16); } function customerAccessState(status: Draft['status'], menuCount: number, vehicleCount: number): CustomerAccessState { if (status === 'disabled') return { key: 'disabled', label: '账号已停用', shortLabel: '停用', color: 'grey', attention: false }; if (menuCount === 0) return { key: 'missing-menus', label: '待开放菜单', shortLabel: '待菜单', color: 'amber', attention: true }; if (vehicleCount === 0) return { key: 'missing-vehicles', label: '待分配车辆', shortLabel: '待车辆', color: 'amber', attention: true }; return { key: 'ready', label: '权限就绪', shortLabel: '就绪', color: 'green', attention: false }; } function customerScopeLabel(scope: CustomerScopeFilter) { if (scope === 'enabled') return '启用账号'; if (scope === 'disabled') return '停用账号'; if (scope === 'attention') return '待完善权限'; return '全部状态'; } function accessBadgeColor(color: CustomerAccessState['color']): WorkspaceSideSheetBadgeColor { if (color === 'amber') return 'orange'; return color; } function authProviderMeta(provider?: string) { const normalized = provider?.trim(); if (!normalized || normalized === 'local') return { label: '本地', color: 'grey' as const, title: '本地账号' }; return { label: normalized, color: 'cyan' as const, title: `外部身份源:${normalized}` }; } export default function UsersPage() { const queryClient = useQueryClient(); const mobileLayout = useMobileLayout(); const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 }); const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]); const [customerKeyword, setCustomerKeyword] = useState(''); const deferredCustomerKeyword = useDeferredValue(customerKeyword.trim()); const [customerStatus, setCustomerStatus] = useState('all'); 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); if (customerStatus === 'attention' && !accessState.attention) return false; if (customerStatus !== 'all' && customerStatus !== 'attention' && user.status !== customerStatus) return false; if (!keyword) return true; return [user.displayName, user.username, user.customerRef, user.tenantRef] .some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword)); }); }, [customerStatus, customers, deferredCustomerKeyword]); const [selectedID, setSelectedID] = useState(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' | null>(null); const attentionCustomerCount = useMemo(() => customers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [customers]); const readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).key === 'ready').length, [visibleCustomers]); const attentionCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [visibleCustomers]); const grantedVehicles = useMemo(() => visibleCustomers.reduce((total, user) => total + user.vehicles.length, 0), [visibleCustomers]); const directoryMetrics: WorkspaceQueueMetricRailItem[] = [ { label: '当前账号', value: <>{visibleCustomers.length} / {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: grantedVehicles, note: '当前范围合计', tone: 'neutral', emphasis: 'secondary' } ]; const editingGrant = useMemo(() => draft.vehicleGrants.find((grant) => grant.vin === editingGrantVIN), [draft.vehicleGrants, editingGrantVIN]); const editingGrantPlate = editingGrant ? vehicleLabels[editingGrant.vin] : ''; const grantWindowInvalid = Boolean(editingGrant && ( !editingGrant.validFrom || (grantEndEnabled && !editingGrant.validTo) || (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom) )); const editorVisible = creating || Boolean(selected); const mobileFiltersOpen = mobileLayout && !filtersCollapsed; const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen; const hasUnsavedChanges = useMemo(() => draftSignature(draft) !== draftSignature(baselineDraft), [baselineDraft, draft]); const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length); useEffect(() => { if (!creating && selected) { const nextDraft = draftFromUser(selected); setDraft(nextDraft); setBaselineDraft(nextDraft); setVehicleLabels(Object.fromEntries(selected.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate]))); } }, [creating, selected?.id, selected?.updatedAt]); const candidates = useQuery({ queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword], queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal), enabled: deferredVehicleKeyword.length >= 1, staleTime: 30_000 }); const assigned = useMemo(() => new Set(draft.vehicleGrants.map((grant) => grant.vin)), [draft.vehicleGrants]); const candidateVehicles = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]); const assignedPageSize = mobileLayout ? 4 : 10; const filteredAssignedGrants = useMemo(() => { const keyword = assignedKeyword.trim().toLocaleLowerCase('zh-CN'); if (!keyword) return draft.vehicleGrants; return draft.vehicleGrants.filter((grant) => [grant.vin, vehicleLabels[grant.vin]] .some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword))); }, [assignedKeyword, draft.vehicleGrants, vehicleLabels]); const assignedTotalPages = Math.max(1, Math.ceil(filteredAssignedGrants.length / assignedPageSize)); const safeAssignedPage = Math.min(assignedPage, assignedTotalPages); const visibleAssignedGrants = useMemo(() => { const offset = (safeAssignedPage - 1) * assignedPageSize; return filteredAssignedGrants.slice(offset, offset + assignedPageSize); }, [assignedPageSize, filteredAssignedGrants, safeAssignedPage]); useEffect(() => { if (candidateVehicles.length === 0) return; setVehicleLabels((current) => { const next = { ...current }; for (const vehicle of candidateVehicles) next[vehicle.vin] = vehicle.plate; return next; }); }, [candidateVehicles]); const save = useMutation({ mutationFn: async () => { const input: CustomerUserInput = { displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status, customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys, vehicleVins: draft.vehicleGrants.map((grant) => grant.vin), vehicleGrants: draft.vehicleGrants }; if (creating) return api.createCustomerUser({ ...input, username: draft.username.trim(), password: draft.password }); if (!selected) throw new Error('请先选择客户账号'); return api.updateCustomerUser(selected.id, input); }, onSuccess: async (result) => { setFeedback(creating ? '客户账号已创建' : '账号与权限已更新'); setCreating(false); setSelectedID(result.id); setDraft((value) => { const savedDraft = { ...value, password: '' }; setBaselineDraft(savedDraft); return savedDraft; }); await queryClient.invalidateQueries({ queryKey: ['admin-users'] }); }, onError: (error) => setFeedback(error instanceof Error ? error.message : '保存失败') }); const startCreate = () => { const nextDraft = draftFromUser(); setCreating(true); setSelectedID(null); setActiveSection('identity'); setDraft(nextDraft); setBaselineDraft(nextDraft); setVehicleKeyword(''); setAssignedKeyword(''); setAssignedPage(1); setBulkVINs(''); setVehicleComposerOpen(true); setVehicleLabels({}); setFeedback(''); setEditingGrantVIN(''); setGrantEndEnabled(false); setConfirmation(null); }; const closeEditor = () => { const nextDraft = draftFromUser(); setCreating(false); setSelectedID(null); setDraft(nextDraft); setBaselineDraft(nextDraft); setVehicleKeyword(''); setAssignedKeyword(''); setAssignedPage(1); setBulkVINs(''); setVehicleComposerOpen(false); setVehicleLabels({}); setFeedback(''); setEditingGrantVIN(''); setGrantEndEnabled(false); setConfirmation(null); }; const requestCloseEditor = () => { if (!hasUnsavedChanges) { closeEditor(); return; } setConfirmation('discard-editor'); }; const selectCustomer = (user: AdminUser) => { const nextDraft = draftFromUser(user); setCreating(false); setSelectedID(user.id); setActiveSection('vehicles'); setDraft(nextDraft); setBaselineDraft(nextDraft); setVehicleKeyword(''); setAssignedKeyword(''); setAssignedPage(1); setBulkVINs(''); setVehicleComposerOpen(user.vehicles.length === 0); setFeedback(''); setEditingGrantVIN(''); setGrantEndEnabled(false); }; const toggleVIN = (vin: string) => { const isAssigned = assigned.has(vin); if (!isAssigned) { setAssignedKeyword(''); setAssignedPage(1); } setDraft((value) => ({ ...value, vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin) ? value.vehicleGrants.filter((grant) => grant.vin !== vin) : [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin)) })); setFeedback(isAssigned ? `${vehicleLabels[vin] || vin} 已从授权草稿移除,保存后生效` : `${vehicleLabels[vin] || vin} 已加入授权草稿,保存后生效`); if (editingGrantVIN === vin) { setEditingGrantVIN(''); setGrantEndEnabled(false); } }; const clearVehicleGrants = () => { const removedCount = draft.vehicleGrants.length; setDraft((value) => ({ ...value, vehicleGrants: [] })); setAssignedKeyword(''); setAssignedPage(1); setVehicleComposerOpen(true); setEditingGrantVIN(''); setGrantEndEnabled(false); setFeedback(`已从草稿移除 ${removedCount} 辆车,保存后生效`); setConfirmation(null); }; const updateGrant = (vin: string, patch: Partial) => setDraft((value) => ({ ...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant) })); const openGrantEditor = (vin: string) => { const grant = draft.vehicleGrants.find((item) => item.vin === vin); setGrantEndEnabled(Boolean(grant?.validTo)); setEditingGrantVIN(vin); }; const closeGrantEditor = () => { setEditingGrantVIN(''); setGrantEndEnabled(false); }; const addBulkVINs = () => { const next = bulkVINs.split(/[\s,,;;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean); setDraft((value) => { const existing = new Set(value.vehicleGrants.map((grant) => grant.vin)); const added = next.filter((vin) => !existing.has(vin)).map((vin) => ({ vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' })); return { ...value, vehicleGrants: [...value.vehicleGrants, ...added].sort((left, right) => left.vin.localeCompare(right.vin)) }; }); setAssignedKeyword(''); setAssignedPage(1); setBulkVINs(''); }; const persistDraft = () => { if (save.isPending) return; setFeedback(''); if (!draft.displayName.trim() || (creating && (!draft.username.trim() || !draft.password))) { setActiveSection('identity'); setFeedback('请先完善登录身份中的必填信息'); return; } const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom)); if (invalidGrant) { setActiveSection('vehicles'); openGrantEditor(invalidGrant.vin); setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间'); return; } save.mutate(); }; const submit = (event: FormEvent) => { event.preventDefault(); persistDraft(); }; const openMobileFilters = () => { setDraftCustomerKeyword(customerKeyword); setDraftCustomerStatus(customerStatus); setFiltersCollapsed(false); }; const closeMobileFilters = () => { setDraftCustomerKeyword(customerKeyword); setDraftCustomerStatus(customerStatus); setFiltersCollapsed(true); }; const applyMobileFilters = () => { setCustomerKeyword(draftCustomerKeyword); setCustomerStatus(draftCustomerStatus); setFiltersCollapsed(true); }; const resetMobileDraft = () => { setDraftCustomerKeyword(''); setDraftCustomerStatus('all'); }; const submitMobileFilters = (event: FormEvent) => { event.preventDefault(); applyMobileFilters(); }; return
本地账号 · 可扩展外部身份源} actions={} /> {mobileLayout ?
安全与系统映射{creating ? '设置初始密码,并按需关联外部客户标识' : '密码留空则保持不变,客户标识用于后续系统对接'} {creating ? '密码必填' : '按需修改'}
菜单权限{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 ?
{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) : '持续有效'}
; })}
: }
第 {(safeAssignedPage - 1) * assignedPageSize + (filteredAssignedGrants.length ? 1 : 0)}–{Math.min(safeAssignedPage * assignedPageSize, filteredAssignedGrants.length)} 条,共 {filteredAssignedGrants.length} 辆} onPageChange={setAssignedPage} />
: } {!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} />
; }