feat: unify workspace dialogs

This commit is contained in:
lingniu
2026-07-19 08:32:48 +08:00
parent 49ae75937e
commit c3881bf25c
7 changed files with 619 additions and 31 deletions

View File

@@ -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 <Modal
return <WorkspaceDialog
className="v2-password-modal"
visible
title={<div className="v2-password-title"><span aria-hidden="true"><IconLock /></span><div><strong></strong><Text aria-hidden="true" type="tertiary" size="small"></Text></div></div>}
aria-label="修改登录密码"
ariaLabel="修改登录密码"
closeLabel="关闭修改登录密码"
title="修改登录密码"
description="更新账号安全凭据"
icon={<IconLock />}
badge="安全设置"
badgeColor="blue"
width={500}
onCancel={onClose}
closeOnEsc={!pending}
maskClosable={!pending}
footer={null}
closeDisabled={pending}
footer={<>
<span className="v2-workspace-dialog-footer-note"><IconLock aria-hidden="true" /></span>
<span className="v2-workspace-dialog-footer-actions">
<Button type="tertiary" onClick={onClose} disabled={pending}></Button>
<Button form="v2-password-change-form" htmlType="submit" theme="solid" type="primary" loading={pending} disabled={pending || !currentPassword || !passwordValid || !passwordsMatch}>{pending ? '正在修改…' : '确认修改'}</Button>
</span>
</>}
>
<form className="v2-password-dialog" onSubmit={submit}>
<form id="v2-password-change-form" className="v2-password-dialog" onSubmit={submit}>
<Banner type="warning" bordered closeIcon={null} title="修改后需要重新登录" description="为保护账号安全,当前设备和其他设备上的旧会话都会失效。" />
<label><span></span><PasswordInput aria-label="当前密码" autoFocus required autoComplete="current-password" visibilityLabel="当前密码" value={currentPassword} onChange={setCurrentPassword} size="large" /></label>
<label><span></span><PasswordInput aria-label="新密码" required minLength={10} autoComplete="new-password" visibilityLabel="新密码" value={newPassword} onChange={setNewPassword} placeholder="至少 10 位,包含三类字符" size="large" /></label>
@@ -258,9 +272,8 @@ function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged
<label><span></span><PasswordInput aria-label="确认新密码" required minLength={10} autoComplete="new-password" visibilityLabel="确认新密码" value={confirmPassword} onChange={setConfirmPassword} size="large" /></label>
{confirmationStarted ? <Text className={`v2-password-match${passwordsMatch ? ' is-valid' : ' is-error'}`} role="status">{passwordsMatch ? '两次输入一致' : '两次输入的新密码不一致'}</Text> : null}
{error ? <Banner className="v2-password-error" type="danger" bordered title="无法修改密码" description={error} /> : null}
<footer><Button type="tertiary" onClick={onClose} disabled={pending}></Button><Button htmlType="submit" theme="solid" type="primary" loading={pending} disabled={pending || !currentPassword || !passwordValid || !passwordsMatch}>{pending ? '正在修改…' : '确认修改'}</Button></footer>
</form>
</Modal>;
</WorkspaceDialog>;
}
const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]];

View File

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

View File

