fix(ui): 看板预热期不再误弹"立即获取数据"引导

v0.3.20 回归: /api/data/status 忙时降级 (977248ce) 在启动指标预热期
(indicators_ready=false, 低配机 50s+) 且 stale 缓存为空时, daily/enriched
返回 null; 看板 hasNoData 判定把 null 当 trading_days=0, 导致升级后有
数据的老用户打开客户端即被误弹"当前暂无数据/立即获取数据"卡片与欢迎
弹窗, 且 staleTime 60s 无轮询, 预热完成后提示也不会自动消失。

修复: 判定提取为 resolveHasNoData — 预热中视为"未知"而非"无数据";
预热期间每 5s 轮询 /status, 预热完成 busy 解除后自动恢复正常判定并停
轮询。老后端无 indicators_ready 字段时行为不变; 新用户引导不受影响。

验证: Dashboard.test.tsx 7 条回归 (修复前 7 failed → 修复后 passed,
覆盖预热 null/预热完成/无字段兼容/部分数据); pnpm build 通过; 全量
vitest 328 passed; 后端契约 test_data_status_busy.py 7 passed。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-29 09:49:20 +08:00
parent a17742c97d
commit dd589733ec
2 changed files with 93 additions and 6 deletions
+68
View File
@@ -0,0 +1,68 @@
// 无本地数据判定 — 预热期不得误判"无数据" (v0.3.20 回归)
//
// 回归复现: 启动时 enriched 指标缓存异步预热 (低配机 50s+), 期间后端
// /api/data/status 走 busy 降级 — stale 缓存为空时 daily/enriched 返回 null。
// 旧判定把 null 一律当 trading_days=0, 导致升级后有数据的老用户打开客户端
// 即被误弹"当前暂无数据 / 立即获取数据"引导卡与欢迎弹窗。
import { describe, expect, it } from 'vitest'
import { resolveHasNoData } from './Dashboard'
import type { DataStatus } from '@/lib/api'
function ds(partial: Partial<DataStatus>): DataStatus {
return partial as DataStatus
}
describe('resolveHasNoData', () => {
it('status 未返回 → 不判无数据', () => {
expect(resolveHasNoData(undefined)).toBe(false)
})
it('预热中 (indicators_ready=false) 两表为 null → 不判无数据 (回归复现)', () => {
const s = ds({ indicators_ready: false, daily: undefined, enriched: undefined })
expect(resolveHasNoData(s)).toBe(false)
})
it('预热中即使 stale 缓存有旧值也保持未知态 → 不判无数据', () => {
const s = ds({
indicators_ready: false,
daily: { trading_days: 0 } as DataStatus['daily'],
enriched: { trading_days: 0 } as DataStatus['enriched'],
})
expect(resolveHasNoData(s)).toBe(false)
})
it('预热完成且两表交易日为 0 → 判无数据 (新用户正常引导)', () => {
const s = ds({
indicators_ready: true,
daily: { trading_days: 0 } as DataStatus['daily'],
enriched: { trading_days: 0 } as DataStatus['enriched'],
})
expect(resolveHasNoData(s)).toBe(true)
})
it('老版本后端无 indicators_ready 字段: 两表 0 仍判无数据 (兼容)', () => {
const s = ds({
daily: { trading_days: 0 } as DataStatus['daily'],
enriched: { trading_days: 0 } as DataStatus['enriched'],
})
expect(resolveHasNoData(s)).toBe(true)
})
it('enriched 有交易日 → 有数据', () => {
const s = ds({
indicators_ready: true,
enriched: { trading_days: 243 } as DataStatus['enriched'],
daily: undefined,
})
expect(resolveHasNoData(s)).toBe(false)
})
it('daily 有交易日 (enriched 未出) → 有数据', () => {
const s = ds({
indicators_ready: true,
daily: { trading_days: 240 } as DataStatus['daily'],
enriched: undefined,
})
expect(resolveHasNoData(s)).toBe(false)
})
})
+25 -6
View File
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { ArrowUpRight, Check, Database, Gauge, GripVertical, Info, Loader2, Play, RefreshCw, RotateCcw, Sparkles, Timer } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type AlertEvent } from '@/lib/api'
import { api, type AlertEvent, type DataStatus } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { DimensionMembersDialog, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
import { useDataStatus, useCapabilities, useSettings, usePreferences } from '@/lib/useSharedQueries'
@@ -20,6 +20,21 @@ import { useDashboardLayout } from '@/components/dashboard/useDashboardLayout'
import { DEFAULT_LAYOUT, widgetDef, type WidgetCtx } from '@/components/dashboard/registry'
import { cloneItems, GRID_COLS, type WidgetType } from '@/components/dashboard/layout'
/** 判断是否处于"指标预热中" — 后端 busy 降级会把 status 表统计置 null。 */
export function isWarmupPending(ds?: DataStatus): boolean {
return ds?.indicators_ready === false
}
/** 无本地数据判定: enriched/daily 交易日均为 0 才算无数据。
* 预热中 (indicators_ready=false) 后端 /status 走 busy 降级, stale 缓存为空时
* 两表返回 null — 那是"未知"而非"无数据", 必须等预热完成再判; 否则升级后
* 有数据的老用户启动预热期即被误弹"立即获取数据" (v0.3.20 回归)。 */
export function resolveHasNoData(ds?: DataStatus): boolean {
if (!ds) return false
if (isWarmupPending(ds)) return false
return (ds.enriched?.trading_days ?? 0) === 0 && (ds.daily?.trading_days ?? 0) === 0
}
/** 打开个股预览的来源榜 (用于行高亮与切股导航列表) */
type PreviewSource = 'gain' | 'loss' | 'amount' | 'active' | 'concept' | 'industry' | 'alert'
@@ -59,7 +74,12 @@ export function Dashboard() {
}, [dashItems, setDashItems])
// 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次
const [showWelcomeModal, setShowWelcomeModal] = useState(false)
const dataStatus = useDataStatus({ staleTime: 60_000 })
// 预热中每 5s 轮询: 预热完成后 busy 解除, /status 恢复真实聚合,
// 前端才能拿到非 null 的 daily/enriched 结束"未知"态 (staleTime 不拦 interval)
const dataStatus = useDataStatus({
staleTime: 60_000,
refetchInterval: (q: any) => (isWarmupPending(q.state.data) ? 5_000 : false),
})
const overview = useQuery({
queryKey: QK.overviewMarket(selectedDate),
queryFn: () => api.overviewMarket(selectedDate),
@@ -89,11 +109,10 @@ export function Dashboard() {
].find(s => s.name === activeProvider)?.display_name
?.replace(/(.*?)|\(.*?\)/g, '').trim() || activeProvider
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
// 注: 后端 status 的 rows 为性能刻意返回 0, 用 trading_days 判断是否有数据
// 注: 后端 status 的 rows 为性能刻意返回 0, 用 trading_days 判断是否有数据;
// 预热中两表为 null 时不算无数据 (见 resolveHasNoData)
const ds = dataStatus.data
const hasNoData = !!ds
&& (ds.enriched?.trading_days ?? 0) === 0
&& (ds.daily?.trading_days ?? 0) === 0
const hasNoData = resolveHasNoData(ds)
// ===== 盘后管道触发(看板内一键获取数据) =====
const [fetchJobId, setFetchJobId] = useState<string | null>(null)