feat(mining): 实时行情与挖掘的冲突确认弹窗

盘中实时开启时 enriched 每轮落盘推进数据世代, 排队中的挖掘任务
开跑会因首轮世代校验失败而报错 (mining data generation changed
after the run was queued, 用户反馈)。两个方向加确认弹窗:

- 挖掘页: 实时开启时点「开始挖掘」→ 三按钮确认 (取消 / 仍要开始 /
  关闭实时并开始 — 后者一键关实时并提交任务)
- 侧边栏实时开关: 存在 queued 挖掘任务时开启 → 确认弹窗;
  运行中任务自动跟随新世代, 不拦截

验证: tsc 零错误 + build 通过 + 浏览器验收 7/7 (弹窗出现/取消
无副作用/关实时并启动任务链路/无排队任务不拦截/环境恢复)。
This commit is contained in:
shy3130
2026-09-02 15:20:48 +08:00
parent 7619fbcf7a
commit 7ac0456c53
2 changed files with 107 additions and 19 deletions
+50 -5
View File
@@ -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<number | null>(null)
const miningWarnBackdrop = useDialogBackdrop(() => setMiningQueuedWarning(null))
// 三态循环切换 (仅桌面): 展开 → 图标条 → 隐藏 → 展开
const toggleNavCollapsed = () => {
setNavStatePersist(navState === 'expanded' ? 'rail' : navState === 'rail' ? 'hidden' : 'expanded')
@@ -577,9 +581,20 @@ 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 doEnableRealtime = async () => {
await toggleQuote.mutateAsync(true)
// 仅在交易时段立即获取一次行情
if (isTrading) {
api.intradayRefresh().catch(() => {})
}
}
const handleToggle = async (enabled: boolean) => {
// 开启时重新校验实时权限 (以 quote_status 的数据源无关判定为准)
if (enabled) {
if (!enabled) {
await toggleQuote.mutateAsync(false)
return
}
const fresh = await qc.fetchQuery({
queryKey: QK.quoteStatus,
queryFn: api.quoteStatus,
@@ -588,12 +603,23 @@ export function Layout() {
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
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
if (enabled && isTrading) {
api.intradayRefresh().catch(() => {})
} catch {
// 挖掘运行历史查询失败不阻塞开关实时行情
}
await doEnableRealtime()
}
return (
@@ -1024,6 +1050,25 @@ export function Layout() {
<AiReportBubble />
<StockAnalysisHost />
<StockAnalysisBubble />
{/* 开启实时行情 + 排队中的挖掘任务 → 冲突确认 */}
{miningQueuedWarning != null && (
<div {...miningWarnBackdrop} className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
<div onClick={e => e.stopPropagation()} className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-2xl">
<div className="text-sm font-semibold text-foreground">
</div>
<p className="mt-2 text-xs leading-5 text-secondary">
{miningQueuedWarning}
</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={() => setMiningQueuedWarning(null)} className="h-8 rounded-btn border border-border px-3 text-xs text-secondary hover:bg-elevated"></button>
<button type="button" onClick={() => { setMiningQueuedWarning(null); void doEnableRealtime() }} className="h-8 rounded-btn bg-accent px-3 text-xs font-semibold text-white hover:opacity-90"></button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -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<MiningScheduleConfig | null>(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<MiningRequestV1 | null>(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 && <div className="flex items-center gap-1.5 border-t border-border px-3 py-2 text-[9px] text-muted"><Link2 className="h-3 w-3" /> run ID worker</div>}
</section>
{/* 实时行情开启时的挖掘确认: 盘中 enriched 持续落盘会让排队任务开跑即
失败 (data generation changed), 建议先关实时再开始。 */}
{pendingMining && (
<div {...confirmBackdrop} className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div onClick={e => e.stopPropagation()} className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-2xl">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
</div>
<p className="mt-2 text-xs leading-5 text-secondary">
mining data generation changed
</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" disabled={realtimeToggle.isPending} onClick={() => setPendingMining(null)} className="h-8 rounded-btn border border-border px-3 text-xs text-secondary hover:bg-elevated disabled:opacity-50"></button>
<button type="button" disabled={realtimeToggle.isPending} onClick={() => { const p = pendingMining; setPendingMining(null); if (p) submitMining(p) }} className="h-8 rounded-btn border border-border px-3 text-xs text-foreground hover:bg-elevated disabled:opacity-50"></button>
<button type="button" disabled={realtimeToggle.isPending} onClick={() => { const p = pendingMining; if (!p) return; void realtimeToggle.mutateAsync(false).then(() => { setPendingMining(null); submitMining(p) }) }} className="inline-flex h-8 items-center gap-1.5 rounded-btn bg-accent px-3 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50">
{realtimeToggle.isPending ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
)}
</div>
)
}