diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx index f3dcbf42..9c4776f1 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.test.tsx @@ -284,6 +284,46 @@ test('uses the shared Semi bottom SideSheet pattern for mobile advanced filters' expect(document.querySelector('.v2-alert-filter-sidesheet .semi-sidesheet-inner')).toHaveStyle({ height: 'min(72dvh, 620px)' }); }); +test('keeps mobile rule selection focused and opens editing in a Semi bottom SideSheet', async () => { + layout.mobile = true; + mocks.alertRulesV2.mockResolvedValue([alertRule()]); + mocks.metricCatalog.mockResolvedValue({ + metrics: [{ key: 'speed_kmh', label: '速度', unit: 'km/h', category: 'driving', valueType: 'numeric', protocols: ['JT808'], sourceFields: {}, searchable: true, chartable: true, alertable: true }], + asOf: '' + }); + mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + const ruleItem = await screen.findByRole('button', { name: /测试超速规则/ }); + await waitFor(() => expect(ruleItem).toHaveAttribute('aria-pressed', 'true')); + expect(screen.getByRole('region', { name: '告警规则列表' })).toBeInTheDocument(); + expect(ruleItem).toHaveAttribute('aria-haspopup', 'dialog'); + expect(ruleItem).toHaveAttribute('aria-expanded', 'false'); + expect(document.querySelector('.v2-alert-rule-editor')).not.toBeInTheDocument(); + + 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 .semi-sidesheet-inner')).toHaveStyle({ height: 'min(92dvh, 820px)' }); + expect(within(editor).getByDisplayValue('测试超速规则')).toBeInTheDocument(); + expect(within(editor).getByRole('button', { name: '保存规则' })).toBeInTheDocument(); + expect(ruleItem).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(within(editor).getByRole('button', { name: '关闭告警规则编辑' })); + await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor')).not.toBeInTheDocument()); + expect(document.querySelector('.v2-alert-rule-editor-sidesheet .semi-sidesheet-inner')).toHaveClass('semi-sidesheet-animation-content_hide_bottom'); + expect(ruleItem).toHaveAttribute('aria-expanded', 'false'); + + const newRule = screen.getByRole('button', { name: /新建规则/ }); + expect(newRule).toHaveAttribute('aria-haspopup', 'dialog'); + fireEvent.click(newRule); + await waitFor(() => expect(document.querySelector('.v2-alert-rule-editor')).toBeInTheDocument()); + const newEditor = screen.getByRole('dialog', { name: '告警规则编辑' }); + expect(within(newEditor).getByText('新建规则')).toBeInTheDocument(); + expect(newRule).toHaveAttribute('aria-expanded', 'true'); +}); + test('creates auditable drafts from simple offline and hydrogen rule templates', async () => { mocks.alertRulesV2.mockResolvedValue([alertRule()]); mocks.metricCatalog.mockResolvedValue({ diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx index eea84c32..6522a486 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AlertsPage.tsx @@ -309,37 +309,115 @@ function ruleDraft(rule: AlertRule): AlertRuleInput { function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: MetricDefinition[] }) { const queryClient = useQueryClient(); + const mobileLayout = useMobileLayout(); const [selectedID, setSelectedID] = useState(''); const [draft, setDraft] = useState(emptyRule()); + const [editorOpen, setEditorOpen] = useState(false); 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); const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label])); const setList = (key: 'scopeProtocols' | 'scopeVins' | 'scopeOems' | 'scopeModels' | 'scopeCompanies', value: string) => setDraft({ ...draft, [key]: value.split(',').map((item) => item.trim()).filter(Boolean) }); + const openNewRule = () => { + setSelectedID('__new__'); + setDraft(emptyRule()); + if (mobileLayout) setEditorOpen(true); + }; + const openRule = (rule: AlertRule) => { + setSelectedID(rule.id); + setDraft(ruleDraft(rule)); + if (mobileLayout) setEditorOpen(true); + }; const applyTemplate = (template: RuleTemplate) => { setSelectedID('__new__'); setDraft({ ...template.draft, scopeProtocols: [...template.draft.scopeProtocols], notificationChannels: [...template.draft.notificationChannels] }); }; const severityColor = (severity: AlertRule['severity']) => severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'amber'; - return + const toggleButton = draft.version ? { + const current = rules.find((item) => item.id === draft.id); + if (current) toggle.mutate(current); + }} + >{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'} : null; + const editor = + { event.preventDefault(); save.mutate(draft); }}> + {!mobileLayout ? : + + {draft.version ? draft.enabled ? '已启用' : '已停用' : '新规则'} + {draft.version ? `v${draft.version} · 配置变更写入版本审计` : '保存后生成首个可审计版本'} + + {toggleButton} + } + + 快速创建从常用业务场景开始,再按车辆、车型或企业收窄范围。 + {RULE_TEMPLATES.map((template) => { + const available = metrics.some((metric) => metric.key === template.draft.metric && metric.alertable); + return applyTemplate(template)} + > + + {template.title}{available ? template.summary : '当前字段目录尚未启用此指标'} + {available ? (template.draft.scopeProtocols.join(' / ') || '全部协议') : '不可用'} + ; + })} + + + 规则名称 setDraft({ ...draft, name: value })} /> + 严重程度 setDraft({ ...draft, severity: String(value) as AlertRuleInput['severity'] })} optionList={[{ value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /> + 值类型 { 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: '布尔' }]} /> + 指标 setDraft({ ...draft, metric: String(value) })} optionList={availableMetrics.map((metric) => ({ value: metric.key, label: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}` }))} /> + 触发比较符 { const operator = String(value); setDraft({ ...draft, operator, durationSec: operator === 'changed' ? 0 : draft.durationSec }); }} optionList={operators.map((value) => ({ value, label: operatorLabels[value] }))} /> + {draft.operator === 'changed' ? 变化语义 : draft.valueType === 'boolean' ? 目标值 setDraft({ ...draft, booleanThreshold: value === 'true' })} optionList={[{ value: 'true', label: '是' }, { value: 'false', label: '否' }]} /> : {draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'} setDraft({ ...draft, threshold: value === '' ? Number.NaN : Number(value) })} />} + {draft.operator === 'between' || draft.operator === 'outside' ? 区间上限 setDraft({ ...draft, thresholdHigh: Number(value) })} /> : null} + 持续时间(秒) setDraft({ ...draft, durationSec: Number(value) })} /> + 恢复比较符 setDraft({ ...draft, recoveryOperator: String(value) })} optionList={[{ value: '', label: '未配置' }, ...['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => ({ value, label: operatorLabels[value] }))]} /> + 恢复阈值 setDraft({ ...draft, recoveryThreshold: Number(value) })} /> + 重复间隔(秒) setDraft({ ...draft, repeatIntervalSec: Number(value) })} /> + 协议范围(逗号分隔;空为全部) setList('scopeProtocols', value)} /> + 车辆 VIN 范围(逗号分隔;空为全部) setList('scopeVins', value)} /> + 厂家范围(逗号分隔;空为全部) setList('scopeOems', value)} /> + 车型范围(来自车辆主档;空为全部) setList('scopeModels', value)} /> + 企业范围(来自车辆主档;空为全部) setList('scopeCompanies', value)} /> + 说明 setDraft({ ...draft, description: value })} /> + + + + ; + return } onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}>新建规则} + actions={} aria-haspopup={mobileLayout ? 'dialog' : undefined} aria-expanded={mobileLayout ? editorOpen && selectedID === '__new__' : undefined} onClick={openNewRule}>新建规则} /> - + {rules.map((rule) => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }} + aria-haspopup={mobileLayout ? 'dialog' : undefined} + aria-expanded={mobileLayout ? editorOpen && selectedID === rule.id : undefined} + onClick={() => openRule(rule)} > @@ -351,54 +429,18 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri {!rules.length ? : null} - - { event.preventDefault(); save.mutate(draft); }}> - { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'} : null} - /> - - 快速创建从常用业务场景开始,再按车辆、车型或企业收窄范围。 - {RULE_TEMPLATES.map((template) => { - const available = metrics.some((metric) => metric.key === template.draft.metric && metric.alertable); - return applyTemplate(template)} - > - - {template.title}{available ? template.summary : '当前字段目录尚未启用此指标'} - {available ? (template.draft.scopeProtocols.join(' / ') || '全部协议') : '不可用'} - ; - })} - - - 规则名称 setDraft({ ...draft, name: value })} /> - 严重程度 setDraft({ ...draft, severity: String(value) as AlertRuleInput['severity'] })} optionList={[{ value: 'critical', label: '紧急' }, { value: 'major', label: '重要' }, { value: 'minor', label: '一般' }]} /> - 值类型 { 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: '布尔' }]} /> - 指标 setDraft({ ...draft, metric: String(value) })} optionList={availableMetrics.map((metric) => ({ value: metric.key, label: `${metric.label}${metric.unit ? ` (${metric.unit})` : ''}` }))} /> - 触发比较符 { const operator = String(value); setDraft({ ...draft, operator, durationSec: operator === 'changed' ? 0 : draft.durationSec }); }} optionList={operators.map((value) => ({ value, label: operatorLabels[value] }))} /> - {draft.operator === 'changed' ? 变化语义 : draft.valueType === 'boolean' ? 目标值 setDraft({ ...draft, booleanThreshold: value === 'true' })} optionList={[{ value: 'true', label: '是' }, { value: 'false', label: '否' }]} /> : {draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'} setDraft({ ...draft, threshold: value === '' ? Number.NaN : Number(value) })} />} - {draft.operator === 'between' || draft.operator === 'outside' ? 区间上限 setDraft({ ...draft, thresholdHigh: Number(value) })} /> : null} - 持续时间(秒) setDraft({ ...draft, durationSec: Number(value) })} /> - 恢复比较符 setDraft({ ...draft, recoveryOperator: String(value) })} optionList={[{ value: '', label: '未配置' }, ...['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => ({ value, label: operatorLabels[value] }))]} /> - 恢复阈值 setDraft({ ...draft, recoveryThreshold: Number(value) })} /> - 重复间隔(秒) setDraft({ ...draft, repeatIntervalSec: Number(value) })} /> - 协议范围(逗号分隔;空为全部) setList('scopeProtocols', value)} /> - 车辆 VIN 范围(逗号分隔;空为全部) setList('scopeVins', value)} /> - 厂家范围(逗号分隔;空为全部) setList('scopeOems', value)} /> - 车型范围(来自车辆主档;空为全部) setList('scopeModels', value)} /> - 企业范围(来自车辆主档;空为全部) setList('scopeCompanies', value)} /> - 说明 setDraft({ ...draft, description: value })} /> - - - - + {!mobileLayout ? editor : null} + {mobileLayout ? {draft.version ? '编辑规则' : '新建规则'}{draft.name || '配置触发条件、范围与通知策略'}} + onCancel={() => setEditorOpen(false)} + > + {editorOpen ? editor : null} + : null} ; } diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index 36e4e97c..e2730e92 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -169,6 +169,7 @@ describe('V2 production entry', () => { expect(corePageSources.AlertsPage).toContain('className="v2-alert-navigation v2-alert-command-bar"'); expect(corePageSources.AlertsPage).toContain(''); 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 592f751d..58e721f2 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -1592,6 +1592,7 @@ .v2-alert-filter-sidesheet .semi-sidesheet-body, .v2-alert-detail-sidesheet .semi-sidesheet-body, +.v2-alert-rule-editor-sidesheet .semi-sidesheet-body, .v2-access-detail-sidesheet .semi-sidesheet-body, .v2-access-governance-sidesheet .semi-sidesheet-body { background: #f6f8fb; @@ -7562,6 +7563,21 @@ overflow: hidden; } +.v2-alert-rule-editor-sidesheet .semi-sidesheet-body { + display: flex; + min-height: 0; + overflow: hidden; + padding: 0; +} + +.v2-alert-rule-editor-sidesheet .v2-alert-rule-editor.semi-card { + width: 100%; + height: 100%; + border: 0; + border-radius: 0; + box-shadow: none; +} + .v2-alert-rule-list > .semi-card-body, .v2-alert-rule-editor > .semi-card-body, .v2-alert-rule-editor-form { @@ -7587,6 +7603,7 @@ .v2-alert-rule-editor-form { overflow: auto; + overscroll-behavior: contain; } .v2-alert-rule-list > .semi-card-body > .v2-alert-rule-list-heading, @@ -7604,6 +7621,38 @@ margin-top: 6px; } +.v2-alert-rule-sheet-status { + display: flex; + min-height: 56px; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid #e3e9f1; + background: #fff; + padding: 9px 14px; +} + +.v2-alert-rule-sheet-status > span { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.v2-alert-rule-sheet-status small { + overflow: hidden; + color: #7c899b; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-alert-rule-sheet-status .semi-button { + min-width: 82px; + flex: 0 0 auto; +} + .v2-alert-rule-templates { grid-template-columns: minmax(0, 1fr); align-items: stretch; @@ -7668,6 +7717,10 @@ backdrop-filter: blur(10px); } +.v2-alert-rule-editor-form > footer .v2-primary-button .semi-button-content { + color: #fff; +} + .v2-alert-notifications.semi-card { min-height: 0; flex: 1 1 0; @@ -8093,46 +8146,146 @@ height: 32px; } + .v2-alert-page.is-rules { + height: 100%; + min-height: 0; + overflow: hidden; + padding-bottom: 8px; + } + .v2-alert-rules { display: flex; - flex: none; flex-direction: column; - overflow: visible; } - .v2-alert-rule-list { - max-height: 270px; + .v2-alert-rules.is-mobile { + min-height: 0; + flex: 1 1 0; + overflow: hidden; } - .v2-alert-rule-items { + .v2-alert-rules.is-mobile .v2-alert-rule-list { + min-height: 0; + max-height: none; + flex: 1 1 0; + } + + .v2-alert-rules.is-mobile .v2-alert-rule-items { + display: flex; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + flex-direction: column; + gap: 8px; + padding: 8px; scrollbar-color: #9dacc0 transparent; scrollbar-width: thin; } - .v2-alert-rule-items:focus-visible { + .v2-alert-rules.is-mobile .v2-alert-rule-items:focus-visible { outline: 2px solid rgba(18, 104, 243, .34); outline-offset: -2px; } - .v2-alert-rule-items::-webkit-scrollbar { + .v2-alert-rules.is-mobile .v2-alert-rule-items::-webkit-scrollbar { display: block; - height: 8px; + width: 8px; } - .v2-alert-rule-items::-webkit-scrollbar-thumb { + .v2-alert-rules.is-mobile .v2-alert-rule-items::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: #9dacc0; background-clip: padding-box; } - .v2-alert-rule-editor.semi-card, - .v2-alert-rule-editor-form { - overflow: visible; + .v2-alert-rules.is-mobile .v2-alert-rule-item.semi-button { + width: 100%; + min-width: 0; + min-height: 76px; + flex: 0 0 auto; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + box-shadow: 0 2px 8px rgba(36, 53, 76, .04); } - .v2-alert-rule-editor-form > footer { - position: static; + .v2-alert-rules.is-mobile .v2-alert-rule-item.semi-button.is-selected { + border-color: rgba(18, 104, 243, .38); + background: linear-gradient(135deg, rgba(18, 104, 243, .075), rgba(255, 255, 255, .98)); + box-shadow: 0 5px 16px rgba(18, 104, 243, .1); + } + + .v2-alert-rule-editor-sidesheet.semi-sidesheet-bottom .semi-sidesheet-inner { + width: 100% !important; + max-width: 100%; + overflow: hidden; + border-radius: 18px 18px 0 0; + box-shadow: 0 -18px 54px rgba(22, 34, 50, .22); + } + + .v2-alert-rule-editor-sidesheet.semi-sidesheet-bottom .semi-sidesheet-header { + min-height: 68px; + border-bottom: 1px solid #dfe6ef; + padding: 13px 15px 11px; + } + + .v2-alert-rule-editor-sidesheet.semi-sidesheet-bottom .semi-sidesheet-body { + min-height: 0; + overflow: hidden; + } + + .v2-alert-rule-editor-sidesheet .v2-alert-rule-editor-form { + overflow-y: auto; + } + + .v2-alert-rule-editor-sidesheet .v2-alert-rule-templates { + padding: 12px; + } + + .v2-alert-rule-editor-sidesheet .v2-alert-rule-templates > nav { + display: flex; + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; + } + + .v2-alert-rule-editor-sidesheet .v2-alert-template-card.semi-button { + min-width: min(294px, calc(100vw - 40px)); + scroll-snap-align: start; + } + + .v2-alert-rule-editor-sidesheet .v2-rule-form-grid { + padding: 14px 14px 18px; + } + + .v2-alert-rule-editor-sidesheet .v2-alert-rule-editor-form > footer { + display: grid; + position: sticky; + min-height: 68px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + flex-direction: row; + gap: 10px; + padding: 9px 14px calc(9px + env(safe-area-inset-bottom)); + } + + .v2-alert-rule-editor-sidesheet .v2-alert-rule-editor-form > footer > div { + overflow: hidden; + } + + .v2-alert-rule-editor-sidesheet .v2-alert-rule-editor-form > footer span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .v2-alert-rule-editor-sidesheet .v2-alert-rule-editor-form > footer > .semi-button { + width: auto; + min-width: 104px; + height: 42px; + justify-content: center; } .v2-alert-notifications.semi-card {