mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
merge: PR #167 监控自定义信号全显 + 信息条弹窗性能优化
冲突解法: SignalPicker 采用 options 形态 (PR 重构) + 保留 main 的 intradayDisabledSignals/intradayDisabledHint 变量 (含自选分组作用域 禁用分时穿越的增强语义, 自动合并会被 PR 旧内联表达式悄悄丢掉) + filterCustomByKind: false (监控场景显示全部自定义信号)。 验证: tsc 零错误, pnpm build 通过。
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
* - 下半区「内置列」:按业务分组折叠
|
* - 下半区「内置列」:按业务分组折叠
|
||||||
* - 底部「扩展数据列」:复用 ext_data schema,按需添加字段
|
* - 底部「扩展数据列」:复用 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 { motion, AnimatePresence } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
||||||
@@ -39,6 +39,13 @@ interface ListColumnCustomizerProps {
|
|||||||
showExtColumns?: boolean
|
showExtColumns?: boolean
|
||||||
/** 是否显示「单独显示」勾选项(默认 false;仅信息条场景启用,让某列独占一行)。 */
|
/** 是否显示「单独显示」勾选项(默认 false;仅信息条场景启用,让某列独占一行)。 */
|
||||||
showStandaloneToggle?: boolean
|
showStandaloneToggle?: boolean
|
||||||
|
/**
|
||||||
|
* 是否禁用遮罩背景模糊 (backdrop-blur)。默认 false。
|
||||||
|
* 信息条场景 (宿主含 Canvas K线/分时图) 传 true:blur 会对 Canvas 做逐帧 GPU 合成,
|
||||||
|
* 是抽屉打开/滑动卡顿的主因;改纯半透明遮罩后合成层锐减。
|
||||||
|
* 选股/自选页背后是 DOM 表格,blur 开销可忽略,保持默认。
|
||||||
|
*/
|
||||||
|
disableBackdropBlur?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 判断扩展数据字段类型是否为数字(int/float/double/number/decimal 等)。
|
/** 判断扩展数据字段类型是否为数字(int/float/double/number/decimal 等)。
|
||||||
@@ -54,16 +61,19 @@ function isNumericFieldType(ft?: string): boolean {
|
|||||||
return true
|
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 }: {
|
function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, intradayConfig: intradayPanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: {
|
||||||
col: ColumnConfig
|
col: ColumnConfig
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
onConfig: (id: string | null) => void
|
onConfig: (id: string | null) => void
|
||||||
configOpen: boolean
|
configOpen: boolean
|
||||||
extTableLabel: string
|
extTableLabel: string
|
||||||
extConfig: React.ReactNode
|
extConfig: () => React.ReactNode
|
||||||
candleConfig: React.ReactNode
|
candleConfig: () => React.ReactNode
|
||||||
intradayConfig: React.ReactNode
|
intradayConfig: () => React.ReactNode
|
||||||
strategiesConfig: React.ReactNode
|
strategiesConfig: () => React.ReactNode
|
||||||
showStandaloneToggle?: boolean
|
showStandaloneToggle?: boolean
|
||||||
onToggleStandalone?: (id: string) => void
|
onToggleStandalone?: (id: string) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -132,7 +142,7 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel,
|
|||||||
<EyeOff className="h-3 w-3" />
|
<EyeOff className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{hasConfig && configOpen && (isExt ? extConfig : isCandle ? candlePanel : isIntraday ? intradayPanel : strategiesConfig)}
|
{hasConfig && configOpen && (isExt ? extConfig() : isCandle ? candlePanel() : isIntraday ? intradayPanel() : strategiesConfig())}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -149,6 +159,7 @@ export function ListColumnCustomizer({
|
|||||||
extFieldFilter,
|
extFieldFilter,
|
||||||
showExtColumns = true,
|
showExtColumns = true,
|
||||||
showStandaloneToggle = false,
|
showStandaloneToggle = false,
|
||||||
|
disableBackdropBlur = false,
|
||||||
}: ListColumnCustomizerProps) {
|
}: ListColumnCustomizerProps) {
|
||||||
const extSchema = useQuery({
|
const extSchema = useQuery({
|
||||||
queryKey: QK.extDataSchemaAll,
|
queryKey: QK.extDataSchemaAll,
|
||||||
@@ -158,6 +169,14 @@ export function ListColumnCustomizer({
|
|||||||
})
|
})
|
||||||
const backdrop = useDialogBackdrop(onClose)
|
const backdrop = useDialogBackdrop(onClose)
|
||||||
|
|
||||||
|
// columns/onChange 用 ref 镜像,让下方多数 useCallback 空依赖(addExtColumn 例外,
|
||||||
|
// 依赖 props.extColumnAlign)。callback 引用稳定后,columns 变更不再导致
|
||||||
|
// SortableActiveCol 等子组件因 props 变化而无谓重渲染。
|
||||||
|
const columnsRef = useRef(columns)
|
||||||
|
columnsRef.current = columns
|
||||||
|
const onChangeRef = useRef(onChange)
|
||||||
|
onChangeRef.current = onChange
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||||
const [configOpenId, setConfigOpenId] = useState<string | null>(null)
|
const [configOpenId, setConfigOpenId] = useState<string | null>(null)
|
||||||
@@ -175,16 +194,18 @@ export function ListColumnCustomizer({
|
|||||||
const activeCols = useMemo(() => columns.filter(c => !c.pinned && c.visible), [columns])
|
const activeCols = useMemo(() => columns.filter(c => !c.pinned && c.visible), [columns])
|
||||||
|
|
||||||
const toggleVisible = useCallback((colId: string) => {
|
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
|
c.id === colId && !c.pinned ? { ...c, visible: !c.visible } : c
|
||||||
))
|
))
|
||||||
}, [columns, onChange])
|
}, [])
|
||||||
|
|
||||||
const toggleStandalone = useCallback((colId: string) => {
|
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
|
c.id === colId ? { ...c, standalone: !c.standalone } : c
|
||||||
))
|
))
|
||||||
}, [columns, onChange])
|
}, [])
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
@@ -194,19 +215,21 @@ export function ListColumnCustomizer({
|
|||||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
if (!over || active.id === over.id) return
|
if (!over || active.id === over.id) return
|
||||||
const reorderCols = columns.filter(c => !c.pinned)
|
const cols = columnsRef.current
|
||||||
const pinnedCols = columns.filter(c => c.pinned)
|
const reorderCols = cols.filter(c => !c.pinned)
|
||||||
|
const pinnedCols = cols.filter(c => c.pinned)
|
||||||
const ids = reorderCols.map(c => c.id)
|
const ids = reorderCols.map(c => c.id)
|
||||||
const oldIdx = ids.indexOf(active.id as string)
|
const oldIdx = ids.indexOf(active.id as string)
|
||||||
const newIdx = ids.indexOf(over.id as string)
|
const newIdx = ids.indexOf(over.id as string)
|
||||||
if (oldIdx < 0 || newIdx < 0) return
|
if (oldIdx < 0 || newIdx < 0) return
|
||||||
const reordered = arrayMove(reorderCols, oldIdx, newIdx)
|
const reordered = arrayMove(reorderCols, oldIdx, newIdx)
|
||||||
onChange([...pinnedCols, ...reordered])
|
onChangeRef.current([...pinnedCols, ...reordered])
|
||||||
}, [columns, onChange])
|
}, [])
|
||||||
|
|
||||||
const addExtColumn = useCallback((configId: string, fieldName: string, fieldLabel?: string, fieldType?: string) => {
|
const addExtColumn = useCallback((configId: string, fieldName: string, fieldLabel?: string, fieldType?: string) => {
|
||||||
|
const cols = columnsRef.current
|
||||||
const colId = `ext:${configId}:${fieldName}`
|
const colId = `ext:${configId}:${fieldName}`
|
||||||
if (columns.some(c => c.id === colId)) {
|
if (cols.some(c => c.id === colId)) {
|
||||||
toggleVisible(colId)
|
toggleVisible(colId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,64 +240,71 @@ export function ListColumnCustomizer({
|
|||||||
visible: true,
|
visible: true,
|
||||||
align: extColumnAlign,
|
align: extColumnAlign,
|
||||||
}
|
}
|
||||||
const actionIdx = columns.findIndex(c => c.id === 'builtin:action')
|
const actionIdx = cols.findIndex(c => c.id === 'builtin:action')
|
||||||
if (actionIdx >= 0) {
|
if (actionIdx >= 0) {
|
||||||
const next = [...columns]
|
const next = [...cols]
|
||||||
next.splice(actionIdx, 0, newCol)
|
next.splice(actionIdx, 0, newCol)
|
||||||
onChange(next)
|
onChangeRef.current(next)
|
||||||
} else {
|
} else {
|
||||||
onChange([...columns, newCol])
|
onChangeRef.current([...cols, newCol])
|
||||||
}
|
}
|
||||||
}, [columns, extColumnAlign, onChange, toggleVisible])
|
}, [extColumnAlign, toggleVisible])
|
||||||
|
|
||||||
const hideColumn = useCallback((colId: string) => {
|
const hideColumn = useCallback((colId: string) => {
|
||||||
onChange(columns.map(c => c.id === colId ? { ...c, visible: false } : c))
|
const cols = columnsRef.current
|
||||||
}, [columns, onChange])
|
onChangeRef.current(cols.map(c => c.id === colId ? { ...c, visible: false } : c))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const updateExtDisplay = useCallback((colId: string, patch: Partial<ExtColumnDisplayConfig>) => {
|
const updateExtDisplay = useCallback((colId: string, patch: Partial<ExtColumnDisplayConfig>) => {
|
||||||
onChange(columns.map(c => {
|
const cols = columnsRef.current
|
||||||
|
onChangeRef.current(cols.map(c => {
|
||||||
if (c.id !== colId) return c
|
if (c.id !== colId) return c
|
||||||
return { ...c, extDisplay: { displayMode: 'tag', ...(c.extDisplay || {}), ...patch } }
|
return { ...c, extDisplay: { displayMode: 'tag', ...(c.extDisplay || {}), ...patch } }
|
||||||
}))
|
}))
|
||||||
}, [columns, onChange])
|
}, [])
|
||||||
|
|
||||||
const resetExtDisplay = useCallback((colId: string) => {
|
const resetExtDisplay = useCallback((colId: string) => {
|
||||||
onChange(columns.map(c => {
|
const cols = columnsRef.current
|
||||||
|
onChangeRef.current(cols.map(c => {
|
||||||
if (c.id !== colId) return c
|
if (c.id !== colId) return c
|
||||||
const { extDisplay, ...rest } = c
|
const { extDisplay, ...rest } = c
|
||||||
return rest
|
return rest
|
||||||
}))
|
}))
|
||||||
}, [columns, onChange])
|
}, [])
|
||||||
|
|
||||||
const updateCandleConfig = useCallback((colId: string, patch: Partial<CandleColumnConfig>) => {
|
const updateCandleConfig = useCallback((colId: string, patch: Partial<CandleColumnConfig>) => {
|
||||||
onChange(columns.map(c => {
|
const cols = columnsRef.current
|
||||||
|
onChangeRef.current(cols.map(c => {
|
||||||
if (c.id !== colId) return c
|
if (c.id !== colId) return c
|
||||||
return { ...c, candleConfig: { ...c.candleConfig, ...patch } }
|
return { ...c, candleConfig: { ...c.candleConfig, ...patch } }
|
||||||
}))
|
}))
|
||||||
}, [columns, onChange])
|
}, [])
|
||||||
|
|
||||||
const resetCandleConfig = useCallback((colId: string) => {
|
const resetCandleConfig = useCallback((colId: string) => {
|
||||||
onChange(columns.map(c => {
|
const cols = columnsRef.current
|
||||||
|
onChangeRef.current(cols.map(c => {
|
||||||
if (c.id !== colId) return c
|
if (c.id !== colId) return c
|
||||||
const { candleConfig, ...rest } = c
|
const { candleConfig, ...rest } = c
|
||||||
return rest
|
return rest
|
||||||
}))
|
}))
|
||||||
}, [columns, onChange])
|
}, [])
|
||||||
|
|
||||||
const updateIntradayConfig = useCallback((colId: string, patch: Partial<IntradayColumnConfig>) => {
|
const updateIntradayConfig = useCallback((colId: string, patch: Partial<IntradayColumnConfig>) => {
|
||||||
onChange(columns.map(c => {
|
const cols = columnsRef.current
|
||||||
|
onChangeRef.current(cols.map(c => {
|
||||||
if (c.id !== colId) return c
|
if (c.id !== colId) return c
|
||||||
return { ...c, intradayConfig: { ...c.intradayConfig, ...patch } }
|
return { ...c, intradayConfig: { ...c.intradayConfig, ...patch } }
|
||||||
}))
|
}))
|
||||||
}, [columns, onChange])
|
}, [])
|
||||||
|
|
||||||
const resetIntradayConfig = useCallback((colId: string) => {
|
const resetIntradayConfig = useCallback((colId: string) => {
|
||||||
onChange(columns.map(c => {
|
const cols = columnsRef.current
|
||||||
|
onChangeRef.current(cols.map(c => {
|
||||||
if (c.id !== colId) return c
|
if (c.id !== colId) return c
|
||||||
const { intradayConfig, ...rest } = c
|
const { intradayConfig, ...rest } = c
|
||||||
return rest
|
return rest
|
||||||
}))
|
}))
|
||||||
}, [columns, onChange])
|
}, [])
|
||||||
|
|
||||||
const toggleGroup = useCallback((groupId: string) => {
|
const toggleGroup = useCallback((groupId: string) => {
|
||||||
setExpandedGroups(prev => {
|
setExpandedGroups(prev => {
|
||||||
@@ -294,8 +324,8 @@ export function ListColumnCustomizer({
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const extTables = extSchema.data?.items ?? []
|
const extTables = extSchema.data?.items ?? EMPTY_EXT_TABLES
|
||||||
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 query = searchQuery.trim().toLowerCase()
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
@@ -722,7 +752,7 @@ export function ListColumnCustomizer({
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
className={`absolute inset-0 bg-black/50 ${disableBackdropBlur ? '' : 'backdrop-blur-sm'}`}
|
||||||
{...backdrop}
|
{...backdrop}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -768,10 +798,10 @@ export function ListColumnCustomizer({
|
|||||||
onConfig={setConfigOpenId}
|
onConfig={setConfigOpenId}
|
||||||
configOpen={configOpenId === col.id}
|
configOpen={configOpenId === col.id}
|
||||||
extTableLabel={col.source.type === 'ext' ? (extTableLabelMap.get(col.source.configId) || col.source.configId) : ''}
|
extTableLabel={col.source.type === 'ext' ? (extTableLabelMap.get(col.source.configId) || col.source.configId) : ''}
|
||||||
extConfig={renderExtConfig(col)}
|
extConfig={() => renderExtConfig(col)}
|
||||||
candleConfig={renderCandleConfig(col)}
|
candleConfig={() => renderCandleConfig(col)}
|
||||||
intradayConfig={renderIntradayConfig(col)}
|
intradayConfig={() => renderIntradayConfig(col)}
|
||||||
strategiesConfig={renderStrategiesConfig(col)}
|
strategiesConfig={() => renderStrategiesConfig(col)}
|
||||||
showStandaloneToggle={showStandaloneToggle}
|
showStandaloneToggle={showStandaloneToggle}
|
||||||
onToggleStandalone={toggleStandalone}
|
onToggleStandalone={toggleStandalone}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export function StockInfoBar({
|
|||||||
builtinSectionLabel="可选指标"
|
builtinSectionLabel="可选指标"
|
||||||
extColumnAlign="left"
|
extColumnAlign="left"
|
||||||
showStandaloneToggle
|
showStandaloneToggle
|
||||||
|
disableBackdropBlur
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -479,9 +479,12 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
|||||||
signals={selectedSignals}
|
signals={selectedSignals}
|
||||||
onChange={onSignalPickerChange}
|
onChange={onSignalPickerChange}
|
||||||
kind="entry"
|
kind="entry"
|
||||||
builtinSignals={pickerSignals}
|
options={{
|
||||||
disabledSignals={intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []}
|
builtinSignals: pickerSignals,
|
||||||
disabledSignalHint={intradaySupport?.reason}
|
disabledSignals: intradayDisabledSignals,
|
||||||
|
disabledSignalHint: intradayDisabledHint,
|
||||||
|
filterCustomByKind: false,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{hasIntradaySignal && (
|
{hasIntradaySignal && (
|
||||||
<div className={`mt-2 text-[10px] ${intradaySupport?.available === false ? 'text-danger' : 'text-muted'}`}>
|
<div className={`mt-2 text-[10px] ${intradaySupport?.available === false ? 'text-danger' : 'text-muted'}`}>
|
||||||
@@ -1271,9 +1274,12 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
|
|||||||
signals={selectedSignals}
|
signals={selectedSignals}
|
||||||
onChange={onSignalPickerChange}
|
onChange={onSignalPickerChange}
|
||||||
kind="entry"
|
kind="entry"
|
||||||
builtinSignals={pickerSignals}
|
options={{
|
||||||
disabledSignals={intradayDisabledSignals}
|
builtinSignals: pickerSignals,
|
||||||
disabledSignalHint={intradayDisabledHint}
|
disabledSignals: intradayDisabledSignals,
|
||||||
|
disabledSignalHint: intradayDisabledHint,
|
||||||
|
filterCustomByKind: false,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{hasIntradaySignal && (
|
{hasIntradaySignal && (
|
||||||
<div className={`mt-2 text-[10px] ${intradaySupport?.available === false ? 'text-danger' : 'text-muted'}`}>
|
<div className={`mt-2 text-[10px] ${intradaySupport?.available === false ? 'text-danger' : 'text-muted'}`}>
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { PenLine } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
import { SIGNAL_OPTIONS, cnSignal } from '@/lib/signals'
|
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 {
|
interface Props {
|
||||||
/** 当前选中的信号 ID 列表 */
|
/** 当前选中的信号 ID 列表 */
|
||||||
signals: string[]
|
signals: string[]
|
||||||
@@ -11,30 +27,27 @@ interface Props {
|
|||||||
onChange: (next: string[]) => void
|
onChange: (next: string[]) => void
|
||||||
/** 买点 / 卖点 — 决定自定义信号的过滤与配色主题 */
|
/** 买点 / 卖点 — 决定自定义信号的过滤与配色主题 */
|
||||||
kind: 'entry' | 'exit'
|
kind: 'entry' | 'exit'
|
||||||
/** 渲染尺寸: dialog = 选股弹窗紧凑样式; panel = 回测页设置抽屉样式 */
|
options?: SignalPickerOptions
|
||||||
variant?: 'dialog' | 'panel'
|
|
||||||
builtinSignals?: { key: string; label: string }[]
|
|
||||||
disabledSignals?: string[]
|
|
||||||
disabledSignalHint?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 买卖触发器信号选择 — 选股页弹窗 / 回测页共用。
|
* 买卖触发器信号选择 — 选股页弹窗 / 回测页共用。
|
||||||
*
|
*
|
||||||
* - 内置信号 (signal_*): 全部展示
|
* - 内置信号 (signal_*): 全部展示
|
||||||
* - 自定义信号 (csg_*): 按 kind 过滤 (entry / exit / both)
|
* - 自定义信号 (csg_*): 按 kind 过滤 (entry / exit / both), 除非 filterCustomByKind=false
|
||||||
* - entry 蓝色主题, exit 橙色主题
|
* - 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 customSignalsQuery = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
|
||||||
|
|
||||||
const customOptions = useMemo(() => {
|
const customOptions = useMemo(() => {
|
||||||
const list = (customSignalsQuery.data?.signals ?? [])
|
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<string, string> = {}
|
const names: Record<string, string> = {}
|
||||||
for (const cs of list) names[`csg_${cs.id}`] = cs.name
|
for (const cs of list) names[`csg_${cs.id}`] = cs.name
|
||||||
return { list, names }
|
return { list, names }
|
||||||
}, [customSignalsQuery.data, kind])
|
}, [customSignalsQuery.data, kind, filterCustomByKind])
|
||||||
|
|
||||||
const toggle = (sig: string) => {
|
const toggle = (sig: string) => {
|
||||||
const next = signals.includes(sig) ? signals.filter(x => x !== sig) : [...signals, sig]
|
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'
|
const idle = variant === 'dialog'
|
||||||
? 'border-border bg-base text-muted hover:border-accent/40'
|
? 'border-border bg-base text-muted hover:border-accent/40'
|
||||||
: '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'
|
const btnCls = variant === 'dialog'
|
||||||
? 'rounded px-1.5 py-0.5 text-[10px] font-medium border transition-colors cursor-pointer'
|
? '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"
|
type="button"
|
||||||
onClick={() => toggle(id)}
|
onClick={() => toggle(id)}
|
||||||
title="自定义信号"
|
title="自定义信号"
|
||||||
className={`${btnCls} ${signals.includes(id) ? customActive : customIdle}`}
|
className={`${btnCls} relative ${signals.includes(id) ? active : idle}`}
|
||||||
>
|
>
|
||||||
{customOptions.names[id]}
|
{customOptions.names[id]}
|
||||||
|
<span className="pointer-events-none absolute -top-1 -right-1 rounded-full border border-border bg-base p-px">
|
||||||
|
<PenLine className="h-2 w-2 text-muted" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -582,7 +582,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
|||||||
defaultOpen={false}
|
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" />}
|
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" />
|
<SignalPicker signals={entrySignals} onChange={setEntrySignals} kind="entry" options={{ variant: 'dialog' }} />
|
||||||
<div className="text-[10px] leading-4 text-muted/70">任一入场点满足即进入候选。</div>
|
<div className="text-[10px] leading-4 text-muted/70">任一入场点满足即进入候选。</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -593,7 +593,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
|||||||
defaultOpen={false}
|
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" />}
|
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" />
|
<SignalPicker signals={exitSignals} onChange={setExitSignals} kind="exit" options={{ variant: 'dialog' }} />
|
||||||
<div className="text-[10px] leading-4 text-muted/70">任一出场点满足即触发出场。</div>
|
<div className="text-[10px] leading-4 text-muted/70">任一出场点满足即触发出场。</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user