fix(ui): 同步和修复期间不再误判无数据

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-29 10:25:29 +08:00
parent dd589733ec
commit e975f78240
6 changed files with 159 additions and 11 deletions
+2
View File
@@ -644,6 +644,8 @@ def status(request: Request) -> dict:
"last_instruments_run": _last_finished("instruments"),
"last_pipeline_run": _last_finished("pipeline"),
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
# 同步/修复期间同样可能返回 null 统计, 不可把它当成无本地数据。
"stats_pending": busy,
# 指标缓存就绪标志 (启动时 enriched 异步预热, 完成前为 false)
"indicators_ready": getattr(request.app.state, "indicators_ready", True),
}
+36
View File
@@ -114,5 +114,41 @@ def test_status_endpoint_busy_skips_aggregates(monkeypatch, clean_caches, tmp_pa
monkeypatch.setattr(data_api, f"_safe_aggregate_{fn}", _boom)
res = data_api.status(req)
assert res["indicators_ready"] is False
assert res["stats_pending"] is True
assert res["checked_at"]
assert res["daily"] is None and res["minute"] is None
@pytest.mark.parametrize("job_status", ["pending", "running"])
def test_status_marks_cold_stats_pending_until_job_finishes(
monkeypatch, clean_caches, tmp_path, job_status,
):
"""预热已完成但同步/修复仍在运行时, null 统计不代表本地无数据。"""
for table in ("kline_daily", "kline_daily_enriched"):
(tmp_path / table / "date=2026-10-28").mkdir(parents=True)
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
req = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(
repo=repo, scheduler=None, indicators_ready=True,
)))
monkeypatch.setattr(job_store, "active_id", lambda: "repair-job")
monkeypatch.setattr(job_store, "get", lambda jid: {"status": job_status})
calls = []
def aggregate():
calls.append(1)
return {"trading_days": 240}
monkeypatch.setattr(data_api, "_safe_aggregate_daily", lambda repo: aggregate())
monkeypatch.setattr(data_api, "_safe_aggregate_enriched", lambda repo: aggregate())
busy = data_api.status(req)
assert busy["indicators_ready"] is True
assert busy["stats_pending"] is True
assert busy["daily"] is None and busy["enriched"] is None
assert calls == []
monkeypatch.setattr(job_store, "get", lambda jid: {"status": "succeeded"})
ready = data_api.status(req)
assert ready["stats_pending"] is False
assert ready["daily"]["trading_days"] == 240
assert ready["enriched"]["trading_days"] == 240
assert len(calls) == 2
+2
View File
@@ -4922,6 +4922,8 @@ export interface DataStatus {
last_pipeline_run: string | null
last_instruments_run: string | null
checked_at: string
/** 预热/同步/修复期间统计暂不可用或仅为旧缓存, 空值不代表无数据。 */
stats_pending?: boolean
indicators_ready?: boolean
}
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { Dashboard } from './Dashboard'
import { api, type DataStatus } from '@/lib/api'
vi.mock('@/lib/api', () => ({ api: {
dataStatus: vi.fn(),
overviewMarket: vi.fn(async () => ({ emotion: { score: 50, label: '中性' } })),
dataSources: vi.fn(async () => ({})),
settings: vi.fn(async () => ({ onboarding_completed: true })),
preferences: vi.fn(async () => ({})),
capabilities: vi.fn(async () => ({ capabilities: {} })),
pipelineJobs: vi.fn(async () => ({ active_id: null })),
pipelineJob: vi.fn(),
pipelineRun: vi.fn(),
} }))
vi.mock('@/components/dashboard/useDashboardLayout', () => ({
useDashboardLayout: () => ({ items: [], setItems: () => {} }),
}))
vi.mock('@/components/dashboard/DashboardGrid', () => ({ DashboardGrid: () => null }))
vi.mock('@/components/StockPreviewDialog', () => ({ StockPreviewDialog: () => null }))
vi.mock('@/components/DimensionMembersDialog', () => ({ DimensionMembersDialog: () => null }))
vi.mock('@/components/AdjFactorSyncGate', () => ({
useAdjFactorSyncGate: () => ({ guard: (run: () => void) => run(), dialog: null }),
}))
let host: HTMLDivElement
let root: Root
let client: QueryClient
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
vi.useFakeTimers()
vi.clearAllMocks()
sessionStorage.clear()
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
})
afterEach(async () => {
await act(async () => root.unmount())
client.clear()
host.remove()
vi.useRealTimers()
})
const busy = { indicators_ready: true, stats_pending: true, daily: null, enriched: null } as DataStatus
async function render() {
await act(async () => root.render(
<MemoryRouter><QueryClientProvider client={client}><Dashboard /></QueryClientProvider></MemoryRouter>,
))
await tick(1)
}
async function tick(ms: number) {
await act(async () => { await vi.advanceTimersByTimeAsync(ms) })
}
it('同步/修复期不弹获取数据, 完成后自动重查并停止轮询', async () => {
vi.mocked(api.dataStatus).mockResolvedValueOnce(busy).mockResolvedValue({
...busy, stats_pending: false, daily: { trading_days: 240 },
} as DataStatus)
await render()
expect(host.textContent).not.toContain('立即获取数据')
expect(sessionStorage.getItem('tf_welcome_shown')).toBeNull()
await tick(5_001)
expect(api.dataStatus).toHaveBeenCalledTimes(2)
expect(host.textContent).not.toContain('立即获取数据')
expect(sessionStorage.getItem('tf_welcome_shown')).toBeNull()
await tick(10_000)
expect(api.dataStatus).toHaveBeenCalledTimes(2)
})
it('等统计可用后才为真正无数据的新用户弹出引导', async () => {
vi.mocked(api.dataStatus).mockResolvedValueOnce(busy).mockResolvedValue({
...busy, stats_pending: false,
})
await render()
expect(sessionStorage.getItem('tf_welcome_shown')).toBeNull()
await tick(5_001)
expect(host.textContent).toContain('立即获取数据')
expect(host.textContent).toContain('欢迎首次使用')
expect(sessionStorage.getItem('tf_welcome_shown')).toBe('1')
})
+19
View File
@@ -31,6 +31,25 @@ describe('resolveHasNoData', () => {
expect(resolveHasNoData(s)).toBe(false)
})
it('预热完成但同步/自动修复运行中, 空统计仍为未知态', () => {
expect(resolveHasNoData(ds({
indicators_ready: true, stats_pending: true, daily: null, enriched: null,
}))).toBe(false)
})
it('同步运行中即使 stale 统计为 0 也不弹首用引导', () => {
expect(resolveHasNoData(ds({
indicators_ready: true, stats_pending: true,
daily: { trading_days: 0 } as DataStatus['daily'], enriched: null,
}))).toBe(false)
})
it('同步结束且两表均为空时保留新用户引导', () => {
expect(resolveHasNoData(ds({
indicators_ready: true, stats_pending: false, daily: null, enriched: null,
}))).toBe(true)
})
it('预热完成且两表交易日为 0 → 判无数据 (新用户正常引导)', () => {
const s = ds({
indicators_ready: true,
+8 -11
View File
@@ -20,18 +20,16 @@ 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
/** 兼容旧后端的预热标志; 同步/自动修复也会使统计暂不可用。 */
export function isDataStatusPending(ds?: DataStatus): boolean {
return ds?.stats_pending === true || ds?.indicators_ready === false
}
/** 无本地数据判定: enriched/daily 交易日均为 0 才算无数据。
* 预热中 (indicators_ready=false) 后端 /status 走 busy 降级, stale 缓存为空时
* 两表返回 null — 那是"未知"而非"无数据", 必须等预热完成再判; 否则升级后
* 有数据的老用户启动预热期即被误弹"立即获取数据" (v0.3.20 回归)。 */
* 预热/同步/修复期间的 null 是未知态, 必须等真实统计就绪再判。 */
export function resolveHasNoData(ds?: DataStatus): boolean {
if (!ds) return false
if (isWarmupPending(ds)) return false
if (isDataStatusPending(ds)) return false
return (ds.enriched?.trading_days ?? 0) === 0 && (ds.daily?.trading_days ?? 0) === 0
}
@@ -74,11 +72,10 @@ export function Dashboard() {
}, [dashItems, setDashItems])
// 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次
const [showWelcomeModal, setShowWelcomeModal] = useState(false)
// 预热中每 5s 轮询: 预热完成后 busy 解除, /status 恢复真实聚合,
// 前端才能拿到非 null 的 daily/enriched 结束"未知"态 (staleTime 不拦 interval)
// 统计暂不可用时持续重查, 同步/自动修复结束后恢复真实统计并停止轮询。
const dataStatus = useDataStatus({
staleTime: 60_000,
refetchInterval: (q: any) => (isWarmupPending(q.state.data) ? 5_000 : false),
refetchInterval: (q: any) => (isDataStatusPending(q.state.data) ? 5_000 : false),
})
const overview = useQuery({
queryKey: QK.overviewMarket(selectedDate),
@@ -110,7 +107,7 @@ export function Dashboard() {
?.replace(/(.*?)|\(.*?\)/g, '').trim() || activeProvider
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
// 注: 后端 status 的 rows 为性能刻意返回 0, 用 trading_days 判断是否有数据;
// 预热中两表为 null 时不算无数据 (见 resolveHasNoData)
// 统计暂不可用时不算无数据 (见 resolveHasNoData)
const ds = dataStatus.data
const hasNoData = resolveHasNoData(ds)