feat: unify task and rule workbenches

This commit is contained in:
lingniu
2026-07-19 05:52:52 +08:00
parent 63b50732cb
commit 860978fad8
8 changed files with 439 additions and 37 deletions

View File

@@ -357,10 +357,13 @@ test('keeps mobile rule selection focused and opens editing in a Semi bottom Sid
fireEvent.click(ruleItem);
const editor = await screen.findByRole('dialog', { name: '告警规则编辑' });
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-alert-rule-editor-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-editor-sidesheet');
expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(92dvh, 820px)' });
expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument();
expect(within(editor).getByRole('button', { name: '保存规则' })).toBeInTheDocument();
expect(within(editor).getByRole('heading', { name: '触发条件' })).toBeInTheDocument();
expect(within(editor).getByRole('heading', { name: '恢复与提醒' })).toBeInTheDocument();
expect(within(editor).getByRole('heading', { name: '适用范围与说明' })).toBeInTheDocument();
expect(within(editor).queryByRole('region', { name: '告警规则模板' })).not.toBeInTheDocument();
expect(ruleItem).toHaveAttribute('aria-expanded', 'true');
@@ -397,6 +400,9 @@ test('creates auditable drafts from simple offline and hydrogen rule templates',
expect(view.container.querySelector('.v2-alert-rule-editor')).toHaveClass('semi-card');
expect(view.container.querySelector('.v2-alert-rule-list-heading')).toHaveClass('v2-workspace-panel-header');
expect(view.container.querySelector('.v2-alert-rule-editor-heading')).toHaveClass('v2-workspace-panel-header');
expect(screen.getByRole('heading', { name: '触发条件' }).closest('.v2-rule-form-section')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '恢复与提醒' }).closest('.v2-rule-form-section')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '适用范围与说明' }).closest('.v2-rule-form-section')).toBeInTheDocument();
expect(screen.getByRole('region', { name: '告警规则列表,可左右滚动选择' })).toHaveAttribute('tabindex', '0');
expect(ruleItem).toHaveClass('semi-button', 'v2-alert-rule-item');
await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true'));

View File

