From 4bd1ad8a85c4f2951d6930bc844c8b3916852cfb Mon Sep 17 00:00:00 2001 From: lingniu Date: Sun, 19 Jul 2026 06:07:26 +0800 Subject: [PATCH] feat: unify account permission workbenches --- .../apps/web/src/v2/pages/UsersPage.test.tsx | 17 +- .../apps/web/src/v2/pages/UsersPage.tsx | 86 ++++--- .../apps/web/src/v2/productionEntry.test.ts | 9 +- .../apps/web/src/v2/styles/workspace.css | 222 ++++++++++++++++++ 4 files changed, 297 insertions(+), 37 deletions(-) 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 a69ed8e4..d0249e5f 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 @@ -67,10 +67,14 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate expect(document.querySelector('.v2-user-editor-sidesheet')).not.toBeInTheDocument(); fireEvent.click(customer); await waitFor(() => expect(customer).toHaveAttribute('aria-expanded', 'true')); - expect(screen.getByLabelText('账号权限概览')).toHaveTextContent('@customer-east菜单 1车辆 1最近登录 尚未登录权限就绪'); + const accountSummary = screen.getByRole('list', { name: '客户账号详情摘要' }); + expect(accountSummary).toHaveTextContent('登录账号@customer-east登录身份'); + expect(accountSummary).toHaveTextContent('权限范围1 菜单 · 1 辆最小必要权限'); + expect(accountSummary).toHaveTextContent('账号状态启用保存后最多 30 秒生效'); const editorSheet = document.querySelector('.v2-user-editor-sidesheet .semi-sidesheet-inner'); expect(editorSheet).toHaveAttribute('aria-label', '客户账号详情'); expect(editorSheet).toHaveStyle({ width: 'min(840px, 100vw)' }); + expect(editorSheet?.closest('.semi-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet'); expect(document.querySelector('.v2-user-editor-sidesheet .v2-user-editor-tabs.semi-tabs')).toBeInTheDocument(); expect(document.querySelector('.v2-user-editor-form')).toHaveAttribute('id', 'v2-user-editor-form'); expect(screen.getAllByRole('tab')).toHaveLength(3); @@ -96,7 +100,14 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate 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(document.querySelector('.v2-user-grant-sidesheet .semi-sidesheet-inner')).toHaveAttribute('aria-label', '车辆授权有效期'); + 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'); + const grantSummary = screen.getByRole('list', { name: '车辆授权有效期摘要' }); + expect(grantSummary).toHaveTextContent('车牌粤A11111'); + expect(grantSummary).toHaveTextContent('启用2026-06-05 00:00授权开始'); + expect(grantSummary).toHaveTextContent('停用持续有效未设置结束时间'); + expect(screen.getByRole('group', { name: '访问时间窗口' })).toHaveTextContent('只在授权区间内开放该车数据上海时间'); fireEvent.click(screen.getByRole('button', { name: '关闭车辆授权有效期' })); expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0); expect(document.querySelector('.v2-user-avatar')).toHaveClass('semi-avatar'); @@ -314,7 +325,7 @@ test('keeps granted vehicles primary and reveals mobile assignment tools on dema expect(screen.getByText('第 1–4 条,共 5 辆')).toBeInTheDocument(); expect(document.querySelectorAll('.v2-assigned-vehicle-card.is-mobile-compact')).toHaveLength(4); expect(document.querySelector('.v2-assigned-vehicle-interval')).toHaveAttribute('aria-label', '授权有效期:启用 2026-06-05 00:00;停用 持续有效'); - expect(document.querySelector('.v2-user-editor-last-login')).toHaveTextContent('最近登录'); + expect(document.querySelector('.v2-user-editor-sidesheet .v2-workspace-config-title small')).toHaveTextContent('@customer-east · 最近登录'); fireEvent.click(toggle); 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 a5ced69d..acfd05d0 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx @@ -1,6 +1,6 @@ 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, Modal, Popconfirm, Select, SideSheet, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui'; +import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, Modal, Popconfirm, 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'; @@ -13,6 +13,7 @@ import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; +import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; import { PanelEmpty, PanelLoading } from '../shared/AsyncState'; @@ -105,6 +106,11 @@ function customerScopeLabel(scope: CustomerScopeFilter) { return '全部状态'; } +function accessBadgeColor(color: CustomerAccessState['color']): WorkspaceSideSheetBadgeColor { + if (color === 'amber') return 'orange'; + return color; +} + export default function UsersPage() { const queryClient = useQueryClient(); const mobileLayout = useMobileLayout(); @@ -155,8 +161,6 @@ export default function UsersPage() { const hasUnsavedChanges = useMemo(() => draftSignature(draft) !== draftSignature(baselineDraft), [baselineDraft, draft]); const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length); useSideSheetA11y(mobileFiltersOpen, '.v2-user-mobile-filter-sidesheet', 'v2-user-mobile-filters', '客户账号筛选', '关闭客户账号筛选'); - useSideSheetA11y(editorVisible, '.v2-user-editor-sidesheet', 'v2-user-editor-sheet', '客户账号详情', '关闭账号详情'); - useSideSheetA11y(Boolean(editingGrant), '.v2-user-grant-sidesheet', 'v2-user-grant-window', '车辆授权有效期', '关闭车辆授权有效期'); useEffect(() => { if (!creating && selected) { @@ -514,29 +518,33 @@ export default function UsersPage() { - - {creating ? '创建客户账号' : selected?.displayName}{creating ? 设置登录身份和最小必要权限 : - @{selected?.username} - 菜单 {draft.menuKeys.length} - 车辆 {draft.vehicleGrants.length} - 最近登录 {formatTime(selected?.lastLoginAt)} - {draftAccessState.label} - } - - } + 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: setDraft((value) => ({ ...value, status: checked ? 'enabled' : 'disabled' }))} />{draft.status === 'enabled' ? '启用' : '停用'}, + detail: '保存后最多 30 秒生效', + tone: draft.status === 'enabled' ? 'success' : 'neutral' + } + ]} onCancel={requestCloseEditor} - footer={
- + footerNote={ {save.isError ? '保存失败' : hasUnsavedChanges ? '待保存' : '已同步'} {feedback || (hasUnsavedChanges ? '更改仅保存在当前草稿,保存后最多 30 秒生效' : '账号与权限已和服务器同步')} - - -
} + } + primaryAction={{ label: creating ? '创建账号' : '保存权限', onClick: persistDraft, disabled: save.isPending || !hasUnsavedChanges, loading: save.isPending }} > {editorVisible ?
setActiveSection(String(key) as EditorSection)}> @@ -680,25 +688,39 @@ export default function UsersPage() {
: null} -
- + 调整车辆有效期{editingGrant ? `${editingGrantPlate || '未登记车牌'} · ${editingGrant.vin}` : '车辆授权时间窗口'}} + 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={() => setEditingGrantVIN('')} - footer={
完成后仍需保存账号权限才会生效
} + footerNote="完成后仍需保存账号权限才会生效" + primaryAction={{ label: '完成', onClick: () => setEditingGrantVIN(''), disabled: grantWindowInvalid }} > {editingGrant ?
- - 车牌{editingGrantPlate || '未登记车牌'} - VIN{editingGrant.vin} - - - +
+
访问时间窗口只在授权区间内开放该车数据上海时间
+
+ + +
+
{grantWindowInvalid ?

请设置启用时间,并确保停用时间晚于启用时间。

:

不设置停用时间时,车辆授权将持续有效。

}
: null} -
+ ; } diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index 00ca67f5..3aae51d3 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -263,7 +263,12 @@ describe('V2 production entry', () => { expect(corePageSources.UsersPage).toContain(' { expect(corePageSources.UsersPage).toContain('aria-expanded={selectedID === user.id && !creating}'); expect(corePageSources.UsersPage).toContain(' span { + overflow: hidden; + text-overflow: ellipsis; +} + +.v2-user-editor-sidesheet .v2-workspace-config-content { + display: flex; + height: 100%; +} + +.v2-user-editor-sidesheet .v2-user-editor-form { + width: 100%; + flex: 1 1 0; +} + +.v2-user-editor-sidesheet .v2-workspace-config-footer > span { + display: flex; + min-width: 0; +} + +.v2-user-editor-sidesheet .v2-workspace-config-footer > div { + min-width: 132px; +} + +.v2-user-editor-sidesheet .v2-workspace-config-footer > div > .semi-button { + width: 100%; + min-width: 132px; +} + +.v2-user-grant-sidesheet .v2-workspace-config-content { + padding: 14px; +} + +.v2-user-grant-sidesheet .v2-workspace-config-summary { + grid-template-columns: minmax(0, .85fr) minmax(0, 1.1fr) minmax(0, 1.05fr); +} + +.v2-user-grant-sidesheet .v2-workspace-config-summary strong { + font-size: 11px; +} + +.v2-user-grant-form { + gap: 10px; +} + +.v2-user-grant-field-group { + overflow: hidden; + border: 1px solid #dce5ef; + border-radius: 11px; + background: #fff; + box-shadow: 0 7px 22px rgba(31, 50, 76, .045); +} + +.v2-user-grant-field-group > header { + display: flex; + min-height: 54px; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid #e5ebf2; + background: linear-gradient(180deg, #fff 0%, #fbfcfe 100%); + padding: 10px 12px; +} + +.v2-user-grant-field-group > header > span { + display: grid; + min-width: 0; + gap: 3px; +} + +.v2-user-grant-field-group > header strong { + color: #2a4058; + font-size: 13px; +} + +.v2-user-grant-field-group > header small { + color: #8390a2; + font-size: 9px; +} + +.v2-user-grant-field-group > header > .semi-tag { + min-height: 22px; + flex: 0 0 auto; + border-radius: 999px; + font-size: 8px; + font-weight: 700; +} + +.v2-user-grant-field-group > div { + display: grid; + gap: 13px; + padding: 13px; +} + +.v2-user-grant-field-group label { + display: grid; + gap: 7px; + color: #53677f; + font-size: 11px; + font-weight: 650; +} + +.v2-user-grant-field-group label > .semi-input-wrapper { + min-height: 42px; + border-color: #d5dfeb; + border-radius: 8px; + background: #fff; +} + +.v2-user-grant-sidesheet .v2-workspace-config-footer > div { + min-width: 96px; +} + +@media (max-width: 680px) { + .v2-user-editor-sidesheet.v2-workspace-editor-sidesheet .semi-sidesheet-header { + min-height: 68px; + padding: 9px 11px 9px 13px; + } + + .v2-user-editor-sidesheet .v2-workspace-config-title strong { + font-size: 17px; + } + + .v2-user-editor-sidesheet .v2-workspace-config-summary { + min-height: 64px; + } + + .v2-user-editor-sidesheet .v2-workspace-config-summary > span { + padding: 7px 8px; + } + + .v2-user-editor-sidesheet .v2-workspace-config-summary strong { + font-size: 10px; + } + + .v2-user-editor-sidesheet .v2-workspace-config-summary em { + font-size: 7px; + } + + .v2-user-editor-status-summary { + gap: 4px; + } + + .v2-user-editor-status-summary > span { + display: none; + } + + .v2-user-editor-sidesheet .v2-workspace-config-footer > span { + display: flex; + flex: 0 0 auto; + } + + .v2-user-editor-sidesheet .v2-workspace-config-footer > div { + min-width: 0; + flex: 1 1 0; + } + + .v2-user-editor-sidesheet .v2-workspace-config-footer > div > .semi-button { + min-width: 0; + } + + .v2-user-grant-sidesheet.v2-workspace-config-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner { + width: 100% !important; + max-width: 100%; + } + + .v2-user-grant-sidesheet .v2-workspace-config-title small { + max-width: 245px; + } + + .v2-user-grant-sidesheet .v2-workspace-config-summary { + min-height: 64px; + } + + .v2-user-grant-sidesheet .v2-workspace-config-summary > span { + padding: 7px 8px; + } + + .v2-user-grant-sidesheet .v2-workspace-config-summary strong { + font-size: 9px; + } + + .v2-user-grant-sidesheet .v2-workspace-config-content { + padding: 9px; + } + + .v2-user-grant-field-group > header { + min-height: 48px; + padding: 8px 10px; + } + + .v2-user-grant-field-group > div { + gap: 10px; + padding: 10px; + } +}