From c3881bf25c151c223077f9c579db6eb498734ed6 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sun, 19 Jul 2026 08:32:48 +0800 Subject: [PATCH] feat: unify workspace dialogs --- .../apps/web/src/v2/layout/AppShell.tsx | 29 +- .../apps/web/src/v2/pages/UsersPage.test.tsx | 8 +- .../apps/web/src/v2/pages/UsersPage.tsx | 63 +++- .../apps/web/src/v2/productionEntry.test.ts | 10 + .../src/v2/shared/WorkspaceDialog.test.tsx | 55 +++ .../web/src/v2/shared/WorkspaceDialog.tsx | 134 +++++++ .../apps/web/src/v2/styles/workspace.css | 351 ++++++++++++++++++ 7 files changed, 619 insertions(+), 31 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDialog.test.tsx create mode 100644 vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDialog.tsx diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index 0a2d0971..75c9797b 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -15,7 +15,7 @@ import { IconUser, IconExit } from '@douyinfe/semi-icons'; -import { Avatar, Banner, Button, Card, Dropdown, Layout, Modal, Nav, Tag, Typography } from '@douyinfe/semi-ui'; +import { Avatar, Banner, Button, Card, Dropdown, Layout, Nav, Tag, Typography } from '@douyinfe/semi-ui'; import { type CSSProperties, FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { api } from '../../api/client'; @@ -24,6 +24,7 @@ import { hasMenu } from '../auth/session'; import { useMobileLayout } from '../hooks/useMobileLayout'; import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules'; import { PasswordInput } from '../shared/PasswordInput'; +import { WorkspaceDialog } from '../shared/WorkspaceDialog'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; const { Header, Sider, Content } = Layout; @@ -237,17 +238,30 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged setError(reason instanceof Error ? reason.message : '密码修改失败'); } finally { setPending(false); } }; - return
修改登录密码
} - aria-label="修改登录密码" + ariaLabel="修改登录密码" + closeLabel="关闭修改登录密码" + title="修改登录密码" + description="更新账号安全凭据" + icon={} + badge="安全设置" + badgeColor="blue" + width={500} onCancel={onClose} closeOnEsc={!pending} maskClosable={!pending} - footer={null} + closeDisabled={pending} + footer={<> + + + + + + } > -
+ @@ -258,9 +272,8 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged {confirmationStarted ? {passwordsMatch ? '两次输入一致' : '两次输入的新密码不一致'} : null} {error ? : null} -
-
; + ; } const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]]; 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 d0249e5f..814fac5c 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 @@ -203,14 +203,16 @@ test('makes draft state explicit, protects bulk removal and preserves edits acro fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ })); fireEvent.click(screen.getByRole('button', { name: '清空全部 1 辆车辆权限' })); - expect(await screen.findByText('清空 1 辆车辆权限?')).toBeInTheDocument(); + expect(await screen.findByRole('dialog', { name: '确认清空车辆权限' })).toHaveTextContent('影响对象1 辆当前账号全部车辆权限'); + expect(screen.getByRole('dialog', { name: '确认清空车辆权限' })).toHaveTextContent('未保存不会影响服务器'); fireEvent.click(screen.getByRole('button', { name: '确认清空' })); await waitFor(() => expect(screen.queryByRole('button', { name: '移除 粤A11111' })).not.toBeInTheDocument()); expect(screen.getByText('已从草稿移除 1 辆车,保存后生效')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '关闭账号详情' })); - expect(await screen.findByText('放弃未保存的更改?')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + expect(await screen.findByRole('dialog', { name: '确认放弃未保存的更改' })).toHaveTextContent('放弃未保存的更改?'); + expect(screen.getByRole('dialog', { name: '确认放弃未保存的更改' })).toHaveTextContent('服务器数据不受影响'); + fireEvent.click(screen.getByRole('button', { name: '继续编辑' })); expect(document.querySelector('.v2-user-editor-form')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '保存权限' })).toBeEnabled(); }); 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 0c165c45..74c61155 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, Spin, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui'; +import { Avatar, Button, Card, Checkbox, Collapse, Empty, Input, List, 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'; @@ -11,6 +11,7 @@ import { TablePagination } from '../shared/TablePagination'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; +import { WorkspaceConfirmDialog } from '../shared/WorkspaceDialog'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; @@ -147,6 +148,7 @@ export default function UsersPage() { const [vehicleLabels, setVehicleLabels] = useState>({}); const [feedback, setFeedback] = useState(''); const [editingGrantVIN, setEditingGrantVIN] = useState(''); + 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]); const attentionCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [visibleCustomers]); @@ -239,6 +241,7 @@ export default function UsersPage() { setVehicleLabels({}); setFeedback(''); setEditingGrantVIN(''); + setConfirmation(null); }; const closeEditor = () => { const nextDraft = draftFromUser(); @@ -254,20 +257,14 @@ export default function UsersPage() { setVehicleLabels({}); setFeedback(''); setEditingGrantVIN(''); + setConfirmation(null); }; const requestCloseEditor = () => { if (!hasUnsavedChanges) { closeEditor(); return; } - Modal.confirm({ - title: '放弃未保存的更改?', - content: '登录身份、菜单或车辆权限的修改尚未保存,关闭后将恢复到上次保存状态。', - okText: '放弃更改', - cancelText: '继续编辑', - okType: 'danger', - onOk: closeEditor - }); + setConfirmation('discard-editor'); }; const selectCustomer = (user: AdminUser) => { const nextDraft = draftFromUser(user); @@ -309,6 +306,7 @@ export default function UsersPage() { setVehicleComposerOpen(true); setEditingGrantVIN(''); setFeedback(`已从草稿移除 ${removedCount} 辆车,保存后生效`); + setConfirmation(null); }; const updateGrant = (vin: string, patch: Partial) => setDraft((value) => ({ ...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant) @@ -541,6 +539,7 @@ export default function UsersPage() { } ]} onCancel={requestCloseEditor} + closeOnEsc={!confirmation} footerNote={ {save.isError ? '保存失败' : hasUnsavedChanges ? '待保存' : '已同步'} {feedback || (hasUnsavedChanges ? '更改仅保存在当前草稿,保存后最多 30 秒生效' : '账号与权限已和服务器同步')} @@ -621,17 +620,7 @@ export default function UsersPage() { aria-label={vehicleComposerOpen ? '收起添加车辆' : '添加授权车辆'} onClick={() => setVehicleComposerOpen((value) => !value)} >{vehicleComposerOpen ? '收起' : '添加'} : null} - {draft.vehicleGrants.length ? - - : null} + {draft.vehicleGrants.length ? : null} : null} > {vehicleComposerVisible ?
@@ -723,5 +712,39 @@ export default function UsersPage() { {grantWindowInvalid ?

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

:

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

}
: null} + setConfirmation(null)} + onConfirm={clearVehicleGrants} + /> + setConfirmation(null)} + onConfirm={closeEditor} + /> ; } 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 3184df98..80523328 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -21,6 +21,7 @@ const mobileFilterSheetSource = readFileSync(resolve(process.cwd(), 'src/v2/shar const passwordInputSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/PasswordInput.tsx'), 'utf8'); const workspaceFilterPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceFilterPanel.tsx'), 'utf8'); const workspaceMetricRailSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceMetricRail.tsx'), 'utf8'); +const workspaceDialogSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceDialog.tsx'), 'utf8'); const workspaceSideSheetSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceSideSheet.tsx'), 'utf8'); const workspaceDetailSideSheetSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/WorkspaceDetailSideSheet.tsx'), 'utf8'); const segmentedTabsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/SegmentedTabs.tsx'), 'utf8'); @@ -275,6 +276,10 @@ describe('V2 production entry', () => { expect(corePageSources.UsersPage).toContain(' { expect(workspaceStyles).toContain('.v2-workspace-filter-sidesheet .v2-workspace-config-title-icon {'); expect(workspaceStyles).toContain('.v2-workspace-config-summary {'); expect(workspaceStyles).toContain('.v2-workspace-config-footer {'); + expect(workspaceDialogSource).toContain("from '@douyinfe/semi-ui'"); + expect(workspaceDialogSource).toContain(' { + const onCancel = vi.fn(); + render(
} + badge="账号安全" + onCancel={onCancel} + footer={} + > +

设置内容

+ ); + + const dialog = screen.getByRole('dialog', { name: '修改安全设置' }); + expect(dialog.closest('.v2-workspace-dialog') ?? document.querySelector('.v2-workspace-dialog')).toBeInTheDocument(); + expect(dialog).toHaveTextContent('安全设置更新账号凭据账号安全设置内容保存'); + fireEvent.click(screen.getByLabelText('关闭安全设置')); + expect(onCancel).toHaveBeenCalledTimes(1); +}); + +test('keeps confirmation impact, cancel and destructive action in one accessible dialog', () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + render(); + + const dialog = screen.getByRole('dialog', { name: '确认批量操作' }); + expect(dialog).toHaveTextContent('影响对象43 辆全部车辆权限正式生效保存后当前仅修改草稿'); + expect(screen.getByRole('list', { name: '操作影响摘要' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '返回核对' })); + fireEvent.click(screen.getByRole('button', { name: '确认清空' })); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledTimes(1); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDialog.tsx b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDialog.tsx new file mode 100644 index 00000000..7b823935 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceDialog.tsx @@ -0,0 +1,134 @@ +import { IconAlertTriangle, IconClose } from '@douyinfe/semi-icons'; +import { Button, Modal, Tag } from '@douyinfe/semi-ui'; +import { cloneElement, type ReactElement, type ReactNode } from 'react'; + +export type WorkspaceDialogBadgeColor = 'blue' | 'green' | 'grey' | 'orange' | 'red'; + +export type WorkspaceDialogSummaryItem = { + label: string; + value: ReactNode; + detail: string; + tone?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger'; +}; + +export type WorkspaceDialogProps = { + visible: boolean; + ariaLabel: string; + closeLabel: string; + title: string; + description: string; + icon: ReactNode; + badge?: ReactNode; + badgeColor?: WorkspaceDialogBadgeColor; + width?: string | number; + className?: string; + closeOnEsc?: boolean; + maskClosable?: boolean; + closeDisabled?: boolean; + footer?: ReactNode; + children: ReactNode; + onCancel: () => void; +}; + +export function WorkspaceDialog({ + visible, + ariaLabel, + closeLabel, + title, + description, + icon, + badge, + badgeColor = 'blue', + width = 480, + className = '', + closeOnEsc = true, + maskClosable = true, + closeDisabled = false, + footer, + children, + onCancel +}: WorkspaceDialogProps) { + return + + {title}{description} + {badge ? {badge} : null} + + + + } + > +

{description}

+
+ {summaryItems.map((item) => + {item.label} + {item.value} + {item.detail} + )} +
+ ; +} 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 e4032702..9c508fa5 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -22266,3 +22266,354 @@ padding: 10px; } } + +/* + * Shared Semi UI dialog language. + * High-impact forms and confirmations now use one title, content, impact + * summary and footer hierarchy on desktop and mobile. + */ +.v2-workspace-dialog .semi-modal { + width: min(480px, calc(100vw - 32px)) !important; + max-width: calc(100vw - 32px); +} + +.v2-workspace-dialog .semi-modal-content { + overflow: hidden; + border: 1px solid #d9e3ef; + border-radius: 17px; + background: #fff; + box-shadow: 0 28px 88px rgba(19, 37, 61, .22); +} + +.v2-workspace-dialog .semi-modal-header { + min-height: 78px; + border-bottom: 1px solid #e5ebf2; + background: + radial-gradient(circle at 0 0, rgba(18, 104, 243, .07), transparent 44%), + linear-gradient(180deg, #fff 0%, #fbfcfe 100%); + padding: 15px 19px; +} + +.v2-workspace-dialog .semi-modal-title { + width: 100%; + min-width: 0; +} + +.v2-workspace-dialog-title { + display: grid; + min-width: 0; + grid-template-columns: 40px minmax(0, 1fr) auto 34px; + align-items: center; + gap: 11px; +} + +.v2-workspace-dialog-title-icon { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border: 1px solid #d7e5f8; + border-radius: 12px; + background: #edf5ff; + color: #1268f3; + font-size: 18px; +} + +.v2-workspace-confirm-dialog .v2-workspace-dialog-title-icon { + border-color: #f4ddbd; + background: #fff7e8; + color: #dc7b12; +} + +.v2-workspace-dialog-title > span:nth-child(2) { + display: grid; + min-width: 0; + gap: 3px; +} + +.v2-workspace-dialog-title strong { + overflow: hidden; + color: #263b53; + font-size: 17px; + font-weight: 740; + letter-spacing: -.02em; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-workspace-dialog-title small { + overflow: hidden; + color: #7d8da1; + font-size: 10px; + font-weight: 520; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-workspace-dialog-title > .semi-tag { + height: 25px; + border-radius: 999px; + padding-inline: 9px; + font-size: 9px; + font-weight: 720; +} + +.v2-workspace-dialog-close.semi-button { + width: 34px; + min-width: 34px; + height: 34px; + border-radius: 10px; + color: #72839a; + padding: 0; +} + +.v2-workspace-dialog-close.semi-button:hover { + background: #edf3fb; + color: #23415f; +} + +.v2-workspace-dialog .semi-modal-body { + padding: 0; +} + +.v2-workspace-dialog-content { + padding: 16px 18px 18px; +} + +.v2-workspace-dialog .semi-modal-footer { + margin: 0; + border-top: 1px solid #e5ebf2; + background: rgba(251, 252, 254, .96); + padding: 11px 14px; +} + +.v2-workspace-dialog-footer { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.v2-workspace-dialog-footer-note { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + overflow: hidden; + color: #75869b; + font-size: 9px; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-workspace-dialog-footer-note > svg { + flex: 0 0 auto; + color: #d78624; +} + +.v2-workspace-dialog-footer-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; +} + +.v2-workspace-dialog-footer-actions > .semi-button { + min-height: 38px; + justify-content: center; + border-radius: 9px; + padding-inline: 16px; + font-size: 11px; + font-weight: 700; +} + +.v2-workspace-confirm-copy { + margin: 0 0 13px; + color: #53677f; + font-size: 12px; + line-height: 1.65; +} + +.v2-workspace-dialog-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid #dfe7f0; + border-radius: 12px; + background: #f8fafc; +} + +.v2-workspace-dialog-summary > span { + display: grid; + min-width: 0; + min-height: 86px; + align-content: center; + gap: 4px; + padding: 12px; +} + +.v2-workspace-dialog-summary > span + span { + border-left: 1px solid #dfe7f0; +} + +.v2-workspace-dialog-summary small { + color: #8795a7; + font-size: 8px; + font-weight: 650; +} + +.v2-workspace-dialog-summary strong { + overflow: hidden; + color: #2d425a; + font-size: 14px; + font-weight: 740; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-workspace-dialog-summary em { + overflow: hidden; + color: #7b8b9f; + font-size: 8px; + font-style: normal; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-workspace-dialog-summary > .is-primary strong { + color: #1268f3; +} + +.v2-workspace-dialog-summary > .is-success strong { + color: #16805b; +} + +.v2-workspace-dialog-summary > .is-warning strong { + color: #b86c18; +} + +.v2-workspace-dialog-summary > .is-danger strong { + color: #c33c32; +} + +.v2-password-modal.v2-workspace-dialog .semi-modal { + width: min(500px, calc(100vw - 32px)) !important; +} + +.v2-password-modal .v2-workspace-dialog-content { + padding: 16px 18px 18px; +} + +.v2-password-modal .v2-password-dialog > label { + font-size: 12px; +} + +.v2-password-modal .v2-password-dialog > label .semi-input-wrapper { + height: 44px; +} + +@media (max-width: 680px) { + .v2-workspace-dialog .semi-modal { + width: calc(100vw - 16px) !important; + max-width: calc(100vw - 16px); + } + + .v2-workspace-dialog .semi-modal-content { + max-height: calc(100dvh - 16px); + border-radius: 16px; + } + + .v2-workspace-dialog .semi-modal-header { + min-height: 70px; + padding: 11px 12px; + } + + .v2-workspace-dialog-title { + grid-template-columns: 36px minmax(0, 1fr) auto 34px; + gap: 9px; + } + + .v2-workspace-dialog-title-icon { + width: 36px; + height: 36px; + border-radius: 11px; + font-size: 16px; + } + + .v2-workspace-dialog-title strong { + font-size: 15px; + } + + .v2-workspace-dialog-title small { + font-size: 9px; + } + + .v2-workspace-dialog-title > .semi-tag { + display: none; + } + + .v2-workspace-dialog-content { + max-height: calc(100dvh - 158px); + overflow-y: auto; + padding: 13px 12px 14px; + } + + .v2-workspace-confirm-copy { + margin-bottom: 10px; + font-size: 11px; + } + + .v2-workspace-dialog-summary > span { + min-height: 76px; + padding: 9px 8px; + } + + .v2-workspace-dialog-summary strong { + font-size: 12px; + } + + .v2-workspace-dialog-summary em { + white-space: normal; + } + + .v2-workspace-dialog .semi-modal-footer { + padding: 9px 10px calc(9px + env(safe-area-inset-bottom)); + } + + .v2-workspace-dialog-footer { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 7px; + } + + .v2-workspace-dialog-footer-note { + min-height: 18px; + justify-content: center; + font-size: 8px; + } + + .v2-workspace-dialog-footer-actions { + display: grid; + grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); + gap: 8px; + } + + .v2-workspace-dialog-footer-actions > .semi-button { + width: 100%; + min-height: 42px; + } + + .v2-password-modal .v2-workspace-dialog-content { + padding: 12px; + } + + .v2-password-modal .v2-password-dialog > label .semi-input-wrapper { + height: 46px; + } +}