Files
tick-stock-panel/frontend/src/components/monitor/RuleEditor.tsx
T
wshy 8e56f3efb8 feat(monitor): 推送渠道拆分为飞书/企业微信独立勾选 (#74)
把监控推送从单一总开关 webhook_enabled(bool)升级为渠道数组
webhook_channels(list),飞书与企业微信各自独立勾选,做到「勾哪个
推哪个」。沿用项目已有的 review_push_channels 渠道数组模式。

后端:
- RuleModel 新增 webhook_channels 字段;normalize() 兼容老规则,
  webhook_enabled=True 自动迁移为 ['feishu','wecom']
- _maybe_send_webhook 按 channels 判断:'feishu' in channels 才推
  飞书,'wecom' in channels 才推企业微信
- 新增偏好 webhook_default_channels + PUT 接口;老 webhook_enabled_default
  保留兼容

前端:
- RuleEditor 飞书/企业微信勾选各自绑数组 includes/toggle
- 设置页推送通知卡片飞书/企业微信默认勾选独立
- 连板梯队封单弹窗「飞书」单胶囊改为飞书+企业微信双胶囊
- api.ts 类型 + updateWebhookDefaultChannels
2026-07-08 13:48:22 +08:00

515 lines
27 KiB
TypeScript

import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Save, X, Plus, Search } from 'lucide-react'
import { api, genRuleId, type MonitorRule, type MonitorCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { SignalPicker } from '@/components/screener/SignalPicker'
import { usePreferences } from '@/lib/useSharedQueries'
interface Props {
/** 编辑现有规则;null=新建 */
rule: MonitorRule | null
/** 新建时的预填值 (如个股弹窗传入 symbol/scope) */
preset?: Partial<MonitorRule>
/** 极简模式: 个股场景, 隐藏 type/scope/阈值等, 只显示信号点选 */
simple?: boolean
onClose: () => void
onSaved?: () => void
}
const TYPE_DEFAULT_NAME: Record<string, string> = {
signal: '个股信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控',
}
const emptyRule = (preset?: Partial<MonitorRule>): MonitorRule => ({
id: genRuleId(),
name: '',
enabled: true,
type: 'signal',
asset_type: 'stock',
scope: 'symbols',
symbols: [],
sector: null,
strategy_id: null,
direction: 'entry',
conditions: [],
logic: 'or',
cooldown_seconds: 3600,
severity: 'info',
message: '',
...preset,
})
export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const qc = useQueryClient()
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
const { data: prefs } = usePreferences()
const feishuConfigured = !!(prefs?.feishu_webhook_url)
const wecomConfigured = !!(prefs?.wecom_webhook_url)
const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(多选数组), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
const [draft, setDraft] = useState<MonitorRule>(
rule
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
: {
...emptyRule(preset),
webhook_channels: preset?.webhook_channels ?? (prefs?.webhook_default_channels ?? []),
},
)
const assetType = draft.asset_type ?? 'stock'
// 策略列表跟随资产类型: ETF 只列技术类策略。
const strategies = useQuery({
queryKey: QK.screenerStrategies(assetType),
queryFn: () => api.screenerStrategies(assetType),
})
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
// ETF 规则时标的搜索一并搜出 ETF。
const symbolAssetTypes = assetType === 'etf' ? 'stock,etf' : 'stock'
const symbolSearch = useQuery({
queryKey: QK.instrumentSearch(symbolQuery, symbolAssetTypes),
queryFn: () => api.instrumentSearch(symbolQuery, 20, symbolAssetTypes),
enabled: symbolQuery.length > 0,
})
const save = useMutation({
mutationFn: () => {
const d = { ...draft }
// name 为空时用默认名
if (!d.name.trim()) {
const base = TYPE_DEFAULT_NAME[d.type] ?? '监控规则'
d.name = d.scope === 'symbols' && d.symbols.length > 0
? `${base} · ${d.symbols[0]}${d.symbols.length > 1 ? ` 等${d.symbols.length}只` : ''}`
: base
}
if (d.type === 'strategy') {
if (!d.strategy_id) throw new Error('策略监控必须选择一个策略')
} else {
if (d.conditions.length === 0) throw new Error('至少选择一个触发条件')
for (const c of d.conditions) {
if (!c.field || !c.op) throw new Error('条件填写不完整')
if (c.op !== 'truth' && (c.value === null || c.value === undefined)) throw new Error('阈值条件需要数值')
}
}
if (d.scope === 'symbols' && d.symbols.length === 0) throw new Error('请选择至少一只股票')
return api.monitorRuleSave(d)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.monitorRules })
onSaved?.()
onClose()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
// 条件编辑
const updateCond = (idx: number, patch: Partial<MonitorCondition>) =>
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
const addCond = (op: 'truth' | 'threshold') =>
setDraft(d => ({
...d,
conditions: [...d.conditions, op === 'truth'
? { field: 'signal_volume_surge', op: 'truth' }
// simple 模式(个股弹窗)默认现价; 完整模式默认 RSI 超卖
: { field: simple ? 'close' : 'rsi_14', op: '<', value: simple ? 0 : 30 }],
}))
const removeCond = (idx: number) =>
setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
const addSymbol = (sym: string) => {
if (!draft.symbols.includes(sym)) {
setDraft(d => ({ ...d, symbols: [...d.symbols, sym] }))
}
setSymbolQuery('')
}
// 勾选/取消勾选某个推送渠道 (飞书 / 企业微信 各自独立)
const toggleChannel = (ch: string) =>
setDraft(d => {
const cur = d.webhook_channels ?? []
return { ...d, webhook_channels: cur.includes(ch) ? cur.filter(c => c !== ch) : [...cur, ch] }
})
const thresholdFields = options.data?.threshold_fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const selectedSignals = draft.conditions.filter(c => c.op === 'truth').map(c => c.field)
const thresholdConds = draft.conditions.filter(c => c.op !== 'truth')
const onSignalPickerChange = (next: string[]) => {
const nonTruthConds = draft.conditions.filter(c => c.op !== 'truth')
const truthConds: MonitorCondition[] = next.map(field => ({ field, op: 'truth' }))
setDraft(d => ({ ...d, conditions: [...nonTruthConds, ...truthConds] }))
}
// ── 极简模式: 只显示信号点选 + 可选描述 ──
if (simple) {
return (
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium text-foreground">{editing ? '编辑监控' : '加入监控'}</h3>
<button onClick={onClose} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground cursor-pointer">
<X className="h-4 w-4" />
</button>
</div>
{draft.symbols.length > 0 && (
<div className="flex flex-wrap gap-1">
{draft.symbols.map(s => (
<span key={s} className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-secondary font-mono">{s}</span>
))}
</div>
)}
<div>
<div className="mb-1.5 text-[11px] text-muted">选择触发信号 (任一命中即报警)</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</div>
{/* 价位条件 (阈值) — 与信号共存, 可选添加 */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted">价位条件 (可选)</span>
<button onClick={() => addCond('threshold')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />添加价位
</button>
</div>
{thresholdConds.length > 0 && (
<div className="space-y-1.5">
{thresholdConds.map((c, i) => {
const realIdx = draft.conditions.indexOf(c)
return (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'}</span>
<select value={c.field} onChange={e => updateCond(realIdx, { field: e.target.value })} className="flex-1 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{thresholdFields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(realIdx, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
{operators.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<input type="number" value={c.value ?? 0} onChange={e => updateCond(realIdx, { value: parseFloat(e.target.value) })} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<button onClick={() => removeCond(realIdx)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
)}
</div>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">备注 (可选)</span>
<input value={draft.message} onChange={e => setDraft(d => ({ ...d, message: e.target.value }))} placeholder="给这条监控加个备注" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer">取消</button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer">
<Save className="h-3.5 w-3.5" />加入监控
</button>
</div>
</div>
)
}
// ── 完整模式: 监控页新建/编辑 ──
return (
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-medium text-foreground">{editing ? '编辑监控规则' : '新建监控规则'}</h3>
<p className="mt-1 text-[11px] text-muted">规则标识自动生成,描述为可选。</p>
</div>
<button onClick={onClose} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground cursor-pointer">
<X className="h-4 w-4" />
</button>
</div>
{/* 资产类型: 股票 / ETF (个股极简模式不显示) */}
{!simple && (
<div className="space-y-1.5">
<span className="text-[11px] text-muted">资产类型</span>
<div className="inline-flex h-9 rounded-btn border border-border overflow-hidden">
{(['stock', 'etf'] as const).map(t => (
<button
key={t}
type="button"
onClick={() => setDraft(d => ({ ...d, asset_type: t, strategy_id: null, symbols: [] }))}
className={`h-full px-4 text-xs font-medium transition-colors cursor-pointer
${assetType === t ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'}`}
>
{t === 'stock' ? '股票' : 'ETF'}
</button>
))}
</div>
</div>
)}
{/* 描述 (可选) + 类型 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[11px] text-muted">描述 (可选)</span>
<input value={draft.name} onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} placeholder="留空用默认名称" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">监控类型</span>
<select value={draft.type} onChange={e => setDraft(d => ({ ...d, type: e.target.value as MonitorRule['type'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.types ?? []).map(t => <option key={t.key} value={t.key}>{t.label}</option>)}
</select>
</label>
</div>
{/* 作用范围 */}
<div className="space-y-2">
<span className="text-[11px] text-muted">作用范围</span>
<div className="flex items-center gap-2">
<select value={draft.scope} onChange={e => setDraft(d => ({ ...d, scope: e.target.value as MonitorRule['scope'] }))} className="h-9 w-32 rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.scopes ?? []).map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
{draft.scope === 'symbols' && (
<div className="flex-1 flex flex-wrap items-center gap-1.5">
{draft.symbols.map(sym => (
<span key={sym} className="inline-flex items-center gap-1 rounded bg-elevated px-1.5 py-0.5 text-[10px] text-secondary">
{sym}
<button onClick={() => setDraft(d => ({ ...d, symbols: d.symbols.filter(s => s !== sym) }))} className="text-muted hover:text-danger cursor-pointer">
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<div className="relative">
<input
value={symbolQuery}
onChange={e => setSymbolQuery(e.target.value)}
placeholder="搜索股票..."
className="h-7 w-32 rounded border border-border bg-base pl-6 pr-2 text-[11px] text-foreground focus:outline-none focus:border-accent/50"
/>
<Search className="absolute left-1.5 top-1.5 h-3.5 w-3.5 text-muted" />
{symbolSearch.data && symbolSearch.data.results.length > 0 && (
<div className="absolute z-10 mt-1 max-h-48 w-48 overflow-auto rounded border border-border bg-surface shadow-lg">
{symbolSearch.data.results.map(r => (
<button key={r.symbol} onClick={() => addSymbol(r.symbol)} className="block w-full px-2 py-1 text-left text-[11px] hover:bg-elevated cursor-pointer">
<span className="font-mono text-foreground/80">{r.symbol}</span>
<span className="ml-1 text-muted">{r.name}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
{draft.scope === 'all' && <span className="text-[11px] text-muted">对全市场所有股票生效</span>}
{draft.scope === 'sector' && <span className="text-[11px] text-muted/60">板块精确过滤(开发中,当前等同全市场)</span>}
</div>
</div>
{/* 触发条件 (非 strategy) */}
{draft.type !== 'strategy' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted">触发条件</span>
<div className="flex items-center gap-2">
<select value={draft.logic} onChange={e => setDraft(d => ({ ...d, logic: e.target.value as MonitorRule['logic'] }))} className="h-7 rounded border border-border bg-base px-1.5 text-[11px] text-foreground">
{(options.data?.logics ?? []).map(l => <option key={l.key} value={l.key}>{l.label}</option>)}
</select>
<button onClick={() => addCond('truth')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />信号条件
</button>
<button onClick={() => addCond('threshold')} className="inline-flex items-center gap-1 text-[11px] text-accent hover:text-accent/80 cursor-pointer">
<Plus className="h-3 w-3" />阈值条件
</button>
</div>
</div>
{selectedSignals.length > 0 || (options.data?.builtin_signals ?? []).length > 0 ? (
<div>
<div className="mb-1.5 text-[10px] text-muted/70">信号条件 (点选)</div>
<SignalPicker signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" />
</div>
) : null}
{thresholdConds.length > 0 && (
<div className="space-y-1.5">
{thresholdConds.map((c, i) => {
const realIdx = draft.conditions.indexOf(c)
return (
<div key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-muted/60 w-6 text-right shrink-0">{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'}</span>
<select value={c.field} onChange={e => updateCond(realIdx, { field: e.target.value })} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
{thresholdFields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(realIdx, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
{operators.map(op => <option key={op} value={op}>{op}</option>)}
</select>
<input type="number" value={c.value ?? 0} onChange={e => updateCond(realIdx, { value: parseFloat(e.target.value) })} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
<button onClick={() => removeCond(realIdx)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3 w-3" />
</button>
</div>
)
})}
</div>
)}
{draft.conditions.length === 0 && (
<div className="rounded border border-dashed border-border px-3 py-4 text-center text-[11px] text-muted">
点击上方「信号条件」或「阈值条件」添加触发规则
</div>
)}
</div>
)}
{/* strategy 类型: 选策略 + 方向 */}
{draft.type === 'strategy' && (
<div className="space-y-2">
<span className="text-[11px] text-muted">策略与方向</span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<label className="md:col-span-2 space-y-1.5">
<span className="text-[10px] text-muted/70">选择策略</span>
<select
value={draft.strategy_id ?? ''}
onChange={e => setDraft(d => ({ ...d, strategy_id: e.target.value || null }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
<option value=""> 请选择 </option>
{(strategies.data?.presets ?? []).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</label>
<label className="space-y-1.5">
<span className="text-[10px] text-muted/70">触发方向</span>
<select
value={draft.direction}
onChange={e => setDraft(d => ({ ...d, direction: e.target.value as MonitorRule['direction'] }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{(options.data?.directions ?? []).map(d => <option key={d.key} value={d.key}>{d.label}</option>)}
</select>
</label>
</div>
<p className="text-[10px] leading-4 text-muted/70">
策略监控自动评估策略的买卖信号。entry=买入信号,exit=卖出信号,both=两者都报。作用范围建议用「全市场」。
</p>
</div>
)}
{/* 通知设置 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">冷却期()</span>
<input type="number" value={draft.cooldown_seconds} onChange={e => setDraft(d => ({ ...d, cooldown_seconds: parseInt(e.target.value) || 0 }))} min={0} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">严重级别</span>
<select value={draft.severity} onChange={e => setDraft(d => ({ ...d, severity: e.target.value as MonitorRule['severity'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.severities ?? []).map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
</label>
<label className="space-y-1.5 md:col-span-1">
<span className="text-[11px] text-muted">自定义提示(可选)</span>
<input value={draft.message} onChange={e => setDraft(d => ({ ...d, message: e.target.value }))} placeholder="留空用默认文案" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
</div>
{/* Webhook 推送 — 飞书 / 企业微信 可用, QMT/ptrade 待定 */}
<div className="rounded-btn border border-border/40 bg-base/40 p-3 space-y-2">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-foreground">Webhook 推送</span>
<span className="text-[9px] text-muted">触发时推送告警到外部</span>
</div>
{/* 渠道列表 */}
<div className="space-y-1.5">
{/* 飞书 (可用) */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={(draft.webhook_channels ?? []).includes('feishu')}
onChange={() => toggleChannel('feishu')}
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-foreground">飞书</span>
<span className="text-[9px] text-muted">群机器人</span>
{(draft.webhook_channels ?? []).includes('feishu') && (
<span className={`ml-auto text-[9px] ${feishuConfigured ? 'text-emerald-500' : 'text-warning'}`}>
{feishuConfigured ? '已配置' : '未配置'}
</span>
)}
</label>
{/* 企业微信 (可用) */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={(draft.webhook_channels ?? []).includes('wecom')}
onChange={() => toggleChannel('wecom')}
className="h-3 w-3 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-foreground">企业微信</span>
<span className="text-[9px] text-muted">群机器人</span>
{(draft.webhook_channels ?? []).includes('wecom') && (
<span className={`ml-auto text-[9px] ${wecomConfigured ? 'text-emerald-500' : 'text-warning'}`}>
{wecomConfigured ? '已配置' : '未配置'}
</span>
)}
</label>
{/* QMT (待定) */}
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">QMT</span>
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted">待定</span>
</label>
{/* ptrade (待定) */}
<label className="flex items-center gap-2 cursor-not-allowed opacity-50">
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">ptrade</span>
<span className="rounded bg-muted/10 px-1 py-px text-[9px] text-muted">待定</span>
</label>
</div>
{/* 勾选了某渠道但该渠道地址未配置 → 提示前往设置 */}
{(draft.webhook_channels ?? []).length > 0 && (() => {
const selected = draft.webhook_channels ?? []
const unconfigured: string[] = []
if (selected.includes('feishu') && !feishuConfigured) unconfigured.push('飞书')
if (selected.includes('wecom') && !wecomConfigured) unconfigured.push('企业微信')
if (unconfigured.length === 0) return null
return (
<p className="text-[10px] leading-relaxed text-warning/80">
{unconfigured.join('、')}尚未配置,
<Link to="/settings?tab=monitoring" className="text-accent hover:text-accent/80">前往设置页配置 </Link>
</p>
)
})()}
{(draft.webhook_channels ?? []).length > 0 && (() => {
const selected = draft.webhook_channels ?? []
const ready: string[] = []
if (selected.includes('feishu') && feishuConfigured) ready.push('飞书')
if (selected.includes('wecom') && wecomConfigured) ready.push('企业微信')
if (ready.length === 0) return null
return (
<p className="text-[10px] leading-relaxed text-muted">
命中本规则时,告警将推送到已配置的{ready.join(' + ')}
</p>
)
})()}
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
<div className="flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs cursor-pointer">取消</button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer">
<Save className="h-3.5 w-3.5" />保存
</button>
</div>
</div>
)
}