refactor: converge account and access workspaces on semi ui
This commit is contained in:
@@ -258,6 +258,9 @@ test('renders mobile access vehicles as selectable Semi cards', async () => {
|
|||||||
expect(within(dialog).getByRole('region', { name: 'JT808 完整接入证据' })).toBeInTheDocument();
|
expect(within(dialog).getByRole('region', { name: 'JT808 完整接入证据' })).toBeInTheDocument();
|
||||||
expect(within(dialog).getByText('test first seen')).toBeInTheDocument();
|
expect(within(dialog).getByText('test first seen')).toBeInTheDocument();
|
||||||
expect(within(dialog).getAllByText('当前未发现真实接入来源')).toHaveLength(2);
|
expect(within(dialog).getAllByText('当前未发现真实接入来源')).toHaveLength(2);
|
||||||
|
expect(within(dialog).getByRole('navigation', { name: '粤A00001 数据操作' })).toHaveTextContent('车辆档案轨迹回放历史数据');
|
||||||
|
expect(within(dialog).getByRole('link', { name: '车辆档案' })).toHaveAttribute('href', '/vehicles/VIN001');
|
||||||
|
expect(within(dialog).getByRole('link', { name: '轨迹回放' })).toHaveAttribute('href', '/tracks?vin=VIN001');
|
||||||
const vehicleRequestsBeforeEvidenceToggle = mocks.accessVehicles.mock.calls.length;
|
const vehicleRequestsBeforeEvidenceToggle = mocks.accessVehicles.mock.calls.length;
|
||||||
const summaryRequestsBeforeEvidenceToggle = mocks.accessSummary.mock.calls.length;
|
const summaryRequestsBeforeEvidenceToggle = mocks.accessSummary.mock.calls.length;
|
||||||
fireEvent.click(evidenceToggle);
|
fireEvent.click(evidenceToggle);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
|||||||
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
|
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
|
||||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||||
|
import { ProtocolTag } from '../shared/ProtocolTag';
|
||||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||||
import { usePlatformSession } from '../auth/AuthGate';
|
import { usePlatformSession } from '../auth/AuthGate';
|
||||||
import { canAdminister } from '../auth/session';
|
import { canAdminister } from '../auth/session';
|
||||||
@@ -266,7 +267,14 @@ function VehicleInspector({ row, onClose, sheet = false, mobile = false }: { row
|
|||||||
expanded={expandedProtocol === protocol}
|
expanded={expandedProtocol === protocol}
|
||||||
onToggle={toggleProtocol}
|
onToggle={toggleProtocol}
|
||||||
/>)}</CardGroup>
|
/>)}</CardGroup>
|
||||||
<footer><span>{row.expectationEvidence}</span><Link to={`/vehicles/${encodeURIComponent(row.vin)}`}>查看车辆详情</Link></footer>
|
<footer className="v2-access-inspector-footer">
|
||||||
|
<span>{row.expectationEvidence}</span>
|
||||||
|
<nav className="v2-access-inspector-actions" aria-label={`${row.plate || row.vin} 数据操作`}>
|
||||||
|
<Link to={`/vehicles/${encodeURIComponent(row.vin)}`}>车辆档案</Link>
|
||||||
|
<Link to={`/tracks?vin=${encodeURIComponent(row.vin)}`}>轨迹回放</Link>
|
||||||
|
<Link to={`/history?vin=${encodeURIComponent(row.vin)}`}>历史数据</Link>
|
||||||
|
</nav>
|
||||||
|
</footer>
|
||||||
</Card>;
|
</Card>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +283,7 @@ function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; to
|
|||||||
return <Collapse id="access-identity-queue" className="v2-access-identity-queue-v3" defaultActiveKey="unresolved-identities" keepDOM={false} lazyRender>
|
return <Collapse id="access-identity-queue" className="v2-access-identity-queue-v3" defaultActiveKey="unresolved-identities" keepDOM={false} lazyRender>
|
||||||
<Collapse.Panel itemKey="unresolved-identities" header={<span className="v2-access-collapse-title"><span><strong>来源身份待绑定</strong><small>这些来源不计入主车辆,绑定权威 VIN 后再归档</small></span><Tag color="orange" type="light" size="small">{total.toLocaleString('zh-CN')} 条</Tag></span>}>
|
<Collapse.Panel itemKey="unresolved-identities" header={<span className="v2-access-collapse-title"><span><strong>来源身份待绑定</strong><small>这些来源不计入主车辆,绑定权威 VIN 后再归档</small></span><Tag color="orange" type="light" size="small">{total.toLocaleString('zh-CN')} 条</Tag></span>}>
|
||||||
<div className="v2-access-identity-grid">{items.slice(0, 6).map((item) => <Card key={item.id} className="v2-access-identity-card" bodyStyle={{ padding: 0 }}>
|
<div className="v2-access-identity-grid">{items.slice(0, 6).map((item) => <Card key={item.id} className="v2-access-identity-card" bodyStyle={{ padding: 0 }}>
|
||||||
<header><span><small>来源标识</small><b>{item.identifierMasked}</b></span><Tag color="blue" type="light" size="small">{item.protocol}</Tag></header>
|
<header><span><small>来源标识</small><b>{item.identifierMasked}</b></span><ProtocolTag protocol={item.protocol} compact /></header>
|
||||||
<div className="v2-access-identity-facts">
|
<div className="v2-access-identity-facts">
|
||||||
<span><small>关联车牌</small><strong>{item.plate || '待核对'}</strong></span>
|
<span><small>关联车牌</small><strong>{item.plate || '待核对'}</strong></span>
|
||||||
<span><small>最后出现</small><strong><AccessTime value={item.latestSeenAt} compact /></strong></span>
|
<span><small>最后出现</small><strong><AccessTime value={item.latestSeenAt} compact /></strong></span>
|
||||||
|
|||||||
@@ -101,8 +101,14 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
|||||||
expect(screen.getByRole('button', { name: '调整 粤A11111 有效期' })).toHaveAttribute('aria-haspopup', 'dialog');
|
expect(screen.getByRole('button', { name: '调整 粤A11111 有效期' })).toHaveAttribute('aria-haspopup', 'dialog');
|
||||||
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
|
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
|
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
|
||||||
expect(await screen.findByLabelText('粤A11111 启用时间')).toHaveValue('2026-06-05T00:00');
|
expect(await screen.findByRole('textbox', { name: '粤A11111 启用时间' })).toHaveValue('2026-06-05 00:00');
|
||||||
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(2);
|
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
|
||||||
|
expect(document.querySelectorAll('.v2-workspace-date-time-field.semi-datepicker')).toHaveLength(1);
|
||||||
|
expect(screen.getByRole('switch', { name: '粤A11111 持续有效' })).toBeChecked();
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: '粤A11111 持续有效' }));
|
||||||
|
expect(await screen.findByRole('textbox', { name: '粤A11111 停用时间' })).toBeInTheDocument();
|
||||||
|
expect(document.querySelectorAll('.v2-workspace-date-time-field.semi-datepicker')).toHaveLength(2);
|
||||||
|
expect(screen.getByRole('button', { name: '完成' })).toBeDisabled();
|
||||||
const grantSheet = document.querySelector('.v2-user-grant-sidesheet .semi-sidesheet-inner');
|
const grantSheet = document.querySelector('.v2-user-grant-sidesheet .semi-sidesheet-inner');
|
||||||
expect(grantSheet).toHaveAttribute('aria-label', '车辆授权有效期');
|
expect(grantSheet).toHaveAttribute('aria-label', '车辆授权有效期');
|
||||||
expect(grantSheet?.closest('.semi-sidesheet')).toHaveClass('v2-workspace-config-sidesheet');
|
expect(grantSheet?.closest('.semi-sidesheet')).toHaveClass('v2-workspace-config-sidesheet');
|
||||||
@@ -427,12 +433,14 @@ test('submits per-vehicle authorization interval instead of only a VIN list', as
|
|||||||
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
||||||
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
|
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
|
||||||
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
|
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
|
||||||
fireEvent.change(await screen.findByLabelText('粤A11111 启用时间'), { target: { value: '2026-06-05T00:00' } });
|
expect(await screen.findByRole('textbox', { name: '粤A11111 启用时间' })).toHaveValue('2026-07-16 14:08');
|
||||||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: '登录身份' }));
|
||||||
|
fireEvent.change(screen.getByDisplayValue('华东客户'), { target: { value: '华东客户(更新)' } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: '保存权限' }));
|
fireEvent.click(screen.getByRole('button', { name: '保存权限' }));
|
||||||
await waitFor(() => expect(mocks.updateCustomerUser).toHaveBeenCalled());
|
await waitFor(() => expect(mocks.updateCustomerUser).toHaveBeenCalled());
|
||||||
expect(mocks.updateCustomerUser.mock.calls[0][1]).toMatchObject({
|
expect(mocks.updateCustomerUser.mock.calls[0][1]).toMatchObject({
|
||||||
vehicleVins: ['VIN001'],
|
vehicleVins: ['VIN001'],
|
||||||
vehicleGrants: [{ vin: 'VIN001', validFrom: '2026-06-05T00:00', validTo: '' }]
|
vehicleGrants: [{ vin: 'VIN001', validFrom: '2026-07-16T14:08', validTo: '' }]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { WorkspaceConfirmDialog } from '../shared/WorkspaceDialog';
|
|||||||
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
|
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
|
||||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||||
import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
|
import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
|
||||||
|
import { WorkspaceDateTimeField } from '../shared/WorkspaceDateTimeField';
|
||||||
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||||||
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||||
|
|
||||||
@@ -149,6 +150,7 @@ export default function UsersPage() {
|
|||||||
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
|
const [vehicleLabels, setVehicleLabels] = useState<Record<string, string>>({});
|
||||||
const [feedback, setFeedback] = useState('');
|
const [feedback, setFeedback] = useState('');
|
||||||
const [editingGrantVIN, setEditingGrantVIN] = useState('');
|
const [editingGrantVIN, setEditingGrantVIN] = useState('');
|
||||||
|
const [grantEndEnabled, setGrantEndEnabled] = useState(false);
|
||||||
const [confirmation, setConfirmation] = useState<'clear-vehicles' | 'discard-editor' | null>(null);
|
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 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 readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).key === 'ready').length, [visibleCustomers]);
|
||||||
@@ -186,7 +188,11 @@ export default function UsersPage() {
|
|||||||
];
|
];
|
||||||
const editingGrant = useMemo(() => draft.vehicleGrants.find((grant) => grant.vin === editingGrantVIN), [draft.vehicleGrants, editingGrantVIN]);
|
const editingGrant = useMemo(() => draft.vehicleGrants.find((grant) => grant.vin === editingGrantVIN), [draft.vehicleGrants, editingGrantVIN]);
|
||||||
const editingGrantPlate = editingGrant ? vehicleLabels[editingGrant.vin] : '';
|
const editingGrantPlate = editingGrant ? vehicleLabels[editingGrant.vin] : '';
|
||||||
const grantWindowInvalid = Boolean(editingGrant && (!editingGrant.validFrom || (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom)));
|
const grantWindowInvalid = Boolean(editingGrant && (
|
||||||
|
!editingGrant.validFrom
|
||||||
|
|| (grantEndEnabled && !editingGrant.validTo)
|
||||||
|
|| (editingGrant.validTo && editingGrant.validTo <= editingGrant.validFrom)
|
||||||
|
));
|
||||||
const editorVisible = creating || Boolean(selected);
|
const editorVisible = creating || Boolean(selected);
|
||||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||||
const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen;
|
const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen;
|
||||||
@@ -272,6 +278,7 @@ export default function UsersPage() {
|
|||||||
setVehicleLabels({});
|
setVehicleLabels({});
|
||||||
setFeedback('');
|
setFeedback('');
|
||||||
setEditingGrantVIN('');
|
setEditingGrantVIN('');
|
||||||
|
setGrantEndEnabled(false);
|
||||||
setConfirmation(null);
|
setConfirmation(null);
|
||||||
};
|
};
|
||||||
const closeEditor = () => {
|
const closeEditor = () => {
|
||||||
@@ -288,6 +295,7 @@ export default function UsersPage() {
|
|||||||
setVehicleLabels({});
|
setVehicleLabels({});
|
||||||
setFeedback('');
|
setFeedback('');
|
||||||
setEditingGrantVIN('');
|
setEditingGrantVIN('');
|
||||||
|
setGrantEndEnabled(false);
|
||||||
setConfirmation(null);
|
setConfirmation(null);
|
||||||
};
|
};
|
||||||
const requestCloseEditor = () => {
|
const requestCloseEditor = () => {
|
||||||
@@ -311,6 +319,7 @@ export default function UsersPage() {
|
|||||||
setVehicleComposerOpen(user.vehicles.length === 0);
|
setVehicleComposerOpen(user.vehicles.length === 0);
|
||||||
setFeedback('');
|
setFeedback('');
|
||||||
setEditingGrantVIN('');
|
setEditingGrantVIN('');
|
||||||
|
setGrantEndEnabled(false);
|
||||||
};
|
};
|
||||||
const toggleVIN = (vin: string) => {
|
const toggleVIN = (vin: string) => {
|
||||||
const isAssigned = assigned.has(vin);
|
const isAssigned = assigned.has(vin);
|
||||||
@@ -327,7 +336,10 @@ export default function UsersPage() {
|
|||||||
setFeedback(isAssigned
|
setFeedback(isAssigned
|
||||||
? `${vehicleLabels[vin] || vin} 已从授权草稿移除,保存后生效`
|
? `${vehicleLabels[vin] || vin} 已从授权草稿移除,保存后生效`
|
||||||
: `${vehicleLabels[vin] || vin} 已加入授权草稿,保存后生效`);
|
: `${vehicleLabels[vin] || vin} 已加入授权草稿,保存后生效`);
|
||||||
if (editingGrantVIN === vin) setEditingGrantVIN('');
|
if (editingGrantVIN === vin) {
|
||||||
|
setEditingGrantVIN('');
|
||||||
|
setGrantEndEnabled(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const clearVehicleGrants = () => {
|
const clearVehicleGrants = () => {
|
||||||
const removedCount = draft.vehicleGrants.length;
|
const removedCount = draft.vehicleGrants.length;
|
||||||
@@ -336,12 +348,22 @@ export default function UsersPage() {
|
|||||||
setAssignedPage(1);
|
setAssignedPage(1);
|
||||||
setVehicleComposerOpen(true);
|
setVehicleComposerOpen(true);
|
||||||
setEditingGrantVIN('');
|
setEditingGrantVIN('');
|
||||||
|
setGrantEndEnabled(false);
|
||||||
setFeedback(`已从草稿移除 ${removedCount} 辆车,保存后生效`);
|
setFeedback(`已从草稿移除 ${removedCount} 辆车,保存后生效`);
|
||||||
setConfirmation(null);
|
setConfirmation(null);
|
||||||
};
|
};
|
||||||
const updateGrant = (vin: string, patch: Partial<CustomerVehicleGrantInput>) => setDraft((value) => ({
|
const updateGrant = (vin: string, patch: Partial<CustomerVehicleGrantInput>) => setDraft((value) => ({
|
||||||
...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant)
|
...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 addBulkVINs = () => {
|
||||||
const next = bulkVINs.split(/[\s,,;;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean);
|
const next = bulkVINs.split(/[\s,,;;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean);
|
||||||
setDraft((value) => {
|
setDraft((value) => {
|
||||||
@@ -364,7 +386,7 @@ export default function UsersPage() {
|
|||||||
const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom));
|
const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom));
|
||||||
if (invalidGrant) {
|
if (invalidGrant) {
|
||||||
setActiveSection('vehicles');
|
setActiveSection('vehicles');
|
||||||
setEditingGrantVIN(invalidGrant.vin);
|
openGrantEditor(invalidGrant.vin);
|
||||||
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
|
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -684,7 +706,7 @@ export default function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 }}>
|
{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={() => setEditingGrantVIN(grant.vin)}>调整有效期</Button><Button theme="borderless" type="tertiary" size="small" aria-label={`移除 ${plate || grant.vin}`} icon={<IconDelete />} onClick={() => toggleVIN(grant.vin)} /></span></header>
|
<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>
|
<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 关键词。" />}
|
</Card>; })}</div> : <Empty className="v2-user-assigned-filter-empty" title="没有匹配的授权车辆" description="请更换车牌或 VIN 关键词。" />}
|
||||||
<div className="v2-assigned-pagination">
|
<div className="v2-assigned-pagination">
|
||||||
@@ -728,19 +750,30 @@ export default function UsersPage() {
|
|||||||
{ label: '启用', value: formatGrantTime(editingGrant.validFrom), detail: '授权开始' },
|
{ label: '启用', value: formatGrantTime(editingGrant.validFrom), detail: '授权开始' },
|
||||||
{ label: '停用', value: editingGrant.validTo ? formatGrantTime(editingGrant.validTo) : '持续有效', detail: editingGrant.validTo ? '授权结束' : '未设置结束时间', tone: editingGrant.validTo ? 'warning' : 'success' }
|
{ label: '停用', value: editingGrant.validTo ? formatGrantTime(editingGrant.validTo) : '持续有效', detail: editingGrant.validTo ? '授权结束' : '未设置结束时间', tone: editingGrant.validTo ? 'warning' : 'success' }
|
||||||
] : []}
|
] : []}
|
||||||
onCancel={() => setEditingGrantVIN('')}
|
onCancel={closeGrantEditor}
|
||||||
footerNote="完成后仍需保存账号权限才会生效"
|
footerNote="完成后仍需保存账号权限才会生效"
|
||||||
primaryAction={{ label: '完成', onClick: () => setEditingGrantVIN(''), disabled: grantWindowInvalid }}
|
primaryAction={{ label: '完成', onClick: closeGrantEditor, disabled: grantWindowInvalid }}
|
||||||
>
|
>
|
||||||
{editingGrant ? <div className="v2-user-grant-form">
|
{editingGrant ? <div className="v2-user-grant-form">
|
||||||
<div className="v2-user-grant-field-group" role="group" aria-labelledby="v2-user-grant-window-heading">
|
<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>
|
<header><span><strong id="v2-user-grant-window-heading">访问时间窗口</strong><small>只在授权区间内开放该车数据</small></span><Tag color="blue" type="light" size="small">上海时间</Tag></header>
|
||||||
<div>
|
<div>
|
||||||
<label><span>启用时间</span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 启用时间`} type="datetime-local" required value={editingGrant.validFrom} onChange={(value) => updateGrant(editingGrant.vin, { validFrom: value })} /></label>
|
<label><span>启用时间</span><WorkspaceDateTimeField ariaLabel={`${editingGrantPlate || editingGrant.vin} 启用时间`} value={editingGrant.validFrom} onChange={(value) => updateGrant(editingGrant.vin, { validFrom: value })} /></label>
|
||||||
<label><span>停用时间(可选)</span><Input aria-label={`${editingGrantPlate || editingGrant.vin} 停用时间`} type="datetime-local" min={editingGrant.validFrom} value={editingGrant.validTo} onChange={(value) => updateGrant(editingGrant.vin, { validTo: 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>
|
||||||
</div>
|
</div>
|
||||||
{grantWindowInvalid ? <p role="alert">请设置启用时间,并确保停用时间晚于启用时间。</p> : <p>不设置停用时间时,车辆授权将持续有效。</p>}
|
{grantWindowInvalid ? <p role="alert">请设置完整时间,并确保停用时间晚于启用时间。</p> : <p>{grantEndEnabled ? '到达停用时间后,客户将不再看到该车数据。' : '当前授权持续有效,不会自动收回。'}</p>}
|
||||||
</div> : null}
|
</div> : null}
|
||||||
</WorkspaceSideSheet>
|
</WorkspaceSideSheet>
|
||||||
<WorkspaceConfirmDialog
|
<WorkspaceConfirmDialog
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { expect, test, vi } from 'vitest';
|
||||||
|
import { normalizeWorkspaceDateTime, WorkspaceDateTimeField } from './WorkspaceDateTimeField';
|
||||||
|
|
||||||
|
test('normalizes Semi single date-time values to a local minute', () => {
|
||||||
|
expect(normalizeWorkspaceDateTime(undefined, '2026-07-19 14:30')).toBe('2026-07-19T14:30');
|
||||||
|
expect(normalizeWorkspaceDateTime(new Date(2026, 6, 19, 14, 30))).toBe('2026-07-19T14:30');
|
||||||
|
expect(normalizeWorkspaceDateTime(undefined, '')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders a clearable Semi date-time field without native datetime-local inputs', () => {
|
||||||
|
const view = render(<WorkspaceDateTimeField
|
||||||
|
ariaLabel="授权启用时间"
|
||||||
|
value="2026-07-19T14:30"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
showClear
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(view.container.querySelector('.v2-workspace-date-time-field.semi-datepicker')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox', { name: '授权启用时间' })).toHaveValue('2026-07-19 14:30');
|
||||||
|
expect(view.container.querySelector('input[type="datetime-local"]')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { IconCalendarClock } from '@douyinfe/semi-icons';
|
||||||
|
import { DatePicker, Input } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
function localMinute(value: Date) {
|
||||||
|
const pad = (part: number) => String(part).padStart(2, '0');
|
||||||
|
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkspaceDateTime(
|
||||||
|
dateValue?: Date | Date[] | string | string[],
|
||||||
|
formattedValue?: string | string[] | Date | Date[]
|
||||||
|
) {
|
||||||
|
const candidate = Array.isArray(formattedValue)
|
||||||
|
? formattedValue[0]
|
||||||
|
: formattedValue ?? (Array.isArray(dateValue) ? dateValue[0] : dateValue);
|
||||||
|
if (!candidate) return '';
|
||||||
|
if (candidate instanceof Date) return localMinute(candidate);
|
||||||
|
const normalized = String(candidate).trim().replace(' ', 'T').slice(0, 16);
|
||||||
|
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(normalized) ? normalized : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceDateTimeField({
|
||||||
|
ariaLabel,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = '选择日期和时间',
|
||||||
|
showClear = false,
|
||||||
|
className = ''
|
||||||
|
}: {
|
||||||
|
ariaLabel: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
showClear?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const parsed = value ? new Date(value) : undefined;
|
||||||
|
const pickerValue = parsed && !Number.isNaN(parsed.getTime()) ? parsed : undefined;
|
||||||
|
const classes = ['v2-workspace-date-time-field', className].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return <DatePicker
|
||||||
|
className={classes}
|
||||||
|
type="dateTime"
|
||||||
|
density="compact"
|
||||||
|
format="yyyy-MM-dd HH:mm"
|
||||||
|
value={pickerValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
showClear={showClear}
|
||||||
|
onChangeWithDateFirst
|
||||||
|
triggerRender={() => <Input
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
value={value ? value.replace('T', ' ') : ''}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readonly
|
||||||
|
suffix={<IconCalendarClock />}
|
||||||
|
/>}
|
||||||
|
onChange={(dateValue, formattedValue) => onChange(normalizeWorkspaceDateTime(dateValue, formattedValue))}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
@@ -21800,10 +21800,100 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v2-workspace-date-time-field.semi-datepicker,
|
||||||
|
.v2-workspace-date-time-field .semi-datepicker-input,
|
||||||
|
.v2-workspace-date-time-field .semi-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-workspace-date-time-field .semi-input-wrapper {
|
||||||
|
min-height: 42px;
|
||||||
|
border-color: #d5dfeb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-workspace-date-time-field .semi-input-wrapper:hover {
|
||||||
|
border-color: #a9c6ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-workspace-date-time-field .semi-input-wrapper-focus {
|
||||||
|
border-color: var(--v2-blue);
|
||||||
|
box-shadow: 0 0 0 3px rgba(18, 104, 243, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-user-grant-policy {
|
||||||
|
display: flex;
|
||||||
|
min-height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #e0e7f0;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-user-grant-policy > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-user-grant-policy strong {
|
||||||
|
color: #30465f;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-user-grant-policy small {
|
||||||
|
color: #8290a2;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-user-grant-policy > .semi-switch {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.v2-user-grant-sidesheet .v2-workspace-config-footer > div {
|
.v2-user-grant-sidesheet .v2-workspace-config-footer > div {
|
||||||
min-width: 96px;
|
min-width: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v2-access-inspector-v3 > .semi-card-body > .v2-access-inspector-footer {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 11px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-access-inspector-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-access-inspector-actions a {
|
||||||
|
display: flex;
|
||||||
|
min-height: 34px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #d9e4f1;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #f6f9fd;
|
||||||
|
color: var(--v2-blue);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 650;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color .14s ease, background .14s ease, transform .14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-access-inspector-actions a:hover,
|
||||||
|
.v2-access-inspector-actions a:focus-visible {
|
||||||
|
border-color: #a9c9fa;
|
||||||
|
background: #eaf3ff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
.v2-user-editor-sidesheet.v2-workspace-editor-sidesheet .semi-sidesheet-header {
|
.v2-user-editor-sidesheet.v2-workspace-editor-sidesheet .semi-sidesheet-header {
|
||||||
min-height: 68px;
|
min-height: 68px;
|
||||||
@@ -21886,6 +21976,15 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v2-user-grant-policy {
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v2-access-inspector-actions a {
|
||||||
|
min-height: 40px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user