@@ -1,7 +1,7 @@
import { IconAlarm, IconBell, IconChevronRight, IconClose, IconFilter, IconPlus, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Collapse, Descriptions, Empty, Input, Radio, Select, SideSheet, Spin, Table, Tag, TextArea, Timeline, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { FormEvent, memo, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AlertEvent, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition, Page } from '../../api/types';
@@ -17,6 +17,7 @@ import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { detailTriggerRow } from '../shared/detailTriggerRow';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister, canOperate } from '../auth/session';
@@ -369,6 +370,13 @@ function ruleDraft(rule: AlertRule): AlertRuleInput {
};
}
function RuleFormSection({ id, title, description, children }: { id: string; title: string; description: string; children: ReactNode }) {
return <section className="v2-rule-form-section" aria-labelledby={id}>
<header><span><Typography.Title heading={6} id={id}>{title}</Typography.Title><small>{description}</small></span></header>
<div className="v2-rule-form-grid">{children}</div>
</section>;
}
function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: MetricDefinition[] }) {
const queryClient = useQueryClient();
const mobileLayout = useMobileLayout();
@@ -378,7 +386,6 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
useEffect(() => { if (!selectedID && rules[0]) { setSelectedID(rules[0].id); setDraft(ruleDraft(rules[0])); } }, [rules, selectedID]);
const save = useMutation({ mutationFn: api.saveAlertRuleV2, onSuccess: async (rule) => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
const toggle = useMutation({ mutationFn: (rule: AlertRule) => api.setAlertRuleEnabledV2(rule.id, { version: rule.version, enabled: !rule.enabled }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
useSideSheetA11y(mobileLayout && editorOpen, '.v2-alert-rule-editor-sidesheet', 'v2-alert-rule-editor', '告警规则编辑', '关闭告警规则编辑');
const mutationError = save.error ?? toggle.error;
const operators = draft.valueType === 'boolean' ? BOOLEAN_OPERATORS : NUMERIC_OPERATORS;
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
@@ -417,7 +424,6 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
actions={toggleButton}
/> : <div className={`v2-alert-rule-sheet-status${draft.version ? '' : ' is-new'}`}>
<span>
<Tag size="small" color={draft.version ? draft.enabled ? 'green' : 'grey' : 'blue'} type="light">{draft.version ? draft.enabled ? '已启用' : '已停用' : '新规则'}</Tag>
<small>{draft.version ? `v${draft.version} · 配置变更写入版本审计` : '保存后生成首个可审计版本'}</small>
</span>
{toggleButton}
@@ -440,24 +446,30 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
</Button>;
})}</nav>
</section> : null}
<div className="v2-rule-form-grid">
<label><span></span><Input required maxLength={80} value={draft.name} onChange={(value) => setDraft({ ...draft, name: value })} /></label>
<label><span></span><Select value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) as AlertRuleInput['severity'] })} optionList={[{ value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
<label><span></span><Select value={draft.valueType} onChange={(value) => { const valueType = String(value) as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }} optionList={[{ value: 'numeric', label: '数值' }, { value: 'boolean', label: '布尔' }]} /></label>
<label><span></span><Select disabled={!availableMetrics.length} value={draft.metric} onChange={(value) => setDraft({ ...draft, metric: String(value) })} optionList={availableMetrics.map((metric) => ({ value: metric.key, label: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}` }))} /></label>
<label><span></span><Select value={draft.operator} onChange={(value) => { const operator = String(value); setDraft({ ...draft, operator, durationSec: operator === 'changed' ? 0 : draft.durationSec }); }} optionList={operators.map((value) => ({ value, label: operatorLabels[value] }))} /></label>
{draft.operator === 'changed' ? <label><span></span><Input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span></span><Select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(value) => setDraft({ ...draft, booleanThreshold: value === 'true' })} optionList={[{ value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><Input required type="number" step="0.01" placeholder={draft.metric === 'hydrogen_concentration_percent' ? '请按厂家标准填写百分比' : undefined} value={Number.isFinite(draft.threshold) ? String(draft.threshold) : ''} onChange={(value) => setDraft({ ...draft, threshold: value === '' ? Number.NaN : Number(value) })} /></label>}
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span></span><Input type="number" step="0.1" value={String(draft.thresholdHigh)} onChange={(value) => setDraft({ ...draft, thresholdHigh: Number(value) })} /></label> : null}
<label><span></span><Input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={String(draft.durationSec)} onChange={(value) => setDraft({ ...draft, durationSec: Number(value) })} /></label>
<label><span></span><Select value={draft.recoveryOperator} onChange={(value) => setDraft({ ...draft, recoveryOperator: String(value) })} optionList={[{ value: '', label: '未配置' }, ...['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => ({ value, label: operatorLabels[value] }))]} /></label>
<label><span></span><Input type="number" step="0.1" value={String(draft.recoveryThreshold)} onChange={(value) => setDraft({ ...draft, recoveryThreshold: Number(value) })} /></label>
<label><span></span><Input type="number" min="0" max="604800" value={String(draft.repeatIntervalSec)} onChange={(value) => setDraft({ ...draft, repeatIntervalSec: Number(value) })} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeProtocols.join(',')} onChange={(value) => setList('scopeProtocols', value)} /></label>
<label className="is-wide"><span> VIN </span><Input value={draft.scopeVins.join(',')} onChange={(value) => setList('scopeVins', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeOems.join(',')} onChange={(value) => setList('scopeOems', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeModels.join(',')} onChange={(value) => setList('scopeModels', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeCompanies.join(',')} onChange={(value) => setList('scopeCompanies', value)} /></label>
<label className="is-wide"><span></span><TextArea maxCount={500} autosize={{ minRows: 3, maxRows: 7 }} value={draft.description} onChange={(value) => setDraft({ ...draft, description: value })} /></label>
<div className="v2-rule-form-sections">
<RuleFormSection id="v2-rule-trigger-heading" title="触发条件" description="定义监控指标、判断方式与持续时间">
<label><span></span><Input required maxLength={80} value={draft.name} onChange={(value) => setDraft({ ...draft, name: value })} /></label>
<label><span></span><Select value={draft.severity} onChange={(value) => setDraft({ ...draft, severity: String(value) as AlertRuleInput['severity'] })} optionList={[{ value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /></label>
<label><span></span><Select value={draft.valueType} onChange={(value) => { const valueType = String(value) as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }} optionList={[{ value: 'numeric', label: '数值' }, { value: 'boolean', label: '布尔' }]} /></label>
<label><span></span><Select disabled={!availableMetrics.length} value={draft.metric} onChange={(value) => setDraft({ ...draft, metric: String(value) })} optionList={availableMetrics.map((metric) => ({ value: metric.key, label: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}` }))} /></label>
<label><span></span><Select value={draft.operator} onChange={(value) => { const operator = String(value); setDraft({ ...draft, operator, durationSec: operator === 'changed' ? 0 : draft.durationSec }); }} optionList={operators.map((value) => ({ value, label: operatorLabels[value] }))} /></label>
{draft.operator === 'changed' ? <label><span></span><Input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span></span><Select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(value) => setDraft({ ...draft, booleanThreshold: value === 'true' })} optionList={[{ value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><Input required type="number" step="0.01" placeholder={draft.metric === 'hydrogen_concentration_percent' ? '请按厂家标准填写百分比' : undefined} value={Number.isFinite(draft.threshold) ? String(draft.threshold) : ''} onChange={(value) => setDraft({ ...draft, threshold: value === '' ? Number.NaN : Number(value) })} /></label>}
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span></span><Input type="number" step="0.1" value={String(draft.thresholdHigh)} onChange={(value) => setDraft({ ...draft, thresholdHigh: Number(value) })} /></label> : null}
<label><span></span><Input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={String(draft.durationSec)} onChange={(value) => setDraft({ ...draft, durationSec: Number(value) })} /></label>
</RuleFormSection>
<RuleFormSection id="v2-rule-recovery-heading" title="恢复与提醒" description="控制恢复条件和重复通知频率">
<label><span></span><Select value={draft.recoveryOperator} onChange={(value) => setDraft({ ...draft, recoveryOperator: String(value) })} optionList={[{ value: '', label: '未配置' }, ...['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => ({ value, label: operatorLabels[value] }))]} /></label>
<label><span></span><Input type="number" step="0.1" value={String(draft.recoveryThreshold)} onChange={(value) => setDraft({ ...draft, recoveryThreshold: Number(value) })} /></label>
<label><span></span><Input type="number" min="0" max="604800" value={String(draft.repeatIntervalSec)} onChange={(value) => setDraft({ ...draft, repeatIntervalSec: Number(value) })} /></label>
</RuleFormSection>
<RuleFormSection id="v2-rule-scope-heading" title="适用范围与说明" description="按协议、车辆与主数据范围精确生效">
<label className="is-wide"><span></span><Input value={draft.scopeProtocols.join(',')} onChange={(value) => setList('scopeProtocols', value)} /></label>
<label className="is-wide"><span> VIN </span><Input value={draft.scopeVins.join(',')} onChange={(value) => setList('scopeVins', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeOems.join(',')} onChange={(value) => setList('scopeOems', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeModels.join(',')} onChange={(value) => setList('scopeModels', value)} /></label>
<label className="is-wide"><span></span><Input value={draft.scopeCompanies.join(',')} onChange={(value) => setList('scopeCompanies', value)} /></label>
<label className="is-wide"><span></span><TextArea maxCount={500} autosize={{ minRows: 3, maxRows: 7 }} value={draft.description} onChange={(value) => setDraft({ ...draft, description: value })} /></label>
</RuleFormSection>
</div>
<footer><div><b></b><span> / </span></div>{mutationError ? <em>{mutationError.message}</em> : null}<Button className="v2-primary-button" theme="solid" htmlType="submit" disabled={save.isPending || toggle.isPending}>{save.isPending ? '保存中' : '保存规则'}</Button></footer>
</form>
@@ -492,17 +504,21 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
</div>
</Card>
{!mobileLayout ? editor : null}
{mobileLayout ? <SideSheet
{mobileLayout ? <WorkspaceSideSheet
className="v2-alert-rule-editor-sidesheet"
variant="editor"
visible={editorOpen}
aria-label="告警规则编辑"
ariaLabel="告警规则编辑"
placement="bottom"
height="min(92dvh, 820px)"
title={<div className="v2-alert-sheet-title"><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span>{draft.name || '配置触发条件、范围与通知策略'}</span></div>}
title={draft.version ? '编辑规则' : '新建规则'}
description={draft.name || '配置触发条件、范围与通知策略'}
badge={draft.version ? draft.enabled ? '已启用' : '已停用' : '新规则'}
badgeColor={draft.version ? draft.enabled ? 'green' : 'grey' : 'blue'}
onCancel={() => setEditorOpen(false)}
>
{editorOpen ? editor : null}
</SideSheet> : null}
</WorkspaceSideSheet> : null}
</div>;
}

View File

@@ -282,7 +282,7 @@ test('renders only selectable Semi history cards on mobile', async () => {
await waitFor(() => expect(toolsButton).toHaveAttribute('aria-expanded', 'true'));
fireEvent.click(screen.getByRole('menuitem', { name: /导出任务/ }));
expect(await screen.findByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument();
expect(document.querySelector('.v2-history-export-sidesheet')).toHaveClass('semi-sidesheet-bottom');
expect(document.querySelector('.v2-history-export-sidesheet')).toHaveClass('semi-sidesheet-bottom', 'v2-workspace-task-sidesheet');
expect(document.querySelector('.v2-history-export-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(76dvh, 680px)' });
await waitFor(() => expect(mocks.historyExports).toHaveBeenCalledTimes(1));
expect(screen.getByText('暂无导出任务')).toBeInTheDocument();
@@ -388,6 +388,7 @@ test('shows only the authenticated customer export workspace with owner and scop
fireEvent.click(await screen.findByRole('button', { name: '查看导出任务' }));
expect(await screen.findByText(/customer-a · 1 辆/)).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: '历史数据导出任务' })).toBeInTheDocument();
expect(screen.getByRole('list', { name: '导出任务状态概览' })).toHaveTextContent('生成中0排队与执行可下载1已完成任务需处理0失败任务');
expect(screen.getByRole('button', { name: /创建导出/ })).toBeInTheDocument();
expect(mocks.historyExports).toHaveBeenCalled();
});

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { IconChevronRight, IconClose, IconDownload, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Empty, Input, Progress, Select, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { Button, Card, CardGroup, Checkbox, Descriptions, Dropdown, Empty, Input, Progress, Select, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { FormEvent, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
@@ -144,11 +144,22 @@ function ExportJobsPanel({ showHeader = true }: { showHeader?: boolean }) {
const jobs = query.data ?? [];
const statusLabel = (status: string) => status === 'queued' ? '排队中' : status === 'running' ? '执行中' : status === 'completed' ? '已完成' : '失败';
const statusColor = (status: string) => status === 'completed' ? 'green' : status === 'running' ? 'blue' : status === 'failed' ? 'red' : 'grey';
const activeJobs = jobs.filter((job) => job.status === 'queued' || job.status === 'running').length;
const completedJobs = jobs.filter((job) => job.status === 'completed').length;
const failedJobs = jobs.filter((job) => job.status === 'failed').length;
return <Card className="v2-export-jobs" bodyStyle={{ padding: 0 }}>{showHeader ? <WorkspacePanelHeader title="导出任务" description="异步生成并保留可追溯下载记录" meta={`${jobs.length.toLocaleString('zh-CN')} 个任务`} /> : null}
{query.isPending || query.isError || !jobs.length ? null : <div className="v2-export-job-overview" role="list" aria-label="导出任务状态概览">
<span className={activeJobs ? 'is-active' : ''} role="listitem"><small></small><strong>{activeJobs}</strong><em></em></span>
<span className="is-completed" role="listitem"><small></small><strong>{completedJobs}</strong><em></em></span>
<span className={failedJobs ? 'is-failed' : ''} role="listitem"><small></small><strong>{failedJobs}</strong><em></em></span>
</div>}
<div className="v2-export-job-list">{query.isPending ? <div className="v2-history-side-loading"><Spin size="middle" tip="正在读取导出任务" /></div> : query.isError ? <Empty className="v2-history-side-empty" title="导出任务加载失败" description="请稍后刷新重试。" /> : !jobs.length ? <Empty className="v2-history-side-empty" title="暂无导出任务" description="创建任务后可在这里查看进度并下载。" /> : <CardGroup type="grid" spacing={0}>{jobs.slice(0, 6).map((job) => <Card className={`v2-export-job-card is-${job.status}`} key={job.id} title={<span className="v2-export-job-name" title={job.name}>{job.name}</span>} headerExtraContent={<Tag color={statusColor(job.status)} type="light" size="small">{statusLabel(job.status)}</Tag>} headerLine bodyStyle={{ padding: 0 }}>
<div className="v2-export-job-summary"><strong>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')}` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)}` : job.error || '任务失败'}</strong><small>{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} </small><small>{job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')}${job.dateTo.replace('T', ' ')}` : job.createdAt}</small></div>
<div className="v2-export-job-summary">
<strong>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')}` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)}` : job.error || '任务失败'}</strong>
<span><small>{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} </small>{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : null}</span>
<small>{job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')}${job.dateTo.replace('T', ' ')}` : job.createdAt}</small>
</div>
{job.status === 'running' ? <Progress className="v2-export-job-progress" percent={job.progress} showInfo /> : null}
{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : null}
</Card>)}</CardGroup>}</div>
</Card>;
}
@@ -562,17 +573,21 @@ export default function HistoryPage() {
>
{mobileLayout && selectedRow ? <EvidencePanel row={selectedRow} metrics={visibleMetrics} onClose={closeSelectedRow} showHeader={false} /> : null}
</WorkspaceDetailSideSheet>
<SideSheet
<WorkspaceSideSheet
className="v2-history-export-sidesheet"
variant="task"
visible={exportAllowed && exportJobsOpen}
aria-label="历史数据导出任务"
ariaLabel="历史数据导出任务"
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 520}
height={mobileLayout ? 'min(76dvh, 680px)' : undefined}
title={<div className="v2-history-mobile-sheet-title"><strong></strong><span></span></div>}
title="导出任务"
description="查看后台生成进度与可追溯下载记录"
badge="后台任务"
badgeColor="blue"
onCancel={() => setExportJobsOpen(false)}
>
{exportJobsOpen ? <ExportJobsPanel showHeader={false} /> : null}
</SideSheet>
</WorkspaceSideSheet>
</div>;
}

View File

@@ -142,7 +142,8 @@ describe('V2 production entry', () => {
expect(corePageSources.HistoryPage).toContain('<Input');
expect(corePageSources.HistoryPage).toContain('<Select');
expect(corePageSources.HistoryPage).toContain('<Checkbox');
expect(corePageSources.HistoryPage).toContain('<SideSheet');
expect(corePageSources.HistoryPage).toContain('<WorkspaceSideSheet');
expect(corePageSources.HistoryPage).toContain('variant="task"');
expect(corePageSources.HistoryPage).toContain('v2-history-column-sidesheet');
expect(corePageSources.HistoryPage).toContain('<Card key={row.id} className={`v2-history-mobile-card');
expect(corePageSources.HistoryPage).toContain('v2-history-trend');
@@ -185,7 +186,11 @@ describe('V2 production entry', () => {
expect(corePageSources.AlertsPage).toContain('className="v2-alert-navigation v2-alert-command-bar"');
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-filter-sidesheet"');
expect(corePageSources.AlertsPage).toContain('<WorkspaceDetailSideSheet\n className="v2-alert-detail-sidesheet"');
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-rule-editor-sidesheet"');
expect(corePageSources.AlertsPage).toContain('<WorkspaceSideSheet\n className="v2-alert-rule-editor-sidesheet"');
expect(corePageSources.AlertsPage).toContain('variant="editor"');
expect(corePageSources.AlertsPage).toContain('<RuleFormSection id="v2-rule-trigger-heading"');
expect(corePageSources.AlertsPage).toContain('<RuleFormSection id="v2-rule-recovery-heading"');
expect(corePageSources.AlertsPage).toContain('<RuleFormSection id="v2-rule-scope-heading"');
expect(corePageSources.AlertsPage).toContain('className={`v2-alert-event-table');
expect(corePageSources.AlertsPage).toContain('detailTriggerRow({');
expect(corePageSources.AlertsPage).not.toContain('<table><thead><tr><th />');

View File

@@ -38,3 +38,35 @@ test('renders one accessible Semi configuration sheet with shared summary and ac
expect(onComplete).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1);
});
test('exposes task and editor variants without changing the accessible dialog contract', () => {
const view = render(<>
<WorkspaceSideSheet
visible
variant="task"
ariaLabel="后台任务"
title="任务"
description="查看生成进度"
onCancel={() => undefined}
>
<span></span>
</WorkspaceSideSheet>
<WorkspaceSideSheet
visible
variant="editor"
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-editor-sidesheet');
expect(screen.getByText('任务内容')).toBeInTheDocument();
expect(screen.getByText('编辑内容')).toBeInTheDocument();
expect(view.baseElement).toHaveTextContent('查看生成进度');
expect(view.baseElement).toHaveTextContent('保存后生效');
});

View File

@@ -21,7 +21,7 @@ export type WorkspaceSideSheetBadgeColor = 'blue' | 'green' | 'orange' | 'red' |
export type WorkspaceSideSheetProps = {
className?: string;
variant?: 'config' | 'detail';
variant?: 'config' | 'detail' | 'task' | 'editor';
visible: boolean;
ariaLabel: string;
closeLabel?: string;

View File

@@ -21068,3 +21068,330 @@
padding: 7px 8px;
}
}
/*
* Shared task and editor SideSheets.
* Task sheets prioritise progress and traceable output; editor sheets preserve
* a stable title/status boundary while the form owns its save action.
*/
:is(.v2-workspace-task-sidesheet, .v2-workspace-editor-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-task-sidesheet, .v2-workspace-editor-sidesheet) .semi-sidesheet-header {
min-height: 78px;
border-bottom: 1px solid #dfe7f0;
background:
radial-gradient(circle at 10% 0%, rgba(18, 104, 243, .075), transparent 36%),
linear-gradient(180deg, #fff 0%, #fbfcfe 100%);
padding: 13px 18px;
}
:is(.v2-workspace-task-sidesheet, .v2-workspace-editor-sidesheet) .semi-sidesheet-title {
min-width: 0;
}
:is(.v2-workspace-task-sidesheet, .v2-workspace-editor-sidesheet) .semi-sidesheet-close {
width: 36px;
height: 36px;
border-radius: 9px;
}
:is(.v2-workspace-task-sidesheet, .v2-workspace-editor-sidesheet) .semi-sidesheet-body {
min-height: 0;
overflow: hidden;
background: #f4f7fb;
padding: 0;
}
.v2-workspace-task-sidesheet .v2-workspace-config-content {
padding: 12px;
}
.v2-workspace-editor-sidesheet .v2-workspace-config-content {
overflow: hidden;
padding: 0;
}
.v2-workspace-editor-sidesheet .v2-workspace-config-body,
.v2-workspace-editor-sidesheet .v2-workspace-config-content {
width: 100%;
}
.v2-workspace-editor-sidesheet .v2-workspace-config-content > .semi-card {
width: 100%;
height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
}
.v2-history-export-sidesheet .v2-export-jobs.semi-card {
min-height: 100%;
border-color: #dce5ef;
border-radius: 12px;
box-shadow: 0 9px 28px rgba(31, 53, 80, .065);
}
.v2-export-job-overview {
display: grid;
min-height: 88px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
border-bottom: 1px solid #e1e8f1;
background: #e1e8f1;
}
.v2-export-job-overview > span {
position: relative;
display: grid;
min-width: 0;
align-content: center;
gap: 3px;
background: #fff;
padding: 12px 13px;
}
.v2-export-job-overview > span::before {
position: absolute;
inset: 0 auto 0 0;
width: 2px;
background: transparent;
content: '';
}
.v2-export-job-overview > span.is-active::before { background: #1268f3; }
.v2-export-job-overview > span.is-completed::before { background: #18a76f; }
.v2-export-job-overview > span.is-failed::before { background: #e95a52; }
.v2-export-job-overview small {
color: #7f8ea1;
font-size: 10px;
font-weight: 650;
}
.v2-export-job-overview strong {
color: #243950;
font-size: 21px;
font-weight: 760;
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.v2-export-job-overview > span.is-active strong { color: #1268f3; }
.v2-export-job-overview > span.is-completed strong { color: #14865c; }
.v2-export-job-overview > span.is-failed strong { color: #c8423b; }
.v2-export-job-overview em {
overflow: hidden;
color: #98a3b2;
font-size: 8px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-history-export-sidesheet .v2-export-job-list {
max-height: none;
padding: 11px;
}
.v2-history-export-sidesheet .v2-export-job-list > .semi-card-group {
gap: 9px;
}
.v2-history-export-sidesheet .v2-export-job-card.semi-card {
border-radius: 10px;
box-shadow: 0 5px 16px rgba(30, 50, 74, .045);
}
.v2-history-export-sidesheet .v2-export-job-card > .semi-card-header {
min-height: 44px;
padding-inline: 12px;
}
.v2-history-export-sidesheet .v2-export-job-name {
max-width: 310px;
color: #32475f;
font-size: 12px;
}
.v2-history-export-sidesheet .v2-export-job-card > .semi-card-header .semi-tag {
min-height: 23px;
font-size: 9px;
}
.v2-history-export-sidesheet .v2-export-job-card > .semi-card-body {
gap: 8px;
padding: 11px 12px;
}
.v2-history-export-sidesheet .v2-export-job-summary {
gap: 5px;
}
.v2-history-export-sidesheet .v2-export-job-summary > span {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.v2-history-export-sidesheet .v2-export-job-summary strong {
color: #354b63;
font-size: 12px;
}
.v2-history-export-sidesheet .v2-export-job-summary small {
font-size: 9px;
}
.v2-history-export-sidesheet .v2-export-job-card .v2-export-download.semi-button {
min-width: 66px;
height: 30px;
flex: 0 0 auto;
justify-content: center;
border-radius: 7px;
background: #edf5ff;
font-size: 10px;
}
.v2-rule-form-sections {
display: grid;
gap: 12px;
background: #f5f7fa;
padding: 14px 15px 18px;
}
.v2-rule-form-section {
overflow: hidden;
border: 1px solid #dce5ef;
border-radius: 11px;
background: #fff;
box-shadow: 0 6px 20px rgba(31, 53, 80, .045);
}
.v2-rule-form-section > header {
min-height: 54px;
border-bottom: 1px solid #e5ebf2;
background: linear-gradient(180deg, #fff 0%, #fbfcfe 100%);
padding: 10px 13px;
}
.v2-rule-form-section > header > span {
display: grid;
gap: 3px;
}
.v2-rule-form-section > header h6.semi-typography {
margin: 0;
color: #2a4058;
font-size: 13px;
font-weight: 720;
}
.v2-rule-form-section > header small {
color: #8390a2;
font-size: 10px;
line-height: 1.45;
}
.v2-rule-form-section > .v2-rule-form-grid {
gap: 13px 15px;
padding: 14px;
}
@media (max-width: 680px) {
:is(.v2-workspace-task-sidesheet, .v2-workspace-editor-sidesheet).semi-sidesheet-bottom .semi-sidesheet-inner {
width: 100% !important;
max-width: 100%;
border: 1px solid #d8e2ed;
border-bottom: 0;
border-radius: 18px 18px 0 0;
box-shadow: 0 -18px 48px rgba(23, 43, 68, .18);
padding-bottom: env(safe-area-inset-bottom);
}
:is(.v2-workspace-task-sidesheet, .v2-workspace-editor-sidesheet).semi-sidesheet-bottom .semi-sidesheet-header {
min-height: 70px;
padding: 10px 12px 10px 14px;
}
.v2-workspace-task-sidesheet .v2-workspace-config-content {
padding: 8px;
}
.v2-export-job-overview {
min-height: 72px;
}
.v2-export-job-overview > span {
gap: 2px;
padding: 9px 10px;
}
.v2-export-job-overview small {
font-size: 8px;
}
.v2-export-job-overview strong {
font-size: 17px;
}
.v2-export-job-overview em {
font-size: 7px;
}
.v2-history-export-sidesheet .v2-export-job-list {
padding: 8px;
}
.v2-history-export-sidesheet .v2-export-job-name {
max-width: 220px;
font-size: 11px;
}
.v2-history-export-sidesheet .v2-export-job-summary strong {
font-size: 11px;
}
.v2-history-export-sidesheet .v2-export-job-summary small {
font-size: 8px;
}
.v2-rule-form-sections {
gap: 9px;
padding: 9px 9px 14px;
}
.v2-rule-form-section {
border-radius: 10px;
}
.v2-rule-form-section > header {
min-height: 50px;
padding: 9px 11px;
}
.v2-rule-form-section > header h6.semi-typography {
font-size: 12px;
}
.v2-rule-form-section > header small {
font-size: 9px;
}
.v2-rule-form-section > .v2-rule-form-grid {
grid-template-columns: 1fr;
gap: 12px;
padding: 12px 11px 14px;
}
.v2-rule-form-section > .v2-rule-form-grid label.is-wide {
grid-column: auto;
}
}