feat: unify global action workbenches

This commit is contained in:
lingniu
2026-07-19 07:13:09 +08:00
parent 3b5a66b687
commit af608b35e0
8 changed files with 295 additions and 34 deletions

View File

@@ -99,6 +99,11 @@ test('opens contextual help for the active module and closes it without navigati
fireEvent.click(help); fireEvent.click(help);
expect(screen.getByRole('dialog', { name: '全局监控' })).toHaveTextContent('筛选会自动生效'); expect(screen.getByRole('dialog', { name: '全局监控' })).toHaveTextContent('筛选会自动生效');
expect(document.querySelector('.v2-help-sheet')).toHaveClass('v2-workspace-help-sidesheet');
const helpSummary = screen.getByRole('list', { name: '全局监控摘要' });
expect(helpSummary).toHaveTextContent('页面目标快速完成');
expect(helpSummary).toHaveTextContent('建议步骤3 项');
expect(helpSummary).toHaveTextContent('快捷关闭Esc');
expect(document.querySelector('.v2-help-overview.semi-card')).toBeInTheDocument(); expect(document.querySelector('.v2-help-overview.semi-card')).toBeInTheDocument();
expect(screen.getByText('建议操作')).toBeInTheDocument(); expect(screen.getByText('建议操作')).toBeInTheDocument();
expect(help).toHaveAttribute('aria-expanded', 'true'); expect(help).toHaveAttribute('aria-expanded', 'true');
@@ -186,7 +191,8 @@ test('uses the compact mobile navigation and opens secondary modules in a Semi S
fireEvent.click(screen.getByRole('button', { name: '更多功能' })); fireEvent.click(screen.getByRole('button', { name: '更多功能' }));
const moreSheet = screen.getByRole('dialog', { name: '更多功能' }); const moreSheet = screen.getByRole('dialog', { name: '更多功能' });
expect(moreSheet).toHaveTextContent('数据分析与系统管理'); expect(moreSheet.closest('.semi-sidesheet')).toHaveClass('v2-workspace-action-sidesheet', 'v2-mobile-more-sidesheet');
expect(moreSheet).toHaveTextContent('数据分析、业务处置与平台治理');
expect(screen.getByRole('navigation', { name: '分析与处置' })).toBeInTheDocument(); expect(screen.getByRole('navigation', { name: '分析与处置' })).toBeInTheDocument();
expect(screen.getByRole('navigation', { name: '平台治理' })).toBeInTheDocument(); expect(screen.getByRole('navigation', { name: '平台治理' })).toBeInTheDocument();
expect(moreSheet).toHaveTextContent('历史证据与业务告警'); expect(moreSheet).toHaveTextContent('历史证据与业务告警');

View File

@@ -15,16 +15,16 @@ import {
IconUser, IconUser,
IconExit IconExit
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { Avatar, Banner, Button, Card, Dropdown, Layout, Modal, Nav, SideSheet, Tag, Typography } from '@douyinfe/semi-ui'; import { Avatar, Banner, Button, Card, Dropdown, Layout, Modal, Nav, Tag, Typography } from '@douyinfe/semi-ui';
import { type CSSProperties, FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react'; import { type CSSProperties, FormEvent, type MouseEvent, useEffect, useLayoutEffect, useState } from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { api } from '../../api/client'; import { api } from '../../api/client';
import { usePlatformSession } from '../auth/AuthGate'; import { usePlatformSession } from '../auth/AuthGate';
import { hasMenu } from '../auth/session'; import { hasMenu } from '../auth/session';
import { useMobileLayout } from '../hooks/useMobileLayout'; import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules'; import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
import { PasswordInput } from '../shared/PasswordInput'; import { PasswordInput } from '../shared/PasswordInput';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
const { Text, Title } = Typography; const { Text, Title } = Typography;
@@ -64,29 +64,42 @@ const pageHelp: Record<string, { summary: string; tips: string[] }> = {
users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] } users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] }
}; };
function ContextHelp({ section, visible, onClose }: { section: string; visible: boolean; onClose: () => void }) { function ContextHelp({ section, visible, mobile, onClose }: { section: string; visible: boolean; mobile: boolean; onClose: () => void }) {
const content = pageHelp[section] ?? { summary: '查看当前模块的操作说明。', tips: ['通过左侧导航切换模块,页面状态会在当前任务中保留。'] }; const content = pageHelp[section] ?? { summary: '查看当前模块的操作说明。', tips: ['通过左侧导航切换模块,页面状态会在当前任务中保留。'] };
const title = pageNames[section] ?? '车辆数据中台'; const title = pageNames[section] ?? '车辆数据中台';
useSideSheetA11y(visible, '.v2-help-sheet', 'v2-context-help', title, '关闭帮助'); return <WorkspaceSideSheet
return <SideSheet
className="v2-help-sheet" className="v2-help-sheet"
variant="help"
visible={visible} visible={visible}
aria-label={title} ariaLabel={title}
closeLabel="关闭帮助"
dialogId="v2-context-help"
placement={mobile ? 'bottom' : 'right'}
width={410} width={410}
title={<div className="v2-help-sheet-title"><span><IconHelpCircle /></span><div><Text type="tertiary" size="small"></Text><Title heading={4}>{title}</Title></div></div>} height={mobile ? 'min(70dvh, 620px)' : undefined}
icon={<IconHelpCircle />}
title={title}
description="当前页面帮助与推荐操作"
badge={`${content.tips.length} 项建议`}
summaryItems={[
{ label: '页面目标', value: '快速完成', detail: '保留当前任务状态', tone: 'primary' },
{ label: '建议步骤', value: `${content.tips.length}`, detail: '按顺序执行' },
{ label: '快捷关闭', value: 'Esc', detail: '随时返回页面' }
]}
footerNote="关闭帮助不会改变当前筛选、地图或任务状态"
primaryAction={{ label: '返回当前页面', onClick: onClose }}
onCancel={onClose} onCancel={onClose}
footer={<Button block theme="light" type="primary" onClick={onClose}></Button>}
> >
<div className="v2-help-panel"> <div className="v2-help-panel">
<Card className="v2-help-overview" bodyStyle={{ padding: 0 }}> <Card className="v2-help-overview" bodyStyle={{ padding: 0 }}>
<Tag color="blue"></Tag> <Tag color="blue"></Tag>
<Text type="secondary">{content.summary}</Text> <Text type="secondary">{content.summary}</Text>
</Card> </Card>
<div className="v2-help-section-label"><Text strong></Text><Text type="tertiary" size="small">{content.tips.length} </Text></div> <div className="v2-help-section-label"><Text strong></Text><Text type="tertiary" size="small">{content.tips.length} </Text></div>
<ol>{content.tips.map((tip, index) => <li key={tip}><span>{index + 1}</span>{tip}</li>)}</ol> <ol>{content.tips.map((tip, index) => <li key={tip}><span>{index + 1}</span>{tip}</li>)}</ol>
<footer><kbd>Esc</kbd><span></span></footer> <footer><kbd>Esc</kbd><span></span></footer>
</div> </div>
</SideSheet>; </WorkspaceSideSheet>;
} }
function AccountMenu({ function AccountMenu({
@@ -189,7 +202,7 @@ export function AppShell() {
</Header> </Header>
<Content key={location.pathname} className={`v2-content${section === 'tracks' ? ' is-track-workspace' : ''}`}><Outlet /></Content> <Content key={location.pathname} className={`v2-content${section === 'tracks' ? ' is-track-workspace' : ''}`}><Outlet /></Content>
</Layout> </Layout>
<ContextHelp section={section} visible={helpOpen} onClose={() => setHelpOpen(false)} /> <ContextHelp section={section} visible={helpOpen} mobile={mobileLayout} onClose={() => setHelpOpen(false)} />
{passwordOpen ? <PasswordDialog onClose={() => setPasswordOpen(false)} onChanged={logout} /> : null} {passwordOpen ? <PasswordDialog onClose={() => setPasswordOpen(false)} onChanged={logout} /> : null}
</Layout> </Layout>
); );
@@ -281,18 +294,20 @@ function MobileNavigation() {
const itemCount = primary.length + (more.length ? 1 : 0); const itemCount = primary.length + (more.length ? 1 : 0);
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
useEffect(() => setMoreOpen(false), [location.pathname]); useEffect(() => setMoreOpen(false), [location.pathname]);
useSideSheetA11y(moreOpen, '.v2-mobile-more-sidesheet', 'v2-mobile-more', '更多功能', '关闭更多功能');
const link = ({ to, label, icon: Icon }: (typeof mobileMoreNavigation)[number]) => <NavLink key={to} to={to} aria-label={label} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}><Icon size="large" /><span>{label}</span></NavLink>; const link = ({ to, label, icon: Icon }: (typeof mobileMoreNavigation)[number]) => <NavLink key={to} to={to} aria-label={label} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}><Icon size="large" /><span>{label}</span></NavLink>;
return <> return <>
{more.length ? <SideSheet {more.length ? <WorkspaceSideSheet
className="v2-mobile-more-sidesheet" className="v2-mobile-more-sidesheet"
variant="action"
visible={moreOpen} visible={moreOpen}
placement="bottom" placement="bottom"
height="auto" height="min(54dvh, 460px)"
title={<div><strong></strong><span></span></div>} icon={<IconMore />}
aria-label="更多功能" title="更多功能"
footer={null} description="数据分析、业务处置与平台治理"
badge={`${more.length} 个入口`}
ariaLabel="更多功能"
dialogId="v2-mobile-more"
onCancel={() => setMoreOpen(false)} onCancel={() => setMoreOpen(false)}
> >
<div className="v2-mobile-more-groups"> <div className="v2-mobile-more-groups">
@@ -304,7 +319,7 @@ function MobileNavigation() {
<nav aria-label={group.title}>{group.items.map(link)}</nav> <nav aria-label={group.title}>{group.items.map(link)}</nav>
</section>)} </section>)}
</div> </div>
</SideSheet> : null} </WorkspaceSideSheet> : null}
<nav className="v2-mobile-navigation" aria-label="主导航" style={{ '--v2-mobile-nav-count': Math.max(itemCount, 1) } as CSSProperties}> <nav className="v2-mobile-navigation" aria-label="主导航" style={{ '--v2-mobile-nav-count': Math.max(itemCount, 1) } as CSSProperties}>
{primary.map(link)} {primary.map(link)}
{more.length ? <Button theme="borderless" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} aria-controls="v2-mobile-more" icon={<IconMore size="large" />} onClick={() => setMoreOpen((value) => !value)}><span></span></Button> : null} {more.length ? <Button theme="borderless" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} aria-controls="v2-mobile-more" icon={<IconMore size="large" />} onClick={() => setMoreOpen((value) => !value)}><span></span></Button> : null}