@@ -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<Record<string, string>>({});
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<CustomerVehicleGrantInput>) => 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={<span className="v2-user-save-state" role="status">
<Tag color={save.isError ? 'red' : hasUnsavedChanges ? 'amber' : 'green'} type="light" size="small">{save.isError ? '保存失败' : hasUnsavedChanges ? '待保存' : '已同步'}</Tag>
<Typography.Text className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} type={save.isError ? 'danger' : 'tertiary'}>{feedback || (hasUnsavedChanges ? '更改仅保存在当前草稿,保存后最多 30 秒生效' : '账号与权限已和服务器同步')}</Typography.Text>
@@ -621,17 +620,7 @@ export default function UsersPage() {
aria-label={vehicleComposerOpen ? '收起添加车辆' : '添加授权车辆'}
onClick={() => setVehicleComposerOpen((value) => !value)}
>{vehicleComposerOpen ? '收起' : '添加'}</Button> : null}
{draft.vehicleGrants.length ? <Popconfirm
title={`清空 ${draft.vehicleGrants.length} 辆车辆权限?`}
content="车辆会先从当前草稿中移除,点击底部保存后正式生效。"
okText="确认清空"
cancelText="取消"
okType="danger"
position="bottomRight"
onConfirm={clearVehicleGrants}
>
<Button theme="borderless" type="tertiary" size="small" aria-label={`清空全部 ${draft.vehicleGrants.length} 辆车辆权限`}></Button>
</Popconfirm> : null}
{draft.vehicleGrants.length ? <Button theme="borderless" type="tertiary" size="small" aria-haspopup="dialog" aria-controls="v2-user-clear-vehicles" aria-label={`清空全部 ${draft.vehicleGrants.length} 辆车辆权限`} onClick={() => setConfirmation('clear-vehicles')}></Button> : null}
</div> : null}
>
{vehicleComposerVisible ? <div id="v2-user-vehicle-composer" className="v2-user-vehicle-composer">
@@ -723,5 +712,39 @@ export default function UsersPage() {
{grantWindowInvalid ? <p role="alert"></p> : <p></p>}
</div> : null}
</WorkspaceSideSheet>
<WorkspaceConfirmDialog
visible={confirmation === 'clear-vehicles'}
ariaLabel="确认清空车辆权限"
className="v2-user-confirm-dialog"
title={`清空 ${draft.vehicleGrants.length} 辆车辆权限?`}
description="车辆会先从当前草稿中移除,只有点击账号详情底部的保存权限后才会正式生效。"
summaryItems={[
{ label: '影响对象', value: `${draft.vehicleGrants.length}`, detail: '当前账号全部车辆权限', tone: 'danger' },
{ label: '当前动作', value: '修改草稿', detail: '确认后仍可继续编辑', tone: 'warning' },
{ label: '正式生效', value: '保存权限后', detail: '未保存不会影响服务器', tone: 'success' }
]}
cancelLabel="返回核对"
confirmLabel="确认清空"
note="这是草稿操作,仍需再次保存权限。"
onCancel={() => setConfirmation(null)}
onConfirm={clearVehicleGrants}
/>
<WorkspaceConfirmDialog
visible={confirmation === 'discard-editor'}
ariaLabel="确认放弃未保存的更改"
className="v2-user-confirm-dialog"
title="放弃未保存的更改?"
description="登录身份、菜单或车辆权限的修改尚未保存,关闭后将恢复到上次保存状态。"
summaryItems={[
{ label: '草稿状态', value: '尚未保存', detail: `${draft.menuKeys.length} 菜单 · ${draft.vehicleGrants.length}`, tone: 'warning' },
{ label: '关闭结果', value: creating ? '清空新建草稿' : '恢复已保存状态', detail: creating ? '账号不会被创建' : `账号 @${selected?.username || draft.username}`, tone: 'danger' },
{ label: '服务器数据', value: '不受影响', detail: '不会发送保存请求', tone: 'success' }
]}
cancelLabel="继续编辑"
confirmLabel="放弃更改"
note="放弃后无法恢复当前草稿。"
onCancel={() => setConfirmation(null)}
onConfirm={closeEditor}
/>
</div>;
}

View File

