mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
冲突解法: SignalPicker 采用 options 形态 (PR 重构) + 保留 main 的 intradayDisabledSignals/intradayDisabledHint 变量 (含自选分组作用域 禁用分时穿越的增强语义, 自动合并会被 PR 旧内联表达式悄悄丢掉) + filterCustomByKind: false (监控场景显示全部自定义信号)。 验证: tsc 零错误, pnpm build 通过。
1581 lines
83 KiB
TypeScript
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>
|
|
)
|
|
}
|