825 lines
48 KiB
TypeScript
825 lines
48 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { IconChevronRight, IconDelete, IconPlus, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||
import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, Select, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
|
||
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||
import { api } from '../../api/client';
|
||
import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
|
||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
|
||
import { TablePagination } from '../shared/TablePagination';
|
||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
|
||
import { WorkspaceConfirmDialog } from '../shared/WorkspaceDialog';
|
||
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
|
||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||
import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
|
||
import { WorkspaceDateTimeField } from '../shared/WorkspaceDateTimeField';
|
||
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||
|
||
const customerMenus = [
|
||
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
|
||
{ key: 'vehicles', label: '车辆查询', description: '查看车辆档案与最新遥测' },
|
||
{ key: 'tracks', label: '轨迹回放', description: '查询授权车辆的历史轨迹' },
|
||
{ key: 'statistics', label: '里程查询', description: '查询授权车辆的每日与区间里程' }
|
||
];
|
||
|
||
type Draft = {
|
||
username: string;
|
||
displayName: string;
|
||
password: string;
|
||
status: 'enabled' | 'disabled';
|
||
customerRef: string;
|
||
tenantRef: string;
|
||
menuKeys: string[];
|
||
vehicleGrants: CustomerVehicleGrantInput[];
|
||
};
|
||
|
||
type EditorSection = 'identity' | 'menus' | 'vehicles';
|
||
type CustomerScopeFilter = 'all' | Draft['status'] | 'attention';
|
||
type CustomerAccessState = {
|
||
key: 'ready' | 'missing-menus' | 'missing-vehicles' | 'disabled';
|
||
label: string;
|
||
shortLabel: string;
|
||
color: 'green' | 'amber' | 'grey';
|
||
attention: boolean;
|
||
};
|
||
|
||
const customerScopeOptions: Array<{ value: CustomerScopeFilter; label: string }> = [
|
||
{ value: 'all', label: '全部状态' },
|
||
{ value: 'enabled', label: '启用账号' },
|
||
{ value: 'disabled', label: '停用账号' },
|
||
{ value: 'attention', label: '待完善权限' }
|
||
];
|
||
|
||
const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', {
|
||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
|
||
});
|
||
|
||
function dateTimeInput(value?: string) {
|
||
if (!value) return '';
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) return '';
|
||
return shanghaiDateTimeFormatter.format(parsed).replace(' ', 'T');
|
||
}
|
||
|
||
const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleGrants: [] };
|
||
|
||
function draftFromUser(user?: AdminUser): Draft {
|
||
if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleGrants: [] };
|
||
return {
|
||
username: user.username, displayName: user.displayName, password: '', status: user.status,
|
||
customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys],
|
||
vehicleGrants: user.vehicles.map((vehicle) => ({ vin: vehicle.vin, validFrom: dateTimeInput(vehicle.validFrom), validTo: dateTimeInput(vehicle.validTo) }))
|
||
};
|
||
}
|
||
|
||
function draftSignature(draft: Draft) {
|
||
return JSON.stringify({
|
||
...draft,
|
||
menuKeys: [...draft.menuKeys].sort(),
|
||
vehicleGrants: [...draft.vehicleGrants].sort((left, right) => left.vin.localeCompare(right.vin))
|
||
});
|
||
}
|
||
|
||
function formatTime(value?: string) {
|
||
if (!value) return '尚未登录';
|
||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
|
||
function formatGrantTime(value?: string) {
|
||
if (!value) return '未设置';
|
||
return value.replace('T', ' ').slice(0, 16);
|
||
}
|
||
|
||
function customerAccessState(status: Draft['status'], menuCount: number, vehicleCount: number): CustomerAccessState {
|
||
if (status === 'disabled') return { key: 'disabled', label: '账号已停用', shortLabel: '停用', color: 'grey', attention: false };
|
||
if (menuCount === 0) return { key: 'missing-menus', label: '待开放菜单', shortLabel: '待菜单', color: 'amber', attention: true };
|
||
if (vehicleCount === 0) return { key: 'missing-vehicles', label: '待分配车辆', shortLabel: '待车辆', color: 'amber', attention: true };
|
||
return { key: 'ready', label: '权限就绪', shortLabel: '就绪', color: 'green', attention: false };
|
||
}
|
||
|
||
function customerScopeLabel(scope: CustomerScopeFilter) {
|
||
if (scope === 'enabled') return '启用账号';
|
||
if (scope === 'disabled') return '停用账号';
|
||
if (scope === 'attention') return '待完善权限';
|
||
return '全部状态';
|
||
}
|
||
|
||
function accessBadgeColor(color: CustomerAccessState['color']): WorkspaceSideSheetBadgeColor {
|
||
if (color === 'amber') return 'orange';
|
||
return color;
|
||
}
|
||
|
||
function authProviderMeta(provider?: string) {
|
||
const normalized = provider?.trim();
|
||
if (!normalized || normalized === 'local') return { label: '本地', color: 'grey' as const, title: '本地账号' };
|
||
return { label: normalized, color: 'cyan' as const, title: `外部身份源:${normalized}` };
|
||
}
|
||
|
||
export default function UsersPage() {
|
||
const queryClient = useQueryClient();
|
||
const mobileLayout = useMobileLayout();
|
||
const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 });
|
||
const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]);
|
||
const [customerKeyword, setCustomerKeyword] = useState('');
|
||
const deferredCustomerKeyword = useDeferredValue(customerKeyword.trim());
|
||
const [customerStatus, setCustomerStatus] = useState<CustomerScopeFilter>('all');
|
||
const [draftCustomerKeyword, setDraftCustomerKeyword] = useState('');
|
||
const [draftCustomerStatus, setDraftCustomerStatus] = useState<CustomerScopeFilter>('all');
|
||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||
const visibleCustomers = useMemo(() => {
|
||
const keyword = deferredCustomerKeyword.toLocaleLowerCase('zh-CN');
|
||
return customers.filter((user) => {
|
||
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length);
|
||
if (customerStatus === 'attention' && !accessState.attention) return false;
|
||
if (customerStatus !== 'all' && customerStatus !== 'attention' && user.status !== customerStatus) return false;
|
||
if (!keyword) return true;
|
||
return [user.displayName, user.username, user.customerRef, user.tenantRef]
|
||
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword));
|
||
});
|
||
}, [customerStatus, customers, deferredCustomerKeyword]);
|
||
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||
const [creating, setCreating] = useState(false);
|
||
const [activeSection, setActiveSection] = useState<EditorSection>('identity');
|
||
const selected = useMemo(() => customers.find((user) => user.id === selectedID), [customers, selectedID]);
|
||
const [draft, setDraft] = useState<Draft>(() => draftFromUser());
|
||
const [baselineDraft, setBaselineDraft] = useState<Draft>(() => draftFromUser());
|
||
const [vehicleKeyword, setVehicleKeyword] = useState('');
|
||
const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim());
|
||
const [assignedKeyword, setAssignedKeyword] = useState('');
|
||
const [assignedPage, setAssignedPage] = useState(1);
|
||
const [bulkVINs, setBulkVINs] = useState('');
|
||
const [vehicleComposerOpen, setVehicleComposerOpen] = useState(false);
|
||
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
|
||
const [feedback, setFeedback] = useState('');
|
||
const [editingGrantVIN, setEditingGrantVIN] = useState('');
|
||
const [grantEndEnabled, setGrantEndEnabled] = useState(false);
|
||
const [confirmation, setConfirmation] = useState<'clear-vehicles' | 'discard-editor' | null>(null);
|
||
const attentionCustomerCount = useMemo(() => customers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [customers]);
|
||
const readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).key === 'ready').length, [visibleCustomers]);
|
||
const attentionCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [visibleCustomers]);
|
||
const grantedVehicles = useMemo(() => visibleCustomers.reduce((total, user) => total + user.vehicles.length, 0), [visibleCustomers]);
|
||
const directoryMetrics: WorkspaceQueueMetricRailItem[] = [
|
||
{
|
||
label: '当前账号',
|
||
value: <>{visibleCustomers.length}<i className="v2-user-directory-metric-unit"> / {customers.length}</i></>,
|
||
note: customerKeyword.trim() || customerStatus !== 'all' ? '筛选结果 / 全部客户' : '全部客户账号',
|
||
tone: 'primary',
|
||
emphasis: 'primary'
|
||
},
|
||
{
|
||
label: '待完善',
|
||
value: attentionCustomers,
|
||
note: '缺少菜单或车辆',
|
||
tone: attentionCustomers ? 'warning' : 'success',
|
||
emphasis: 'primary'
|
||
},
|
||
{
|
||
label: '权限就绪',
|
||
value: readyCustomers,
|
||
note: '启用且权限完整',
|
||
tone: 'success',
|
||
emphasis: 'secondary'
|
||
},
|
||
{
|
||
label: '车辆授权',
|
||
value: grantedVehicles,
|
||
note: '当前范围合计',
|
||
tone: 'neutral',
|
||
emphasis: 'secondary'
|
||
}
|
||
];
|
||
const editingGrant = useMemo(() => draft.vehicleGrants.find((grant) => grant.vin === editingGrantVIN), [draft.vehicleGrants, editingGrantVIN]);
|
||
const editingGrantPlate = editingGrant ? vehicleLabels[editingGrant.vin] : '';
|
||
const grantWindowInvalid = Boolean(editingGrant && (
|
||
!editingGrant.validFrom
|
||
|| (grantEndEnabled && !editingGrant.validTo)
|
||
|| (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom)
|
||
));
|
||
const editorVisible = creating || Boolean(selected);
|
||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||
const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen;
|
||
const hasUnsavedChanges = useMemo(() => draftSignature(draft) !== draftSignature(baselineDraft), [baselineDraft, draft]);
|
||
const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length);
|
||
|
||
useEffect(() => {
|
||
if (!creating && selected) {
|
||
const nextDraft = draftFromUser(selected);
|
||
setDraft(nextDraft);
|
||
setBaselineDraft(nextDraft);
|
||
setVehicleLabels(Object.fromEntries(selected.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate])));
|
||
}
|
||
}, [creating, selected?.id, selected?.updatedAt]);
|
||
|
||
const candidates = useQuery({
|
||
queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword],
|
||
queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal),
|
||
enabled: deferredVehicleKeyword.length >= 1,
|
||
staleTime: 30_000
|
||
});
|
||
const assigned = useMemo(() => new Set(draft.vehicleGrants.map((grant) => grant.vin)), [draft.vehicleGrants]);
|
||
const candidateVehicles = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
|
||
const assignedPageSize = mobileLayout ? 4 : 10;
|
||
const filteredAssignedGrants = useMemo(() => {
|
||
const keyword = assignedKeyword.trim().toLocaleLowerCase('zh-CN');
|
||
if (!keyword) return draft.vehicleGrants;
|
||
return draft.vehicleGrants.filter((grant) => [grant.vin, vehicleLabels[grant.vin]]
|
||
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword)));
|
||
}, [assignedKeyword, draft.vehicleGrants, vehicleLabels]);
|
||
const assignedTotalPages = Math.max(1, Math.ceil(filteredAssignedGrants.length / assignedPageSize));
|
||
const safeAssignedPage = Math.min(assignedPage, assignedTotalPages);
|
||
const visibleAssignedGrants = useMemo(() => {
|
||
const offset = (safeAssignedPage - 1) * assignedPageSize;
|
||
return filteredAssignedGrants.slice(offset, offset + assignedPageSize);
|
||
}, [assignedPageSize, filteredAssignedGrants, safeAssignedPage]);
|
||
useEffect(() => {
|
||
if (candidateVehicles.length === 0) return;
|
||
setVehicleLabels((current) => {
|
||
const next = { ...current };
|
||
for (const vehicle of candidateVehicles) next[vehicle.vin] = vehicle.plate;
|
||
return next;
|
||
});
|
||
}, [candidateVehicles]);
|
||
|
||
const save = useMutation({
|
||
mutationFn: async () => {
|
||
const input: CustomerUserInput = {
|
||
displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status,
|
||
customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys,
|
||
vehicleVins: draft.vehicleGrants.map((grant) => grant.vin), vehicleGrants: draft.vehicleGrants
|
||
};
|
||
if (creating) return api.createCustomerUser({ ...input, username: draft.username.trim(), password: draft.password });
|
||
if (!selected) throw new Error('请先选择客户账号');
|
||
return api.updateCustomerUser(selected.id, input);
|
||
},
|
||
onSuccess: async (result) => {
|
||
setFeedback(creating ? '客户账号已创建' : '账号与权限已更新');
|
||
setCreating(false);
|
||
setSelectedID(result.id);
|
||
setDraft((value) => {
|
||
const savedDraft = { ...value, password: '' };
|
||
setBaselineDraft(savedDraft);
|
||
return savedDraft;
|
||
});
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||
},
|
||
onError: (error) => setFeedback(error instanceof Error ? error.message : '保存失败')
|
||
});
|
||
|
||
const startCreate = () => {
|
||
const nextDraft = draftFromUser();
|
||
setCreating(true);
|
||
setSelectedID(null);
|
||
setActiveSection('identity');
|
||
setDraft(nextDraft);
|
||
setBaselineDraft(nextDraft);
|
||
setVehicleKeyword('');
|
||
setAssignedKeyword('');
|
||
setAssignedPage(1);
|
||
setBulkVINs('');
|
||
setVehicleComposerOpen(true);
|
||
setVehicleLabels({});
|
||
setFeedback('');
|
||
setEditingGrantVIN('');
|
||
setGrantEndEnabled(false);
|
||
setConfirmation(null);
|
||
};
|
||
const closeEditor = () => {
|
||
const nextDraft = draftFromUser();
|
||
setCreating(false);
|
||
setSelectedID(null);
|
||
setDraft(nextDraft);
|
||
setBaselineDraft(nextDraft);
|
||
setVehicleKeyword('');
|
||
setAssignedKeyword('');
|
||
setAssignedPage(1);
|
||
setBulkVINs('');
|
||
setVehicleComposerOpen(false);
|
||
setVehicleLabels({});
|
||
setFeedback('');
|
||
setEditingGrantVIN('');
|
||
setGrantEndEnabled(false);
|
||
setConfirmation(null);
|
||
};
|
||
const requestCloseEditor = () => {
|
||
if (!hasUnsavedChanges) {
|
||
closeEditor();
|
||
return;
|
||
}
|
||
setConfirmation('discard-editor');
|
||
};
|
||
const selectCustomer = (user: AdminUser) => {
|
||
const nextDraft = draftFromUser(user);
|
||
setCreating(false);
|
||
setSelectedID(user.id);
|
||
setActiveSection('vehicles');
|
||
setDraft(nextDraft);
|
||
setBaselineDraft(nextDraft);
|
||
setVehicleKeyword('');
|
||
setAssignedKeyword('');
|
||
setAssignedPage(1);
|
||
setBulkVINs('');
|
||
setVehicleComposerOpen(user.vehicles.length === 0);
|
||
setFeedback('');
|
||
setEditingGrantVIN('');
|
||
setGrantEndEnabled(false);
|
||
};
|
||
const toggleVIN = (vin: string) => {
|
||
const isAssigned = assigned.has(vin);
|
||
if (!isAssigned) {
|
||
setAssignedKeyword('');
|
||
setAssignedPage(1);
|
||
}
|
||
setDraft((value) => ({
|
||
...value,
|
||
vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin)
|
||
? value.vehicleGrants.filter((grant) => grant.vin !== vin)
|
||
: [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin))
|
||
}));
|
||
setFeedback(isAssigned
|
||
? `${vehicleLabels[vin] || vin} 已从授权草稿移除,保存后生效`
|
||
: `${vehicleLabels[vin] || vin} 已加入授权草稿,保存后生效`);
|
||
if (editingGrantVIN === vin) {
|
||
setEditingGrantVIN('');
|
||
setGrantEndEnabled(false);
|
||
}
|
||
};
|
||
const clearVehicleGrants = () => {
|
||
const removedCount = draft.vehicleGrants.length;
|
||
setDraft((value) => ({ ...value, vehicleGrants: [] }));
|
||
setAssignedKeyword('');
|
||
setAssignedPage(1);
|
||
setVehicleComposerOpen(true);
|
||
setEditingGrantVIN('');
|
||
setGrantEndEnabled(false);
|
||
setFeedback(`已从草稿移除 ${removedCount} 辆车,保存后生效`);
|
||
setConfirmation(null);
|
||
};
|
||
const updateGrant = (vin: string, patch: Partial<CustomerVehicleGrantInput>) => setDraft((value) => ({
|
||
...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant)
|
||
}));
|
||
const openGrantEditor = (vin: string) => {
|
||
const grant = draft.vehicleGrants.find((item) => item.vin === vin);
|
||
setGrantEndEnabled(Boolean(grant?.validTo));
|
||
setEditingGrantVIN(vin);
|
||
};
|
||
const closeGrantEditor = () => {
|
||
setEditingGrantVIN('');
|
||
setGrantEndEnabled(false);
|
||
};
|
||
const addBulkVINs = () => {
|
||
const next = bulkVINs.split(/[\s,,;;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean);
|
||
setDraft((value) => {
|
||
const existing = new Set(value.vehicleGrants.map((grant) => grant.vin));
|
||
const added = next.filter((vin) => !existing.has(vin)).map((vin) => ({ vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }));
|
||
return { ...value, vehicleGrants: [...value.vehicleGrants, ...added].sort((left, right) => left.vin.localeCompare(right.vin)) };
|
||
});
|
||
setAssignedKeyword('');
|
||
setAssignedPage(1);
|
||
setBulkVINs('');
|
||
};
|
||
const persistDraft = () => {
|
||
if (save.isPending) return;
|
||
setFeedback('');
|
||
if (!draft.displayName.trim() || (creating && (!draft.username.trim() || !draft.password))) {
|
||
setActiveSection('identity');
|
||
setFeedback('请先完善登录身份中的必填信息');
|
||
return;
|
||
}
|
||
const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom));
|
||
if (invalidGrant) {
|
||
setActiveSection('vehicles');
|
||
openGrantEditor(invalidGrant.vin);
|
||
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
|
||
return;
|
||
}
|
||
save.mutate();
|
||
};
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
persistDraft();
|
||
};
|
||
const openMobileFilters = () => {
|
||
setDraftCustomerKeyword(customerKeyword);
|
||
setDraftCustomerStatus(customerStatus);
|
||
setFiltersCollapsed(false);
|
||
};
|
||
const closeMobileFilters = () => {
|
||
setDraftCustomerKeyword(customerKeyword);
|
||
setDraftCustomerStatus(customerStatus);
|
||
setFiltersCollapsed(true);
|
||
};
|
||
const applyMobileFilters = () => {
|
||
setCustomerKeyword(draftCustomerKeyword);
|
||
setCustomerStatus(draftCustomerStatus);
|
||
setFiltersCollapsed(true);
|
||
};
|
||
const resetMobileDraft = () => {
|
||
setDraftCustomerKeyword('');
|
||
setDraftCustomerStatus('all');
|
||
};
|
||
const submitMobileFilters = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
applyMobileFilters();
|
||
};
|
||
|
||
return <div className="v2-user-admin">
|
||
<div className="v2-user-discovery-shell">
|
||
<WorkspaceCommandBar
|
||
className="v2-user-command-bar"
|
||
ariaLabel="账号管理操作"
|
||
title="客户访问治理"
|
||
description="菜单与车辆按最小权限开放,变更最多 30 秒生效"
|
||
status={`${customers.length} 个客户账号${attentionCustomerCount ? ` · ${attentionCustomerCount} 待完善` : ' · 权限就绪'}`}
|
||
meta={<Typography.Text type="tertiary">本地账号 · 可扩展外部身份源</Typography.Text>}
|
||
actions={<Button className="v2-workspace-mobile-tool-button is-primary" theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}>新建客户账号</Button>}
|
||
/>
|
||
{mobileLayout ? <div className="v2-user-mobile-discovery">
|
||
<MobileFilterToggle
|
||
title="账号范围"
|
||
summary={`${customerScopeLabel(customerStatus)}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
|
||
expanded={mobileFiltersOpen}
|
||
controls="v2-user-mobile-filters"
|
||
expandedLabel="关闭"
|
||
collapsedLabel="修改"
|
||
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
|
||
/>
|
||
<MobileFilterSheet
|
||
className="v2-user-mobile-filter-sidesheet"
|
||
visible={mobileFiltersOpen}
|
||
height="min(62dvh, 480px)"
|
||
ariaLabel="客户账号筛选"
|
||
dialogId="v2-user-mobile-filters"
|
||
closeLabel="关闭客户账号筛选"
|
||
title="筛选客户账号"
|
||
description="按账号信息与权限状态缩小客户范围"
|
||
onCancel={closeMobileFilters}
|
||
secondaryAction={{ label: '重置条件', onClick: resetMobileDraft }}
|
||
primaryAction={{ label: '应用筛选', onClick: applyMobileFilters }}
|
||
>
|
||
<form className="v2-user-mobile-filter-form" onSubmit={submitMobileFilters}>
|
||
<MobileFilterSheetSection ariaLabel="客户账号筛选条件" title="账号范围" description="筛选仅在应用后更新客户目录,关闭不会丢失当前结果。">
|
||
<div>
|
||
<label>
|
||
<span>客户账号</span>
|
||
<Input aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={draftCustomerKeyword} onChange={setDraftCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
|
||
</label>
|
||
<label>
|
||
<span id="v2-user-mobile-status-filter-label">访问状态</span>
|
||
<Select
|
||
aria-labelledby="v2-user-mobile-status-filter-label"
|
||
value={draftCustomerStatus}
|
||
onChange={(value) => setDraftCustomerStatus(String(value) as CustomerScopeFilter)}
|
||
optionList={customerScopeOptions}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</MobileFilterSheetSection>
|
||
</form>
|
||
</MobileFilterSheet>
|
||
</div> : <WorkspaceFilterPanel
|
||
className="v2-user-filter-panel"
|
||
title="账号范围"
|
||
description="按客户名称、登录账号与权限可用状态查找"
|
||
mobileSummary={`${customerScopeLabel(customerStatus)}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
|
||
expanded
|
||
status={users.isPending ? '正在读取账号' : `${visibleCustomers.length} / ${customers.length} 个账号`}
|
||
statusColor={customerKeyword.trim() || customerStatus !== 'all' ? 'blue' : 'grey'}
|
||
collapsedLabel="修改"
|
||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||
>
|
||
<div className="v2-user-filter-form">
|
||
<label className="v2-user-filter-keyword">
|
||
<span>客户账号</span>
|
||
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={setCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
|
||
</label>
|
||
<label>
|
||
<span id="v2-user-status-filter-label">访问状态</span>
|
||
<Select
|
||
aria-labelledby="v2-user-status-filter-label"
|
||
value={customerStatus}
|
||
onChange={(value) => setCustomerStatus(String(value) as typeof customerStatus)}
|
||
optionList={customerScopeOptions}
|
||
/>
|
||
</label>
|
||
<Button
|
||
className="v2-user-filter-reset"
|
||
theme="light"
|
||
type="tertiary"
|
||
icon={<IconRefresh />}
|
||
aria-label="清空账号筛选"
|
||
disabled={!customerKeyword.trim() && customerStatus === 'all'}
|
||
onClick={() => {
|
||
setCustomerKeyword('');
|
||
setCustomerStatus('all');
|
||
}}
|
||
>
|
||
清空
|
||
</Button>
|
||
</div>
|
||
</WorkspaceFilterPanel>}
|
||
</div>
|
||
<div className={`v2-user-admin-grid${creating || selected ? ' is-editor-open' : ''}`}>
|
||
<Card className="v2-user-list" bodyStyle={{ padding: 0 }}>
|
||
<WorkspacePanelHeader
|
||
className="v2-user-directory-header"
|
||
title="客户权限目录"
|
||
description="当前范围内的账号、菜单与车辆授权摘要"
|
||
/>
|
||
<WorkspaceMetricRail
|
||
variant="queue"
|
||
className="v2-user-directory-metric-rail"
|
||
ariaLabel="客户权限目录统计"
|
||
items={directoryMetrics}
|
||
/>
|
||
<div className="v2-user-directory-body">
|
||
{users.isPending ? <PanelLoading className="v2-user-list-loading" title="正在加载客户账号" description="账号目录和授权摘要就绪后会自动显示。" /> : customers.length === 0 ? <PanelEmpty className="v2-user-list-empty" title="还没有客户账号" description="创建客户账号后,可在这里配置菜单和车辆范围。" action={<Button theme="solid" onClick={startCreate}>创建第一个客户账号</Button>} /> : <>
|
||
{visibleCustomers.length ? <div className="v2-user-directory-columns" role="row" aria-label="客户账号目录列">
|
||
<span>客户账号</span>
|
||
<span>菜单权限</span>
|
||
<span>车辆权限</span>
|
||
<span>最近登录</span>
|
||
<span>访问状态</span>
|
||
</div> : null}
|
||
<div className="v2-customer-list-scroll" role="region" aria-label="客户账号目录,可上下滚动" tabIndex={0}>
|
||
<List
|
||
className="v2-customer-list"
|
||
dataSource={visibleCustomers}
|
||
emptyContent={<Empty className="v2-user-filter-empty" title="没有匹配账号" description="请调整关键词或访问状态筛选。" />}
|
||
renderItem={(user) => {
|
||
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length);
|
||
const authProvider = authProviderMeta(user.authProvider);
|
||
return <List.Item key={user.id} className={`v2-user-list-row is-access-${accessState.key}${accessState.attention ? ' has-attention' : ''}${selectedID === user.id && !creating ? ' is-active' : ''}`}>
|
||
<Button
|
||
className="v2-user-list-item"
|
||
theme="borderless"
|
||
type="tertiary"
|
||
aria-label={`选择客户 ${user.displayName},账号 ${user.username},${user.vehicles.length} 辆授权车,${accessState.label}`}
|
||
aria-pressed={selectedID === user.id && !creating}
|
||
aria-expanded={selectedID === user.id && !creating}
|
||
onClick={() => selectCustomer(user)}
|
||
>
|
||
<Avatar className="v2-user-avatar" color={user.status === 'enabled' ? 'light-blue' : 'grey'} shape="square" size="small">{user.displayName.slice(0, 1)}</Avatar>
|
||
<span className="v2-user-list-identity">
|
||
<span className="v2-user-list-name"><b>{user.displayName}</b><span className="v2-user-provider-hint" title={authProvider.title}><Tag className="v2-user-provider-tag" color={authProvider.color} type="light" size="small">{authProvider.label}</Tag></span></span>
|
||
<small>@{user.username}{user.customerRef ? ` · ${user.customerRef}` : ''}</small>
|
||
</span>
|
||
<span className="v2-user-list-facts is-menu"><small>菜单权限</small><b>{user.menuKeys.length}<i> / {customerMenus.length}</i></b></span>
|
||
<span className="v2-user-list-facts is-vehicle"><small>车辆权限</small><b>{user.vehicles.length} 辆</b></span>
|
||
<span className="v2-user-list-facts is-login"><small>最近登录</small><b>{formatTime(user.lastLoginAt)}</b></span>
|
||
<span className="v2-user-list-trailing"><Tag className={`v2-user-status-tag is-${accessState.key}`} color={accessState.color} type="light" size="small"><span className="v2-user-status-tag-desktop">{accessState.label}</span><span className="v2-user-status-tag-mobile">{accessState.shortLabel}</span></Tag><IconChevronRight /></span>
|
||
</Button>
|
||
</List.Item>;
|
||
}}
|
||
/>
|
||
</div>
|
||
</>}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
<WorkspaceSideSheet
|
||
className="v2-user-editor-sidesheet"
|
||
variant="editor"
|
||
visible={editorVisible}
|
||
width={mobileLayout ? '100%' : 'min(840px, 100vw)'}
|
||
ariaLabel="客户账号详情"
|
||
closeLabel="关闭账号详情"
|
||
title={creating ? '创建客户账号' : selected?.displayName || '客户账号'}
|
||
description={creating ? '设置登录身份和最小必要权限' : `@${selected?.username || '—'} · 最近登录 ${formatTime(selected?.lastLoginAt)}`}
|
||
badge={draftAccessState.label}
|
||
badgeColor={accessBadgeColor(draftAccessState.color)}
|
||
summaryItems={[
|
||
{ label: '登录账号', value: draft.username ? `@${draft.username}` : '待设置', detail: creating ? '创建后不可修改' : '登录身份', tone: 'primary' },
|
||
{ label: '权限范围', value: `${draft.menuKeys.length} 菜单 · ${draft.vehicleGrants.length} 辆`, detail: '最小必要权限', tone: draftAccessState.attention ? 'warning' : 'success' },
|
||
{
|
||
label: '账号状态',
|
||
value: <span className="v2-user-editor-status-summary"><Switch aria-label={draft.status === 'enabled' ? '账号启用' : '账号停用'} checked={draft.status === 'enabled'} onChange={(checked) => setDraft((value) => ({ ...value, status: checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '启用' : '停用'}</span></span>,
|
||
detail: '保存后最多 30 秒生效',
|
||
tone: draft.status === 'enabled' ? 'success' : 'neutral'
|
||
}
|
||
]}
|
||
onCancel={requestCloseEditor}
|
||
closeOnEsc={!confirmation}
|
||
footerNote={<span className="v2-user-save-state" role="status">
|
||
<Tag color={save.isError ? 'red' : hasUnsavedChanges ? 'amber' : 'green'} type="light" size="small">{save.isError ? '保存失败' : hasUnsavedChanges ? '待保存' : '已同步'}</Tag>
|
||
<Typography.Text className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} type={save.isError ? 'danger' : 'tertiary'}>{feedback || (hasUnsavedChanges ? '更改仅保存在当前草稿,保存后最多 30 秒生效' : '账号与权限已和服务器同步')}</Typography.Text>
|
||
</span>}
|
||
primaryAction={{ label: creating ? '创建账号' : '保存权限', onClick: persistDraft, disabled: save.isPending || !hasUnsavedChanges, loading: save.isPending }}
|
||
>
|
||
{editorVisible ? <form id="v2-user-editor-form" className="v2-user-editor-form" onSubmit={submit}>
|
||
<Tabs className="v2-user-editor-tabs" activeKey={activeSection} onChange={(key) => setActiveSection(String(key) as EditorSection)}>
|
||
<Tabs.TabPane tab="登录身份" itemKey="identity">
|
||
<Card className="v2-user-editor-section v2-user-identity-section" title="登录身份" headerLine>
|
||
<div className="v2-user-identity-overview" aria-label="登录身份摘要">
|
||
<div>
|
||
<span><small>登录账号</small><strong>{draft.username || '待设置'}</strong></span>
|
||
<Tag color={creating ? 'blue' : 'grey'} type="light" size="small">{creating ? '新账号' : '不可修改'}</Tag>
|
||
</div>
|
||
<div>
|
||
<span><small>客户显示名称</small><strong>{draft.displayName || '待设置'}</strong></span>
|
||
<Tag color={draft.displayName ? 'green' : 'amber'} type="light" size="small">{draft.displayName ? '已设置' : '待完善'}</Tag>
|
||
</div>
|
||
<div>
|
||
<span><small>身份来源</small><strong>{selected?.authProvider && selected.authProvider !== 'local' ? selected.authProvider : '本地账号'}</strong></span>
|
||
<Tag color="blue" type="light" size="small">可扩展</Tag>
|
||
</div>
|
||
</div>
|
||
<div className="v2-user-field-group" role="group" aria-labelledby="v2-user-primary-identity">
|
||
<header>
|
||
<span><strong id="v2-user-primary-identity">基础身份</strong><small>用于客户登录和平台内展示</small></span>
|
||
<Tag color="blue" type="light" size="small">必填</Tag>
|
||
</header>
|
||
<div className="v2-user-fields is-primary">
|
||
<label><span>用户名</span><Input required disabled={!creating} value={draft.username} onChange={(value) => setDraft((current) => ({ ...current, username: value }))} placeholder="例如 customer-huadong" /></label>
|
||
<label><span>客户名称</span><Input required value={draft.displayName} onChange={(value) => setDraft((current) => ({ ...current, displayName: value }))} placeholder="显示在平台右上角" /></label>
|
||
</div>
|
||
</div>
|
||
<div className="v2-user-field-group" role="group" aria-labelledby="v2-user-security-mapping">
|
||
<header>
|
||
<span><strong id="v2-user-security-mapping">安全与系统映射</strong><small>{creating ? '设置初始密码,并按需关联外部客户标识' : '密码留空则保持不变,客户标识用于后续系统对接'}</small></span>
|
||
<Tag color="grey" type="light" size="small">{creating ? '密码必填' : '按需修改'}</Tag>
|
||
</header>
|
||
<div className="v2-user-fields is-secondary">
|
||
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><Input required={creating} mode="password" autoComplete="new-password" value={draft.password} onChange={(value) => setDraft((current) => ({ ...current, password: value }))} placeholder="至少 10 位,包含三类字符" /></label>
|
||
<label><span>客户标识(可选)</span><Input value={draft.customerRef} onChange={(value) => setDraft((current) => ({ ...current, customerRef: value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab={<span className="v2-user-tab-label">菜单权限<Tag color="blue" type="light" size="small">{draft.menuKeys.length}</Tag></span>} itemKey="menus">
|
||
<Card className="v2-user-editor-section v2-user-menu-section" title={<span className="v2-user-section-title">菜单权限 <Tag color="blue" type="light" size="small">客户开放菜单</Tag></span>} headerLine>
|
||
<div className="v2-user-menu-overview" aria-label={`已开放 ${draft.menuKeys.length} 个菜单,共 ${customerMenus.length} 个`}>
|
||
<span><small>当前开放</small><strong>{draft.menuKeys.length}<i> / {customerMenus.length}</i></strong></span>
|
||
<p><b>{draft.menuKeys.length === customerMenus.length ? '客户工作台已完整开放' : '客户工作台按最小权限开放'}</b><small>客户登录后只会看到下方已开放菜单,变更保存后最多 30 秒生效。</small></p>
|
||
<Tag color={draft.menuKeys.length ? 'green' : 'amber'} type="light" size="small">{draft.menuKeys.length ? '权限已配置' : '尚未开放'}</Tag>
|
||
</div>
|
||
<div className="v2-menu-permissions">{customerMenus.map((menu) => {
|
||
const selectedMenu = draft.menuKeys.includes(menu.key);
|
||
return <label key={menu.key} className={selectedMenu ? 'is-selected' : ''}>
|
||
<Checkbox checked={selectedMenu} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} />
|
||
<span className="v2-menu-permission-copy"><b>{menu.label}</b><small>{menu.description}</small></span>
|
||
<Tag className="v2-menu-permission-state" color={selectedMenu ? 'blue' : 'grey'} type="light" size="small">{selectedMenu ? '已开放' : '未开放'}</Tag>
|
||
</label>;
|
||
})}</div>
|
||
</Card>
|
||
</Tabs.TabPane>
|
||
<Tabs.TabPane tab={<span className="v2-user-tab-label">车辆权限<Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length}</Tag></span>} itemKey="vehicles">
|
||
<Card
|
||
className={`v2-user-editor-section v2-user-vehicle-section${mobileLayout && !vehicleComposerVisible ? ' is-composer-collapsed' : ''}`}
|
||
title={<span className="v2-user-section-title">车辆权限与有效期 <Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length} 辆</Tag></span>}
|
||
headerLine
|
||
headerExtraContent={draft.vehicleGrants.length ? <div className="v2-user-section-actions">
|
||
{mobileLayout && draft.vehicleGrants.length ? <Button
|
||
className="v2-user-vehicle-tools-toggle"
|
||
theme="light"
|
||
type="primary"
|
||
size="small"
|
||
icon={!vehicleComposerOpen ? <IconPlus /> : undefined}
|
||
aria-controls="v2-user-vehicle-composer"
|
||
aria-expanded={vehicleComposerOpen}
|
||
aria-label={vehicleComposerOpen ? '收起添加车辆' : '添加授权车辆'}
|
||
onClick={() => setVehicleComposerOpen((value) => !value)}
|
||
>{vehicleComposerOpen ? '收起' : '添加'}</Button> : null}
|
||
{draft.vehicleGrants.length ? <Button theme="borderless" type="tertiary" size="small" aria-haspopup="dialog" aria-controls="v2-user-clear-vehicles" aria-label={`清空全部 ${draft.vehicleGrants.length} 辆车辆权限`} onClick={() => setConfirmation('clear-vehicles')}>清空</Button> : null}
|
||
</div> : null}
|
||
>
|
||
{vehicleComposerVisible ? <div id="v2-user-vehicle-composer" className="v2-user-vehicle-composer">
|
||
<div className="v2-vehicle-permission-tools"><label><span>按车牌或 VIN 搜索</span><Input aria-label="按车牌或 VIN 搜索" value={vehicleKeyword} onChange={setVehicleKeyword} placeholder="输入后显示候选车辆" /></label><label><span>批量粘贴 VIN</span><span className="v2-bulk-vin"><Input value={bulkVINs} onChange={setBulkVINs} placeholder="空格、逗号或换行分隔" /><Button theme="light" disabled={!bulkVINs.trim()} onClick={addBulkVINs}>加入</Button></span></label></div>
|
||
{deferredVehicleKeyword ? <VehicleCandidateList
|
||
className="v2-vehicle-candidates"
|
||
layout="grid"
|
||
items={candidateVehicles}
|
||
loading={candidates.isPending}
|
||
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
|
||
onRetry={() => candidates.refetch()}
|
||
selectedVins={assigned}
|
||
selectedLabel="已分配"
|
||
onSelect={(vehicle) => toggleVIN(vehicle.vin)}
|
||
/> : null}
|
||
</div> : null}
|
||
{draft.vehicleGrants.length ? <>
|
||
<div className="v2-assigned-toolbar">
|
||
<span><strong>已授权车辆</strong><small>{assignedKeyword ? `${filteredAssignedGrants.length} 条匹配结果` : `${draft.vehicleGrants.length} 辆车拥有当前访问权限`}</small></span>
|
||
<Input
|
||
aria-label="筛选已授权车辆"
|
||
prefix={<IconSearch />}
|
||
showClear
|
||
value={assignedKeyword}
|
||
onChange={(value) => {
|
||
setAssignedKeyword(value);
|
||
setAssignedPage(1);
|
||
}}
|
||
placeholder="筛选已授权车牌或 VIN"
|
||
/>
|
||
</div>
|
||
{visibleAssignedGrants.length ? <div className="v2-assigned-vins">{visibleAssignedGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return <Card key={grant.vin} className={`v2-assigned-vehicle-card${mobileLayout ? ' is-mobile-compact' : ''}`} bodyStyle={{ padding: 0 }}>
|
||
<header><span><b>{plate || '未登记车牌'}</b><small>{grant.vin}</small></span><span className="v2-assigned-vehicle-actions"><Button theme="light" type="tertiary" size="small" aria-haspopup="dialog" aria-controls="v2-user-grant-window" aria-label={`调整 ${plate || grant.vin} 有效期`} onClick={() => openGrantEditor(grant.vin)}>调整有效期</Button><Button theme="borderless" type="tertiary" size="small" aria-label={`移除 ${plate || grant.vin}`} icon={<IconDelete />} onClick={() => toggleVIN(grant.vin)} /></span></header>
|
||
<div className="v2-assigned-vehicle-interval" aria-label={`授权有效期:启用 ${formatGrantTime(grant.validFrom)};停用 ${grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}`}><span><small>启用</small><strong>{formatGrantTime(grant.validFrom)}</strong></span><i aria-hidden="true">→</i><span><small>停用</small><strong>{grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}</strong></span></div>
|
||
</Card>; })}</div> : <Empty className="v2-user-assigned-filter-empty" title="没有匹配的授权车辆" description="请更换车牌或 VIN 关键词。" />}
|
||
<div className="v2-assigned-pagination">
|
||
<TablePagination
|
||
page={safeAssignedPage}
|
||
totalPages={assignedTotalPages}
|
||
info={<>第 {(safeAssignedPage - 1) * assignedPageSize + (filteredAssignedGrants.length ? 1 : 0)}–{Math.min(safeAssignedPage * assignedPageSize, filteredAssignedGrants.length)} 条,共 {filteredAssignedGrants.length} 辆</>}
|
||
onPageChange={setAssignedPage}
|
||
/>
|
||
</div>
|
||
</> : <Empty className="v2-user-vehicle-empty" title="尚未分配车辆" description="客户将无法看到任何车辆数据。" />}
|
||
{!creating && selected?.grantHistory?.length ? <Collapse className="v2-grant-history">
|
||
<Collapse.Panel itemKey="grant-history" header={<span className="v2-grant-history-title">历次开放记录 <Tag color="blue" type="light" size="small">{selected.grantHistory.length} 条</Tag></span>}>
|
||
<div className="v2-grant-history-list">{selected.grantHistory.map((item) => <Card key={item.id} className="v2-grant-history-card" bodyStyle={{ padding: 0 }}>
|
||
<header><span><strong>{item.plate || '未登记车牌'}</strong><small>{item.vin}</small></span><Tag color={item.validTo ? 'grey' : 'green'} type="light" size="small">{item.validTo ? '已结束' : '有效中'}</Tag></header>
|
||
<p>{formatTime(item.validFrom)} <i>→</i> {item.validTo ? formatTime(item.validTo) : '持续有效'}</p>
|
||
<small>开通:{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}</small>
|
||
</Card>)}</div>
|
||
</Collapse.Panel>
|
||
</Collapse> : null}
|
||
</Card>
|
||
</Tabs.TabPane>
|
||
</Tabs>
|
||
</form> : null}
|
||
</WorkspaceSideSheet>
|
||
<WorkspaceSideSheet
|
||
className="v2-user-grant-sidesheet"
|
||
variant="config"
|
||
visible={Boolean(editingGrant)}
|
||
placement={mobileLayout ? 'bottom' : 'right'}
|
||
width={420}
|
||
height={mobileLayout ? 'min(82dvh, 680px)' : undefined}
|
||
ariaLabel="车辆授权有效期"
|
||
closeLabel="关闭车辆授权有效期"
|
||
title="车辆授权有效期"
|
||
description={editingGrant ? `${editingGrantPlate || '未登记车牌'} · ${editingGrant.vin}` : '设置车辆可访问时间窗口'}
|
||
badge={editingGrant?.validTo ? '定时停用' : '持续有效'}
|
||
badgeColor={editingGrant?.validTo ? 'orange' : 'green'}
|
||
summaryItems={editingGrant ? [
|
||
{ label: '车牌', value: editingGrantPlate || '未登记车牌', tone: 'primary' },
|
||
{ label: '启用', value: formatGrantTime(editingGrant.validFrom), detail: '授权开始' },
|
||
{ label: '停用', value: editingGrant.validTo ? formatGrantTime(editingGrant.validTo) : '持续有效', detail: editingGrant.validTo ? '授权结束' : '未设置结束时间', tone: editingGrant.validTo ? 'warning' : 'success' }
|
||
] : []}
|
||
onCancel={closeGrantEditor}
|
||
footerNote="完成后仍需保存账号权限才会生效"
|
||
primaryAction={{ label: '完成', onClick: closeGrantEditor, disabled: grantWindowInvalid }}
|
||
>
|
||
{editingGrant ? <div className="v2-user-grant-form">
|
||
<div className="v2-user-grant-field-group" role="group" aria-labelledby="v2-user-grant-window-heading">
|
||
<header><span><strong id="v2-user-grant-window-heading">访问时间窗口</strong><small>只在授权区间内开放该车数据</small></span><Tag color="blue" type="light" size="small">上海时间</Tag></header>
|
||
<div>
|
||
<label><span>启用时间</span><WorkspaceDateTimeField ariaLabel={`${editingGrantPlate || editingGrant.vin} 启用时间`} value={editingGrant.validFrom} onChange={(value) => updateGrant(editingGrant.vin, { validFrom: value })} /></label>
|
||
<div className="v2-user-grant-policy">
|
||
<span><strong>持续有效</strong><small>{grantEndEnabled ? '关闭后必须指定停用时间' : '保持开启则不自动收回权限'}</small></span>
|
||
<Switch
|
||
aria-label={`${editingGrantPlate || editingGrant.vin} 持续有效`}
|
||
checked={!grantEndEnabled}
|
||
onChange={(checked) => {
|
||
setGrantEndEnabled(!checked);
|
||
if (checked) updateGrant(editingGrant.vin, { validTo: '' });
|
||
}}
|
||
/>
|
||
</div>
|
||
{grantEndEnabled ? <label><span>停用时间</span><WorkspaceDateTimeField ariaLabel={`${editingGrantPlate || editingGrant.vin} 停用时间`} value={editingGrant.validTo} onChange={(value) => updateGrant(editingGrant.vin, { validTo: value })} placeholder="选择停用日期和时间" showClear /></label> : null}
|
||
</div>
|
||
</div>
|
||
{grantWindowInvalid ? <p role="alert">请设置完整时间,并确保停用时间晚于启用时间。</p> : <p>{grantEndEnabled ? '到达停用时间后,客户将不再看到该车数据。' : '当前授权持续有效,不会自动收回。'}</p>}
|
||
</div> : null}
|
||
</WorkspaceSideSheet>
|
||
<WorkspaceConfirmDialog
|
||
visible={confirmation === 'clear-vehicles'}
|
||
ariaLabel="确认清空车辆权限"
|
||
className="v2-user-confirm-dialog"
|
||
title={`清空 ${draft.vehicleGrants.length} 辆车辆权限?`}
|
||
description="车辆会先从当前草稿中移除,只有点击账号详情底部的保存权限后才会正式生效。"
|
||
summaryItems={[
|
||
{ label: '影响对象', value: `${draft.vehicleGrants.length} 辆`, detail: '当前账号全部车辆权限', tone: 'danger' },
|
||
{ label: '当前动作', value: '修改草稿', detail: '确认后仍可继续编辑', tone: 'warning' },
|
||
{ label: '正式生效', value: '保存权限后', detail: '未保存不会影响服务器', tone: 'success' }
|
||
]}
|
||
cancelLabel="返回核对"
|
||
confirmLabel="确认清空"
|
||
note="这是草稿操作,仍需再次保存权限。"
|
||
onCancel={() => setConfirmation(null)}
|
||
onConfirm={clearVehicleGrants}
|
||
/>
|
||
<WorkspaceConfirmDialog
|
||
visible={confirmation === 'discard-editor'}
|
||
ariaLabel="确认放弃未保存的更改"
|
||
className="v2-user-confirm-dialog"
|
||
title="放弃未保存的更改?"
|
||
description="登录身份、菜单或车辆权限的修改尚未保存,关闭后将恢复到上次保存状态。"
|
||
summaryItems={[
|
||
{ label: '草稿状态', value: '尚未保存', detail: `${draft.menuKeys.length} 菜单 · ${draft.vehicleGrants.length} 辆`, tone: 'warning' },
|
||
{ label: '关闭结果', value: creating ? '清空新建草稿' : '恢复已保存状态', detail: creating ? '账号不会被创建' : `账号 @${selected?.username || draft.username}`, tone: 'danger' },
|
||
{ label: '服务器数据', value: '不受影响', detail: '不会发送保存请求', tone: 'success' }
|
||
]}
|
||
cancelLabel="继续编辑"
|
||
confirmLabel="放弃更改"
|
||
note="放弃后无法恢复当前草稿。"
|
||
onCancel={() => setConfirmation(null)}
|
||
onConfirm={closeEditor}
|
||
/>
|
||
</div>;
|
||
}
|