1406 lines
86 KiB
TypeScript
1406 lines
86 KiB
TypeScript
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>;
|
||
}
|