View File

@@ -1,8 +1,8 @@
import { IconChevronRight, IconMore } from '@douyinfe/semi-icons'; import { IconChevronRight, IconMore } from '@douyinfe/semi-icons';
import { Button, SideSheet } from '@douyinfe/semi-ui'; import { Button } from '@douyinfe/semi-ui';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
export type VehicleAction = { export type VehicleAction = {
key: string; key: string;
@@ -28,8 +28,6 @@ export default function VehicleActions({
setOpen(false); setOpen(false);
onSelect(to); onSelect(to);
}; };
useSideSheetA11y(open, '.v2-vehicle-mobile-actions-sidesheet', 'v2-vehicle-mobile-actions', '更多车辆操作', '关闭更多车辆操作');
if (!mobile) return <div className="v2-identity-actions" role="group" aria-label="车辆快捷操作"> if (!mobile) return <div className="v2-identity-actions" role="group" aria-label="车辆快捷操作">
{actions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => onSelect(action.to)}>{action.label}</Button>)} {actions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => onSelect(action.to)}>{action.label}</Button>)}
</div>; </div>;
@@ -39,19 +37,25 @@ export default function VehicleActions({
{primaryActions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => selectAction(action.to)}>{action.label}</Button>)} {primaryActions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => selectAction(action.to)}>{action.label}</Button>)}
{overflowActions.length ? <Button className="v2-vehicle-more-trigger" theme="light" type="tertiary" icon={<IconMore />} aria-label="更多车辆操作" aria-haspopup="dialog" aria-controls="v2-vehicle-mobile-actions" aria-expanded={open} onClick={() => setOpen(true)}></Button> : null} {overflowActions.length ? <Button className="v2-vehicle-more-trigger" theme="light" type="tertiary" icon={<IconMore />} aria-label="更多车辆操作" aria-haspopup="dialog" aria-controls="v2-vehicle-mobile-actions" aria-expanded={open} onClick={() => setOpen(true)}></Button> : null}
</div> </div>
<SideSheet <WorkspaceSideSheet
className="v2-vehicle-mobile-actions-sidesheet" className="v2-vehicle-mobile-actions-sidesheet"
variant="action"
visible={open} visible={open}
placement="bottom" placement="bottom"
height="min(48dvh, 360px)" height="min(48dvh, 380px)"
aria-label="更多车辆操作" icon={<IconMore />}
title={<div className="v2-vehicle-mobile-actions-title"><strong></strong><span></span></div>} ariaLabel="更多车辆操作"
dialogId="v2-vehicle-mobile-actions"
title="更多车辆操作"
description="继续查看这辆车的历史、里程与告警"
badge={`${overflowActions.length}`}
onCancel={() => setOpen(false)} onCancel={() => setOpen(false)}
footer={<Button block theme="solid" type="primary" onClick={() => setOpen(false)}></Button>} footerNote="选择操作后将自动进入对应车辆页面"
primaryAction={{ label: '完成', onClick: () => setOpen(false) }}
> >
<div className="v2-vehicle-mobile-actions-list"> <div className="v2-vehicle-mobile-actions-list">
{overflowActions.map((action) => <Button key={action.key} block theme="light" type="tertiary" icon={action.icon} iconPosition="left" aria-label={action.label} onClick={() => selectAction(action.to)}>{action.label}<IconChevronRight /></Button>)} {overflowActions.map((action) => <Button key={action.key} block theme="light" type="tertiary" icon={action.icon} iconPosition="left" aria-label={action.label} onClick={() => selectAction(action.to)}>{action.label}<IconChevronRight /></Button>)}
</div> </div>
</SideSheet> </WorkspaceSideSheet>
</>; </>;
} }

