关联车牌{item.plate || '待核对'}
最后出现
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx
index a7171aaf..d02c3e6c 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx
@@ -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(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
- expect(await screen.findByLabelText('粤A11111 启用时间')).toHaveValue('2026-06-05T00:00');
- expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(2);
+ expect(await screen.findByRole('textbox', { name: '粤A11111 启用时间' })).toHaveValue('2026-06-05 00:00');
+ 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');
expect(grantSheet).toHaveAttribute('aria-label', '车辆授权有效期');
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: /选择客户 华东客户/ }));
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
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('tab', { name: '登录身份' }));
+ fireEvent.change(screen.getByDisplayValue('华东客户'), { target: { value: '华东客户(更新)' } });
fireEvent.click(screen.getByRole('button', { name: '保存权限' }));
await waitFor(() => expect(mocks.updateCustomerUser).toHaveBeenCalled());
expect(mocks.updateCustomerUser.mock.calls[0][1]).toMatchObject({
vehicleVins: ['VIN001'],
- vehicleGrants: [{ vin: 'VIN001', validFrom: '2026-06-05T00:00', validTo: '' }]
+ vehicleGrants: [{ vin: 'VIN001', validFrom: '2026-07-16T14:08', validTo: '' }]
});
});
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx
index cabaaae0..16dd1976 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx
@@ -15,6 +15,7 @@ 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';
@@ -149,6 +150,7 @@ export default function UsersPage() {
const [vehicleLabels, setVehicleLabels] = useState
>({});
const [feedback, setFeedback] = useState('');
const [editingGrantVIN, setEditingGrantVIN] = useState('');
+ const [grantEndEnabled, setGrantEndEnabled] = useState(false);
const [confirmation, setConfirmation] = useState<'clear-vehicles' | 'discard-editor' | null>(null);
const attentionCustomerCount = useMemo(() => customers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [customers]);
const readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).key === 'ready').length, [visibleCustomers]);
@@ -186,7 +188,11 @@ export default function UsersPage() {
];
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 || (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 mobileFiltersOpen = mobileLayout && !filtersCollapsed;
const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen;
@@ -272,6 +278,7 @@ export default function UsersPage() {
setVehicleLabels({});
setFeedback('');
setEditingGrantVIN('');
+ setGrantEndEnabled(false);
setConfirmation(null);
};
const closeEditor = () => {
@@ -288,6 +295,7 @@ export default function UsersPage() {
setVehicleLabels({});
setFeedback('');
setEditingGrantVIN('');
+ setGrantEndEnabled(false);
setConfirmation(null);
};
const requestCloseEditor = () => {
@@ -311,6 +319,7 @@ export default function UsersPage() {
setVehicleComposerOpen(user.vehicles.length === 0);
setFeedback('');
setEditingGrantVIN('');
+ setGrantEndEnabled(false);
};
const toggleVIN = (vin: string) => {
const isAssigned = assigned.has(vin);
@@ -327,7 +336,10 @@ export default function UsersPage() {
setFeedback(isAssigned
? `${vehicleLabels[vin] || vin} 已从授权草稿移除,保存后生效`
: `${vehicleLabels[vin] || vin} 已加入授权草稿,保存后生效`);
- if (editingGrantVIN === vin) setEditingGrantVIN('');
+ if (editingGrantVIN === vin) {
+ setEditingGrantVIN('');
+ setGrantEndEnabled(false);
+ }
};
const clearVehicleGrants = () => {
const removedCount = draft.vehicleGrants.length;
@@ -336,12 +348,22 @@ export default function UsersPage() {
setAssignedPage(1);
setVehicleComposerOpen(true);
setEditingGrantVIN('');
+ setGrantEndEnabled(false);
setFeedback(`已从草稿移除 ${removedCount} 辆车,保存后生效`);
setConfirmation(null);
};
const updateGrant = (vin: string, patch: Partial) => setDraft((value) => ({
...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant)
}));
+ const openGrantEditor = (vin: string) => {
+ const grant = draft.vehicleGrants.find((item) => item.vin === vin);
+ setGrantEndEnabled(Boolean(grant?.validTo));
+ setEditingGrantVIN(vin);
+ };
+ const closeGrantEditor = () => {
+ setEditingGrantVIN('');
+ setGrantEndEnabled(false);
+ };
const addBulkVINs = () => {
const next = bulkVINs.split(/[\s,,;;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean);
setDraft((value) => {
@@ -364,7 +386,7 @@ export default function UsersPage() {
const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom));
if (invalidGrant) {
setActiveSection('vehicles');
- setEditingGrantVIN(invalidGrant.vin);
+ openGrantEditor(invalidGrant.vin);
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
return;
}
@@ -684,7 +706,7 @@ export default function UsersPage() {
/>
{visibleAssignedGrants.length ?
@@ -728,19 +750,30 @@ export default function UsersPage() {
{ label: '启用', value: formatGrantTime(editingGrant.validFrom), detail: '授权开始' },
{ label: '停用', value: editingGrant.validTo ? formatGrantTime(editingGrant.validTo) : '持续有效', detail: editingGrant.validTo ? '授权结束' : '未设置结束时间', tone: editingGrant.validTo ? 'warning' : 'success' }
] : []}
- onCancel={() => setEditingGrantVIN('')}
+ onCancel={closeGrantEditor}
footerNote="完成后仍需保存账号权限才会生效"
- primaryAction={{ label: '完成', onClick: () => setEditingGrantVIN(''), disabled: grantWindowInvalid }}
+ primaryAction={{ label: '完成', onClick: closeGrantEditor, disabled: grantWindowInvalid }}
>
{editingGrant ?
-
-
+
+
+ 持续有效{grantEndEnabled ? '关闭后必须指定停用时间' : '保持开启则不自动收回权限'}
+ {
+ setGrantEndEnabled(!checked);
+ if (checked) updateGrant(editingGrant.vin, { validTo: '' });
+ }}
+ />
+
+ {grantEndEnabled ?
: null}
- {grantWindowInvalid ?
请设置启用时间,并确保停用时间晚于启用时间。
:
不设置停用时间时,车辆授权将持续有效。
}
+ {grantWindowInvalid ?
请设置完整时间,并确保停用时间晚于启用时间。
:
{grantEndEnabled ? '到达停用时间后,客户将不再看到该车数据。' : '当前授权持续有效,不会自动收回。'}
}
: null}
{
+ 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();
+
+ 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();
+});
diff --git a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDateTimeField.tsx b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDateTimeField.tsx
new file mode 100644
index 00000000..2e13903c
--- /dev/null
+++ b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDateTimeField.tsx
@@ -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 }
+ />}
+ onChange={(dateValue, formattedValue) => onChange(normalizeWorkspaceDateTime(dateValue, formattedValue))}
+ />;
+}
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
index fd13b04a..b3f34f79 100644
--- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
+++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
@@ -21800,10 +21800,100 @@
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 {
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) {
.v2-user-editor-sidesheet.v2-workspace-editor-sidesheet .semi-sidesheet-header {
min-height: 68px;
@@ -21886,6 +21976,15 @@
gap: 10px;
padding: 10px;
}
+
+ .v2-user-grant-policy {
+ min-height: 54px;
+ }
+
+ .v2-access-inspector-actions a {
+ min-height: 40px;
+ font-size: 11px;
+ }
}
/*