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 /** 极简模式: 个股场景, 隐藏 type/scope/阈值等, 只显示信号点选 */ simple?: boolean onClose: () => void onSaved?: () => void } const TYPE_DEFAULT_NAME: Record = { signal: '个股信号监控', price: '价格监控', market: '市场异动监控', strategy: '策略监控', } const emptyRule = (preset?: Partial): 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 [editing] = useState(!!rule) // 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。 // 编辑规则: 完全沿用规则自身配置, 不受默认值影响。 const [draft, setDraft] = useState( rule ? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) } : { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) }, ) 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) => 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 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 (

{editing ? '编辑监控' : '加入监控'}

{draft.symbols.length > 0 && (
{draft.symbols.map(s => ( {s} ))}
)}
选择触发信号 (任一命中即报警)
{/* 价位条件 (阈值) — 与信号共存, 可选添加 */}
价位条件 (可选)
{thresholdConds.length > 0 && (
{thresholdConds.map((c, i) => { const realIdx = draft.conditions.indexOf(c) return (
{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'} 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" />
) })}
)}
{error &&
{error}
}
) } // ── 完整模式: 监控页新建/编辑 ── return (

{editing ? '编辑监控规则' : '新建监控规则'}

规则标识自动生成,描述为可选。

{/* 资产类型: 股票 / ETF (个股极简模式不显示) */} {!simple && (
资产类型
{(['stock', 'etf'] as const).map(t => ( ))}
)} {/* 描述 (可选) + 类型 */}
{/* 作用范围 */}
作用范围
{draft.scope === 'symbols' && (
{draft.symbols.map(sym => ( {sym} ))}
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" /> {symbolSearch.data && symbolSearch.data.results.length > 0 && (
{symbolSearch.data.results.map(r => ( ))}
)}
)} {draft.scope === 'all' && 对全市场所有股票生效} {draft.scope === 'sector' && 板块精确过滤(开发中,当前等同全市场)}
{/* 触发条件 (非 strategy) */} {draft.type !== 'strategy' && (
触发条件
{selectedSignals.length > 0 || (options.data?.builtin_signals ?? []).length > 0 ? (
信号条件 (点选)
) : null} {thresholdConds.length > 0 && (
{thresholdConds.map((c, i) => { const realIdx = draft.conditions.indexOf(c) return (
{i === 0 && selectedSignals.length === 0 ? '当' : draft.logic === 'and' ? '且' : '或'} 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" />
) })}
)} {draft.conditions.length === 0 && (
点击上方「信号条件」或「阈值条件」添加触发规则
)}
)} {/* strategy 类型: 选策略 + 方向 */} {draft.type === 'strategy' && (
策略与方向

策略监控自动评估策略的买卖信号。entry=买入信号,exit=卖出信号,both=两者都报。作用范围建议用「全市场」。

)} {/* 通知设置 */}
{/* Webhook 推送 — 飞书可用, QMT/ptrade 待定 */}
Webhook 推送 触发时推送告警到外部
{/* 渠道列表 */}
{/* 飞书 (可用) */} {/* QMT (待定) */} {/* ptrade (待定) */}
{/* 飞书勾选但全局未配置 → 提示前往设置 */} {draft.webhook_enabled && !feishuConfigured && (

飞书 Webhook 地址尚未配置, 前往设置页配置 →

)} {draft.webhook_enabled && feishuConfigured && (

命中本规则时,告警将推送到设置页配置的飞书群。

)}
{error &&
{error}
}
) }