mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat(mining): 实时行情与挖掘的冲突确认弹窗
盘中实时开启时 enriched 每轮落盘推进数据世代, 排队中的挖掘任务 开跑会因首轮世代校验失败而报错 (mining data generation changed after the run was queued, 用户反馈)。两个方向加确认弹窗: - 挖掘页: 实时开启时点「开始挖掘」→ 三按钮确认 (取消 / 仍要开始 / 关闭实时并开始 — 后者一键关实时并提交任务) - 侧边栏实时开关: 存在 queued 挖掘任务时开启 → 确认弹窗; 运行中任务自动跟随新世代, 不拦截 验证: tsc 零错误 + build 通过 + 浏览器验收 7/7 (弹窗出现/取消 无副作用/关实时并启动任务链路/无排队任务不拦截/环境恢复)。
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user