Files
tick-stock-panel/frontend/src/components/monitor/RuleEditor.tsx
T
shy3130 22add7179c merge: PR #167 监控自定义信号全显 + 信息条弹窗性能优化
冲突解法: SignalPicker 采用 options 形态 (PR 重构) + 保留 main 的
intradayDisabledSignals/intradayDisabledHint 变量 (含自选分组作用域
禁用分时穿越的增强语义, 自动合并会被 PR 旧内联表达式悄悄丢掉)
+ filterCustomByKind: false (监控场景显示全部自定义信号)。
验证: tsc 零错误, pnpm build 通过。
2026-09-02 10:56:49 +08:00

1581 lines
83 KiB
TypeScript

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<MonitorRule>
/** 极简模式: 个股场景, 隐藏 type/scope/阈值等, 只显示信号点选 */
simple?: boolean
onClose: () => void
onSaved?: () => void
}
const TYPE_DEFAULT_NAME: Record<string, string> = {
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>): 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<MonitorRule>(() => {
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<HTMLDivElement>(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<HTMLDivElement>(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<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 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<Record<string, string>>({})
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<string, number> = {}
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 (
<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"
options={{
builtinSignals: pickerSignals,
disabledSignals: intradayDisabledSignals,
disabledSignalHint: intradayDisabledHint,
filterCustomByKind: false,
}}
/>
{hasIntradaySignal && (
<div className={`mt-2 text-[10px] ${intradaySupport?.available === false ? 'text-danger' : 'text-muted'}`}>
{intradaySupport?.available === false
? intradaySupport.reason
: `按已完成的一分钟判断,当前最多监听 ${intradaySupport?.max_symbols ?? 0} 只标的。`}
</div>
)}
</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 && draft.type !== 'sector' && draft.type !== 'abnormal' && (
<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', 'index'] as const).map(t => (
<button
key={t}
type="button"
aria-pressed={assetType === t}
onClick={() => {
if (assetType === t) return
setDraft(d => ({
...d,
asset_type: t,
strategy_id: null,
symbols: [],
type: t === 'index' && d.type !== 'signal' && d.type !== 'price' ? 'signal' : d.type,
// 指数仅指定标的; ETF 不支持分组作用域 (自选分组为个股)
scope: t === 'index' || (t !== 'stock' && d.scope === 'watchlist_group')
? 'symbols'
: d.scope,
}))
setStrategyQuery('')
setStrategyCategory('all')
}}
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' ? '股票' : t === 'etf' ? 'ETF' : '指数'}
</button>
))}
</div>
</div>
)}
{/* 监控类型 */}
<div className="space-y-1.5">
<span className="text-[11px] text-muted">监控类型</span>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-6">
{visibleTypes.map(t => {
const Icon = TYPE_ICONS[t.key as keyof typeof TYPE_ICONS] ?? Activity
const active = draft.type === t.key
return (
<button
key={t.key}
type="button"
aria-pressed={active}
onClick={() => setDraft(d => {
const type = t.key as MonitorRule['type']
return {
...d,
type,
// 轮询放量依赖全市场股票快照, 仅支持个股
asset_type: type === 'volume_delta' ? 'stock' : d.asset_type,
notify_events: type === 'strategy'
? [...(d.notify_events ?? DEFAULT_STRATEGY_NOTIFY_EVENTS)]
: undefined,
scope: type === 'sector' || type === 'abnormal' || type === 'volume_delta'
? 'all'
: type === 'strategy' && d.scope === 'symbols' && d.symbols.length === 0 ? 'all' : d.scope,
// 轮询放量: 冷却期默认 300s (持续放量会连续多轮达标); 切走时还原 3600
cooldown_seconds: type === 'volume_delta' && d.type !== 'volume_delta' ? 300
: type !== 'volume_delta' && d.type === 'volume_delta' ? 3600
: d.cooldown_seconds,
// 轮询放量: metric / 金额阈值 / 基础过滤默认 (与策略 basic_filter 对齐)
metric: type === 'volume_delta' && d.type !== 'volume_delta' ? 'volume' : d.metric,
threshold_amount: type === 'volume_delta' && d.type !== 'volume_delta' ? 1e6 : d.threshold_amount,
basic_filter: type === 'volume_delta' && d.type !== 'volume_delta'
? { price_min: 3, price_max: 300, market_cap_min: 10e8, float_cap_min: null, float_cap_max: null, amount_min: 0.2e8, exclude_st: true }
: d.basic_filter,
direction: type === 'sector' ? 'up'
: type === 'abnormal' ? 'both'
: d.type === 'sector' || d.type === 'abnormal' ? 'entry' : d.direction,
// 异动规则复用 threshold_pct 存接近度阈值%, 其他类型为涨跌幅%
threshold_pct: type === 'abnormal' && d.type !== 'abnormal' ? 70
: type !== 'abnormal' && d.type === 'abnormal' ? 1
: d.threshold_pct,
}
})}
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-btn border px-2 text-xs font-medium transition-colors cursor-pointer ${
active
? 'border-accent/40 bg-accent/12 text-accent'
: 'border-border bg-base text-secondary hover:border-accent/25 hover:text-foreground'
}`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span>{t.label}</span>
</button>
)
})}
</div>
</div>
<label className="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>
{draft.type === 'sector' && (
<div className="space-y-4 border-t border-border/60 pt-4">
<div className="space-y-1.5">
<span className="text-[11px] text-muted">板块分类</span>
<div className="grid grid-cols-3 gap-1.5">
{SECTOR_KIND_OPTIONS.map(option => {
const Icon = option.icon
const active = sectorKind === option.key
return (
<button
key={option.key}
type="button"
aria-pressed={active}
onClick={() => selectSectorKind(option.key)}
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-btn border text-xs font-medium transition-colors cursor-pointer ${
active ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-base text-secondary hover:border-accent/25'
}`}
>
<Icon className="h-3.5 w-3.5" />
{option.label}
</button>
)
})}
</div>
</div>
{sectorKind === 'industry' && (
<div className="space-y-1.5">
<span className="text-[11px] text-muted">行业层级</span>
<div className="inline-flex h-8 overflow-hidden rounded-btn border border-border bg-base">
{([1, 2, 3] as const).map(level => (
<button
key={level}
type="button"
aria-pressed={industryLevel === level}
onClick={() => {
setIndustryLevel(level)
setDraft(d => ({ ...d, sector_targets: [] }))
}}
className={`px-3 text-[11px] transition-colors cursor-pointer ${
industryLevel === level ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
}`}
>
{level}
</button>
))}
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] text-muted">监控对象</span>
<span className="text-[10px] font-mono text-muted">{draft.sector_targets?.length ?? 0}/20</span>
</div>
{(draft.sector_targets?.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-1">
{draft.sector_targets?.map(target => (
<span key={target.key} className="inline-flex items-center gap-1 rounded bg-accent/8 px-1.5 py-1 text-[10px] text-accent">
{target.name}
<button type="button" onClick={() => toggleSectorTarget(target)} title="移除" className="text-accent/60 hover:text-danger cursor-pointer">
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
</div>
)}
<label className="relative block">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted" />
<input
value={sectorQuery}
onChange={event => setSectorQuery(event.target.value)}
placeholder={`搜索${SECTOR_KIND_OPTIONS.find(option => option.key === sectorKind)?.label ?? '板块'}`}
className="h-9 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:border-accent/50 focus:outline-none"
/>
</label>
<div className="grid max-h-48 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
{visibleSectorTargets.length === 0 ? (
<div className="col-span-full rounded-btn border border-dashed border-border py-6 text-center text-xs text-muted">
{options.isLoading ? '正在加载...' : '没有可用的监控对象'}
</div>
) : 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 (
<button
key={target.key}
type="button"
disabled={unavailable}
aria-pressed={selected}
onClick={() => toggleSectorTarget(target)}
title={!target.available ? '请先在实时监控设置中加入该指数' : target.member_count < 5 ? '有效成分少于 5 只' : targetLabel}
className={`flex h-9 min-w-0 items-center gap-2 rounded-btn border px-2.5 text-left transition-colors ${
unavailable
? 'cursor-not-allowed border-border/40 bg-base/40 text-muted/40'
: selected
? 'cursor-pointer border-accent/40 bg-accent/10 text-accent'
: 'cursor-pointer border-border bg-base text-secondary hover:border-accent/25 hover:text-foreground'
}`}
>
<span className="min-w-0 flex-1 truncate text-[11px]">{targetLabel}</span>
{target.symbol && <span className="shrink-0 font-mono text-[9px] opacity-60">{target.symbol}</span>}
{target.kind !== 'index' && <span className="shrink-0 font-mono text-[9px] opacity-60">{target.member_count}</span>}
<span className={`grid h-4 w-4 shrink-0 place-items-center rounded-full border ${selected ? 'border-accent bg-accent text-white' : 'border-border text-transparent'}`}>
<Check className="h-2.5 w-2.5" />
</span>
</button>
)
})}
</div>
</div>
<div className="grid gap-3 border-t border-border/60 pt-4 sm:grid-cols-2">
<div className="space-y-1.5">
<span className="text-[11px] text-muted">触发方式</span>
<div className="grid h-9 grid-cols-2 overflow-hidden rounded-btn border border-border bg-base">
{([
['change_pct', '涨跌幅到达'],
['momentum', '快速异动'],
] as const).map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={(draft.sector_trigger ?? 'change_pct') === key}
onClick={() => setDraft(d => ({ ...d, sector_trigger: key }))}
className={`text-[11px] font-medium transition-colors cursor-pointer ${
(draft.sector_trigger ?? 'change_pct') === key ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<span className="text-[11px] text-muted">方向</span>
<div className="grid h-9 grid-cols-2 overflow-hidden rounded-btn border border-border bg-base">
{([
['up', draft.sector_trigger === 'momentum' ? '快速上涨' : '上涨'],
['down', draft.sector_trigger === 'momentum' ? '快速下跌' : '下跌'],
] as const).map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={draft.direction === key}
onClick={() => setDraft(d => ({ ...d, direction: key }))}
className={`text-[11px] font-medium transition-colors cursor-pointer ${
draft.direction === key ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
</div>
{draft.sector_trigger === 'momentum' && (
<label className="space-y-1.5">
<span className="text-[11px] text-muted">统计窗口</span>
<select
value={draft.window_minutes ?? 5}
onChange={event => setDraft(d => ({ ...d, window_minutes: Number(event.target.value) as MonitorRule['window_minutes'] }))}
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
>
{[1, 3, 5, 10, 15].map(window => <option key={window} value={window}>{window} 分钟</option>)}
</select>
</label>
)}
<label className="space-y-1.5">
<span className="text-[11px] text-muted">{draft.sector_trigger === 'momentum' ? '窗口变化阈值' : '板块涨跌幅阈值'}</span>
<span className="relative block">
<input
type="number"
min="0.01"
max="20"
step="0.1"
value={draft.threshold_pct ?? 1}
onChange={event => setDraft(d => ({ ...d, threshold_pct: Number(event.target.value) }))}
className="h-9 w-full rounded-btn border border-border bg-base pl-3 pr-8 text-xs font-mono text-foreground"
/>
<span className="absolute right-3 top-2.5 text-xs text-muted">%</span>
</span>
</label>
</div>
{sectorKind !== 'index' && (
<div className="flex flex-wrap gap-1.5 text-[9px] text-muted">
<span className="rounded bg-elevated px-1.5 py-0.5">等权平均</span>
<span className="rounded bg-elevated px-1.5 py-0.5">行情覆盖 80%</span>
<span className="rounded bg-elevated px-1.5 py-0.5">有效成分 5</span>
</div>
)}
</div>
)}
{draft.type === 'abnormal' && (
<div className="space-y-4 border-t border-border/60 pt-4">
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">接近度阈值</span>
<span className="relative block">
<input
type="number"
min="1"
max="150"
step="5"
value={draft.threshold_pct ?? 70}
onChange={event => setDraft(d => ({ ...d, threshold_pct: Number(event.target.value) }))}
className="h-9 w-full rounded-btn border border-border bg-base pl-3 pr-8 text-xs font-mono text-foreground"
/>
<span className="absolute right-3 top-2.5 text-xs text-muted">%</span>
</span>
<span className="block text-[10px] text-muted/70">
接近度 = |偏离值| ÷ 交易所阈值。70=边缘预警, 100=已触发
</span>
</label>
<div className="space-y-1.5">
<span className="text-[11px] text-muted">方向</span>
<div className="grid h-9 grid-cols-3 overflow-hidden rounded-btn border border-border bg-base">
{([
['both', '全部'],
['up', '涨势偏离'],
['down', '跌势偏离'],
] as const).map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={(draft.direction ?? 'both') === key}
onClick={() => setDraft(d => ({ ...d, direction: key }))}
className={`text-[11px] font-medium transition-colors cursor-pointer ${
(draft.direction ?? 'both') === key ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
</div>
</div>
<div className="space-y-1.5">
<span className="text-[11px] text-muted">关注窗口</span>
<div className="grid h-9 grid-cols-4 overflow-hidden rounded-btn border border-border bg-base">
{([
['any', '全部'],
['3d', '3日 (异常波动)'],
['10d', '10日 (严重)'],
['30d', '30日 (严重)'],
] as const).map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={(draft.abnormal_window ?? 'any') === key}
onClick={() => setDraft(d => ({ ...d, abnormal_window: key }))}
className={`text-[11px] font-medium transition-colors cursor-pointer ${
(draft.abnormal_window ?? 'any') === key ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
</div>
<div className="rounded-btn bg-base px-3 py-2 text-[10px] leading-relaxed text-muted">
按交易所异动规则口径 (3日±20%/30%… 10日+100%、30日+200% 等按板块) 计算
个股涨跌幅偏离值的接近度, 上穿阈值时告警; 冷却期内同一标的不重复提醒。
</div>
</div>
)}
{draft.type === 'volume_delta' && (
<div className="space-y-4 border-t border-border/60 pt-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<span className="text-[11px] text-muted">阈值口径</span>
<div className="grid h-9 grid-cols-2 overflow-hidden rounded-btn border border-border bg-base">
{([['volume', '按手数'], ['amount', '按金额']] as const).map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={(draft.metric ?? 'volume') === key}
onClick={() => setDraft(d => ({ ...d, metric: key }))}
className={`text-[11px] font-medium transition-colors cursor-pointer ${
(draft.metric ?? 'volume') === key ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
</div>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">
单轮放量阈值{draft.metric === 'amount' ? ' (万元)' : ' (手)'}
</span>
<span className="relative block">
<input
type="number"
min="1"
step={draft.metric === 'amount' ? 10 : 1000}
value={draft.metric === 'amount'
? Math.round((draft.threshold_amount ?? 1e6) / 1e4)
: (draft.threshold_volume ?? 9000)}
onChange={event => setDraft(d => {
const v = Number(event.target.value)
return d.metric === 'amount'
? { ...d, threshold_amount: v * 1e4 }
: { ...d, threshold_volume: v }
})}
className="h-9 w-full rounded-btn border border-border bg-base pl-3 pr-12 text-xs font-mono text-foreground"
/>
<span className="absolute right-3 top-2.5 text-xs text-muted">
{draft.metric === 'amount' ? '万元' : '手'}
</span>
</span>
<span className="block text-[10px] text-muted/70">
相邻两次全市场轮询 (当前间隔 {Math.round(quoteInterval ?? 6)} ) 间的增量 此值即提醒;
金额口径对不同股价更公平
</span>
</label>
</div>
<div className="space-y-2">
<span className="text-[11px] text-muted">基础过滤 (与策略选股口径对齐, 留空不过滤)</span>
<div className="grid gap-2 sm:grid-cols-3">
<label className="space-y-1">
<span className="text-[10px] text-muted/70">股价区间 ()</span>
<div className="flex items-center gap-1">
<input type="number" min="0" step="0.5" placeholder="下限"
value={draft.basic_filter?.price_min ?? ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, price_min: e.target.value === '' ? null : Number(e.target.value) } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
<span className="text-[10px] text-muted"></span>
<input type="number" min="0" step="0.5" placeholder="上限"
value={draft.basic_filter?.price_max ?? ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, price_max: e.target.value === '' ? null : Number(e.target.value) } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</div>
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted/70">总市值下限 (亿元)</span>
<input type="number" min="0" step="1" placeholder="不限"
value={draft.basic_filter?.market_cap_min != null ? draft.basic_filter.market_cap_min / 1e8 : ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, market_cap_min: e.target.value === '' ? null : Number(e.target.value) * 1e8 } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted/70">当日成交额下限 (万元)</span>
<input type="number" min="0" step="100" placeholder="不限"
value={draft.basic_filter?.amount_min != null ? draft.basic_filter.amount_min / 1e4 : ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, amount_min: e.target.value === '' ? null : Number(e.target.value) * 1e4 } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted/70">流通市值下限 (亿元)</span>
<input type="number" min="0" step="1" placeholder="不限"
value={draft.basic_filter?.float_cap_min != null ? draft.basic_filter.float_cap_min / 1e8 : ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, float_cap_min: e.target.value === '' ? null : Number(e.target.value) * 1e8 } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</label>
<label className="space-y-1">
<span className="text-[10px] text-muted/70">流通市值上限 (亿元)</span>
<input type="number" min="0" step="1" placeholder="不限"
value={draft.basic_filter?.float_cap_max != null ? draft.basic_filter.float_cap_max / 1e8 : ''}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, float_cap_max: e.target.value === '' ? null : Number(e.target.value) * 1e8 } }))}
className="h-8 w-full rounded border border-border bg-base px-2 text-xs font-mono text-foreground" />
</label>
<label className="flex items-center gap-2 pt-4">
<input type="checkbox" checked={draft.basic_filter?.exclude_st ?? true}
onChange={e => setDraft(d => ({ ...d, basic_filter: { ...d.basic_filter, exclude_st: e.target.checked } }))}
className="h-3.5 w-3.5 accent-[hsl(var(--accent))]" />
<span className="text-[11px] text-secondary">剔除 ST / 风险警示</span>
</label>
</div>
</div>
<div className="rounded-btn bg-base px-3 py-2 text-[10px] leading-relaxed text-muted">
捕捉单次轮询间隔内的突发放量 (大单连续扫货)。开盘首轮与暂停恢复后的第一轮不触发,
防止集合竞价撮合量误报; 冷却期内同一标的不重复提醒, 命中超过 5 只时合并为一条批量通知。
</div>
</div>
)}
{/* 作用范围 */}
{draft.type !== 'sector' && <div className="space-y-2">
<span className="text-[11px] text-muted">作用范围</span>
<div className="flex items-start gap-1.5">
<select value={draft.scope} onChange={e => setDraft(d => ({ ...d, scope: e.target.value as MonitorRule['scope'] }))} className="h-7 w-32 shrink-0 rounded border border-border bg-base px-2 text-[11px] text-foreground">
{visibleScopes.map(s => <option key={s.key} value={s.key} disabled={hasIntradaySignal && s.key !== 'symbols'}>{s.label}</option>)}
</select>
{draft.scope === 'symbols' && (
<div className="min-w-0 flex-1 space-y-1.5">
{/* 导入与搜索: 与范围下拉同一行等高(h-7), 不换行, 搜索框占满剩余宽度 */}
<div className="flex items-center gap-1.5">
<div className="relative" ref={watchMenuRef}>
<button
type="button"
onClick={() => setWatchMenuOpen(v => !v)}
title="从自选 / 自选分组导入当前成员 (一次性拷贝, 后续增删自选不影响本规则); 需要动态跟随分组请把作用范围切到「自选分组」"
className={`inline-flex h-7 shrink-0 items-center gap-1 rounded border px-2 text-[11px] transition-colors cursor-pointer ${
watchMenuOpen
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-base text-secondary hover:border-accent/30 hover:text-foreground'
}`}
>
<ListPlus className="h-3 w-3" />自选导入
</button>
{watchMenuOpen && (
<div className="absolute z-10 mt-1 max-h-56 w-44 overflow-y-auto rounded border border-border bg-surface py-1 shadow-lg">
{watchlistQ.isLoading ? (
<div className="px-2.5 py-2 text-[11px] text-muted">正在加载自选...</div>
) : watchImportOptions.length === 0 ? (
<div className="px-2.5 py-2 text-[11px] text-muted">自选列表为空</div>
) : watchImportOptions.map(option => (
<button
key={option.key}
type="button"
onClick={() => importSymbols(option.symbols)}
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left text-[11px] text-secondary transition-colors hover:bg-elevated hover:text-foreground cursor-pointer"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${option.dot}`} />
<span className="min-w-0 flex-1 truncate">{option.name}</span>
<span className="shrink-0 font-mono text-[9px] tabular-nums text-muted">{option.symbols.length}</span>
</button>
))}
</div>
)}
</div>
<div className="relative min-w-0 flex-1">
<input
value={symbolQuery}
onChange={e => 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"
/>
<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-full 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>
{(() => { const b = boardTag(r.symbol); return b && <span className={`ml-1 inline-flex items-center justify-center rounded px-0.5 text-[9px] font-bold leading-tight border ${b.color}`}>{b.label}</span> })()}
<span className="ml-1 text-muted">{r.name}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* 「已加入 N 只」单独成行(收起态, 与控件列左对齐) / 标签管理区(展开态) */}
{draft.symbols.length > 0 && !symbolsExpanded && (
<button
type="button"
onClick={() => setSymbolsExpanded(true)}
title="展开管理标的列表"
className="inline-flex items-center gap-1 rounded border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] text-accent transition-colors hover:bg-accent/20 cursor-pointer"
>
已加入 <span className="font-mono font-semibold tabular-nums">{draft.symbols.length}</span>
<ChevronDown className="h-3 w-3" />
</button>
)}
{draft.symbols.length > 0 && symbolsExpanded && (
<>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted">已加入 <span className="font-mono tabular-nums text-secondary">{draft.symbols.length}</span> </span>
<span className="flex items-center gap-2">
<button
type="button"
onClick={() => setDraft(d => ({ ...d, symbols: [] }))}
className="inline-flex items-center gap-0.5 text-[10px] text-muted transition-colors hover:text-warning cursor-pointer"
title="移除全部标的"
>
<Eraser className="h-3 w-3" />清空
</button>
<button
type="button"
onClick={() => setSymbolsExpanded(false)}
className="inline-flex items-center gap-0.5 text-[10px] text-muted transition-colors hover:text-foreground cursor-pointer"
>
收起<ChevronUp className="h-3 w-3" />
</button>
</span>
</div>
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto rounded border border-border/60 bg-base/40 p-1.5">
{draft.symbols.map(sym => {
const b = boardTag(sym)
const name = nameBySymbol[sym]
return (
<span key={sym} className="inline-flex items-center gap-1 rounded border border-border bg-elevated px-1.5 py-0.5 text-[10px] text-secondary">
<span className="max-w-24 truncate text-foreground/90" title={name ? `${name} ${sym}` : sym}>{name ?? sym}</span>
{b && <span className={`inline-flex items-center justify-center rounded px-0.5 text-[9px] font-bold leading-tight border ${b.color}`}>{b.label}</span>}
<span className="font-mono text-[9px] tabular-nums text-muted">{sym}</span>
<button
onClick={() => setDraft(d => ({ ...d, symbols: d.symbols.filter(s => s !== sym) }))}
className="text-muted transition-colors hover:text-danger cursor-pointer"
title={name ? `移除 ${name}` : `移除 ${sym}`}
>
<X className="h-2.5 w-2.5" />
</button>
</span>
)
})}
</div>
</>
)}
</div>
)}
{draft.scope === 'watchlist_group' && (
<div className="min-w-0 flex-1 space-y-1.5">
<div className="relative" ref={groupMenuRef}>
<button
type="button"
onClick={() => setGroupMenuOpen(v => !v)}
title="选择要监控的自选分组 (动态绑定, 分组内增删标的自动生效)"
className={`inline-flex h-7 max-w-full items-center gap-1.5 rounded border px-2 text-[11px] transition-colors cursor-pointer ${
groupMenuOpen
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-base text-secondary hover:border-accent/30 hover:text-foreground'
}`}
>
{selectedGroup ? (
<>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${resolveWatchlistGroupColor(selectedGroup.color).dot}`} />
<span className="max-w-32 truncate">{selectedGroup.name}</span>
<span className="shrink-0 font-mono text-[9px] tabular-nums text-muted">{groupCounts[selectedGroup.id] ?? 0}</span>
</>
) : (
<span className="text-muted">{watchGroupsQ.isLoading ? '加载分组中...' : '选择自选分组...'}</span>
)}
{groupMenuOpen ? <ChevronUp className="h-3 w-3 shrink-0" /> : <ChevronDown className="h-3 w-3 shrink-0" />}
</button>
{groupMenuOpen && (
<div className="absolute z-10 mt-1 max-h-56 w-56 overflow-y-auto rounded border border-border bg-surface py-1 shadow-lg">
{watchGroupsQ.isLoading ? (
<div className="px-2.5 py-2 text-[11px] text-muted">正在加载分组...</div>
) : groupList.length === 0 ? (
<div className="px-2.5 py-2 text-[11px] text-muted">
还没有自选分组,<Link to="/watchlist" className="text-accent hover:text-accent/80">去自选页创建 </Link>
</div>
) : groupList.map(g => (
<button
key={g.id}
type="button"
onClick={() => {
setDraft(d => ({ ...d, group_id: g.id }))
setGroupMenuOpen(false)
}}
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left text-[11px] text-secondary transition-colors hover:bg-elevated hover:text-foreground cursor-pointer"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${resolveWatchlistGroupColor(g.color).dot}`} />
<span className="min-w-0 flex-1 truncate">{g.name}</span>
<span className="shrink-0 font-mono text-[9px] tabular-nums text-muted">{groupCounts[g.id] ?? 0}</span>
{draft.group_id === g.id && <Check className="h-3 w-3 shrink-0 text-accent" />}
</button>
))}
</div>
)}
</div>
{/* 成员预览 (只读): 让用户明确当前监控哪些标的; 与手动选标的的可编辑标签区分 */}
{selectedGroup && (
<div className="space-y-1">
{selectedGroupSymbols.length > 0 ? (
<div className="flex max-h-24 flex-wrap gap-1 overflow-y-auto rounded border border-border/60 bg-base/40 p-1.5">
{selectedGroupSymbols.map(sym => {
const b = boardTag(sym)
return (
<span key={sym} className="inline-flex items-center gap-1 rounded border border-border bg-elevated px-1.5 py-0.5 text-[10px] text-secondary">
<span className="max-w-24 truncate text-foreground/90" title={groupNameBySymbol[sym] ? `${groupNameBySymbol[sym]} ${sym}` : sym}>
{groupNameBySymbol[sym] ?? sym}
</span>
{b && <span className={`inline-flex items-center justify-center rounded px-0.5 text-[9px] font-bold leading-tight border ${b.color}`}>{b.label}</span>}
<span className="font-mono text-[9px] tabular-nums text-muted">{sym}</span>
</span>
)
})}
</div>
) : (
<div className="rounded border border-dashed border-border px-2 py-1.5 text-[10px] text-muted">
该分组当前没有标的, 后续在分组内添加自选会自动纳入监控
</div>
)}
<div className="text-[10px] text-muted/70">
动态绑定: 分组内增删标的自动同步监控范围, 无需修改本规则
</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' && draft.type !== 'sector' && draft.type !== 'abnormal' && draft.type !== 'volume_delta' && (
<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"
options={{
builtinSignals: pickerSignals,
disabledSignals: intradayDisabledSignals,
disabledSignalHint: intradayDisabledHint,
filterCustomByKind: false,
}}
/>
{hasIntradaySignal && (
<div className={`mt-2 text-[10px] ${intradaySupport?.available === false ? 'text-danger' : 'text-muted'}`}>
{intradaySupport?.available === false
? intradaySupport.reason
: `分时穿越按已完成的一分钟判断,仅支持指定标的,当前最多监听 ${intradaySupport?.max_symbols ?? 0} 只。`}
</div>
)}
</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-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<label className="min-w-0 flex-1 space-y-1.5">
<span className="text-[11px] text-muted">搜索策略</span>
<span className="relative block">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted" />
<input
value={strategyQuery}
onChange={e => setStrategyQuery(e.target.value)}
placeholder="搜索名称、标签或策略 ID"
className="h-9 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground placeholder:text-muted/50 focus:border-accent/50 focus:outline-none"
/>
</span>
</label>
<div className="grid grid-cols-4 gap-1 rounded-btn border border-border bg-base p-1 sm:w-[19rem]">
{strategyCategories.map(category => (
<button
key={category.key}
type="button"
aria-pressed={strategyCategory === category.key}
onClick={() => setStrategyCategory(category.key)}
className={`flex h-7 min-w-0 items-center justify-center gap-1 rounded px-1 text-[10px] font-medium transition-colors cursor-pointer ${
strategyCategory === category.key
? 'bg-elevated text-foreground'
: 'text-muted hover:text-secondary'
}`}
>
<span className="truncate">{category.label}</span>
<span className="font-mono text-[9px] opacity-70">{category.count}</span>
</button>
))}
</div>
</div>
<div className="grid max-h-56 grid-cols-1 gap-1.5 overflow-y-auto pr-1 sm:grid-cols-2">
{strategies.isLoading ? (
<div className="col-span-full py-8 text-center text-xs text-muted">正在加载策略...</div>
) : visibleStrategies.length === 0 ? (
<div className="col-span-full rounded-btn border border-dashed border-border py-8 text-center text-xs text-muted">没有匹配的策略</div>
) : 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 (
<button
key={strategy.id}
type="button"
aria-pressed={active}
onClick={() => setDraft(d => ({ ...d, strategy_id: strategy.id }))}
className={`flex min-h-14 min-w-0 items-start gap-2 rounded-btn border px-3 py-2 text-left transition-colors cursor-pointer ${
active
? 'border-accent/45 bg-accent/10'
: 'border-border bg-base hover:border-accent/25 hover:bg-elevated/50'
}`}
>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className={`shrink-0 rounded border px-1 py-px text-[9px] font-medium ${sourceMeta.className}`}>{sourceMeta.label}</span>
<span className="truncate text-xs font-medium text-foreground">{strategy.name}</span>
</span>
<span className="mt-1 block truncate text-[10px] text-muted" title={summary}>{summary}</span>
</span>
<span className={`mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full border ${
active ? 'border-accent bg-accent text-white' : 'border-border text-transparent'
}`}>
<Check className="h-2.5 w-2.5" />
</span>
</button>
)
})}
</div>
<div className="border-t border-border/60 pt-3">
<div
className="mb-2 text-[11px] text-muted"
title="评分范围仅过滤选股结果与买入信号,卖出信号不受限制"
>
评分范围
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<label className="space-y-1.5">
<span className="text-[10px] text-muted">最低分(含)</span>
<input
type="number"
min={0}
max={100}
step="any"
value={draft.score_min ?? ''}
onChange={event => setDraft(d => ({
...d,
score_min: event.target.value === '' ? null : Number(event.target.value),
}))}
placeholder="不限"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground placeholder:text-muted/50 focus:border-accent/50 focus:outline-none"
/>
</label>
<span className="mt-5 text-xs text-muted"></span>
<label className="space-y-1.5">
<span className="text-[10px] text-muted">最高分(含)</span>
<input
type="number"
min={0}
max={100}
step="any"
value={draft.score_max ?? ''}
onChange={event => setDraft(d => ({
...d,
score_max: event.target.value === '' ? null : Number(event.target.value),
}))}
placeholder="不限"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground placeholder:text-muted/50 focus:border-accent/50 focus:outline-none"
/>
</label>
</div>
</div>
<div className="border-t border-border/60 pt-3">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-[11px] text-muted">通知事件</span>
<span className="text-[9px] text-muted">至少选择一项</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{(['signal', 'pool'] as const).map(group => (
<div key={group} className="rounded-btn border border-border bg-base p-2.5">
<div className="mb-2 text-[10px] font-medium text-secondary">
{group === 'signal' ? '交易信号' : '选股结果'}
</div>
<div className="space-y-2">
{STRATEGY_NOTIFY_EVENT_OPTIONS.filter(option => option.group === group).map(option => (
<label key={option.key} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={(draft.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS).includes(option.key)}
onChange={() => toggleStrategyEvent(option.key)}
className="h-3.5 w-3.5 accent-accent cursor-pointer"
/>
<span className="text-[11px] text-foreground">{option.label}</span>
</label>
))}
</div>
</div>
))}
</div>
{(draft.notify_events ?? LEGACY_STRATEGY_NOTIFY_EVENTS).length === 0 && (
<div className="mt-2 text-[10px] text-danger">至少选择一个通知事件</div>
)}
</div>
</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 推送 — 飞书 / 企业微信 */}
<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">群推送 Webhook</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">群推送 Webhook</span>
{(draft.webhook_channels ?? []).includes('wecom') && (
<span className={`ml-auto text-[9px] ${wecomConfigured ? 'text-emerald-500' : 'text-warning'}`}>
{wecomConfigured ? '已配置' : '未配置'}
</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&highlight=webhooks" 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>
)
}