refine Semi UI mobile alert rule editing
This commit is contained in:
@@ -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)' });
|
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(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?tab=rules']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||||
|
|
||||||
|
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 () => {
|
test('creates auditable drafts from simple offline and hydrogen rule templates', async () => {
|
||||||
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
|
mocks.alertRulesV2.mockResolvedValue([alertRule()]);
|
||||||
mocks.metricCatalog.mockResolvedValue({
|
mocks.metricCatalog.mockResolvedValue({
|
||||||
|
|||||||
@@ -309,37 +309,115 @@ function ruleDraft(rule: AlertRule): AlertRuleInput {
|
|||||||
|
|
||||||
function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: MetricDefinition[] }) {
|
function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: MetricDefinition[] }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const mobileLayout = useMobileLayout();
|
||||||
const [selectedID, setSelectedID] = useState('');
|
const [selectedID, setSelectedID] = useState('');
|
||||||
const [draft, setDraft] = useState<AlertRuleInput>(emptyRule());
|
const [draft, setDraft] = useState<AlertRuleInput>(emptyRule());
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
useEffect(() => { if (!selectedID && rules[0]) { setSelectedID(rules[0].id); setDraft(ruleDraft(rules[0])); } }, [rules, selectedID]);
|
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 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'] }); } });
|
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 mutationError = save.error ?? toggle.error;
|
||||||
const operators = draft.valueType === 'boolean' ? BOOLEAN_OPERATORS : NUMERIC_OPERATORS;
|
const operators = draft.valueType === 'boolean' ? BOOLEAN_OPERATORS : NUMERIC_OPERATORS;
|
||||||
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
|
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
|
||||||
const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label]));
|
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 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) => {
|
const applyTemplate = (template: RuleTemplate) => {
|
||||||
setSelectedID('__new__');
|
setSelectedID('__new__');
|
||||||
setDraft({ ...template.draft, scopeProtocols: [...template.draft.scopeProtocols], notificationChannels: [...template.draft.notificationChannels] });
|
setDraft({ ...template.draft, scopeProtocols: [...template.draft.scopeProtocols], notificationChannels: [...template.draft.notificationChannels] });
|
||||||
};
|
};
|
||||||
const severityColor = (severity: AlertRule['severity']) => severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'amber';
|
const severityColor = (severity: AlertRule['severity']) => severity === 'critical' ? 'red' : severity === 'major' ? 'orange' : 'amber';
|
||||||
return <div className="v2-alert-rules">
|
const toggleButton = draft.version ? <Button
|
||||||
|
htmlType="button"
|
||||||
|
theme="light"
|
||||||
|
disabled={toggle.isPending || save.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const current = rules.find((item) => item.id === draft.id);
|
||||||
|
if (current) toggle.mutate(current);
|
||||||
|
}}
|
||||||
|
>{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'}</Button> : null;
|
||||||
|
const editor = <Card className="v2-alert-rule-editor" bodyStyle={{ padding: 0 }}>
|
||||||
|
<form className="v2-alert-rule-editor-form" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
|
||||||
|
{!mobileLayout ? <WorkspacePanelHeader
|
||||||
|
className="v2-alert-rule-editor-heading"
|
||||||
|
title={draft.version ? '编辑规则' : '新建规则'}
|
||||||
|
description="数值、状态与主数据范围均纳入版本审计"
|
||||||
|
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}
|
||||||
|
</div>}
|
||||||
|
<section className="v2-alert-rule-templates" aria-label="告警规则模板">
|
||||||
|
<div><strong>快速创建</strong><span>从常用业务场景开始,再按车辆、车型或企业收窄范围。</span></div>
|
||||||
|
<nav>{RULE_TEMPLATES.map((template) => {
|
||||||
|
const available = metrics.some((metric) => metric.key === template.draft.metric && metric.alertable);
|
||||||
|
return <Button
|
||||||
|
className="v2-alert-template-card"
|
||||||
|
key={template.id}
|
||||||
|
theme="borderless"
|
||||||
|
type="tertiary"
|
||||||
|
disabled={!available}
|
||||||
|
onClick={() => applyTemplate(template)}
|
||||||
|
>
|
||||||
|
<span className="v2-alert-template-icon"><IconAlarm /></span>
|
||||||
|
<span className="v2-alert-template-copy"><b>{template.title}</b><small>{available ? template.summary : '当前字段目录尚未启用此指标'}</small></span>
|
||||||
|
<Tag size="small" color={available ? 'blue' : 'grey'} type="light">{available ? (template.draft.scopeProtocols.join(' / ') || '全部协议') : '不可用'}</Tag>
|
||||||
|
</Button>;
|
||||||
|
})}</nav>
|
||||||
|
</section>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</Card>;
|
||||||
|
return <div className={`v2-alert-rules${mobileLayout ? ' is-mobile' : ''}`}>
|
||||||
<Card className="v2-alert-rule-list" bodyStyle={{ padding: 0 }}>
|
<Card className="v2-alert-rule-list" bodyStyle={{ padding: 0 }}>
|
||||||
<WorkspacePanelHeader
|
<WorkspacePanelHeader
|
||||||
className="v2-alert-rule-list-heading"
|
className="v2-alert-rule-list-heading"
|
||||||
title="规则配置"
|
title="规则配置"
|
||||||
description={rules.length ? `${rules.length} 条可审计规则` : '尚未创建规则'}
|
description={rules.length ? `${rules.length} 条可审计规则` : '尚未创建规则'}
|
||||||
actions={<Button theme="light" type="primary" icon={<IconPlus />} onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}>新建规则</Button>}
|
actions={<Button theme="light" type="primary" icon={<IconPlus />} aria-haspopup={mobileLayout ? 'dialog' : undefined} aria-expanded={mobileLayout ? editorOpen && selectedID === '__new__' : undefined} onClick={openNewRule}>新建规则</Button>}
|
||||||
/>
|
/>
|
||||||
<div className="v2-alert-rule-items" role="region" tabIndex={0} aria-label="告警规则列表,可左右滚动选择">
|
<div className="v2-alert-rule-items" role="region" tabIndex={0} aria-label={mobileLayout ? '告警规则列表' : '告警规则列表,可左右滚动选择'}>
|
||||||
{rules.map((rule) => <Button
|
{rules.map((rule) => <Button
|
||||||
className={`v2-alert-rule-item${selectedID === rule.id ? ' is-selected' : ''}`}
|
className={`v2-alert-rule-item${selectedID === rule.id ? ' is-selected' : ''}`}
|
||||||
key={rule.id}
|
key={rule.id}
|
||||||
theme="borderless"
|
theme="borderless"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
aria-pressed={selectedID === rule.id}
|
aria-pressed={selectedID === rule.id}
|
||||||
onClick={() => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }}
|
aria-haspopup={mobileLayout ? 'dialog' : undefined}
|
||||||
|
aria-expanded={mobileLayout ? editorOpen && selectedID === rule.id : undefined}
|
||||||
|
onClick={() => openRule(rule)}
|
||||||
>
|
>
|
||||||
<span className={`v2-alert-rule-dot is-${rule.severity}`} aria-hidden="true" />
|
<span className={`v2-alert-rule-dot is-${rule.severity}`} aria-hidden="true" />
|
||||||
<span className="v2-alert-rule-copy">
|
<span className="v2-alert-rule-copy">
|
||||||
@@ -351,54 +429,18 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
|
|||||||
{!rules.length ? <Empty className="v2-alert-rule-empty" title="暂无告警规则" description="从右侧模板快速创建,或新建一条自定义规则。" /> : null}
|
{!rules.length ? <Empty className="v2-alert-rule-empty" title="暂无告警规则" description="从右侧模板快速创建,或新建一条自定义规则。" /> : null}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="v2-alert-rule-editor" bodyStyle={{ padding: 0 }}>
|
{!mobileLayout ? editor : null}
|
||||||
<form className="v2-alert-rule-editor-form" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
|
{mobileLayout ? <SideSheet
|
||||||
<WorkspacePanelHeader
|
className="v2-alert-rule-editor-sidesheet"
|
||||||
className="v2-alert-rule-editor-heading"
|
visible={editorOpen}
|
||||||
title={draft.version ? '编辑规则' : '新建规则'}
|
aria-label="告警规则编辑"
|
||||||
description="数值、状态与主数据范围均纳入版本审计"
|
placement="bottom"
|
||||||
actions={draft.version ? <Button htmlType="button" theme="light" disabled={toggle.isPending || save.isPending} onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{toggle.isPending ? '更新中' : draft.enabled ? '停用规则' : '启用规则'}</Button> : null}
|
height="min(92dvh, 820px)"
|
||||||
/>
|
title={<div className="v2-alert-sheet-title"><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span>{draft.name || '配置触发条件、范围与通知策略'}</span></div>}
|
||||||
<section className="v2-alert-rule-templates" aria-label="告警规则模板">
|
onCancel={() => setEditorOpen(false)}
|
||||||
<div><strong>快速创建</strong><span>从常用业务场景开始,再按车辆、车型或企业收窄范围。</span></div>
|
>
|
||||||
<nav>{RULE_TEMPLATES.map((template) => {
|
{editorOpen ? editor : null}
|
||||||
const available = metrics.some((metric) => metric.key === template.draft.metric && metric.alertable);
|
</SideSheet> : null}
|
||||||
return <Button
|
|
||||||
className="v2-alert-template-card"
|
|
||||||
key={template.id}
|
|
||||||
theme="borderless"
|
|
||||||
type="tertiary"
|
|
||||||
disabled={!available}
|
|
||||||
onClick={() => applyTemplate(template)}
|
|
||||||
>
|
|
||||||
<span className="v2-alert-template-icon"><IconAlarm /></span>
|
|
||||||
<span className="v2-alert-template-copy"><b>{template.title}</b><small>{available ? template.summary : '当前字段目录尚未启用此指标'}</small></span>
|
|
||||||
<Tag size="small" color={available ? 'blue' : 'grey'} type="light">{available ? (template.draft.scopeProtocols.join(' / ') || '全部协议') : '不可用'}</Tag>
|
|
||||||
</Button>;
|
|
||||||
})}</nav>
|
|
||||||
</section>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</Card>
|
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ describe('V2 production entry', () => {
|
|||||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-navigation v2-alert-command-bar"');
|
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('<SideSheet\n className="v2-alert-filter-sidesheet"');
|
||||||
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-detail-sidesheet"');
|
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-detail-sidesheet"');
|
||||||
|
expect(corePageSources.AlertsPage).toContain('<SideSheet\n className="v2-alert-rule-editor-sidesheet"');
|
||||||
expect(corePageSources.AlertsPage).toContain('className={`v2-alert-event-table');
|
expect(corePageSources.AlertsPage).toContain('className={`v2-alert-event-table');
|
||||||
expect(corePageSources.AlertsPage).toContain('detailTriggerRow({');
|
expect(corePageSources.AlertsPage).toContain('detailTriggerRow({');
|
||||||
expect(corePageSources.AlertsPage).not.toContain('<table><thead><tr><th />');
|
expect(corePageSources.AlertsPage).not.toContain('<table><thead><tr><th />');
|
||||||
|
|||||||
@@ -1592,6 +1592,7 @@
|
|||||||
|
|
||||||
.v2-alert-filter-sidesheet .semi-sidesheet-body,
|
.v2-alert-filter-sidesheet .semi-sidesheet-body,
|
||||||
.v2-alert-detail-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-detail-sidesheet .semi-sidesheet-body,
|
||||||
.v2-access-governance-sidesheet .semi-sidesheet-body {
|
.v2-access-governance-sidesheet .semi-sidesheet-body {
|
||||||
background: #f6f8fb;
|
background: #f6f8fb;
|
||||||
@@ -7562,6 +7563,21 @@
|
|||||||
overflow: hidden;
|
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-list > .semi-card-body,
|
||||||
.v2-alert-rule-editor > .semi-card-body,
|
.v2-alert-rule-editor > .semi-card-body,
|
||||||
.v2-alert-rule-editor-form {
|
.v2-alert-rule-editor-form {
|
||||||
@@ -7587,6 +7603,7 @@
|
|||||||
|
|
||||||
.v2-alert-rule-editor-form {
|
.v2-alert-rule-editor-form {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-alert-rule-list > .semi-card-body > .v2-alert-rule-list-heading,
|
.v2-alert-rule-list > .semi-card-body > .v2-alert-rule-list-heading,
|
||||||
@@ -7604,6 +7621,38 @@
|
|||||||
margin-top: 6px;
|
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 {
|
.v2-alert-rule-templates {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -7668,6 +7717,10 @@
|
|||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v2-alert-rule-editor-form > footer .v2-primary-button .semi-button-content {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.v2-alert-notifications.semi-card {
|
.v2-alert-notifications.semi-card {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
@@ -8093,46 +8146,146 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v2-alert-page.is-rules {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.v2-alert-rules {
|
.v2-alert-rules {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: none;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-alert-rule-list {
|
.v2-alert-rules.is-mobile {
|
||||||
max-height: 270px;
|
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-color: #9dacc0 transparent;
|
||||||
scrollbar-width: thin;
|
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: 2px solid rgba(18, 104, 243, .34);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-alert-rule-items::-webkit-scrollbar {
|
.v2-alert-rules.is-mobile .v2-alert-rule-items::-webkit-scrollbar {
|
||||||
display: block;
|
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: 2px solid transparent;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #9dacc0;
|
background: #9dacc0;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-alert-rule-editor.semi-card,
|
.v2-alert-rules.is-mobile .v2-alert-rule-item.semi-button {
|
||||||
.v2-alert-rule-editor-form {
|
width: 100%;
|
||||||
overflow: visible;
|
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 {
|
.v2-alert-rules.is-mobile .v2-alert-rule-item.semi-button.is-selected {
|
||||||
position: static;
|
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 {
|
.v2-alert-notifications.semi-card {
|
||||||
|
|||||||
Reference in New Issue
Block a user