diff --git a/backend/app/__init__.py b/backend/app/__init__.py index b5d5881..219bf01 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,3 +1,3 @@ """TickFlow Stock Panel backend.""" -__version__ = "0.1.28" +__version__ = "0.1.31" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 91c560e..88289ac 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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" diff --git a/frontend/package.json b/frontend/package.json index 1807c38..a3592bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.30", + "version": "0.1.31", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/screener/StrategySettingsDialog.tsx b/frontend/src/components/screener/StrategySettingsDialog.tsx index 534a85a..f4eb336 100644 --- a/frontend/src/components/screener/StrategySettingsDialog.tsx +++ b/frontend/src/components/screener/StrategySettingsDialog.tsx @@ -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 = {} @@ -41,15 +42,17 @@ function Section({ icon: Icon, title, accent, defaultOpen = true, children, extr const [open, setOpen] = useState(defaultOpen) return (
- +
+ + {extra &&
{extra}
} +
{open && ( -
+
} + >
任一买点满足即进入候选。
-
+
} + >
任一卖点满足即触发卖出。
diff --git a/frontend/src/components/signals/CustomSignalDialog.tsx b/frontend/src/components/signals/CustomSignalDialog.tsx new file mode 100644 index 0000000..2939189 --- /dev/null +++ b/frontend/src/components/signals/CustomSignalDialog.tsx @@ -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(() => 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) => { + 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 ( + + {open && ( + + e.stopPropagation()} + > +
+
+

{editing ? '编辑自定义信号' : '新建自定义信号'}

+

标识保存后不可修改,如需更换请新建。自定义信号保存为 csg_* 列。

+
+ +
+ +
+
+ + + +
+ +
+
+ 条件(多条件为「且」关系) + +
+
+ {draft.conditions.map((c, i) => ( +
+ {i === 0 ? '当' : '且'} + + + updateCond(i, { right: v })} /> + {draft.conditions.length > 1 && ( + + )} +
+ ))} +
+
+ + {error &&
{error}
} +
+ +
+ + +
+
+
+ )} +
+ ) +} + +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 ( +
+ {isField ? ( + <> + + + + ) : ( + <> + 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" /> + + + )} +
+ ) +} diff --git a/frontend/src/components/signals/SignalTriggerActions.tsx b/frontend/src/components/signals/SignalTriggerActions.tsx new file mode 100644 index 0000000..89bc172 --- /dev/null +++ b/frontend/src/components/signals/SignalTriggerActions.tsx @@ -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 ( + <> + + + + setOpen(false)} + onSaved={handleSaved} + /> + + ) +} diff --git a/frontend/src/lib/signals.ts b/frontend/src/lib/signals.ts index 05652db..eff1b76 100644 --- a/frontend/src/lib/signals.ts +++ b/frontend/src/lib/signals.ts @@ -5,28 +5,140 @@ * (signal_* 前缀为内置原子信号, csg_ 前缀为用户自定义信号)。 */ -/** 内置原子信号 → 中文标签 (权威来源, 两页统一) */ -export const SIGNAL_LABELS: Record = { - 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 = BUILTIN_SIGNAL_DEFINITIONS.reduce>((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 = { diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 3fd6ecd..89f0647 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx index 1443eab..3d38018 100644 --- a/frontend/src/pages/backtest/StrategyBacktest.tsx +++ b/frontend/src/pages/backtest/StrategyBacktest.tsx @@ -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 (
-
- {title} - {hint && {hint}} +
+
+ {title} + {hint && {hint}} +
+ {actions &&
{actions}
}
{children}
) } + const scoringToPct = (values: Record) => { 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 @@ -1948,7 +1953,11 @@ export function StrategyBacktest() { )} {settingsTab === 'entry' && ( - + updateOverride('entry_signals', next)} />} + > updateOverride('entry_signals', next)} @@ -1958,7 +1967,11 @@ export function StrategyBacktest() { )} {settingsTab === 'exit' && ( - + updateOverride('exit_signals', next)} />} + > updateOverride('exit_signals', next)} diff --git a/frontend/src/pages/settings/CustomSignals.tsx b/frontend/src/pages/settings/CustomSignals.tsx index b4bb2ea..56b2c9a 100644 --- a/frontend/src/pages/settings/CustomSignals.tsx +++ b/frontend/src/pages/settings/CustomSignals.tsx @@ -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 = { 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 = { entry: '买入', exit: '卖出', both: '买卖通用' } +const KIND_CLASS: Record = { + 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('builtin') const [showForm, setShowForm] = useState(false) const [editing, setEditing] = useState(null) - const [draft, setDraft] = useState(emptySignal()) - const [error, setError] = useState('') + const [confirmingDeleteId, setConfirmingDeleteId] = useState(null) + const resetDeleteTimer = useRef | 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) => { - 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 ( -
+
-
自定义信号
-

用「字段 + 运算符 + 值」组合买卖信号

+
信号库
+

统一查看策略、回测与监控可用信号

- 无需写代码,挑选已有指标字段组合条件(如 最低价 ≤ MA5),即可在回测与监控中作为买卖信号使用。多条件间为「且」关系。 + 内置信号由系统预计算,作为只读信号库展示;自定义信号可用「字段 + 运算符 + 值」组合条件创建,保存后可在策略、回测与监控中选择使用。

+ +
+ + + +
+ +
+
+ {tabs.map(tab => { + const active = activeSection === tab.key + return ( + + ) + })} +
+
- {showForm && ( + {activeSection === 'builtin' && (
-
+
-

{editing ? '编辑信号' : '新建信号'}

-

标识保存后不可修改,如需更换请新建。

+
+ +

内置信号

+ 只读 +
+

这些信号由系统在 enriched 数据中预计算,策略选择器会直接展示。

- +
ID 前缀:signal_
- -
- - - -
- - {/* 条件组 */} -
-
- 条件(多条件为「且」关系) - -
- {draft.conditions.map((c, i) => ( -
- {i === 0 ? '当' : '且'} - - - updateCond(i, { right: v })} /> - {draft.conditions.length > 1 && ( - - )} +
+ {BUILTIN_SIGNAL_DEFINITIONS.map(sig => ( +
+
+
+
+

{sig.name}

+ + {KIND_LABEL[sig.kind]} + +
+

{sig.id}

+
+ {sig.category} +
+

{sig.description}

))}
- - {error &&
{error}
} - -
- - -
)} -
- {signals.map(sig => ( -
-
-
-
-

{sig.name}

- - {KIND_LABEL[sig.kind]} - - {!sig.enabled && 已停用} -
-

{sig.id}

-
-
- - - + {activeSection === 'custom' && ( +
+
+
+
+ +

自定义信号

+ 可配置
+

这些信号由你定义,可启用/停用,并在策略、回测与监控中作为 csg_* 信号使用。

-
- {sig.conditions.map((c, i) => ( -
- {i === 0 ? '当' : '且'} - {fieldLabel(c.left, fields)} - {c.op} - {rightDisplay(c.right, fields)} + +
+ +
+ {signals.map(sig => ( +
+
+
+
+

{sig.name}

+ + {KIND_LABEL[sig.kind]} + + {!sig.enabled && 已停用} +
+

csg_{sig.id}

+
+
+ + + {confirmingDeleteId === sig.id ? ( + + ) : ( + + )} +
- ))} -
+
+ {sig.conditions.map((c, i) => ( +
+ {i === 0 ? '当' : '且'} + {fieldLabel(c.left, fields)} + {c.op} + {rightDisplay(c.right, fields)} +
+ ))} +
+
+ ))} + {signals.length === 0 && ( +
+ 暂无自定义信号,点击右上角「新建自定义信号」。 +
+ )}
- ))} - {signals.length === 0 && ( -
- 暂无自定义信号,点击右上角「新建信号」。 -
- )} -
+
+ )} + +
) } -// ── 右值输入:可填数字,也可选「字段引用」──────────────── -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 ( -
- {isField ? ( - <> - - - - ) : ( - <> - 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" /> - - - )} +
+
{label}
+
{value}
+
{hint}
) } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 9a2b1ed..50ac7e1 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -9,6 +9,7 @@ export default defineConfig({ }, }, server: { + host: '0.0.0.0', // 允许局域网访问 port: 3011, proxy: { // dev 时 /api 转发到 FastAPI