From e864ecde4b02f38635a3a2b0b39d2dfbaaaa1053 Mon Sep 17 00:00:00 2001 From: intfoo Date: Sun, 2 Aug 2026 22:34:28 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(monitor):=20=E7=9B=91=E6=8E=A7=E8=A7=84?= =?UTF-8?q?=E5=88=99=E4=BF=A1=E5=8F=B7=E9=80=89=E6=8B=A9=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E8=87=AA=E5=AE=9A=E4=B9=89=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?,=20=E4=B8=8D=E5=86=8D=E6=8C=89=20kind=20=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SignalPicker 新增 filterCustomByKind 选项 (默认 true 保持原行为); 监控规则页传 false — 报警语义是"命中即报", 不分入场/出场, 与后端监控引擎按 csg_ 布尔列直接求值对齐; 可选 props 收拢为 options 对象; 自定义信号按钮配色与内置一致, 右上角 PenLine 角标区分 --- .../src/components/monitor/RuleEditor.tsx | 18 +++++--- .../src/components/screener/SignalPicker.tsx | 42 ++++++++++++------- .../screener/StrategySettingsDialog.tsx | 4 +- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/monitor/RuleEditor.tsx b/frontend/src/components/monitor/RuleEditor.tsx index 58d7639..62d52a9 100644 --- a/frontend/src/components/monitor/RuleEditor.tsx +++ b/frontend/src/components/monitor/RuleEditor.tsx @@ -253,9 +253,12 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" - builtinSignals={pickerSignals} - disabledSignals={intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []} - disabledSignalHint={intradaySupport?.reason} + options={{ + builtinSignals: pickerSignals, + disabledSignals: intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : [], + disabledSignalHint: intradaySupport?.reason, + filterCustomByKind: false, + }} /> {hasIntradaySignal && (
@@ -472,9 +475,12 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) { signals={selectedSignals} onChange={onSignalPickerChange} kind="entry" - builtinSignals={pickerSignals} - disabledSignals={intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []} - disabledSignalHint={intradaySupport?.reason} + options={{ + builtinSignals: pickerSignals, + disabledSignals: intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : [], + disabledSignalHint: intradaySupport?.reason, + filterCustomByKind: false, + }} /> {hasIntradaySignal && (
diff --git a/frontend/src/components/screener/SignalPicker.tsx b/frontend/src/components/screener/SignalPicker.tsx index 79e098d..8ce3f60 100644 --- a/frontend/src/components/screener/SignalPicker.tsx +++ b/frontend/src/components/screener/SignalPicker.tsx @@ -1,9 +1,25 @@ import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' +import { PenLine } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { SIGNAL_OPTIONS, cnSignal } from '@/lib/signals' +/** 展示与过滤可选项 (全部可选, 有默认值) */ +interface SignalPickerOptions { + /** 渲染尺寸: dialog = 选股弹窗紧凑样式; panel = 回测页设置抽屉样式 */ + variant?: 'dialog' | 'panel' + builtinSignals?: { key: string; label: string }[] + disabledSignals?: string[] + disabledSignalHint?: string + /** + * 是否按 kind 过滤自定义信号 (csg_*)。默认 true (选股/回测: 入场区只显示 entry)。 + * 监控规则页设 false: 报警语义是"命中即报", 不分入场出场, 自定义信号全部显示 + * (与内置信号一致: builtinSignals 透传时本就不按 kind 过滤)。 + */ + filterCustomByKind?: boolean +} + interface Props { /** 当前选中的信号 ID 列表 */ signals: string[] @@ -11,30 +27,27 @@ interface Props { onChange: (next: string[]) => void /** 买点 / 卖点 — 决定自定义信号的过滤与配色主题 */ kind: 'entry' | 'exit' - /** 渲染尺寸: dialog = 选股弹窗紧凑样式; panel = 回测页设置抽屉样式 */ - variant?: 'dialog' | 'panel' - builtinSignals?: { key: string; label: string }[] - disabledSignals?: string[] - disabledSignalHint?: string + options?: SignalPickerOptions } /** * 买卖触发器信号选择 — 选股页弹窗 / 回测页共用。 * * - 内置信号 (signal_*): 全部展示 - * - 自定义信号 (csg_*): 按 kind 过滤 (entry / exit / both) - * - entry 蓝色主题, exit 橙色主题 + * - 自定义信号 (csg_*): 按 kind 过滤 (entry / exit / both), 除非 filterCustomByKind=false + * - entry 蓝色主题, exit 橙色主题; 自定义信号边框配色与内置一致, 右上角 PenLine 角标区分 */ -export function SignalPicker({ signals, onChange, kind, variant = 'panel', builtinSignals, disabledSignals = [], disabledSignalHint }: Props) { +export function SignalPicker({ signals, onChange, kind, options }: Props) { + const { variant = 'panel', builtinSignals, disabledSignals = [], disabledSignalHint, filterCustomByKind = true } = options ?? {} const customSignalsQuery = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList }) const customOptions = useMemo(() => { const list = (customSignalsQuery.data?.signals ?? []) - .filter(s => s.enabled && (s.kind === kind || s.kind === 'both')) + .filter(s => s.enabled && (!filterCustomByKind || s.kind === kind || s.kind === 'both')) const names: Record = {} for (const cs of list) names[`csg_${cs.id}`] = cs.name return { list, names } - }, [customSignalsQuery.data, kind]) + }, [customSignalsQuery.data, kind, filterCustomByKind]) const toggle = (sig: string) => { const next = signals.includes(sig) ? signals.filter(x => x !== sig) : [...signals, sig] @@ -49,10 +62,6 @@ export function SignalPicker({ signals, onChange, kind, variant = 'panel', built const idle = variant === 'dialog' ? 'border-border bg-base text-muted hover:border-accent/40' : 'border-border bg-base text-muted hover:border-accent/40' - const customActive = isEntry - ? 'border-accent/50 bg-accent/10 text-accent' - : 'border-warning/50 bg-warning/10 text-warning' - const customIdle = 'border-amber-400/30 bg-amber-400/5 text-secondary hover:border-amber-400/50 hover:text-amber-400' const btnCls = variant === 'dialog' ? 'rounded px-1.5 py-0.5 text-[10px] font-medium border transition-colors cursor-pointer' @@ -84,9 +93,12 @@ export function SignalPicker({ signals, onChange, kind, variant = 'panel', built type="button" onClick={() => toggle(id)} title="自定义信号" - className={`${btnCls} ${signals.includes(id) ? customActive : customIdle}`} + className={`${btnCls} relative ${signals.includes(id) ? active : idle}`} > {customOptions.names[id]} + + + ) })} diff --git a/frontend/src/components/screener/StrategySettingsDialog.tsx b/frontend/src/components/screener/StrategySettingsDialog.tsx index 0a01321..4f29999 100644 --- a/frontend/src/components/screener/StrategySettingsDialog.tsx +++ b/frontend/src/components/screener/StrategySettingsDialog.tsx @@ -638,7 +638,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif defaultOpen={false} extra={} > - +
任一入场点满足即进入候选。
@@ -649,7 +649,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif defaultOpen={false} extra={} > - +
任一出场点满足即触发出场。
From 51192321b284aa4b6bdee91e99c71f1461db1f1a Mon Sep 17 00:00:00 2001 From: intfoo Date: Mon, 3 Aug 2026 16:05:29 +0800 Subject: [PATCH 2/3] =?UTF-8?q?perf(info-bar):=20=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=9D=A1=E6=8C=87=E6=A0=87=E5=BC=B9=E7=AA=97=E6=89=93=E5=BC=80?= =?UTF-8?q?/=E6=BB=91=E5=8A=A8/=E6=93=8D=E4=BD=9C=E5=8D=A1=E9=A1=BF?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListColumnCustomizer: 新增 disableBackdropBlur prop (信息条场景传 true, 避免对背后 K线 Canvas 做 backdrop-blur 逐帧 GPU 合成 — 打开/滑动卡顿主因); columns/onChange 用 ref 镜像, 8 个 useCallback 改空依赖 (callback 引用稳定, 不再随 columns 变化重建导致 SortableActiveCol memo 失效); 4 个配置面板 prop 从 ReactNode 改为 () => ReactNode 惰性渲染 (未展开不构造); extTableLabelMap 包 useMemo。StockInfoBar: 信息条场景传 disableBackdropBlur。选股/自选页保持 backdrop-blur 不变 (背后是 DOM 表格, 开销可忽略)。 --- .../src/components/ListColumnCustomizer.tsx | 106 +++++++++++------- frontend/src/components/StockInfoBar.tsx | 1 + 2 files changed, 67 insertions(+), 40 deletions(-) diff --git a/frontend/src/components/ListColumnCustomizer.tsx b/frontend/src/components/ListColumnCustomizer.tsx index 0998e08..5eb25aa 100644 --- a/frontend/src/components/ListColumnCustomizer.tsx +++ b/frontend/src/components/ListColumnCustomizer.tsx @@ -6,7 +6,7 @@ * - 下半区「内置列」:按业务分组折叠 * - 底部「扩展数据列」:复用 ext_data schema,按需添加字段 */ -import React, { useState, useCallback, useEffect, useMemo } from 'react' +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, @@ -39,6 +39,13 @@ interface ListColumnCustomizerProps { showExtColumns?: boolean /** 是否显示「单独显示」勾选项(默认 false;仅信息条场景启用,让某列独占一行)。 */ showStandaloneToggle?: boolean + /** + * 是否禁用遮罩背景模糊 (backdrop-blur)。默认 false。 + * 信息条场景 (宿主含 Canvas K线/分时图) 传 true:blur 会对 Canvas 做逐帧 GPU 合成, + * 是抽屉打开/滑动卡顿的主因;改纯半透明遮罩后合成层锐减。 + * 选股/自选页背后是 DOM 表格,blur 开销可忽略,保持默认。 + */ + disableBackdropBlur?: boolean } /** 判断扩展数据字段类型是否为数字(int/float/double/number/decimal 等)。 @@ -60,10 +67,10 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, onConfig: (id: string | null) => void configOpen: boolean extTableLabel: string - extConfig: React.ReactNode - candleConfig: React.ReactNode - intradayConfig: React.ReactNode - strategiesConfig: React.ReactNode + extConfig: () => React.ReactNode + candleConfig: () => React.ReactNode + intradayConfig: () => React.ReactNode + strategiesConfig: () => React.ReactNode showStandaloneToggle?: boolean onToggleStandalone?: (id: string) => void }) { @@ -132,7 +139,7 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel,
- {hasConfig && configOpen && (isExt ? extConfig : isCandle ? candlePanel : isIntraday ? intradayPanel : strategiesConfig)} + {hasConfig && configOpen && (isExt ? extConfig() : isCandle ? candlePanel() : isIntraday ? intradayPanel() : strategiesConfig())} ) } @@ -149,6 +156,7 @@ export function ListColumnCustomizer({ extFieldFilter, showExtColumns = true, showStandaloneToggle = false, + disableBackdropBlur = false, }: ListColumnCustomizerProps) { const extSchema = useQuery({ queryKey: QK.extDataSchemaAll, @@ -158,6 +166,13 @@ export function ListColumnCustomizer({ }) const backdrop = useDialogBackdrop(onClose) + // columns/onChange 用 ref 镜像,让下方 8 个 useCallback 空依赖, + // 避免 columns 每次变更都重建 callback 导致 SortableActiveCol 等 memo 失效。 + const columnsRef = useRef(columns) + columnsRef.current = columns + const onChangeRef = useRef(onChange) + onChangeRef.current = onChange + const [searchQuery, setSearchQuery] = useState('') const [expandedGroups, setExpandedGroups] = useState>(new Set()) const [configOpenId, setConfigOpenId] = useState(null) @@ -175,16 +190,18 @@ export function ListColumnCustomizer({ const activeCols = useMemo(() => columns.filter(c => !c.pinned && c.visible), [columns]) const toggleVisible = useCallback((colId: string) => { - onChange(columns.map(c => + const cols = columnsRef.current + onChangeRef.current(cols.map(c => c.id === colId && !c.pinned ? { ...c, visible: !c.visible } : c )) - }, [columns, onChange]) + }, []) const toggleStandalone = useCallback((colId: string) => { - onChange(columns.map(c => + const cols = columnsRef.current + onChangeRef.current(cols.map(c => c.id === colId ? { ...c, standalone: !c.standalone } : c )) - }, [columns, onChange]) + }, []) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -194,19 +211,21 @@ export function ListColumnCustomizer({ const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return - const reorderCols = columns.filter(c => !c.pinned) - const pinnedCols = columns.filter(c => c.pinned) + const cols = columnsRef.current + const reorderCols = cols.filter(c => !c.pinned) + const pinnedCols = cols.filter(c => c.pinned) const ids = reorderCols.map(c => c.id) const oldIdx = ids.indexOf(active.id as string) const newIdx = ids.indexOf(over.id as string) if (oldIdx < 0 || newIdx < 0) return const reordered = arrayMove(reorderCols, oldIdx, newIdx) - onChange([...pinnedCols, ...reordered]) - }, [columns, onChange]) + onChangeRef.current([...pinnedCols, ...reordered]) + }, []) const addExtColumn = useCallback((configId: string, fieldName: string, fieldLabel?: string, fieldType?: string) => { + const cols = columnsRef.current const colId = `ext:${configId}:${fieldName}` - if (columns.some(c => c.id === colId)) { + if (cols.some(c => c.id === colId)) { toggleVisible(colId) return } @@ -217,64 +236,71 @@ export function ListColumnCustomizer({ visible: true, align: extColumnAlign, } - const actionIdx = columns.findIndex(c => c.id === 'builtin:action') + const actionIdx = cols.findIndex(c => c.id === 'builtin:action') if (actionIdx >= 0) { - const next = [...columns] + const next = [...cols] next.splice(actionIdx, 0, newCol) - onChange(next) + onChangeRef.current(next) } else { - onChange([...columns, newCol]) + onChangeRef.current([...cols, newCol]) } - }, [columns, extColumnAlign, onChange, toggleVisible]) + }, [extColumnAlign, toggleVisible]) const hideColumn = useCallback((colId: string) => { - onChange(columns.map(c => c.id === colId ? { ...c, visible: false } : c)) - }, [columns, onChange]) + const cols = columnsRef.current + onChangeRef.current(cols.map(c => c.id === colId ? { ...c, visible: false } : c)) + }, []) const updateExtDisplay = useCallback((colId: string, patch: Partial) => { - onChange(columns.map(c => { + const cols = columnsRef.current + onChangeRef.current(cols.map(c => { if (c.id !== colId) return c return { ...c, extDisplay: { displayMode: 'tag', ...(c.extDisplay || {}), ...patch } } })) - }, [columns, onChange]) + }, []) const resetExtDisplay = useCallback((colId: string) => { - onChange(columns.map(c => { + const cols = columnsRef.current + onChangeRef.current(cols.map(c => { if (c.id !== colId) return c const { extDisplay, ...rest } = c return rest })) - }, [columns, onChange]) + }, []) const updateCandleConfig = useCallback((colId: string, patch: Partial) => { - onChange(columns.map(c => { + const cols = columnsRef.current + onChangeRef.current(cols.map(c => { if (c.id !== colId) return c return { ...c, candleConfig: { ...c.candleConfig, ...patch } } })) - }, [columns, onChange]) + }, []) const resetCandleConfig = useCallback((colId: string) => { - onChange(columns.map(c => { + const cols = columnsRef.current + onChangeRef.current(cols.map(c => { if (c.id !== colId) return c const { candleConfig, ...rest } = c return rest })) - }, [columns, onChange]) + }, []) const updateIntradayConfig = useCallback((colId: string, patch: Partial) => { - onChange(columns.map(c => { + const cols = columnsRef.current + onChangeRef.current(cols.map(c => { if (c.id !== colId) return c return { ...c, intradayConfig: { ...c.intradayConfig, ...patch } } })) - }, [columns, onChange]) + }, []) const resetIntradayConfig = useCallback((colId: string) => { - onChange(columns.map(c => { + const cols = columnsRef.current + onChangeRef.current(cols.map(c => { if (c.id !== colId) return c const { intradayConfig, ...rest } = c return rest })) - }, [columns, onChange]) + }, []) const toggleGroup = useCallback((groupId: string) => { setExpandedGroups(prev => { @@ -295,7 +321,7 @@ export function ListColumnCustomizer({ }, []) const extTables = extSchema.data?.items ?? [] - const extTableLabelMap = new Map(extTables.map(t => [t.id, t.label])) + const extTableLabelMap = useMemo(() => new Map(extTables.map(t => [t.id, t.label])), [extTables]) const query = searchQuery.trim().toLowerCase() const filteredGroups = useMemo(() => { @@ -722,7 +748,7 @@ export function ListColumnCustomizer({ renderExtConfig(col)} + candleConfig={() => renderCandleConfig(col)} + intradayConfig={() => renderIntradayConfig(col)} + strategiesConfig={() => renderStrategiesConfig(col)} showStandaloneToggle={showStandaloneToggle} onToggleStandalone={toggleStandalone} /> diff --git a/frontend/src/components/StockInfoBar.tsx b/frontend/src/components/StockInfoBar.tsx index 1063249..a5f643f 100644 --- a/frontend/src/components/StockInfoBar.tsx +++ b/frontend/src/components/StockInfoBar.tsx @@ -272,6 +272,7 @@ export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsCh builtinSectionLabel="可选指标" extColumnAlign="left" showStandaloneToggle + disableBackdropBlur />
) From 01f20ae4c31df6fd689388970e19e6e62c4f6665 Mon Sep 17 00:00:00 2001 From: intfoo Date: Mon, 3 Aug 2026 16:12:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?style(ListColumnCustomizer):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20ref=20=E9=95=9C=E5=83=8F=E6=B3=A8=E9=87=8A=20+=20ex?= =?UTF-8?q?tTables=20=E7=A9=BA=E6=95=B0=E7=BB=84=E5=B8=B8=E9=87=8F?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code review P3 反馈修正: 1) 注释原写"8个useCallback空依赖"不准确(addExtColumn依赖extColumnAlign), 改为"多数空依赖并注明例外"; 2) 注释原提"memo失效"但SortableActiveCol未memo, 改为"子组件无谓重渲染"; 3) extTables的`?? []`每次创建新空数组导致useMemo失效, 改用模块级EMPTY_EXT_TABLES常量。另: P3-4同类backdrop-blur排查StockPreviewDialog/TradeKlineModal均为居中弹窗(面板不透明覆盖中心, blur只影响边缘, K线不持续刷新), 影响远小于信息条抽屉, 不修改。 --- frontend/src/components/ListColumnCustomizer.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ListColumnCustomizer.tsx b/frontend/src/components/ListColumnCustomizer.tsx index 5eb25aa..e672dc0 100644 --- a/frontend/src/components/ListColumnCustomizer.tsx +++ b/frontend/src/components/ListColumnCustomizer.tsx @@ -61,6 +61,9 @@ function isNumericFieldType(ft?: string): boolean { return true } +/** 模块级空数组常量:extSchema 未加载时用,避免 `?? []` 每次创建新引用导致 useMemo 失效。 */ +const EMPTY_EXT_TABLES: readonly { id: string; label: string; mode: string; columns: { name: string; label: string; type: string }[] }[] = [] + function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, intradayConfig: intradayPanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: { col: ColumnConfig onRemove: (id: string) => void @@ -166,8 +169,9 @@ export function ListColumnCustomizer({ }) const backdrop = useDialogBackdrop(onClose) - // columns/onChange 用 ref 镜像,让下方 8 个 useCallback 空依赖, - // 避免 columns 每次变更都重建 callback 导致 SortableActiveCol 等 memo 失效。 + // columns/onChange 用 ref 镜像,让下方多数 useCallback 空依赖(addExtColumn 例外, + // 依赖 props.extColumnAlign)。callback 引用稳定后,columns 变更不再导致 + // SortableActiveCol 等子组件因 props 变化而无谓重渲染。 const columnsRef = useRef(columns) columnsRef.current = columns const onChangeRef = useRef(onChange) @@ -320,7 +324,7 @@ export function ListColumnCustomizer({ }) }, []) - const extTables = extSchema.data?.items ?? [] + const extTables = extSchema.data?.items ?? EMPTY_EXT_TABLES const extTableLabelMap = useMemo(() => new Map(extTables.map(t => [t.id, t.label])), [extTables]) const query = searchQuery.trim().toLowerCase()