@@ -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 < AlertRuleInput > ( 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 < 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 } } >
< WorkspacePanelHeader
className = "v2-alert-rule-list-heading"
title = "规则配置"
description = { rules . length ? ` ${ rules . length } 条可审计规则 ` : '尚未创建规则' }
actions = { < Button theme = "light" type = "primary" icon = { < IconPlus / > } onClick = { ( ) = > { setSe lectedID ( '__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
className = { ` v2-alert-rule-item ${ selectedID === rule . id ? ' is-selected' : '' } ` }
key = { rule . id }
theme = "borderless"
type = "tertiary"
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-copy" >
@@ -351,54 +429,18 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
{ ! rules . length ? < Empty className = "v2-alert-rule-empty" title = "暂无告警规则" description = "从右侧模板快速创建,或新建一条自定义规则。" / > : null }
< / div >
< / Card >
< Card className = "v2-alert-rule-editor" bodyStyle = { { padding : 0 } } >
< form className = "v2-alert-rule-editor-form" onSubmit = { ( event ) = > { event . preventDefault ( ) ; save . mutate ( draft ) ; } } >
< WorkspacePanelHeader
className = "v2-alert-rule-editor-heading"
title = { draft . version ? '编辑规则' : '新建规则' }
description = "数值、状态与主数据范围均纳入版本审计 "
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 }
/ >
< 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 >
{ ! mobileLayout ? editor : null }
{ mobileLayout ? < SideSheet
className = "v2-alert-rule-editor-sidesheet"
visible = { editorOpen }
aria-label = "告警规则编辑"
placement = "bottom "
height = "min(92dvh, 820px)"
title = { < div className = "v2-alert-sheet-title" > < strong > { draft . version ? '编辑规则' : '新建规则' } < / strong > < span > { draft . name || '配置触发条件、范围与通知策略' } < / span > < / div > }
onCancel = { ( ) = > setEditorOpen ( false ) }
>
{ editorOpen ? editor : null }
< / SideSheet > : null }
< / div > ;
}