From dd589733ec383faaddf32eb527e2438dda1313f5 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Thu, 29 Oct 2026 09:49:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E7=9C=8B=E6=9D=BF=E9=A2=84=E7=83=AD?= =?UTF-8?q?=E6=9C=9F=E4=B8=8D=E5=86=8D=E8=AF=AF=E5=BC=B9"=E7=AB=8B?= =?UTF-8?q?=E5=8D=B3=E8=8E=B7=E5=8F=96=E6=95=B0=E6=8D=AE"=E5=BC=95?= =?UTF-8?q?=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/pages/Dashboard.test.tsx | 68 +++++++++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 31 +++++++++--- 2 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/Dashboard.test.tsx diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx new file mode 100644 index 00000000..b636e018 --- /dev/null +++ b/frontend/src/pages/Dashboard.test.tsx @@ -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 { + 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) + }) +}) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 86156e01..63f18af8 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(null)