diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a1e1c87..fe0a3b2 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -58,6 +58,7 @@ import { Logo } from './Logo' import { api, type CapabilityMatrix, type IndexQuote } from '@/lib/api' import { cn } from '@/lib/cn' import { useIsDesktop } from '@/lib/useMediaQuery' +import { useDialogBackdrop } from '@/lib/useDialogBackdrop' import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' import { computeGroupPcts, groupPctColor, groupPctTitle } from '@/lib/watchlistGroupStats' import { fmtPct } from '@/lib/format' @@ -457,6 +458,9 @@ export function Layout() { const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false // 自选实时模式限制提示: 可手动关闭, 不持久化 (刷新后恢复显示) const [dismissFreeHint, setDismissFreeHint] = useState(false) + // 开启实时行情时若存在排队中的挖掘任务 → 确认弹窗 (实时落盘会让排队任务开跑即失败) + const [miningQueuedWarning, setMiningQueuedWarning] = useState(null) + const miningWarnBackdrop = useDialogBackdrop(() => setMiningQueuedWarning(null)) // 三态循环切换 (仅桌面): 展开 → 图标条 → 隐藏 → 展开 const toggleNavCollapsed = () => { setNavStatePersist(navState === 'expanded' ? 'rail' : navState === 'rail' ? 'hidden' : 'expanded') @@ -577,25 +581,47 @@ export function Layout() { const hiddenIds = new Set(prefs?.nav_hidden ?? []) const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, ''))) - const handleToggle = async (enabled: boolean) => { - // 开启时重新校验实时权限 (以 quote_status 的数据源无关判定为准) - if (enabled) { - const fresh = await qc.fetchQuery({ - queryKey: QK.quoteStatus, - queryFn: api.quoteStatus, - }) - if (!fresh.realtime_allowed) { - toast('当前数据源无实时行情能力, 请先配置数据源', 'error') - return - } - } - await toggleQuote.mutateAsync(enabled) + const doEnableRealtime = async () => { + await toggleQuote.mutateAsync(true) // 仅在交易时段立即获取一次行情 - if (enabled && isTrading) { + if (isTrading) { api.intradayRefresh().catch(() => {}) } } + const handleToggle = async (enabled: boolean) => { + // 开启时重新校验实时权限 (以 quote_status 的数据源无关判定为准) + if (!enabled) { + await toggleQuote.mutateAsync(false) + return + } + const fresh = await qc.fetchQuery({ + queryKey: QK.quoteStatus, + queryFn: api.quoteStatus, + }) + if (!fresh.realtime_allowed) { + toast('当前数据源无实时行情能力, 请先配置数据源', 'error') + return + } + // 有排队中的挖掘任务时确认: 实时落盘会让排队任务开跑即失败 + // (data generation changed); 运行中的任务会自动跟随新数据, 不受影响。 + try { + const runs = await qc.fetchQuery({ + queryKey: QK.miningRuns, + queryFn: api.miningRuns, + staleTime: 5_000, + }) + const queued = (runs?.items ?? []).filter(r => r.status === 'queued').length + if (queued > 0) { + setMiningQueuedWarning(queued) + return + } + } catch { + // 挖掘运行历史查询失败不阻塞开关实时行情 + } + await doEnableRealtime() + } + return (
+ + {/* 开启实时行情 + 排队中的挖掘任务 → 冲突确认 */} + {miningQueuedWarning != null && ( +
+
e.stopPropagation()} className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-2xl"> +
+ 有挖掘任务正在排队 +
+

+ 当前有 {miningQueuedWarning} 个挖掘任务排队等待执行。开启实时行情后盘中数据会持续落盘, + 排队中的任务启动时可能因数据更新校验而失败(需重新开始挖掘);已开始运行的任务不受影响。 +

+
+ + +
+
+
+ )}
) } diff --git a/frontend/src/pages/backtest/MiningWorkbench.tsx b/frontend/src/pages/backtest/MiningWorkbench.tsx index c217ce5..facfeb0 100644 --- a/frontend/src/pages/backtest/MiningWorkbench.tsx +++ b/frontend/src/pages/backtest/MiningWorkbench.tsx @@ -38,6 +38,9 @@ import { useMiningTask, } from '@/lib/miningTask' import { QK } from '@/lib/queryKeys' +import { usePreferences } from '@/lib/useSharedQueries' +import { useToggleRealtimeQuotes } from '@/lib/useSharedMutations' +import { useDialogBackdrop } from '@/lib/useDialogBackdrop' import { FactorCorrelationHeatmap } from './charts/FactorCorrelationHeatmap' import { MiningOosChart } from './charts/MiningOosChart' import { RegimeComparisonChart } from './charts/RegimeComparisonChart' @@ -273,6 +276,21 @@ export function MiningWorkbench() { const [scheduleDraft, setScheduleDraft] = useState(null) const [correlationScope, setCorrelationScope] = useState<'all' | 'selected'>('selected') const task = useMiningTask() + // 实时行情开启时盘中 enriched 持续落盘, 排队中的挖掘任务开跑会因数据世代 + // 校验失败 (ValueError: mining data generation changed)。开始挖掘前弹窗 + // 建议暂时关闭实时; 挂起的 payload 非空即弹窗打开。 + const { data: prefs } = usePreferences() + const realtimeToggle = useToggleRealtimeQuotes() + const realtimeOn = !!prefs?.realtime_quotes_enabled + const [pendingMining, setPendingMining] = useState(null) + const confirmBackdrop = useDialogBackdrop(() => setPendingMining(null), () => !realtimeToggle.isPending) + const submitMining = (payload: MiningRequestV1) => { + const params = new URLSearchParams(searchParams) + params.delete('run') + params.delete('candidate') + setSearchParams(params, { replace: true }) + void startMining(payload).then(() => queryClient.invalidateQueries({ queryKey: QK.miningRuns })) + } const runFromUrl = searchParams.get('run') || '' const selectedCandidate = searchParams.get('candidate') || '' @@ -491,11 +509,11 @@ export function MiningWorkbench() { max_finalists: maxFinalists!, force: draft.force, } - const params = new URLSearchParams(searchParams) - params.delete('run') - params.delete('candidate') - setSearchParams(params, { replace: true }) - void startMining(payload).then(() => queryClient.invalidateQueries({ queryKey: QK.miningRuns })) + if (realtimeOn) { + setPendingMining(payload) + return + } + submitMining(payload) } const promote = useMutation({ @@ -712,6 +730,31 @@ export function MiningWorkbench() { {task.runId &&
刷新后通过持久 run ID 自动重连;浏览器断开不会取消 worker。
} + + {/* 实时行情开启时的挖掘确认: 盘中 enriched 持续落盘会让排队任务开跑即 + 失败 (data generation changed), 建议先关实时再开始。 */} + {pendingMining && ( +
+
e.stopPropagation()} className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-2xl"> +
+ + 实时行情已开启 +
+

+ 实时行情开启期间,盘中数据会持续落盘,挖掘任务可能在启动时因数据更新校验而失败(提示 mining data generation changed)。 + 建议先暂时关闭实时行情,任务启动后再重新开启。 +

+
+ + + +
+
+
+ )} ) }