import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Activity, BarChart3, Building2, ChartNoAxesCombined, Check, ChevronDown, ChevronUp, Eraser, Layers3, ListPlus, Plus, RadioTower, Save, Search, Siren, Tags, TrendingUp, Waypoints, X } from 'lucide-react' import { api, genRuleId, type MonitorRule, type MonitorCondition, type SectorKind, type SectorMonitorTarget, type StrategyNotifyEvent } from '@/lib/api' import { DEFAULT_STRATEGY_NOTIFY_EVENTS, LEGACY_STRATEGY_NOTIFY_EVENTS, STRATEGY_NOTIFY_EVENT_OPTIONS } from '@/lib/strategyMonitorEvents' import { QK } from '@/lib/queryKeys' import { boardTag } from '@/components/stock-table/primitives' import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' import { SignalPicker } from '@/components/screener/SignalPicker' import { MONITOR_INTRADAY_SIGNAL_OPTIONS, SIGNAL_OPTIONS, cnSignal } from '@/lib/signals' import { usePreferences, useQuoteStatus } 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: '策略监控', sector: '板块监控', abnormal: '异动监控', volume_delta: '轮询放量监控', } const TYPE_ICONS = { signal: Activity, price: TrendingUp, market: RadioTower, strategy: Waypoints, sector: Layers3, abnormal: Siren, volume_delta: BarChart3, } const SECTOR_KIND_OPTIONS: Array<{ key: SectorKind; label: string; icon: typeof ChartNoAxesCombined }> = [ { key: 'index', label: '大盘指数', icon: ChartNoAxesCombined }, { key: 'concept', label: '概念题材', icon: Tags }, { key: 'industry', label: '行业板块', icon: Building2 }, ] const STRATEGY_SOURCE_META = { builtin: { label: '内置', className: 'border-accent/25 bg-accent/10 text-accent' }, custom: { label: '自定义', className: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-400' }, ai: { label: 'AI', className: 'border-amber-400/25 bg-amber-400/10 text-amber-400' }, composite: { label: '叠加', className: 'border-teal-500/25 bg-teal-500/10 text-teal-400' }, } as const const emptyRule = (preset?: Partial): MonitorRule => ({ id: genRuleId(), name: '', enabled: true, type: 'signal', asset_type: 'stock', scope: 'symbols', symbols: [], group_id: null, sector: null, sector_kind: 'index', sector_targets: [], sector_trigger: 'change_pct', threshold_pct: 1, window_minutes: 5, abnormal_window: 'any', strategy_id: null, score_min: null, score_max: null, direction: 'entry', conditions: [], logic: 'or', cooldown_seconds: 3600, severity: 'info', message: '', threshold_volume: 9000, ...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 { data: quoteStatus } = useQuoteStatus() const quoteInterval = quoteStatus?.interval_s const feishuConfigured = !!(prefs?.feishu_webhook_url) const wecomConfigured = !!(prefs?.wecom_webhook_url) const [editing] = useState(!!rule) // 新建规则: 预填全局「默认推送渠道」(多选数组), preset 显式指定时以 preset 为准。 // 编辑规则: 完全沿用规则自身配置, 不受默认值影响。 const [draft, setDraft] = useState(() => { if (rule) { return { ...rule, notify_events: rule.type === 'strategy' ? [...(rule.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS)] : undefined, conditions: rule.conditions.map(c => ({ ...c })), sector_targets: rule.sector_targets?.map(target => ({ ...target })) ?? [], } } const initial = { ...emptyRule(preset), webhook_channels: preset?.webhook_channels ?? (prefs?.webhook_default_channels ?? []), } if (initial.type === 'strategy' && !initial.notify_events) { initial.notify_events = [...DEFAULT_STRATEGY_NOTIFY_EVENTS] } return initial }) 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('') const isGroupScope = draft.scope === 'watchlist_group' // 「自选导入」下拉: 从自选/自选分组批量并入标的 (与自选页共用查询缓存)。 // 分组作用域模式同样需要分组/成员数据 (选择分组 + 成员预览)。 const [watchMenuOpen, setWatchMenuOpen] = useState(false) const watchMenuRef = useRef(null) const watchlistQ = useQuery({ queryKey: QK.watchlist, queryFn: api.watchlistList, enabled: watchMenuOpen || isGroupScope, }) const watchGroupsQ = useQuery({ queryKey: QK.watchlistGroups, queryFn: api.watchlistGroups, enabled: watchMenuOpen || isGroupScope, }) // 分组选择下拉 (scope=watchlist_group) const [groupMenuOpen, setGroupMenuOpen] = useState(false) const groupMenuRef = useRef(null) useEffect(() => { if (!groupMenuOpen) return const handleClick = (e: MouseEvent) => { if (groupMenuRef.current && !groupMenuRef.current.contains(e.target as Node)) { setGroupMenuOpen(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [groupMenuOpen]) useEffect(() => { if (!watchMenuOpen) return const handleClick = (e: MouseEvent) => { if (watchMenuRef.current && !watchMenuRef.current.contains(e.target as Node)) { setWatchMenuOpen(false) } } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [watchMenuOpen]) const [sectorQuery, setSectorQuery] = useState('') const [industryLevel, setIndustryLevel] = useState<1 | 2 | 3>(() => { const level = rule?.sector_targets?.[0]?.level return level === 1 || level === 3 ? level : 2 }) const [strategyQuery, setStrategyQuery] = useState('') const [strategyCategory, setStrategyCategory] = useState<'all' | 'builtin' | 'custom' | 'ai' | 'composite'>('all') // 标的搜索资产类型: ETF 一并搜股票; 指数只搜指数; 否则只搜股票。 const symbolAssetTypes = assetType === 'etf' ? 'stock,etf' : assetType === 'index' ? 'index' : '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 } delete d.runtime_warning // name 为空时用默认名 if (!d.name.trim()) { const base = TYPE_DEFAULT_NAME[d.type] ?? '监控规则' d.name = d.type === 'sector' && d.sector_targets?.length ? `${base} · ${d.sector_targets[0].name}${d.sector_targets.length > 1 ? ` 等${d.sector_targets.length}个` : ''}` : d.type === 'abnormal' ? `${base} · 接近度≥${d.threshold_pct ?? 70}%${d.abnormal_window && d.abnormal_window !== 'any' ? ` (${d.abnormal_window.toUpperCase()})` : ''}` : d.scope === 'watchlist_group' && selectedGroup ? `${base} · 分组「${selectedGroup.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('策略监控必须选择一个策略') if (!d.notify_events?.length) throw new Error('至少选择一个通知事件') for (const [label, value] of [['最低分', d.score_min], ['最高分', d.score_max]] as const) { if (value != null && (!Number.isFinite(value) || value < 0 || value > 100)) { throw new Error(`${label}必须在 0 到 100 之间`) } } if (d.score_min != null && d.score_max != null && d.score_min > d.score_max) { throw new Error('最低分不能高于最高分') } } else if (d.type === 'sector') { delete d.score_min delete d.score_max d.scope = 'all' d.symbols = [] d.conditions = [] delete d.notify_events if (!d.sector_targets?.length) throw new Error('请选择至少一个监控对象') if ((d.threshold_pct ?? 0) <= 0 || (d.threshold_pct ?? 0) > 20) throw new Error('阈值必须大于 0 且不超过 20%') } else if (d.type === 'abnormal') { delete d.score_min delete d.score_max d.conditions = [] delete d.notify_events if ((d.threshold_pct ?? 0) < 1 || (d.threshold_pct ?? 0) > 150) { throw new Error('接近度阈值必须在 1 到 150 之间 (70=边缘, 100=已触发)') } } else if (d.type === 'volume_delta') { delete d.score_min delete d.score_max d.conditions = [] delete d.notify_events if (d.metric === 'amount') { if (!Number.isFinite(d.threshold_amount) || (d.threshold_amount ?? 0) < 1) { throw new Error('金额阈值必须是 ≥1 的数字 (万元)') } } else if (!Number.isFinite(d.threshold_volume) || (d.threshold_volume ?? 0) < 1) { throw new Error('单轮放量阈值必须是 ≥1 的手数') } } else { delete d.score_min delete d.score_max delete d.notify_events 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.type !== 'sector' && d.scope === 'symbols' && d.symbols.length === 0) throw new Error('请选择至少一只标的') if (d.type !== 'sector' && d.scope === 'watchlist_group' && !d.group_id) 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 importSymbols = (syms: string[]) => { setDraft(d => { const merged = [...d.symbols] for (const s of syms) { if (!merged.includes(s)) merged.push(s) } return { ...d, symbols: merged } }) setWatchMenuOpen(false) } // ── 标的标签: 名称 + 板标(创/科/北) + 代码, 可逐个删除 ── const [symbolsExpanded, setSymbolsExpanded] = useState(false) const symbolsKey = draft.symbols.join(',') // 名称映射: 本地即时缓存(搜索/自选数据) 优先, 缺失的由批量名称接口补齐 // (覆盖编辑旧规则等本地无名称的场景)。key 随标的集变化, staleTime 长防抖。 const localNamesRef = useRef>({}) const recordLocalNames = (pairs: { symbol: string; name?: string | null }[]) => { for (const p of pairs) { if (p.name) localNamesRef.current[p.symbol] = p.name } } if (watchlistQ.data?.symbols) recordLocalNames(watchlistQ.data.symbols) if (symbolSearch.data?.results) recordLocalNames(symbolSearch.data.results) const namesQ = useQuery({ queryKey: ['instrument-names', symbolsKey], queryFn: () => api.instrumentNames(draft.symbols), enabled: draft.symbols.length > 0, staleTime: 5 * 60_000, }) const nameBySymbol = useMemo( () => ({ ...localNamesRef.current, ...(namesQ.data?.names ?? {}) }), [symbolsKey, namesQ.data], ) // 自选导入选项: 全部自选 + 各分组 (空分组隐藏) + 未分组 const watchImportOptions = (() => { const entries = watchlistQ.data?.symbols ?? [] if (entries.length === 0) return [] const options = [{ key: 'all', name: '全部自选', dot: 'bg-muted/60', symbols: entries.map(e => e.symbol), }] for (const group of watchGroupsQ.data?.groups ?? []) { const syms = entries.filter(e => e.group_ids?.includes(group.id)).map(e => e.symbol) if (syms.length === 0) continue options.push({ key: group.id, name: group.name, dot: resolveWatchlistGroupColor(group.color).dot, symbols: syms }) } const ungrouped = entries.filter(e => !(e.group_ids?.length)).map(e => e.symbol) if (ungrouped.length > 0) { options.push({ key: 'ungrouped', name: '未分组', dot: 'bg-muted/60', symbols: ungrouped }) } return options })() // ── 自选分组作用域 (scope=watchlist_group): 分组选择 + 只读成员预览 ── const groupList = watchGroupsQ.data?.groups ?? [] const watchEntries = watchlistQ.data?.symbols ?? [] const groupCounts = useMemo(() => { const counts: Record = {} for (const entry of watchEntries) { for (const gid of entry.group_ids ?? []) counts[gid] = (counts[gid] ?? 0) + 1 } return counts }, [watchEntries]) const selectedGroup = groupList.find(g => g.id === draft.group_id) const selectedGroupSymbols = useMemo( () => selectedGroup ? watchEntries.filter(e => e.group_ids?.includes(selectedGroup.id)).map(e => e.symbol) : [], [selectedGroup, watchEntries], ) // 预览区名称补齐 (分组成员通常不在 draft.symbols 里, 单独批量查询) const groupNamesQ = useQuery({ queryKey: ['instrument-names', selectedGroupSymbols.join(',')], queryFn: () => api.instrumentNames(selectedGroupSymbols), enabled: isGroupScope && selectedGroupSymbols.length > 0, staleTime: 5 * 60_000, }) const groupNameBySymbol = groupNamesQ.data?.names ?? {} const selectSectorKind = (kind: SectorKind) => { setDraft(d => ({ ...d, sector_kind: kind, sector_targets: [] })) setSectorQuery('') } const toggleSectorTarget = (target: SectorMonitorTarget) => { setDraft(d => { const current = d.sector_targets ?? [] if (current.some(item => item.key === target.key)) { return { ...d, sector_targets: current.filter(item => item.key !== target.key) } } if (current.length >= 20) return d return { ...d, sector_targets: [...current, target] } }) } // 勾选/取消勾选某个推送渠道 (飞书 / 企业微信 各自独立) 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 toggleStrategyEvent = (event: StrategyNotifyEvent) => setDraft(d => { const current = d.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS return { ...d, notify_events: current.includes(event) ? current.filter(item => item !== event) : [...current, event], } }) 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 hasIntradaySignal = selectedSignals.some(signal => MONITOR_INTRADAY_SIGNAL_OPTIONS.includes(signal)) const intradaySupport = options.data?.intraday_signal_support const monitorBuiltinSignals = [ ...SIGNAL_OPTIONS.map(key => ({ key, label: cnSignal(key) })), ...(options.data?.builtin_signals ?? []).filter(option => MONITOR_INTRADAY_SIGNAL_OPTIONS.includes(option.key)), ] // 指数: 隐藏涨跌停/连板类 (指数无这些列) 与分时信号 (无本地分钟K, 会静默不触发) const INDEX_HIDDEN_SIGNALS = (key: string) => key.includes('limit') || MONITOR_INTRADAY_SIGNAL_OPTIONS.includes(key) const pickerSignals = assetType === 'index' ? monitorBuiltinSignals.filter(o => !INDEX_HIDDEN_SIGNALS(o.key)) : monitorBuiltinSignals // 分时穿越信号: 数据按标的清单订阅且有上限, 自选分组是动态集合 (静默超限风险) → 禁用 const intradayDisabledSignals = intradaySupport?.available === false || isGroupScope ? MONITOR_INTRADAY_SIGNAL_OPTIONS : [] const intradayDisabledHint = isGroupScope ? '分时穿越信号需逐股订阅, 暂不支持自选分组作用域' : intradaySupport?.reason // 指数: 监控类型仅 signal/price (无涨跌停/策略/封单语义) const visibleTypes = (options.data?.types ?? []).filter( t => assetType !== 'index' || t.key === 'signal' || t.key === 'price', ) // 指数: 作用范围仅 symbols (无全市场/板块语义); ETF: 不支持自选分组 (分组为个股) const visibleScopes = (options.data?.scopes ?? []).filter( s => (assetType !== 'index' || s.key === 'symbols') && (assetType === 'stock' || s.key !== 'watchlist_group'), ) const sectorKind = draft.sector_kind ?? 'index' const sectorTargets = options.data?.sector_targets?.[sectorKind] ?? [] const visibleSectorTargets = sectorTargets.filter(target => { if (sectorKind === 'industry' && target.level !== industryLevel) return false const query = sectorQuery.trim().toLowerCase() if (!query) return true return `${target.name} ${target.symbol ?? ''} ${target.value ?? ''}`.toLowerCase().includes(query) }).slice(0, 100) const thresholdConds = draft.conditions.filter(c => c.op !== 'truth') const strategyPresets = strategies.data?.presets ?? [] const normalizedStrategyQuery = strategyQuery.trim().toLowerCase() const visibleStrategies = strategyPresets.filter(strategy => { if (strategyCategory !== 'all' && strategy.source !== strategyCategory) return false if (!normalizedStrategyQuery) return true return [strategy.name, strategy.id, strategy.description, ...(strategy.tags ?? [])] .some(value => String(value ?? '').toLowerCase().includes(normalizedStrategyQuery)) }) const strategyCategories = [ { key: 'all' as const, label: '全部', count: strategyPresets.length }, { key: 'builtin' as const, label: '内置', count: strategyPresets.filter(strategy => strategy.source === 'builtin').length }, { key: 'custom' as const, label: '自定义', count: strategyPresets.filter(strategy => strategy.source === 'custom').length }, { key: 'ai' as const, label: 'AI', count: strategyPresets.filter(strategy => strategy.source === 'ai').length }, { key: 'composite' as const, label: '叠加', count: strategyPresets.filter(strategy => strategy.source === 'composite').length }, ] const onSignalPickerChange = (next: string[]) => { setDraft(d => { const nonTruthConds = d.conditions.filter(c => c.op !== 'truth') const truthConds: MonitorCondition[] = next.map(field => ({ field, op: 'truth' })) return { ...d, scope: next.some(signal => MONITOR_INTRADAY_SIGNAL_OPTIONS.includes(signal)) ? 'symbols' : d.scope, conditions: [...nonTruthConds, ...truthConds], } }) } // ── 极简模式: 只显示信号点选 + 可选描述 ── if (simple) { return (

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

{draft.symbols.length > 0 && (
{draft.symbols.map(s => ( {s} ))}
)}
选择触发信号 (任一命中即报警)
{hasIntradaySignal && (
{intradaySupport?.available === false ? intradaySupport.reason : `按已完成的一分钟判断,当前最多监听 ${intradaySupport?.max_symbols ?? 0} 只标的。`}
)}
{/* 价位条件 (阈值) — 与信号共存, 可选添加 */}
价位条件 (可选)
{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 && draft.type !== 'sector' && draft.type !== 'abnormal' && (
资产类型
{(['stock', 'etf', 'index'] as const).map(t => ( ))}
)} {/* 监控类型 */}
监控类型
{visibleTypes.map(t => { const Icon = TYPE_ICONS[t.key as keyof typeof TYPE_ICONS] ?? Activity const active = draft.type === t.key return ( ) })}
{draft.type === 'sector' && (
板块分类
{SECTOR_KIND_OPTIONS.map(option => { const Icon = option.icon const active = sectorKind === option.key return ( ) })}
{sectorKind === 'industry' && (
行业层级
{([1, 2, 3] as const).map(level => ( ))}
)}
监控对象 {draft.sector_targets?.length ?? 0}/20
{(draft.sector_targets?.length ?? 0) > 0 && (
{draft.sector_targets?.map(target => ( {target.name} ))}
)}
{visibleSectorTargets.length === 0 ? (
{options.isLoading ? '正在加载...' : '没有可用的监控对象'}
) : visibleSectorTargets.map(target => { const selected = draft.sector_targets?.some(item => item.key === target.key) ?? false const unavailable = !target.available || (target.kind !== 'index' && target.member_count < 5) const targetLabel = target.kind === 'industry' ? (target.value ?? target.name).replaceAll('-', ' / ') : target.name return ( ) })}
触发方式
{([ ['change_pct', '涨跌幅到达'], ['momentum', '快速异动'], ] as const).map(([key, label]) => ( ))}
方向
{([ ['up', draft.sector_trigger === 'momentum' ? '快速上涨' : '上涨'], ['down', draft.sector_trigger === 'momentum' ? '快速下跌' : '下跌'], ] as const).map(([key, label]) => ( ))}
{draft.sector_trigger === 'momentum' && ( )}
{sectorKind !== 'index' && (
等权平均 行情覆盖 ≥ 80% 有效成分 ≥ 5
)}
)} {draft.type === 'abnormal' && (
方向
{([ ['both', '全部'], ['up', '涨势偏离'], ['down', '跌势偏离'], ] as const).map(([key, label]) => ( ))}
关注窗口
{([ ['any', '全部'], ['3d', '3日 (异常波动)'], ['10d', '10日 (严重)'], ['30d', '30日 (严重)'], ] as const).map(([key, label]) => ( ))}
按交易所异动规则口径 (3日±20%/30%… 10日+100%、30日+200% 等按板块) 计算 个股涨跌幅偏离值的接近度, 上穿阈值时告警; 冷却期内同一标的不重复提醒。
)} {draft.type === 'volume_delta' && (
阈值口径
{([['volume', '按手数'], ['amount', '按金额']] as const).map(([key, label]) => ( ))}
基础过滤 (与策略选股口径对齐, 留空不过滤)
捕捉单次轮询间隔内的突发放量 (大单连续扫货)。开盘首轮与暂停恢复后的第一轮不触发, 防止集合竞价撮合量误报; 冷却期内同一标的不重复提醒, 命中超过 5 只时合并为一条批量通知。
)} {/* 作用范围 */} {draft.type !== 'sector' &&
作用范围
{draft.scope === 'symbols' && (
{/* 导入与搜索: 与范围下拉同一行等高(h-7), 不换行, 搜索框占满剩余宽度 */}
{watchMenuOpen && (
{watchlistQ.isLoading ? (
正在加载自选...
) : watchImportOptions.length === 0 ? (
自选列表为空
) : watchImportOptions.map(option => ( ))}
)}
setSymbolQuery(e.target.value)} placeholder="搜索代码或名称添加标的..." className="h-7 w-full 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 => ( ))}
)}
{/* 「已加入 N 只」单独成行(收起态, 与控件列左对齐) / 标签管理区(展开态) */} {draft.symbols.length > 0 && !symbolsExpanded && ( )} {draft.symbols.length > 0 && symbolsExpanded && ( <>
已加入 {draft.symbols.length}
{draft.symbols.map(sym => { const b = boardTag(sym) const name = nameBySymbol[sym] return ( {name ?? sym} {b && {b.label}} {sym} ) })}
)}
)} {draft.scope === 'watchlist_group' && (
{groupMenuOpen && (
{watchGroupsQ.isLoading ? (
正在加载分组...
) : groupList.length === 0 ? (
还没有自选分组,去自选页创建 →
) : groupList.map(g => ( ))}
)}
{/* 成员预览 (只读): 让用户明确当前监控哪些标的; 与手动选标的的可编辑标签区分 */} {selectedGroup && (
{selectedGroupSymbols.length > 0 ? (
{selectedGroupSymbols.map(sym => { const b = boardTag(sym) return ( {groupNameBySymbol[sym] ?? sym} {b && {b.label}} {sym} ) })}
) : (
该分组当前没有标的, 后续在分组内添加自选会自动纳入监控
)}
动态绑定: 分组内增删标的自动同步监控范围, 无需修改本规则
)}
)} {draft.scope === 'all' && 对全市场所有标的生效} {draft.scope === 'sector' && 板块精确过滤(开发中,当前等同全市场)}
} {/* 触发条件 (非 strategy) */} {draft.type !== 'strategy' && draft.type !== 'sector' && draft.type !== 'abnormal' && draft.type !== 'volume_delta' && (
触发条件
{selectedSignals.length > 0 || (options.data?.builtin_signals ?? []).length > 0 ? (
信号条件 (点选)
{hasIntradaySignal && (
{intradaySupport?.available === false ? intradaySupport.reason : `分时穿越按已完成的一分钟判断,仅支持指定标的,当前最多监听 ${intradaySupport?.max_symbols ?? 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' && (
{strategyCategories.map(category => ( ))}
{strategies.isLoading ? (
正在加载策略...
) : visibleStrategies.length === 0 ? (
没有匹配的策略
) : visibleStrategies.map(strategy => { const active = draft.strategy_id === strategy.id const sourceMeta = STRATEGY_SOURCE_META[strategy.source] const summary = strategy.tags?.length ? strategy.tags.slice(0, 3).join(' · ') : (strategy.description || strategy.id) return ( ) })}
评分范围
通知事件 至少选择一项
{(['signal', 'pool'] as const).map(group => (
{group === 'signal' ? '交易信号' : '选股结果'}
{STRATEGY_NOTIFY_EVENT_OPTIONS.filter(option => option.group === group).map(option => ( ))}
))}
{(draft.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS).length === 0 && (
至少选择一个通知事件
)}
)} {/* 通知设置 */}
{/* Webhook 推送 — 飞书 / 企业微信 */}
Webhook 推送 触发时推送告警到外部
{/* 渠道列表 */}
{/* 飞书 (可用) */} {/* 企业微信 (可用) */}
{/* 勾选了某渠道但该渠道地址未配置 → 提示前往设置 */} {(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 (

{unconfigured.join('、')}尚未配置, 前往设置页配置 →

) })()} {(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 (

命中本规则时,告警将推送到已配置的{ready.join(' + ')}。

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