@@ -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('<Card className="v2-user-list"');
expect(corePageSources.UsersPage).toContain('className="v2-user-editor-sidesheet"');
expect(corePageSources.UsersPage).toContain("from '../shared/WorkspaceSideSheet'");
expect(corePageSources.UsersPage).toContain("from '../shared/WorkspaceDialog'");
expect(corePageSources.UsersPage).toContain('<WorkspaceConfirmDialog');
expect(corePageSources.UsersPage).not.toContain('Modal.confirm');
expect(corePageSources.UsersPage).not.toContain('<Popconfirm');
expect(corePageSources.UsersPage).toContain('<WorkspaceSideSheet\n className="v2-user-editor-sidesheet"');
expect(corePageSources.UsersPage).toContain('<WorkspaceSideSheet\n className="v2-user-grant-sidesheet"');
expect(corePageSources.UsersPage).toContain('variant="editor"');
@@ -440,6 +445,11 @@ describe('V2 production entry', () => {
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('<Modal');
expect(workspaceDialogSource).toContain('export function WorkspaceConfirmDialog');
expect(appShellSource).toContain('<WorkspaceDialog');
expect(appShellSource).not.toContain('<Modal');
expect(vehicleSearchSource).toContain('<MobileFilterSheet');
expect(corePageSources.StatisticsPage).not.toContain('v2-mileage-source-popover');
expect(corePageSources.StatisticsPage).toContain('<Table className="v2-mileage-table"');

View File

@@ -0,0 +1,55 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { WorkspaceConfirmDialog, WorkspaceDialog } from './WorkspaceDialog';
afterEach(cleanup);
test('renders one shared Semi dialog shell with a localized close action', () => {
const onCancel = vi.fn();
render(<WorkspaceDialog
visible
ariaLabel="修改安全设置"
closeLabel="关闭安全设置"
title="安全设置"
description="更新账号凭据"
icon={<span></span>}
badge="账号安全"
onCancel={onCancel}
footer={<button></button>}
>
<p></p>
</WorkspaceDialog>);
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(<WorkspaceConfirmDialog
visible
ariaLabel="确认批量操作"
title="清空车辆权限?"
description="确认后先修改当前草稿。"
summaryItems={[
{ label: '影响对象', value: '43 辆', detail: '全部车辆权限', tone: 'danger' },
{ label: '正式生效', value: '保存后', detail: '当前仅修改草稿', tone: 'success' }
]}
cancelLabel="返回核对"
confirmLabel="确认清空"
onCancel={onCancel}
onConfirm={onConfirm}
/>);
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);
});

View File

@@ -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 <Modal
className={`v2-workspace-dialog${className ? ` ${className}` : ''}`}
visible={visible}
centered
width={width}
aria-label={ariaLabel}
title={<div className="v2-workspace-dialog-title">
<span className="v2-workspace-dialog-title-icon" aria-hidden="true">{icon}</span>
<span><strong>{title}</strong><small>{description}</small></span>
{badge ? <Tag color={badgeColor} type="light" size="small">{badge}</Tag> : null}
<Button className="v2-workspace-dialog-close" theme="borderless" type="tertiary" size="small" icon={<IconClose />} aria-label={closeLabel} disabled={closeDisabled} onClick={onCancel} />
</div>}
closable={false}
closeOnEsc={closeOnEsc}
maskClosable={maskClosable}
onCancel={onCancel}
modalRender={(content) => cloneElement(content as ReactElement, { 'aria-label': ariaLabel, 'aria-labelledby': undefined })}
footer={footer ? <div className="v2-workspace-dialog-footer">{footer}</div> : null}
>
<div className="v2-workspace-dialog-content">{children}</div>
</Modal>;
}
export type WorkspaceConfirmDialogProps = {
visible: boolean;
ariaLabel: string;
title: string;
description: string;
summaryItems: WorkspaceDialogSummaryItem[];
confirmLabel: string;
cancelLabel: string;
note?: string;
pending?: boolean;
className?: string;
onConfirm: () => void;
onCancel: () => void;
};
export function WorkspaceConfirmDialog({
visible,
ariaLabel,
title,
description,
summaryItems,
confirmLabel,
cancelLabel,
note = '确认前请再次核对影响范围。',
pending = false,
className = '',
onConfirm,
onCancel
}: WorkspaceConfirmDialogProps) {
return <WorkspaceDialog
visible={visible}
ariaLabel={ariaLabel}
closeLabel={`关闭${ariaLabel}`}
className={`v2-workspace-confirm-dialog${className ? ` ${className}` : ''}`}
title={title}
description={description}
icon={<IconAlertTriangle />}
badge="需要确认"
badgeColor="orange"
closeOnEsc={!pending}
maskClosable={!pending}
closeDisabled={pending}
onCancel={onCancel}
footer={<>
<span className="v2-workspace-dialog-footer-note"><IconAlertTriangle aria-hidden="true" />{note}</span>
<span className="v2-workspace-dialog-footer-actions">
<Button type="tertiary" disabled={pending} onClick={onCancel}>{cancelLabel}</Button>
<Button theme="solid" type="danger" loading={pending} disabled={pending} onClick={onConfirm}>{confirmLabel}</Button>
</span>
</>}
>
<p className="v2-workspace-confirm-copy">{description}</p>
<div className="v2-workspace-dialog-summary" role="list" aria-label="操作影响摘要">
{summaryItems.map((item) => <span className={`is-${item.tone || 'neutral'}`} role="listitem" key={item.label}>
<small>{item.label}</small>
<strong>{item.value}</strong>
<em>{item.detail}</em>
</span>)}
</div>
</WorkspaceDialog>;
}

View File

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