diff --git a/frontend/src/components/screener/CompositeStrategyDialog.tsx b/frontend/src/components/screener/CompositeStrategyDialog.tsx index bd0d37e..efa61d7 100644 --- a/frontend/src/components/screener/CompositeStrategyDialog.tsx +++ b/frontend/src/components/screener/CompositeStrategyDialog.tsx @@ -1,7 +1,9 @@ -import { useState, useMemo, useEffect, useRef } from 'react' +import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { X, Layers, Plus, Loader2, Search } from 'lucide-react' +import { X, Layers, Plus, Loader2, Search, Settings2 } from 'lucide-react' import { api, type ScreenerStrategy } from '@/lib/api' +import { toPercentages, normalizeWeights } from '@/lib/weights' +import { StrategySettingsDialog } from '@/components/screener/StrategySettingsDialog' interface Props { open: boolean @@ -26,10 +28,12 @@ const BADGE_CLS: Record = { export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategyId }: Props) { const isEdit = !!editStrategyId const [name, setName] = useState('') + // 用户手动编辑过名称后停止自动生成(子策略名用 + 连接) + const [nameDirty, setNameDirty] = useState(false) const [description, setDescription] = useState('') const [strategyId, setStrategyId] = useState('') const [children, setChildren] = useState([]) - const [mergeMode, setMergeMode] = useState<'union' | 'intersect'>('union') + const [mergeMode, setMergeMode] = useState<'union' | 'intersect'>('intersect') const [minConfirm, setMinConfirm] = useState(0) const [saving, setSaving] = useState(false) const [error, setError] = useState('') @@ -40,6 +44,19 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy // 拉取所有可用子策略(排除 composite 自身) const [available, setAvailable] = useState([]) const [loadingList, setLoadingList] = useState(false) + // 点击子策略名打开其配置编辑 + const [editingChildId, setEditingChildId] = useState(null) + + const loadAvailable = useCallback(() => { + setLoadingList(true) + api.screenerStrategies() + .then(data => { + // 排除 composite 策略(不能嵌套) + setAvailable((data.presets ?? []).filter(s => s.source !== 'composite')) + }) + .catch(() => setAvailable([])) + .finally(() => setLoadingList(false)) + }, []) useEffect(() => { if (!open) return @@ -49,18 +66,13 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy // 创建模式自动生成 ID(composite_ + 时间戳), 编辑模式用现有 ID setStrategyId(isEdit ? (editStrategyId ?? '') : `composite_${Date.now().toString(36)}`) setChildren([]) - setMergeMode('union') + setMergeMode('intersect') setMinConfirm(0) setError('') setSearch('') - setLoadingList(true) - api.screenerStrategies() - .then(data => { - // 排除 composite 策略(不能嵌套) - setAvailable((data.presets ?? []).filter(s => s.source !== 'composite')) - }) - .catch(() => setAvailable([])) - .finally(() => setLoadingList(false)) + setNameDirty(isEdit) + setEditingChildId(null) + loadAvailable() // 编辑模式: 加载现有配置回显 if (isEdit && editStrategyId) { api.strategyGet(editStrategyId) @@ -70,12 +82,14 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy setMergeMode((detail.params_defaults?.merge_mode as 'union' | 'intersect') ?? 'union') setMinConfirm(detail.params_defaults?.min_confirm ?? 0) if (detail.composite_children) { - setChildren(detail.composite_children.map(c => ({ strategy_id: c.id, weight: c.weight }))) + // 存储的小数权重 → 滑块百分比口径 + const pcts = toPercentages(detail.composite_children.map(c => c.weight)) + setChildren(detail.composite_children.map((c, i) => ({ strategy_id: c.id, weight: pcts[i] }))) } }) .catch(() => {}) } - }, [open, isEdit, editStrategyId]) + }, [open, isEdit, editStrategyId, loadAvailable]) const filteredAvailable = useMemo(() => { const keyword = search.trim().toLowerCase() @@ -88,25 +102,33 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy }, [available, children, search]) const addChild = (s: ScreenerStrategy) => { - setChildren(prev => [...prev, { strategy_id: s.id, weight: 1.0 }]) + // 首个子策略独占 100%, 后续默认 10% (与因子编辑口径一致); 保存时自动按比例归一 + setChildren(prev => [...prev, { strategy_id: s.id, weight: prev.length === 0 ? 100 : 10 }]) } const removeChild = (id: string) => { setChildren(prev => prev.filter(c => c.strategy_id !== id)) } + + // 未手动命名时, 默认名称跟随子策略: 多个策略名用 + 连接 + useEffect(() => { + if (nameDirty) return + if (children.length === 0) { + setName('') + return + } + const names = children.map(c => available.find(a => a.id === c.strategy_id)?.name ?? c.strategy_id) + setName(names.join('+')) + }, [children, nameDirty, available]) const updateWeight = (id: string, weight: number) => { setChildren(prev => prev.map(c => c.strategy_id === id ? { ...c, weight } : c)) } - const totalWeight = useMemo( + // 滑块百分比总和; 允许 ≠100 (黄色提示), 保存时自动按比例归一, 无需手动操作 + const totalPct = useMemo( () => children.reduce((sum, c) => sum + (c.weight || 0), 0), [children], ) - const normalizeWeights = () => { - if (totalWeight <= 0) return - setChildren(prev => prev.map(c => ({ ...c, weight: Math.round((c.weight / totalWeight) * 1000) / 1000 }))) - } - const handleSave = async () => { setError('') if (!name.trim()) { @@ -119,11 +141,12 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy } setSaving(true) try { + const normalized = normalizeWeights(children.map(c => c.weight)) const result = await api.strategySaveComposite({ strategy_id: isEdit ? (editStrategyId ?? '') : strategyId.trim(), name: name.trim(), description: description.trim(), - children: children.map(c => ({ strategy_id: c.strategy_id, weight: c.weight })), + children: children.map((c, i) => ({ strategy_id: c.strategy_id, weight: normalized[i] })), merge_mode: mergeMode, min_confirm: minConfirm, mode: isEdit ? 'update' : 'create', @@ -137,7 +160,8 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy } return ( - + <> + {open && ( 策略名称 setName(e.target.value)} + onChange={e => { setName(e.target.value); setNameDirty(true) }} placeholder="我的叠加策略" className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground placeholder:text-muted/40" /> @@ -216,8 +240,8 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy onChange={e => setMergeMode(e.target.value as 'union' | 'intersect')} className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground" > - +
@@ -240,9 +264,12 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy 子策略({children.length}) - 权重总和: {totalWeight.toFixed(2)} - {totalWeight > 0 && Math.abs(totalWeight - 1) > 0.001 && ( - + 权重 + 0 && totalPct !== 100 ? 'text-amber-400' : 'text-emerald-400'}`}> + {totalPct}% + + {children.length > 0 && totalPct !== 100 && ( + (保存时自动按比例归一) )}
@@ -256,20 +283,31 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy const s = available.find(a => a.id === c.strategy_id) return (
- {s?.name ?? c.strategy_id} + {s?.source && ( {SRC_MAP[s.source] ?? s.source} )} updateWeight(c.strategy_id, parseFloat(e.target.value) || 0)} - className="w-16 rounded border border-border bg-base px-1.5 py-0.5 text-[11px] text-foreground" + onChange={e => updateWeight(c.strategy_id, parseInt(e.target.value) || 0)} + className="h-1 w-24 cursor-pointer accent-teal-400" + aria-label={`${s?.name ?? c.strategy_id}权重`} /> + {Math.round(c.weight)}% @@ -291,7 +329,7 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy className="w-full rounded-btn border border-border bg-elevated py-1.5 pl-7 pr-2 text-xs text-foreground placeholder:text-muted/40" />
-
+
{loadingList && (
加载中 @@ -346,6 +384,20 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy )} - + + + {/* 子策略配置编辑: 点击已选子策略名打开, 渲染在后覆盖于叠加对话框之上 */} + setEditingChildId(null)} + onSaved={() => loadAvailable()} + onDeleted={() => { + // 子策略被删除: 从已选列表移除并刷新可选列表 + setChildren(prev => prev.filter(c => c.strategy_id !== editingChildId)) + setEditingChildId(null) + loadAvailable() + }} + /> + ) } diff --git a/frontend/src/components/screener/StrategyPoolDialog.tsx b/frontend/src/components/screener/StrategyPoolDialog.tsx index 19e12ff..fa11e10 100644 --- a/frontend/src/components/screener/StrategyPoolDialog.tsx +++ b/frontend/src/components/screener/StrategyPoolDialog.tsx @@ -14,6 +14,8 @@ const SOURCE_CLS: Record = { builtin: 'bg-accent/10 text-accent border-accent/20', custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30', ai: 'bg-purple-500/10 text-purple-400 border-purple-500/20', + // 叠加策略归入「自定义」分组展示, 徽标与 StrategyCard 一致用 teal 区分 + composite: 'bg-teal-500/10 text-teal-400 border-teal-500/30', invalid: 'bg-danger/10 text-danger border-danger/20', } @@ -21,6 +23,7 @@ const SOURCE_LABEL: Record = { builtin: '内置', custom: '自定义', ai: 'AI', + composite: '叠加', invalid: '失效', } @@ -98,9 +101,12 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { [allStrategies] ) - // 按 Tab 分组过滤待选 + // 按 Tab 分组过滤待选; 叠加策略(composite)并入「自定义」分组 const filteredAvailable = useMemo(() => { if (activeTab === 'all') return available + if (activeTab === 'custom') { + return available.filter(s => s.source === 'custom' || s.source === 'composite') + } return available.filter(s => s.source === activeTab) }, [available, activeTab]) @@ -242,7 +248,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { {TABS.map(tab => { const count = tab.id === 'all' ? available.length - : available.filter(s => s.source === tab.id).length + : tab.id === 'custom' + ? available.filter(s => s.source === 'custom' || s.source === 'composite').length + : available.filter(s => s.source === tab.id).length return ( + 共 {compositeChildren.length} 个 · 权重 + 0 && compositeTotal !== 100 ? 'text-amber-400' : 'text-emerald-400'}`}> + {compositeTotal}% + + {compositeChildren.length > 0 && compositeTotal !== 100 && ( + (保存时自动按比例归一) )} {c.source && ( {SRC_LABEL[c.source] ?? c.source} )}
{c.id}
-
+
setCompositeChildren(prev => prev.map((p, j) => j === i ? { ...p, weight: parseFloat(e.target.value) || 0 } : p))} - className="w-16 h-7 px-1.5 rounded-lg bg-base border border-border/40 text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50" + onChange={e => setCompositeChildren(prev => prev.map((p, j) => j === i ? { ...p, weight: parseInt(e.target.value) || 0 } : p))} + className="h-1 w-24 cursor-pointer accent-teal-400" + aria-label={`${c.name || c.id}权重`} /> + {Math.round(c.weight)}% @@ -482,7 +509,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
)}
- 提示: 权重建议归一为 1.0; 修改后点底部"保存设置"生效。 + 提示: 权重按相对比例生效, 保存时自动归一; 修改后点底部"保存设置"生效。
) @@ -623,7 +650,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif )}
- {(detail?.source === 'ai' || detail?.source === 'custom') && ( + {onAiModify && (detail?.source === 'ai' || detail?.source === 'custom') && (