mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user