View File

@@ -552,7 +552,7 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
expect(moreActions).toHaveAttribute('aria-expanded', 'false'); expect(moreActions).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(moreActions); fireEvent.click(moreActions);
expect(await screen.findByRole('dialog', { name: '更多车辆操作' })).toBeVisible(); expect(await screen.findByRole('dialog', { name: '更多车辆操作' })).toBeVisible();
expect(document.querySelector('.v2-vehicle-mobile-actions-sidesheet')).toHaveClass('semi-sidesheet-bottom'); expect(document.querySelector('.v2-vehicle-mobile-actions-sidesheet')).toHaveClass('v2-workspace-action-sidesheet', 'semi-sidesheet-bottom');
expect(screen.getByRole('button', { name: '历史数据' })).toBeVisible(); expect(screen.getByRole('button', { name: '历史数据' })).toBeVisible();
expect(screen.getByRole('button', { name: '里程查询' })).toBeVisible(); expect(screen.getByRole('button', { name: '里程查询' })).toBeVisible();
expect(screen.getByRole('button', { name: '告警事件' })).toBeVisible(); expect(screen.getByRole('button', { name: '告警事件' })).toBeVisible();

View File

@@ -31,6 +31,7 @@ const vehicleCandidateListSource = readFileSync(resolve(process.cwd(), 'src/v2/s
const recoveryActionsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/RecoveryActions.tsx'), 'utf8'); const recoveryActionsSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/RecoveryActions.tsx'), 'utf8');
const detailTriggerRowSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/detailTriggerRow.ts'), 'utf8'); const detailTriggerRowSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/detailTriggerRow.ts'), 'utf8');
const appShellSource = readFileSync(resolve(process.cwd(), 'src/v2/layout/AppShell.tsx'), 'utf8'); const appShellSource = readFileSync(resolve(process.cwd(), 'src/v2/layout/AppShell.tsx'), 'utf8');
const vehicleActionsSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleActions.tsx'), 'utf8');
const authGateSource = readFileSync(resolve(process.cwd(), 'src/v2/auth/AuthGate.tsx'), 'utf8'); const authGateSource = readFileSync(resolve(process.cwd(), 'src/v2/auth/AuthGate.tsx'), 'utf8');
const routeBoundarySource = readFileSync(resolve(process.cwd(), 'src/v2/routing/RouteBoundary.tsx'), 'utf8'); const routeBoundarySource = readFileSync(resolve(process.cwd(), 'src/v2/routing/RouteBoundary.tsx'), 'utf8');
const fleetMapSource = readFileSync(resolve(process.cwd(), 'src/v2/map/FleetMap.tsx'), 'utf8'); const fleetMapSource = readFileSync(resolve(process.cwd(), 'src/v2/map/FleetMap.tsx'), 'utf8');
@@ -581,6 +582,13 @@ describe('V2 production entry', () => {
expect(appShellSource).toContain('<Layout className="v2-shell">'); expect(appShellSource).toContain('<Layout className="v2-shell">');
expect(appShellSource).toContain('<Nav className="v2-navigation"'); expect(appShellSource).toContain('<Nav className="v2-navigation"');
expect(appShellSource).toContain('className="v2-mobile-more-sidesheet"'); expect(appShellSource).toContain('className="v2-mobile-more-sidesheet"');
expect(appShellSource.match(/<WorkspaceSideSheet/g)).toHaveLength(2);
expect(appShellSource).not.toContain('<SideSheet');
expect(appShellSource).not.toContain('useSideSheetA11y');
expect(vehicleActionsSource).toContain('<WorkspaceSideSheet');
expect(vehicleActionsSource).toContain('variant="action"');
expect(vehicleActionsSource).not.toContain('<SideSheet');
expect(vehicleActionsSource).not.toContain('useSideSheetA11y');
expect(appShellSource).toContain('className="v2-password-modal"'); expect(appShellSource).toContain('className="v2-password-modal"');
expect(appShellSource.match(/<PasswordInput/g)).toHaveLength(3); expect(appShellSource.match(/<PasswordInput/g)).toHaveLength(3);
expect(authGateSource.match(/<PasswordInput/g)).toHaveLength(2); expect(authGateSource.match(/<PasswordInput/g)).toHaveLength(2);

View File

@@ -12,6 +12,7 @@ test('renders one accessible Semi configuration sheet with shared summary and ac
className="test-config-sheet" className="test-config-sheet"
visible visible
ariaLabel="测试策略配置" ariaLabel="测试策略配置"
dialogId="test-strategy-sheet"
title="策略设置" title="策略设置"
description="统一维护优先级与启停状态" description="统一维护优先级与启停状态"
badge="2 项已启用" badge="2 项已启用"
@@ -28,6 +29,7 @@ test('renders one accessible Semi configuration sheet with shared summary and ac
</WorkspaceSideSheet>); </WorkspaceSideSheet>);
const dialog = screen.getByRole('dialog', { name: '测试策略配置' }); const dialog = screen.getByRole('dialog', { name: '测试策略配置' });
expect(dialog).toHaveAttribute('id', 'test-strategy-sheet');
expect(dialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-config-sidesheet', 'test-config-sheet'); expect(dialog.closest('.semi-sidesheet')).toHaveClass('v2-workspace-config-sidesheet', 'test-config-sheet');
expect(screen.getByText('统一维护优先级与启停状态')).toBeInTheDocument(); expect(screen.getByText('统一维护优先级与启停状态')).toBeInTheDocument();
expect(screen.getByRole('list', { name: '测试策略配置摘要' })).toHaveTextContent('当前首选来源 A健康状态正常实时上报'); expect(screen.getByRole('list', { name: '测试策略配置摘要' })).toHaveTextContent('当前首选来源 A健康状态正常实时上报');
@@ -39,7 +41,7 @@ test('renders one accessible Semi configuration sheet with shared summary and ac
expect(onCancel).toHaveBeenCalledTimes(1); expect(onCancel).toHaveBeenCalledTimes(1);
}); });
test('exposes task and editor variants without changing the accessible dialog contract', () => { test('exposes task, editor, help and action variants without changing the accessible dialog contract', () => {
const view = render(<> const view = render(<>
<WorkspaceSideSheet <WorkspaceSideSheet
visible visible
@@ -61,12 +63,38 @@ test('exposes task and editor variants without changing the accessible dialog co
> >
<span></span> <span></span>
</WorkspaceSideSheet> </WorkspaceSideSheet>
<WorkspaceSideSheet
visible
variant="help"
ariaLabel="页面帮助"
title="帮助"
description="查看当前模块说明"
icon={<span data-testid="help-icon">?</span>}
onCancel={() => undefined}
>
<span></span>
</WorkspaceSideSheet>
<WorkspaceSideSheet
visible
variant="action"
ariaLabel="快捷操作"
title="操作"
description="选择下一步"
onCancel={() => undefined}
>
<span></span>
</WorkspaceSideSheet>
</>); </>);
expect(screen.getByRole('dialog', { name: '后台任务' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-task-sidesheet'); expect(screen.getByRole('dialog', { name: '后台任务' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-task-sidesheet');
expect(screen.getByRole('dialog', { name: '策略编辑' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet'); expect(screen.getByRole('dialog', { name: '策略编辑' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet');
expect(screen.getByRole('dialog', { name: '页面帮助' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-help-sidesheet');
expect(screen.getByRole('dialog', { name: '快捷操作' }).closest('.semi-sidesheet')).toHaveClass('v2-workspace-action-sidesheet');
expect(screen.getByText('任务内容')).toBeInTheDocument(); expect(screen.getByText('任务内容')).toBeInTheDocument();
expect(screen.getByText('编辑内容')).toBeInTheDocument(); expect(screen.getByText('编辑内容')).toBeInTheDocument();
expect(screen.getByText('帮助内容')).toBeInTheDocument();
expect(screen.getByText('操作内容')).toBeInTheDocument();
expect(screen.getByTestId('help-icon').closest('.v2-workspace-config-title-icon')).toHaveAttribute('aria-hidden', 'true');
expect(view.baseElement).toHaveTextContent('查看生成进度'); expect(view.baseElement).toHaveTextContent('查看生成进度');
expect(view.baseElement).toHaveTextContent('保存后生效'); expect(view.baseElement).toHaveTextContent('保存后生效');
}); });

View File

@@ -21,12 +21,14 @@ export type WorkspaceSideSheetBadgeColor = 'blue' | 'green' | 'orange' | 'red' |
export type WorkspaceSideSheetProps = { export type WorkspaceSideSheetProps = {
className?: string; className?: string;
variant?: 'config' | 'detail' | 'task' | 'editor'; variant?: 'config' | 'detail' | 'task' | 'editor' | 'help' | 'action';
visible: boolean; visible: boolean;
ariaLabel: string; ariaLabel: string;
closeLabel?: string; closeLabel?: string;
dialogId?: string;
title: ReactNode; title: ReactNode;
description: ReactNode; description: ReactNode;
icon?: ReactNode;
badge?: ReactNode; badge?: ReactNode;
badgeColor?: WorkspaceSideSheetBadgeColor; badgeColor?: WorkspaceSideSheetBadgeColor;
placement?: 'left' | 'right' | 'top' | 'bottom'; placement?: 'left' | 'right' | 'top' | 'bottom';
@@ -46,8 +48,10 @@ export function WorkspaceSideSheet({
visible, visible,
ariaLabel, ariaLabel,
closeLabel = `关闭${ariaLabel}`, closeLabel = `关闭${ariaLabel}`,
dialogId,
title, title,
description, description,
icon,
badge, badge,
badgeColor = 'blue', badgeColor = 'blue',
placement = 'right', placement = 'right',
@@ -69,12 +73,13 @@ export function WorkspaceSideSheet({
const apply = () => { const apply = () => {
const dialog = document.querySelector(`[data-workspace-sheet-id="${sheetId}"] .semi-sidesheet-inner`); const dialog = document.querySelector(`[data-workspace-sheet-id="${sheetId}"] .semi-sidesheet-inner`);
dialog?.setAttribute('aria-label', ariaLabel); dialog?.setAttribute('aria-label', ariaLabel);
if (dialogId) dialog?.setAttribute('id', dialogId);
dialog?.querySelector('.semi-sidesheet-close')?.setAttribute('aria-label', closeLabel); dialog?.querySelector('.semi-sidesheet-close')?.setAttribute('aria-label', closeLabel);
}; };
apply(); apply();
const frame = window.requestAnimationFrame(apply); const frame = window.requestAnimationFrame(apply);
return () => window.cancelAnimationFrame(frame); return () => window.cancelAnimationFrame(frame);
}, [ariaLabel, closeLabel, sheetId, visible]); }, [ariaLabel, closeLabel, dialogId, sheetId, visible]);
return <SideSheet return <SideSheet
className={sheetClassName} className={sheetClassName}
@@ -85,6 +90,7 @@ export function WorkspaceSideSheet({
width={placement === 'left' || placement === 'right' ? width : undefined} width={placement === 'left' || placement === 'right' ? width : undefined}
height={placement === 'top' || placement === 'bottom' ? height : undefined} height={placement === 'top' || placement === 'bottom' ? height : undefined}
title={<div className="v2-workspace-config-title"> title={<div className="v2-workspace-config-title">
{icon ? <i className="v2-workspace-config-title-icon" aria-hidden="true">{icon}</i> : null}
<span><strong>{title}</strong><small>{description}</small></span> <span><strong>{title}</strong><small>{description}</small></span>
{badge ? <Tag color={badgeColor} type="light" size="small">{badge}</Tag> : null} {badge ? <Tag color={badgeColor} type="light" size="small">{badge}</Tag> : null}
</div>} </div>}

View File

@@ -21777,3 +21777,197 @@
grid-column: 1; grid-column: 1;
} }
} }
/*
* Global help and action workbenches.
* These high-frequency shell surfaces now use the same Semi UI identity,
* scrolling and footer hierarchy as configuration, evidence and editor sheets.
*/
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .semi-sidesheet-inner {
overflow: hidden;
border-left: 1px solid #d9e3ef;
background: #f4f7fb;
box-shadow: -22px 0 64px rgba(24, 43, 68, .16);
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .semi-sidesheet-header {
min-height: 78px;
border-bottom: 1px solid #dfe7f0;
background:
radial-gradient(circle at 10% 0%, rgba(18, 104, 243, .08), transparent 36%),
linear-gradient(180deg, #fff 0%, #fbfcfe 100%);
padding: 13px 18px;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .semi-sidesheet-title {
min-width: 0;
}
.v2-workspace-config-title-icon {
display: grid;
width: 38px;
height: 38px;
flex: 0 0 auto;
place-items: center;
border: 1px solid #d7e5f8;
border-radius: 11px;
background: linear-gradient(145deg, #f2f7ff 0%, #e8f1ff 100%);
color: #1268f3;
font-size: 18px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .9);
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .semi-sidesheet-close {
width: 36px;
height: 36px;
border-radius: 9px;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .semi-sidesheet-body {
min-height: 0;
overflow: hidden;
background: #f4f7fb;
padding: 0;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .v2-workspace-config-content {
padding: 11px;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .semi-sidesheet-footer {
min-height: 64px;
border-top: 1px solid #e1e8f1;
background: rgba(255, 255, 255, .97);
padding: 10px 14px;
}
.v2-workspace-help-sidesheet .v2-workspace-config-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.v2-workspace-help-sidesheet .v2-workspace-config-summary strong {
font-size: 13px;
}
.v2-workspace-help-sidesheet .v2-help-panel {
align-content: start;
}
.v2-workspace-help-sidesheet .v2-help-overview.semi-card {
min-height: 112px;
}
.v2-workspace-action-sidesheet .v2-workspace-config-title strong {
font-size: 18px;
}
.v2-workspace-action-sidesheet .v2-workspace-config-title > .semi-tag {
color: #1268f3;
}
.v2-mobile-more-sidesheet .v2-workspace-config-content {
padding: 10px;
}
.v2-mobile-more-sidesheet .semi-sidesheet-title > .v2-workspace-config-title {
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
}
.v2-mobile-more-sidesheet .v2-mobile-more-groups {
align-content: start;
}
.v2-vehicle-mobile-actions-sidesheet .v2-workspace-config-content {
padding: 10px 12px;
}
@media (max-width: 680px) {
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet).semi-sidesheet-bottom .semi-sidesheet-inner {
width: 100% !important;
max-width: 100%;
overflow: hidden;
border: 1px solid #d8e2ed;
border-bottom: 0;
border-radius: 18px 18px 0 0;
background: #f4f7fb;
box-shadow: 0 -18px 48px rgba(23, 43, 68, .18);
padding-bottom: env(safe-area-inset-bottom);
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet).semi-sidesheet-bottom .semi-sidesheet-header {
min-height: 70px;
padding: 10px 12px 10px 14px;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .v2-workspace-config-title {
gap: 8px;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .v2-workspace-config-title-icon {
width: 34px;
height: 34px;
border-radius: 10px;
font-size: 16px;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .v2-workspace-config-title strong {
font-size: 16px;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .v2-workspace-config-title small {
max-width: 210px;
font-size: 9px;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet) .v2-workspace-config-content {
padding: 8px;
}
:is(.v2-workspace-help-sidesheet, .v2-workspace-action-sidesheet).semi-sidesheet-bottom .semi-sidesheet-footer {
min-height: 58px;
padding: 7px 9px calc(7px + env(safe-area-inset-bottom));
}
.v2-workspace-help-sidesheet .v2-workspace-config-summary {
min-height: 64px;
}
.v2-workspace-help-sidesheet .v2-workspace-config-summary > span {
padding: 7px 8px;
}
.v2-workspace-help-sidesheet .v2-workspace-config-summary strong {
font-size: 10px;
}
.v2-workspace-help-sidesheet .v2-help-overview.semi-card {
min-height: 0;
}
.v2-workspace-help-sidesheet .v2-help-panel li {
min-height: 54px;
}
.v2-mobile-more-sidesheet .v2-workspace-config-content {
padding: 8px 9px 10px;
}
.v2-vehicle-mobile-actions-sidesheet .v2-workspace-config-content {
padding: 9px 11px;
}
}
@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape) {
.v2-workspace-action-sidesheet.v2-vehicle-mobile-actions-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner {
height: min(100dvh, 380px) !important;
max-height: 100dvh;
border-radius: 16px 16px 0 0;
}
.v2-workspace-action-sidesheet.v2-vehicle-mobile-actions-sidesheet .v2-vehicle-mobile-actions-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}