diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx index 9760892e..4d68e449 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx @@ -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).getByText('test first seen')).toBeInTheDocument(); 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 summaryRequestsBeforeEvidenceToggle = mocks.accessSummary.mock.calls.length; fireEvent.click(evidenceToggle); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index ab1b069d..3aeed478 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -16,6 +16,7 @@ import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; +import { ProtocolTag } from '../shared/ProtocolTag'; import { detailTriggerRow } from '../shared/detailTriggerRow'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; @@ -266,7 +267,14 @@ function VehicleInspector({ row, onClose, sheet = false, mobile = false }: { row expanded={expandedProtocol === protocol} onToggle={toggleProtocol} />)} - + ; } @@ -275,7 +283,7 @@ function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; to return 来源身份待绑定这些来源不计入主车辆,绑定权威 VIN 后再归档{total.toLocaleString('zh-CN')} 条}>
{items.slice(0, 6).map((item) => -
来源标识{item.identifierMasked}{item.protocol}
+
来源标识{item.identifierMasked}
关联车牌{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 ?
{visibleAssignedGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return -
{plate || '未登记车牌'}{grant.vin}
+
{plate || '未登记车牌'}{grant.vin}
启用{formatGrantTime(grant.validFrom)}停用{grant.validTo ? formatGrantTime(grant.validTo) : '持续有效'}
; })}
: }
@@ -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; + } } /*