feat(signals): 统一信号库与自定义信号弹窗,三处复用同一组件

- 设置页「自定义信号」升级为「信号库」:内置信号(只读) + 自定义信号分 Tab 展示
- 内置信号补充类型/分类/说明元数据
- 自定义信号配置改为通用弹窗组件 CustomSignalDialog
- 抽出 SignalTriggerActions:新增信号 / 去信号库按钮 + 保存后自动加入触发器
- 回测高级设置、策略卡片配置弹窗均复用上述组件,去除重复逻辑
- 自定义信号删除改为二次确认(闪烁提示,3 秒自动复位)
- 版本号 0.1.30 -> 0.1.31
This commit is contained in:
shy3130
2026-06-23 16:47:11 +08:00
parent b3c59dd127
commit 7a15338281
11 changed files with 628 additions and 223 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""TickFlow Stock Panel backend."""
__version__ = "0.1.28"
__version__ = "0.1.31"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "tickflow-stock-panel-backend"
version = "0.1.30"
version = "0.1.31"
description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配"
readme = "../README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tickflow-stock-panel-frontend",
"private": true,
"version": "0.1.30",
"version": "0.1.31",
"type": "module",
"scripts": {
"dev": "vite",
@@ -5,6 +5,7 @@ import { api, type StrategyDetail, type StrategyParamDef } from '@/lib/api'
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
import { color } from '@/lib/colors'
import { SignalPicker } from './SignalPicker'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
// 内置列名 → 中文标签
const FIELD_LABEL: Record<string, string> = {}
@@ -41,15 +42,17 @@ function Section({ icon: Icon, title, accent, defaultOpen = true, children, extr
const [open, setOpen] = useState(defaultOpen)
return (
<div className="rounded-xl border border-border/15 bg-surface/20 overflow-hidden">
<button
onClick={() => setOpen(v => !v)}
className="w-full flex items-center gap-2 px-3.5 py-2 hover:bg-surface/30 transition-colors cursor-pointer"
>
<ChevronDown className={`h-3 w-3 text-muted/40 transition-transform duration-200 ${open ? '' : '-rotate-90'}`} />
{Icon && <Icon className={`h-3.5 w-3.5 ${accent ?? 'text-muted'}`} />}
<span className="text-[11px] font-medium text-foreground/70">{title}</span>
<div className="ml-auto">{extra}</div>
</button>
<div className="flex items-center gap-2 px-3.5 py-2 hover:bg-surface/30 transition-colors">
<button
onClick={() => setOpen(v => !v)}
className="flex min-w-0 flex-1 items-center gap-2 text-left cursor-pointer"
>
<ChevronDown className={`h-3 w-3 text-muted/40 transition-transform duration-200 ${open ? '' : '-rotate-90'}`} />
{Icon && <Icon className={`h-3.5 w-3.5 ${accent ?? 'text-muted'}`} />}
<span className="text-[11px] font-medium text-foreground/70">{title}</span>
</button>
{extra && <div className="ml-auto flex items-center gap-1">{extra}</div>}
</div>
<AnimatePresence initial={false}>
{open && (
<motion.div
@@ -69,6 +72,7 @@ function Section({ icon: Icon, title, accent, defaultOpen = true, children, extr
)
}
// ===== 区间字段(最小 ~ 最大) =====
function RangeField({ label, minVal, maxVal, onMinChange, onMaxChange, unit, step }: {
label: string
@@ -491,12 +495,24 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
</div>
</Section>
<Section icon={TrendingUp} title="买入触发器" accent="text-accent" defaultOpen={false}>
<Section
icon={TrendingUp}
title="买入触发器"
accent="text-accent"
defaultOpen={false}
extra={<SignalTriggerActions kind="entry" signals={entrySignals} onChange={setEntrySignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
>
<SignalPicker signals={entrySignals} onChange={setEntrySignals} kind="entry" variant="dialog" />
<div className="text-[10px] leading-4 text-muted/70"></div>
</Section>
<Section icon={TrendingUp} title="卖出触发器" accent="text-warning" defaultOpen={false}>
<Section
icon={TrendingUp}
title="卖出触发器"
accent="text-warning"
defaultOpen={false}
extra={<SignalTriggerActions kind="exit" signals={exitSignals} onChange={setExitSignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
>
<SignalPicker signals={exitSignals} onChange={setExitSignals} kind="exit" variant="dialog" />
<div className="text-[10px] leading-4 text-muted/70"></div>
</Section>
@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowRight, Plus, Save, X } from 'lucide-react'
import { api, type CustomSignal, type CustomSignalCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
interface Props {
open: boolean
signal?: CustomSignal | null
defaultKind?: CustomSignal['kind']
onClose: () => void
onSaved?: (signal: CustomSignal) => void
}
const emptySignal = (kind: CustomSignal['kind'] = 'exit'): CustomSignal => ({
id: '', name: '', kind, enabled: true,
conditions: [{ left: 'close', op: '>', right: 'field:ma20' }],
})
export function CustomSignalDialog({ open, signal, defaultKind = 'exit', onClose, onSaved }: Props) {
const qc = useQueryClient()
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, enabled: open })
const [draft, setDraft] = useState<CustomSignal>(() => emptySignal(defaultKind))
const [error, setError] = useState('')
const fields = options.data?.fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const editing = !!signal
useEffect(() => {
if (!open) return
setDraft(signal ? { ...signal, conditions: signal.conditions.map(c => ({ ...c })) } : emptySignal(defaultKind))
setError('')
}, [open, signal, defaultKind])
const save = useMutation({
mutationFn: () => {
const d = draft
if (!d.id.trim()) throw new Error('请输入信号标识')
if (!/^[a-z0-9_]{1,40}$/.test(d.id)) throw new Error('标识仅允许小写字母、数字、下划线(1-40字符)')
if (!d.name.trim()) throw new Error('请输入信号名称')
if (d.conditions.length === 0) throw new Error('至少需要一个条件')
for (const c of d.conditions) {
if (!c.left || !c.op || c.right === '') throw new Error('条件填写不完整')
}
return api.customSignalSave(d)
},
onSuccess: res => {
qc.invalidateQueries({ queryKey: QK.customSignals })
onSaved?.(res.signal)
onClose()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
const updateCond = (idx: number, patch: Partial<CustomSignalCondition>) => {
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
}
const addCond = () => setDraft(d => ({ ...d, conditions: [...d.conditions, { left: 'close', op: '>', right: '0' }] }))
const removeCond = (idx: number) => setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={onClose}
>
<motion.div
role="dialog"
aria-modal="true"
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-5 py-4">
<div>
<h3 className="text-sm font-semibold text-foreground">{editing ? '编辑自定义信号' : '新建自定义信号'}</h3>
<p className="mt-1 text-[11px] text-muted"> csg_* </p>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-muted transition-colors hover:bg-elevated hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input
value={draft.id}
disabled={editing}
onChange={e => setDraft(d => ({ ...d, id: e.target.value.replace(/[^a-z0-9_]/g, '') }))}
placeholder="如 low_touches_ma5"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground disabled:opacity-60"
/>
</label>
<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="如 跌至MA5" 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.kind} onChange={e => setDraft(d => ({ ...d, kind: e.target.value as CustomSignal['kind'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
<option value="entry"></option>
<option value="exit"></option>
<option value="both"></option>
</select>
</label>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"></span>
<button onClick={addCond} 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 className="space-y-2 rounded-card border border-border/70 bg-base/50 p-3">
{draft.conditions.map((c, i) => (
<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 ? '当' : '且'}</span>
<select value={c.left} onChange={e => updateCond(i, { left: 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">
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(i, { 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>
<RightValueInput cond={c} fields={fields} onChange={v => updateCond(i, { right: v })} />
{draft.conditions.length > 1 && (
<button onClick={() => removeCond(i)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
</div>
<div className="flex justify-end gap-2 border-t border-border/50 px-5 py-4">
<button onClick={onClose} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-amber-500/90 text-base text-xs font-medium disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
function RightValueInput({ cond, fields, onChange }: { cond: CustomSignalCondition; fields: { key: string; label: string }[]; onChange: (v: string) => void }) {
const isField = cond.right.startsWith('field:')
const fieldValue = isField ? cond.right.slice(6) : ''
const numValue = isField ? '' : cond.right
return (
<div className="flex items-center gap-1">
{isField ? (
<>
<select value={fieldValue} onChange={e => onChange(`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">
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<button onClick={() => onChange('0')} title="切换为数字" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
<ArrowRight className="h-3 w-3 rotate-90" />
</button>
</>
) : (
<>
<input type="number" value={numValue} onChange={e => onChange(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={() => onChange('field:close')} title="切换为字段" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
<ArrowRight className="h-3 w-3 -rotate-90" />
</button>
</>
)}
</div>
)
}
@@ -0,0 +1,56 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Settings2 } from 'lucide-react'
import type { CustomSignal } from '@/lib/api'
import { CustomSignalDialog } from './CustomSignalDialog'
interface Props {
kind: 'entry' | 'exit'
signals: string[]
onChange: (next: string[]) => void
buttonClassName?: string
iconClassName?: string
}
export function SignalTriggerActions({ kind, signals, onChange, buttonClassName, iconClassName }: Props) {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const accent = kind === 'entry' ? 'hover:text-accent hover:border-accent/40' : 'hover:text-warning hover:border-warning/40'
const btnCls = buttonClassName ?? 'rounded-btn border border-border bg-base p-1 text-muted transition-colors cursor-pointer'
const iconCls = iconClassName ?? 'h-3.5 w-3.5'
const handleSaved = (signal: CustomSignal) => {
if (signal.kind !== kind && signal.kind !== 'both') return
const signalId = `csg_${signal.id}`
onChange(signals.includes(signalId) ? signals : [...signals, signalId])
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title="新增自定义信号"
className={`${btnCls} ${accent}`}
>
<Plus className={iconCls} />
</button>
<button
type="button"
onClick={() => navigate('/settings?tab=signals')}
title="去信号库"
className={`${btnCls} hover:border-amber-400/40 hover:text-amber-400`}
>
<Settings2 className={iconCls} />
</button>
<CustomSignalDialog
open={open}
defaultKind={kind}
onClose={() => setOpen(false)}
onSaved={handleSaved}
/>
</>
)
}
+131 -19
View File
@@ -5,28 +5,140 @@
* (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。
*/
/** 内置原子信号 → 中文标签 (权威来源, 两页统一) */
export const SIGNAL_LABELS: Record<string, string> = {
signal_ma_golden_5_20: 'MA5上穿MA20',
signal_ma_dead_5_20: 'MA5下穿MA20',
signal_ma_golden_20_60: 'MA20上穿MA60',
signal_macd_golden: 'MACD金叉',
signal_macd_dead: 'MACD死叉',
signal_ma20_breakout: '突破MA20',
signal_ma20_breakdown: '跌破MA20',
signal_n_day_high: '60日新高',
signal_n_day_low: '60日新低',
signal_boll_breakout_upper: '突破布林上轨',
signal_boll_breakdown_lower: '跌破布林下轨',
signal_volume_surge: '放量',
signal_limit_up: '涨停',
signal_limit_down: '跌停',
signal_limit_down_recovery: '跌停翘板',
signal_broken_limit_up: '炸板',
export type SignalKind = 'entry' | 'exit' | 'both'
export interface BuiltinSignalDefinition {
id: string
name: string
kind: SignalKind
category: string
description: string
}
/** 内置原子信号清单 (权威展示来源, 两页统一) */
export const BUILTIN_SIGNAL_DEFINITIONS: BuiltinSignalDefinition[] = [
{
id: 'signal_ma_golden_5_20',
name: 'MA5上穿MA20',
kind: 'entry',
category: '均线',
description: '短期均线 MA5 上穿中期均线 MA20,常用于趋势转强确认。',
},
{
id: 'signal_ma_dead_5_20',
name: 'MA5下穿MA20',
kind: 'exit',
category: '均线',
description: '短期均线 MA5 下穿中期均线 MA20,常用于趋势转弱或止盈止损。',
},
{
id: 'signal_ma_golden_20_60',
name: 'MA20上穿MA60',
kind: 'entry',
category: '均线',
description: '中期均线 MA20 上穿长期均线 MA60,偏中线趋势信号。',
},
{
id: 'signal_macd_golden',
name: 'MACD金叉',
kind: 'entry',
category: 'MACD',
description: 'MACD DIF 上穿 DEA,表示动能可能由弱转强。',
},
{
id: 'signal_macd_dead',
name: 'MACD死叉',
kind: 'exit',
category: 'MACD',
description: 'MACD DIF 下穿 DEA,表示动能可能由强转弱。',
},
{
id: 'signal_ma20_breakout',
name: '突破MA20',
kind: 'entry',
category: '趋势',
description: '收盘价向上突破 MA20,常用于趋势突破买点。',
},
{
id: 'signal_ma20_breakdown',
name: '跌破MA20',
kind: 'exit',
category: '趋势',
description: '收盘价向下跌破 MA20,常用于趋势破位卖点。',
},
{
id: 'signal_n_day_high',
name: '60日新高',
kind: 'entry',
category: '趋势',
description: '收盘价创近 60 日新高,表示阶段强势或突破。',
},
{
id: 'signal_n_day_low',
name: '60日新低',
kind: 'exit',
category: '趋势',
description: '收盘价创近 60 日新低,表示阶段弱势或风险释放。',
},
{
id: 'signal_boll_breakout_upper',
name: '突破布林上轨',
kind: 'entry',
category: 'BOLL',
description: '价格突破布林上轨,偏强势突破或加速信号。',
},
{
id: 'signal_boll_breakdown_lower',
name: '跌破布林下轨',
kind: 'exit',
category: 'BOLL',
description: '价格跌破布林下轨,偏弱势破位或超跌风险信号。',
},
{
id: 'signal_volume_surge',
name: '放量',
kind: 'both',
category: '量价',
description: '成交量显著放大,可作为买入确认、卖出确认或告警条件。',
},
{
id: 'signal_limit_up',
name: '涨停',
kind: 'entry',
category: '涨跌停',
description: '收盘封住涨停,用于强势股、连板与市场情绪监控。',
},
{
id: 'signal_limit_down',
name: '跌停',
kind: 'exit',
category: '涨跌停',
description: '收盘触及跌停,用于风险控制与弱势监控。',
},
{
id: 'signal_limit_down_recovery',
name: '跌停翘板',
kind: 'entry',
category: '涨跌停',
description: '盘中触及跌停后回升,常用于短线情绪修复观察。',
},
{
id: 'signal_broken_limit_up',
name: '炸板',
kind: 'exit',
category: '涨跌停',
description: '盘中触及涨停但收盘未封住,用于强转弱或分歧监控。',
},
]
/** 内置原子信号 → 中文标签 */
export const SIGNAL_LABELS: Record<string, string> = BUILTIN_SIGNAL_DEFINITIONS.reduce<Record<string, string>>((acc, sig) => {
acc[sig.id] = sig.name
return acc
}, {})
/** 内置信号 ID 列表 */
export const SIGNAL_OPTIONS = Object.keys(SIGNAL_LABELS)
export const SIGNAL_OPTIONS = BUILTIN_SIGNAL_DEFINITIONS.map(sig => sig.id)
/** 常用技术指标/字段 → 中文 (阈值条件展示用, 与后端 ENRICHED_COLUMNS 对齐) */
const FIELD_LABELS: Record<string, string> = {
+1 -1
View File
@@ -23,7 +23,7 @@ const TABS = [
{ key: 'ai', label: 'AI 设置', icon: Sparkles, panel: SettingsAIPanel },
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
{ key: 'ext-pages', label: '扩展页面', icon: BarChart3, panel: SettingsExtPagesPanel },
{ key: 'signals', label: '自定义信号', icon: Zap, panel: SettingsCustomSignalsPanel },
{ key: 'signals', label: '信号', icon: Zap, panel: SettingsCustomSignalsPanel },
{ key: 'menus', label: '菜单设置', icon: SlidersHorizontal, panel: SettingsMenuSettingsPanel },
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
] as const
@@ -22,6 +22,7 @@ import { DatePicker } from '@/components/DatePicker'
import { StrategyNavChart } from './charts/StrategyNavChart'
import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
import { TradeKlineModal } from './components/TradeKlineModal'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
const monthsAgo = (months: number) => {
@@ -387,18 +388,22 @@ function Stat({ label, value, color }: { label: ReactNode; value: string; color?
)
}
function ConfigSection({ title, hint, children }: { title: string; hint?: ReactNode; children: ReactNode }) {
function ConfigSection({ title, hint, actions, children }: { title: string; hint?: ReactNode; actions?: ReactNode; children: ReactNode }) {
return (
<div className="rounded-btn border border-border bg-surface/70 p-3">
<div className="text-xs font-medium text-foreground">
{title}
{hint && <span className="ml-1 text-[10px] font-normal text-muted">{hint}</span>}
<div className="flex items-start justify-between gap-3">
<div className="text-xs font-medium text-foreground">
{title}
{hint && <span className="ml-1 text-[10px] font-normal text-muted">{hint}</span>}
</div>
{actions && <div className="flex shrink-0 items-center gap-1">{actions}</div>}
</div>
<div className="mt-3 space-y-2">{children}</div>
</div>
)
}
const scoringToPct = (values: Record<string, number>) => {
const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0)
if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record<string, number>
@@ -1948,7 +1953,11 @@ export function StrategyBacktest() {
)}
{settingsTab === 'entry' && (
<ConfigSection title="买入触发器" hint="任一买点满足即可进入候选">
<ConfigSection
title="买入触发器"
hint="任一买点满足即可进入候选"
actions={<SignalTriggerActions kind="entry" signals={entrySignals} onChange={next => updateOverride('entry_signals', next)} />}
>
<SignalPicker
signals={entrySignals}
onChange={next => updateOverride('entry_signals', next)}
@@ -1958,7 +1967,11 @@ export function StrategyBacktest() {
)}
{settingsTab === 'exit' && (
<ConfigSection title="卖出触发器" hint="任一卖点满足即触发卖出">
<ConfigSection
title="卖出触发器"
hint="任一卖点满足即触发卖出"
actions={<SignalTriggerActions kind="exit" signals={exitSignals} onChange={next => updateOverride('exit_signals', next)} />}
>
<SignalPicker
signals={exitSignals}
onChange={next => updateOverride('exit_signals', next)}
+200 -183
View File
@@ -1,90 +1,98 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus, Save, Trash2, X, Zap, ArrowRight, Settings2 } from 'lucide-react'
import { api, type CustomSignal, type CustomSignalCondition } from '@/lib/api'
import { Plus, Trash2, Zap, Settings2, Lock } from 'lucide-react'
import { api, type CustomSignal } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { BUILTIN_SIGNAL_DEFINITIONS, type SignalKind } from '@/lib/signals'
import { CustomSignalDialog } from '@/components/signals/CustomSignalDialog'
const KIND_LABEL: Record<string, string> = { entry: '买入', exit: '卖出', both: '买卖通用' }
type SignalSection = 'builtin' | 'custom'
const emptySignal = (): CustomSignal => ({
id: '', name: '', kind: 'exit', enabled: true,
conditions: [{ left: 'close', op: '>', right: 'ma20' }],
})
const KIND_LABEL: Record<SignalKind, string> = { entry: '买入', exit: '卖出', both: '买卖通用' }
const KIND_CLASS: Record<SignalKind, string> = {
entry: 'bg-accent/10 text-accent',
exit: 'bg-warning/10 text-warning',
both: 'bg-muted/10 text-muted',
}
export function SettingsCustomSignalsPanel() {
const qc = useQueryClient()
const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions })
const [activeSection, setActiveSection] = useState<SignalSection>('builtin')
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CustomSignal | null>(null)
const [draft, setDraft] = useState<CustomSignal>(emptySignal())
const [error, setError] = useState('')
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
const resetDeleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const fields = options.data?.fields ?? []
const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
const signals = list.data?.signals ?? []
const enabledCustomSignals = signals.filter(sig => sig.enabled).length
const tabs = [
{ key: 'builtin' as const, label: '内置信号', count: BUILTIN_SIGNAL_DEFINITIONS.length, hint: '系统提供,只读' },
{ key: 'custom' as const, label: '自定义信号', count: signals.length, hint: `${enabledCustomSignals} 个已启用` },
]
const resetForm = () => {
setEditing(null)
setDraft(emptySignal())
setError('')
useEffect(() => () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
}, [])
const clearDeleteConfirm = () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = null
setConfirmingDeleteId(null)
}
const openNew = () => { resetForm(); setShowForm(true) }
const openEdit = (sig: CustomSignal) => {
setEditing(sig)
setDraft({ ...sig, conditions: sig.conditions.map(c => ({ ...c })) })
setError('')
const openNew = () => {
setEditing(null)
clearDeleteConfirm()
setActiveSection('custom')
setShowForm(true)
}
const save = useMutation({
mutationFn: () => {
const d = draft
if (!d.id.trim()) throw new Error('请输入信号标识')
if (!/^[a-z0-9_]{1,40}$/.test(d.id)) throw new Error('标识仅允许小写字母、数字、下划线(1-40字符)')
if (!d.name.trim()) throw new Error('请输入信号名称')
if (d.conditions.length === 0) throw new Error('至少需要一个条件')
for (const c of d.conditions) {
if (!c.left || !c.op || c.right === '') throw new Error('条件填写不完整')
}
return api.customSignalSave(d)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.customSignals })
setShowForm(false)
resetForm()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
const openEdit = (sig: CustomSignal) => {
setEditing(sig)
clearDeleteConfirm()
setActiveSection('custom')
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const del = useMutation({
mutationFn: api.customSignalDelete,
onSuccess: () => qc.invalidateQueries({ queryKey: QK.customSignals }),
onSuccess: () => {
clearDeleteConfirm()
qc.invalidateQueries({ queryKey: QK.customSignals })
},
})
// 条件编辑辅助
const updateCond = (idx: number, patch: Partial<CustomSignalCondition>) => {
setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
}
const addCond = () => setDraft(d => ({ ...d, conditions: [...d.conditions, { left: 'close', op: '>', right: '0' }] }))
const removeCond = (idx: number) => setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
const toggleEnabled = (sig: CustomSignal) => {
api.customSignalSave({ ...sig, enabled: !sig.enabled }).then(() => qc.invalidateQueries({ queryKey: QK.customSignals }))
}
const signals = list.data?.signals ?? []
const handleDeleteClick = (sig: CustomSignal) => {
if (confirmingDeleteId === sig.id) {
clearDeleteConfirm()
del.mutate(sig.id)
return
}
setConfirmingDeleteId(sig.id)
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = setTimeout(() => setConfirmingDeleteId(null), 3000)
}
return (
<div className="max-w-5xl space-y-6">
<div className="max-w-6xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(234,179,8,0.12),transparent_38%)]">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[11px] uppercase tracking-[0.2em] text-amber-400/80"></div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground"> + + </h2>
<div className="text-[11px] uppercase tracking-[0.2em] text-amber-400/80"></div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
<span className="font-mono text-foreground/80"> MA5</span>使
+ + 使
</p>
</div>
<button
@@ -92,159 +100,168 @@ export function SettingsCustomSignalsPanel() {
className="inline-flex items-center justify-center gap-1.5 rounded-btn bg-amber-500/90 px-3 py-1.5 text-xs font-medium text-base hover:bg-amber-500 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-5 grid grid-cols-1 gap-3 md:grid-cols-3">
<StatCard label="内置信号" value={BUILTIN_SIGNAL_DEFINITIONS.length} hint="系统提供,只读" />
<StatCard label="自定义信号" value={signals.length} hint="用户创建,可编辑" />
<StatCard label="已启用自定义" value={enabledCustomSignals} hint="会注入 csg_* 列" />
</div>
<div className="mt-5 rounded-card border border-border bg-base/60 p-1.5">
<div className="grid grid-cols-1 gap-1.5 md:grid-cols-2">
{tabs.map(tab => {
const active = activeSection === tab.key
return (
<button
key={tab.key}
type="button"
onClick={() => setActiveSection(tab.key)}
className={`rounded-btn px-4 py-3 text-left transition-colors ${active ? 'bg-amber-500/15 text-amber-300 shadow-sm' : 'text-secondary hover:bg-elevated hover:text-foreground'}`}
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{tab.label}</span>
<span className={`rounded px-2 py-0.5 text-[11px] ${active ? 'bg-amber-400/15 text-amber-300' : 'bg-elevated text-muted'}`}>{tab.count}</span>
</div>
<div className="mt-1 text-[11px] text-muted">{tab.hint}</div>
</button>
)
})}
</div>
</div>
</section>
{showForm && (
{activeSection === 'builtin' && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<h3 className="text-sm font-medium text-foreground">{editing ? '编辑信号' : '新建信号'}</h3>
<p className="mt-1 text-[11px] text-muted"></p>
<div className="flex items-center gap-2">
<Lock className="h-3.5 w-3.5 text-muted" />
<h3 className="text-sm font-medium text-foreground"></h3>
<span className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-muted"></span>
</div>
<p className="mt-1 text-xs text-muted"> enriched </p>
</div>
<button onClick={() => { setShowForm(false); setError('') }} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground">
<X className="h-4 w-4" />
</button>
<div className="text-[11px] text-muted">ID <span className="font-mono text-foreground/70">signal_</span></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<input
value={draft.id}
disabled={!!editing}
onChange={e => setDraft(d => ({ ...d, id: e.target.value.replace(/[^a-z0-9_]/g, '') }))}
placeholder="如 low_touches_ma5"
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground disabled:opacity-60"
/>
</label>
<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="如 跌至MA5" 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.kind} onChange={e => setDraft(d => ({ ...d, kind: e.target.value as CustomSignal['kind'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
<option value="entry"></option>
<option value="exit"></option>
<option value="both"></option>
</select>
</label>
</div>
{/* 条件组 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted"></span>
<button onClick={addCond} 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>
{draft.conditions.map((c, i) => (
<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 ? '当' : '且'}</span>
<select value={c.left} onChange={e => updateCond(i, { left: 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">
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<select value={c.op} onChange={e => updateCond(i, { 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>
<RightValueInput cond={c} fields={fields} onChange={v => updateCond(i, { right: v })} />
{draft.conditions.length > 1 && (
<button onClick={() => removeCond(i)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
<X className="h-3 w-3" />
</button>
)}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{BUILTIN_SIGNAL_DEFINITIONS.map(sig => (
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground truncate">{sig.name}</h4>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
{KIND_LABEL[sig.kind]}
</span>
</div>
<p className="mt-1 text-[11px] text-muted font-mono truncate">{sig.id}</p>
</div>
<span className="shrink-0 rounded border border-border bg-elevated px-1.5 py-0.5 text-[10px] text-muted">{sig.category}</span>
</div>
<p className="mt-3 text-xs leading-5 text-secondary">{sig.description}</p>
</div>
))}
</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={() => { setShowForm(false); setError('') }} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs"></button>
<button onClick={() => save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-amber-500/90 text-base text-xs font-medium disabled:opacity-50">
<Save className="h-3.5 w-3.5" />
</button>
</div>
</section>
)}
<section className="grid grid-cols-1 md:grid-cols-2 gap-4">
{signals.map(sig => (
<div key={sig.id} className="rounded-card border border-border bg-surface p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground truncate">{sig.name}</h3>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${sig.kind === 'entry' ? 'bg-accent/10 text-accent' : sig.kind === 'exit' ? 'bg-warning/10 text-warning' : 'bg-muted/10 text-muted'}`}>
{KIND_LABEL[sig.kind]}
</span>
{!sig.enabled && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted"></span>}
</div>
<p className="mt-1 text-[11px] text-muted font-mono truncate">{sig.id}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleEnabled(sig)} title={sig.enabled ? '停用' : '启用'} className={`p-1 rounded cursor-pointer ${sig.enabled ? 'text-emerald-400 hover:bg-emerald-400/10' : 'text-muted hover:bg-elevated'}`}>
<Zap className="h-3.5 w-3.5" />
</button>
<button onClick={() => openEdit(sig)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 cursor-pointer" title="编辑">
<Settings2 className="h-3.5 w-3.5" />
</button>
<button onClick={() => del.mutate(sig.id)} disabled={del.isPending} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer" title="删除">
<Trash2 className="h-3.5 w-3.5" />
</button>
{activeSection === 'custom' && (
<section className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-2">
<Zap className="h-3.5 w-3.5 text-amber-400" />
<h3 className="text-sm font-medium text-foreground"></h3>
<span className="rounded bg-amber-400/10 px-1.5 py-0.5 text-[10px] text-amber-400"></span>
</div>
<p className="mt-1 text-xs text-muted">/ csg_* 使</p>
</div>
<div className="mt-3 space-y-1">
{sig.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-1.5 text-[11px] text-secondary">
<span className="text-muted/50 w-6 text-right">{i === 0 ? '当' : '且'}</span>
<span className="font-mono text-foreground/80">{fieldLabel(c.left, fields)}</span>
<span className="font-mono text-muted">{c.op}</span>
<span className="font-mono text-foreground/80">{rightDisplay(c.right, fields)}</span>
<button
onClick={openNew}
className="inline-flex items-center justify-center gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/5 px-3 py-1.5 text-xs font-medium text-amber-400 hover:bg-amber-400/10 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{signals.map(sig => (
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground truncate">{sig.name}</h3>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
{KIND_LABEL[sig.kind]}
</span>
{!sig.enabled && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted"></span>}
</div>
<p className="mt-1 text-[11px] text-muted font-mono truncate">csg_{sig.id}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleEnabled(sig)} title={sig.enabled ? '停用' : '启用'} className={`p-1 rounded cursor-pointer ${sig.enabled ? 'text-emerald-400 hover:bg-emerald-400/10' : 'text-muted hover:bg-elevated'}`}>
<Zap className="h-3.5 w-3.5" />
</button>
<button onClick={() => openEdit(sig)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 cursor-pointer" title="编辑">
<Settings2 className="h-3.5 w-3.5" />
</button>
{confirmingDeleteId === sig.id ? (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[10px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer disabled:opacity-50"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
) : (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer disabled:opacity-50"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
))}
</div>
<div className="mt-3 space-y-1">
{sig.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-1.5 text-[11px] text-secondary">
<span className="text-muted/50 w-6 text-right">{i === 0 ? '当' : '且'}</span>
<span className="font-mono text-foreground/80">{fieldLabel(c.left, fields)}</span>
<span className="font-mono text-muted">{c.op}</span>
<span className="font-mono text-foreground/80">{rightDisplay(c.right, fields)}</span>
</div>
))}
</div>
</div>
))}
{signals.length === 0 && (
<div className="rounded-card border border-border bg-base px-5 py-10 text-center text-sm text-muted md:col-span-2">
</div>
)}
</div>
))}
{signals.length === 0 && (
<div className="rounded-card border border-border bg-surface px-5 py-10 text-center text-sm text-muted md:col-span-2">
</div>
)}
</section>
</section>
)}
<CustomSignalDialog open={showForm} signal={editing} onClose={closeForm} />
</div>
)
}
// ── 右值输入:可填数字,也可选「字段引用」────────────────
function RightValueInput({ cond, fields, onChange }: { cond: CustomSignalCondition; fields: { key: string; label: string }[]; onChange: (v: string) => void }) {
const isField = cond.right.startsWith('field:')
const fieldValue = isField ? cond.right.slice(6) : ''
const numValue = isField ? '' : cond.right
function StatCard({ label, value, hint }: { label: string; value: number; hint: string }) {
return (
<div className="flex items-center gap-1">
{isField ? (
<>
<select value={fieldValue} onChange={e => onChange(`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">
{fields.map(f => <option key={f.key} value={f.key}>{f.label}</option>)}
</select>
<button onClick={() => onChange('0')} title="切换为数字" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
<ArrowRight className="h-3 w-3 rotate-90" />
</button>
</>
) : (
<>
<input type="number" value={numValue} onChange={e => onChange(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={() => onChange('field:close')} title="切换为字段" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
<ArrowRight className="h-3 w-3 -rotate-90" />
</button>
</>
)}
<div className="rounded-card border border-border/80 bg-base/70 px-4 py-3">
<div className="text-[11px] text-muted">{label}</div>
<div className="mt-1 text-2xl font-semibold text-foreground">{value}</div>
<div className="mt-0.5 text-[11px] text-muted">{hint}</div>
</div>
)
}
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
},
},
server: {
host: '0.0.0.0', // 允许局域网访问
port: 3011,
proxy: {
// dev 时 /api 转发到 FastAPI