refactor: converge account and access workspaces on semi ui

This commit is contained in:
lingniu
2026-07-19 15:24:03 +08:00
parent fb105a67c8
commit 81532d91b7
7 changed files with 247 additions and 15 deletions

View File

@@ -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);

View File

@@ -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>

View File

@@ -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: '' }]
}); });
}); });

View File

@@ -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

View File

@@ -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();
});

View File

@@ -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))}
/>;
}

View File

@@ -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;
}
} }
/* /*