diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 59258af3..8a6ddf6e 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -1384,8 +1384,31 @@ async def sync_minute(request: Request): ) -async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=None) -> dict: - """触发分钟K同步/向前扩展后台任务(HTTP 端点与 AI 助手共用同一条触发路径)。""" +@router.post("/repair_minute_gaps") +async def repair_minute_gaps(request: Request, body: dict): + """定向批量补拉缺口标的, 返回现有管道任务 ID; 不承诺源窗口外的缺口可补齐。""" + from app.services.minute_gaps import MAX_GAP_ROWS + + symbols = body.get("symbols") + if not isinstance(symbols, list) or not symbols: + raise HTTPException(status_code=400, detail="symbols 必须是非空标的列表") + if len(symbols) > MAX_GAP_ROWS: + raise HTTPException(status_code=400, detail=f"一次最多补拉 {MAX_GAP_ROWS} 只标的") + if any(not isinstance(symbol, str) or not symbol.strip() for symbol in symbols): + raise HTTPException(status_code=400, detail="标的代码必须是非空字符串") + symbols = list(dict.fromkeys(symbol.strip() for symbol in symbols)) + repo = request.app.state.repo + if any(repo.resolve_asset_type(symbol) != "stock" for symbol in symbols): + raise HTTPException(status_code=400, detail="分钟缺口补拉仅支持股票, ETF 和指数请使用各自的数据入口") + return await trigger_minute_sync( + repo, request.app.state.capabilities, override_days=10, repair_symbols=symbols, + ) + + +async def trigger_minute_sync( + repo, capset, *, override_days=None, extend_flag=None, repair_symbols: list[str] | None = None, +) -> dict: + """复用分钟同步的分块落盘、容量限制和取消机制; 缺口补拉限定标的且强制回溯。""" import asyncio from app.api.data import invalidate_storage_cache @@ -1397,14 +1420,17 @@ async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=N try_acquire_run_slot, ) from app.services.preferences import get_minute_sync_days - from app.tickflow.capabilities import Cap from app.tickflow.pools import get_pool if not _minute_allowed(capset): + if repair_symbols is not None: + raise HTTPException(status_code=403, detail="当前分钟数据源不可用, 请先在设置中配置可用数据源") raise HTTPException(status_code=403, detail="需要 Pro+ 权限") # 分钟K全市场同步是长任务(数据量是日K的 ~240 倍),用更宽松的卡死阈值 job_id, is_new = job_store.create(long_running=True) if not is_new: + if repair_symbols is not None: + raise HTTPException(status_code=409, detail="已有数据任务正在运行, 请等待完成后再批量补拉") return {"status": "reused", "job_id": job_id} async def task() -> None: @@ -1417,20 +1443,23 @@ async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=N job_store.progress(job_id, stage, pct, msg) try: - progress("sync_minute", 5, "解析标的池…") - # 标的池 = instruments 维表 + 自选 (单一来源, 不拉 CN_Equity_A/SW1 池) - universe: set[str] = set(get_pool("watchlist")) - inst_path = repo.store.data_dir / "instruments" / "instruments.parquet" - if inst_path.exists(): - try: - import polars as pl - inst = pl.read_parquet(inst_path, columns=["symbol"]) - universe.update(inst["symbol"].to_list()) - except Exception: # noqa: BLE001 - pass - # 剔除指数 symbol: 指数分钟K无本地存储, 落库会污染 kline_minute - index_set = repo.get_index_symbol_set() - universe = sorted({s for s in universe if s not in index_set}) + progress("sync_minute", 5, "准备补拉缺口标的…" if repair_symbols is not None else "解析标的池…") + if repair_symbols is not None: + universe = repair_symbols + else: + # 标的池 = instruments 维表 + 自选 (单一来源, 不拉 CN_Equity_A/SW1 池) + all_symbols = set(get_pool("watchlist")) + inst_path = repo.store.data_dir / "instruments" / "instruments.parquet" + if inst_path.exists(): + try: + import polars as pl + inst = pl.read_parquet(inst_path, columns=["symbol"]) + all_symbols.update(inst["symbol"].to_list()) + except Exception: # noqa: BLE001 + pass + # 剔除指数 symbol: 指数分钟K无本地存储, 落库会污染 kline_minute + index_set = repo.get_index_symbol_set() + universe = sorted(s for s in all_symbols if s not in index_set) progress("sync_minute", 10, f"标的池 {len(universe)} 只") days = override_days if override_days else get_minute_sync_days() @@ -1440,22 +1469,37 @@ async def trigger_minute_sync(repo, capset, *, override_days=None, extend_flag=N def _on_chunk(done: int, total: int, seg_label: str) -> None: # 进度映射: 10% (标的池解析完) → 95%, 留 5% 给写入+刷新 pct = 10 + int((done / max(total, 1)) * 85) - progress("sync_minute", pct, f"拉取分钟K… {done}/{total} 批 [{seg_label}]") + action = "补拉缺口分钟K" if repair_symbols is not None else "拉取分钟K" + progress("sync_minute", pct, f"{action}… {done}/{total} 批 [{seg_label}]") def _run(): - return kline_sync.sync_and_persist_minute( - universe, repo, capset, days=days, - extend_backward=extend_backward, - on_chunk_done=_on_chunk, - ) + from app.jobs.daily_pipeline import _refresh_single_view + + try: + if repair_symbols is not None: + progress("sync_minute", 10, f"补拉 {len(universe)} 只缺口标的的分钟K…") + return kline_sync.sync_and_persist_minute( + universe, repo, capset, days=days, + extend_backward=extend_backward, + force_full_days=repair_symbols is not None, + on_chunk_done=_on_chunk, + ) + finally: + if repair_symbols is not None: + # 补拉分块已写入的数据在失败/取消后也要可见, 刷新仍占用原执行槽。 + _refresh_single_view(repo, "kline_minute") written = await loop.run_in_executor(_long_task_executor, run_with_capacity, job_id, _run) + if repair_symbols is None: + from app.jobs.daily_pipeline import _refresh_single_view - # 刷新视图 - from app.jobs.daily_pipeline import _refresh_single_view - _refresh_single_view(repo, "kline_minute") + _refresh_single_view(repo, "kline_minute") - progress("done", 100, f"分钟 K 同步完成,{written} 行") + message = ( + f"缺口分钟补拉完成, 写入 {written} 行; 是否补齐以体检结果为准" + if repair_symbols is not None else f"分钟 K 同步完成,{written} 行" + ) + progress("done", 100, message) job_store.succeed(job_id, {"minute_rows": written, "universe_size": len(universe)}) invalidate_storage_cache() except JobCancelledError: diff --git a/backend/app/services/minute_gaps.py b/backend/app/services/minute_gaps.py index b6adb9ab..0d283207 100644 --- a/backend/app/services/minute_gaps.py +++ b/backend/app/services/minute_gaps.py @@ -10,6 +10,8 @@ - 实盘落盘常见 241 根 (含 09:30 与 15:00 两个边界) 或 238~241 浮动的少量差异, 因此与 /api/kline/minute 的 is_complete 同口径: >= expected * 0.9 视为完整。 - 当日 (北京今天) 按已开盘分钟数折算 expected, 避免把盘中正常进度误报为缺口。 +- 整日成交量/额均为零且本地日线没有成交的记录单独计数, 不把停牌残留当作缺口。 + 日线缺失、读失败或成交字段未知时保留原判定, 避免掩盖真实缺失。 """ from __future__ import annotations @@ -73,6 +75,28 @@ def _expected_bars_for(trade_date: _date, today: _date, now_hm: tuple[int, int]) return FULL_DAY_BARS +def _daily_active_symbols(daily_dir: Path, date_str: str) -> set[str] | None: + """用同日股票日线核对成交; None 表示无法核对, 不能排除零成交分钟记录。""" + part = daily_dir / f"date={date_str}" / "part.parquet" + if not part.exists(): + return None + try: + if not {"symbol", "volume", "amount"}.issubset(pl.read_parquet_schema(part)): + return None + daily = pl.read_parquet(part, columns=["symbol", "volume", "amount"]) + if daily.is_empty(): + return None + daily = daily.with_columns(pl.col(c).cast(pl.Float64, strict=False) for c in ("volume", "amount")) + # 非零或未知字段都不能作为「无成交」依据。零成交分钟在正常交易日也会出现, + # 因而只在标的整日零成交、且日线没有相反证据时排除该标的日。 + return set(daily.filter( + (pl.col("volume") != 0).fill_null(True) | (pl.col("amount") != 0).fill_null(True) + )["symbol"].to_list()) + except Exception as e: # noqa: BLE001 + logger.warning("分钟体检日线核对失败 %s: %s", part, e) + return None + + def scan_minute_gaps( minute_dir: Path, *, @@ -90,6 +114,7 @@ def scan_minute_gaps( return { "status": "ok", "scanned_dates": 0, "scanned_symbols": 0, "complete_dates": [], "gaps": [], "summary_by_date": [], "truncated": False, + "inactive_symbol_days": 0, } symbol_set = set(symbols) if symbols else None @@ -99,6 +124,7 @@ def scan_minute_gaps( scanned_dates = 0 scanned_symbols: set[str] = set() complete_dates: list[str] = [] + inactive_symbol_days = 0 truncated = False for part in sorted(minute_dir.glob("date=*/part.parquet")): @@ -113,7 +139,9 @@ def scan_minute_gaps( if expected <= 0: continue # 未开盘的当日分区不判定 try: - df = pl.read_parquet(part, columns=["symbol", "datetime"]) + schema = pl.read_parquet_schema(part) + activity_columns = [c for c in ("volume", "amount") if c in schema] + df = pl.read_parquet(part, columns=["symbol", "datetime", *activity_columns]) except Exception as e: # noqa: BLE001 logger.warning("分钟分区读取失败 %s: %s", part, e) continue @@ -127,11 +155,29 @@ def scan_minute_gaps( continue scanned_symbols.update(df["symbol"].unique().to_list()) - agg = df.group_by("symbol").agg( + aggregations = [ pl.len().alias("n"), pl.col("datetime").min().alias("first"), pl.col("datetime").max().alias("last"), - ) + ] + if len(activity_columns) == 2: + aggregations.append( + pl.all_horizontal( + (pl.col(c).cast(pl.Float64, strict=False) == 0).fill_null(False) + for c in activity_columns + ).all().alias("zero_activity") + ) + agg = df.group_by("symbol").agg(aggregations) + if "zero_activity" in agg.columns and agg["zero_activity"].any(): + daily_active = _daily_active_symbols(minute_dir.parent / "kline_daily", date_str) + if daily_active is not None: + inactive = agg.filter( + pl.col("zero_activity") & ~pl.col("symbol").is_in(list(daily_active)) + )["symbol"].to_list() + inactive_symbol_days += len(inactive) + agg = agg.filter(~pl.col("symbol").is_in(inactive)) + if agg.is_empty(): + continue # 无成交记录不标记为完整交易日, 也没有可补齐的分钟缺口 threshold = expected * COMPLETE_RATIO bad = agg.filter(pl.col("n") < threshold) if bad.is_empty(): @@ -165,6 +211,7 @@ def scan_minute_gaps( "status": "ok", "scanned_dates": scanned_dates, "scanned_symbols": len(scanned_symbols), + "inactive_symbol_days": inactive_symbol_days, "complete_dates": sorted(complete_dates), "gaps": [g.to_dict() for g in gaps], "summary_by_date": sorted(by_date.values(), key=lambda c: c["date"]), diff --git a/backend/tests/test_minute_gap_repair.py b/backend/tests/test_minute_gap_repair.py new file mode 100644 index 00000000..08703cc7 --- /dev/null +++ b/backend/tests/test_minute_gap_repair.py @@ -0,0 +1,155 @@ +"""分钟缺口一键补拉: 定向批量、任务互斥、失败与取消后的可见性。""" +from __future__ import annotations + +import threading +import time +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api import kline +from app.services import heavy_job_limiter, pipeline_jobs, preferences +from app.services.heavy_job_limiter import HeavyJobLimiter +from app.services.pipeline_jobs import JobStore + + +def wait_for(predicate): + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.005) + raise AssertionError("分钟补拉任务未在测试期限内完成") + + +@pytest.fixture +def repair_env(monkeypatch, tmp_path): + monkeypatch.setattr(preferences, "load", lambda: {}) + monkeypatch.setattr(pipeline_jobs, "_CANCEL_FLAGS", {}) + monkeypatch.setattr(pipeline_jobs, "_run_slot_owner", None) + store = JobStore(store_dir=tmp_path / "jobs") + limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.005) + monkeypatch.setattr(pipeline_jobs, "job_store", store) + monkeypatch.setattr(heavy_job_limiter, "shared_heavy_job_limiter", limiter) + monkeypatch.setattr(kline, "_minute_allowed", lambda _: True) + sync = MagicMock(return_value=480) + refresh = MagicMock() + invalidate = MagicMock() + pool = MagicMock(return_value=["600519.SH"]) + monkeypatch.setattr(kline.kline_sync, "sync_and_persist_minute", sync) + monkeypatch.setattr("app.jobs.daily_pipeline._refresh_single_view", refresh) + monkeypatch.setattr("app.api.data.invalidate_storage_cache", invalidate) + monkeypatch.setattr("app.tickflow.pools.get_pool", pool) + app = FastAPI() + app.include_router(kline.router) + app.state.repo = SimpleNamespace( + store=SimpleNamespace(data_dir=tmp_path), + resolve_asset_type=lambda symbol: {"000001.SH": "index", "510300.SH": "etf"}.get(symbol, "stock"), + get_index_symbol_set=lambda: {"000001.SH"}, + ) + app.state.capabilities = object() + with TestClient(app) as client: + yield SimpleNamespace( + client=client, store=store, limiter=limiter, repo=app.state.repo, + sync=sync, refresh=refresh, invalidate=invalidate, pool=pool, + ) + + +def test_repair_deduplicates_symbols_and_forces_historical_window(repair_env): + env = repair_env + response = env.client.post("/api/kline/repair_minute_gaps", json={ + "symbols": ["600000.SH", " 000001.SZ ", "600000.SH"], + }) + assert response.status_code == 200, response.text + result = response.json() + wait_for(lambda: env.store.get(result["job_id"])["status"] == "succeeded") + args, kwargs = env.sync.call_args + assert args[0] == ["600000.SH", "000001.SZ"] + assert kwargs["days"] == 10 + assert kwargs["force_full_days"] is True + assert kwargs["extend_backward"] is False + assert env.store.get(result["job_id"])["result"] == {"minute_rows": 480, "universe_size": 2} + env.pool.assert_not_called() + env.refresh.assert_called_once_with(env.repo, "kline_minute") + env.invalidate.assert_called() + + +@pytest.mark.parametrize("symbols", [None, [], "600000.SH", [None], [""], [" "], [123], + ["600000.SH"] * 2001, ["000001.SH"], ["510300.SH"]]) +def test_repair_rejects_invalid_scope_before_creating_job(repair_env, symbols): + env = repair_env + response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": symbols}) + assert response.status_code == 400 + env.sync.assert_not_called() + assert env.store.active_id() is None + + +def test_repair_requires_usable_minute_provider(repair_env, monkeypatch): + monkeypatch.setattr(kline, "_minute_allowed", lambda _: False) + response = repair_env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]}) + assert response.status_code == 403 + assert "数据源" in response.json()["detail"] + repair_env.sync.assert_not_called() + + +def test_repair_does_not_reuse_unrelated_pending_job(repair_env): + env = repair_env + existing_id, _ = env.store.create() + response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]}) + assert response.status_code == 409 + assert env.store.active_id() == existing_id + env.sync.assert_not_called() + + +def test_failed_repair_refreshes_partial_data_and_reports_error(repair_env): + env = repair_env + env.sync.side_effect = ValueError("分钟数据源连接中断") + response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]}) + assert response.status_code == 200 + job_id = response.json()["job_id"] + wait_for(lambda: env.store.get(job_id)["status"] == "failed") + assert "分钟数据源连接中断" in env.store.get(job_id)["error"] + env.refresh.assert_called_once_with(env.repo, "kline_minute") + env.invalidate.assert_called() + wait_for(lambda: env.limiter.in_use == 0) + + +def test_zero_rows_are_reported_without_claiming_gaps_fixed(repair_env): + env = repair_env + env.sync.return_value = 0 + response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]}) + assert response.status_code == 200 + job_id = response.json()["job_id"] + wait_for(lambda: env.store.get(job_id)["status"] == "succeeded") + job = env.store.get(job_id) + assert job["result"]["minute_rows"] == 0 + assert "补齐成功" not in str(job["log"]) + + +def test_cancelled_repair_refreshes_partial_data_and_releases_capacity(repair_env): + env = repair_env + entered, finish = threading.Event(), threading.Event() + + def sync(*args, **kwargs): + entered.set() + assert finish.wait(3) + kwargs["on_chunk_done"](1, 2, "最近窗口") + raise AssertionError("取消后不得继续拉取") + + env.sync.side_effect = sync + response = env.client.post("/api/kline/repair_minute_gaps", json={"symbols": ["600000.SH"]}) + assert response.status_code == 200 + job_id = response.json()["job_id"] + try: + assert entered.wait(1) + env.store.terminate(job_id, "用户停止分钟补拉") + finally: + finish.set() + wait_for(lambda: env.limiter.in_use == 0) + assert env.store.get(job_id)["status"] == "failed" + assert "用户停止" in env.store.get(job_id)["error"] + assert env.store.get(job_id)["stage"] == "sync_minute" + env.refresh.assert_called_once_with(env.repo, "kline_minute") diff --git a/backend/tests/test_minute_gaps.py b/backend/tests/test_minute_gaps.py index 349ad30d..b99a6815 100644 --- a/backend/tests/test_minute_gaps.py +++ b/backend/tests/test_minute_gaps.py @@ -115,6 +115,89 @@ def test_missing_dir_returns_empty(tmp_path): assert r["scanned_dates"] == 0 +def _zero_activity(minute_dir: Path, trade_date: str, symbol: str) -> None: + part = minute_dir / f"date={trade_date}" / "part.parquet" + df = pl.read_parquet(part).with_columns( + pl.when(pl.col("symbol") == symbol).then(0.0).otherwise(pl.col(c)).alias(c) + for c in ("volume", "amount") + ) + df.write_parquet(part) + + +def _write_daily(daily_dir: Path, trade_date: str, symbol: str, volume: float) -> None: + part = daily_dir / f"date={trade_date}" / "part.parquet" + part.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame({"symbol": [symbol], "volume": [volume], "amount": [volume * 10]}).write_parquet(part) + + +@pytest.mark.parametrize("count", [171, 23]) +def test_zero_activity_residue_is_separate_from_real_gaps(tmp_path, count): + """停牌残留不会被合并补拉清除, 不能把它反复列为可补齐缺口。""" + minute_dir, daily_dir = tmp_path / "kline_minute", tmp_path / "kline_daily" + day = "2026-10-20" + _write_partition(minute_dir, day, _full_day("002731.SZ")[:count] + _full_day("600519.SH")[:100]) + _zero_activity(minute_dir, day, "002731.SZ") + _write_daily(daily_dir, day, "600519.SH", 1000) + + r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0)) + + assert [g["symbol"] for g in r["gaps"]] == ["600519.SH"] + assert r["gaps"][0]["actual_bars"] == 100 + assert r["inactive_symbol_days"] == 1 + assert r["summary_by_date"][0]["gap_symbols"] == 1 + + +@pytest.mark.parametrize("daily_state", ["traded", "missing", "unknown", "corrupt", "empty"]) +def test_zero_activity_is_still_a_gap_without_daily_support(tmp_path, daily_state): + """日线有成交或无法核对时保留告警, 不用零成交量掩盖真实数据缺失。""" + minute_dir, daily_dir = tmp_path / "kline_minute", tmp_path / "kline_daily" + day = "2026-10-20" + _write_partition(minute_dir, day, _full_day("002731.SZ")[:171]) + _zero_activity(minute_dir, day, "002731.SZ") + if daily_state != "missing": + _write_daily(daily_dir, day, "002731.SZ", 1000) + part = daily_dir / f"date={day}" / "part.parquet" + if daily_state == "unknown": + pl.read_parquet(part).with_columns( + pl.lit(None).cast(pl.Float64).alias("volume"), pl.lit(0.0).alias("amount"), + ).write_parquet(part) + elif daily_state == "corrupt": + part.write_bytes(b"not parquet") + elif daily_state == "empty": + pl.read_parquet(part).head(0).write_parquet(part) + + r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0)) + + assert len(r["gaps"]) == 1 + assert r["inactive_symbol_days"] == 0 + + +@pytest.mark.parametrize("activity", ["volume", "amount", "unknown_volume", "missing_amount"]) +def test_partial_day_with_activity_or_unknown_fields_keeps_gap(tmp_path, activity): + minute_dir, daily_dir = tmp_path / "kline_minute", tmp_path / "kline_daily" + day = "2026-10-20" + _write_partition(minute_dir, day, _full_day("002731.SZ")[:171]) + _zero_activity(minute_dir, day, "002731.SZ") + part = minute_dir / f"date={day}" / "part.parquet" + df = pl.read_parquet(part) + if activity == "missing_amount": + df = df.drop("amount") + else: + column = "volume" if activity == "unknown_volume" else activity + value = None if activity == "unknown_volume" else 1.0 + df = df.with_columns( + pl.when(pl.col("datetime") == df["datetime"][0]).then(value).otherwise(pl.col(column)).alias(column) + ) + df.write_parquet(part) + _write_daily(daily_dir, day, "600519.SH", 1000) + + r = scan_minute_gaps(minute_dir, today=_BJ, now_hm=(20, 0)) + + assert len(r["gaps"]) == 1 + assert r["gaps"][0]["actual_bars"] == 171 + assert r["inactive_symbol_days"] == 0 + + # ---------- /api/kline/minute 的 local_partial 返回 ---------- def _fake_req(repo): @@ -122,6 +205,30 @@ def _fake_req(repo): return type("R", (), {"app": type("A", (), {"state": state})()})() +def test_gap_api_exposes_inactive_days_without_changing_source_data(tmp_path, monkeypatch): + from types import SimpleNamespace + + from app.api import kline as kline_api + + minute_dir, daily_dir = tmp_path / "kline_minute", tmp_path / "kline_daily" + day = "2026-10-20" + _write_partition(minute_dir, day, _full_day("002731.SZ")[:171]) + _zero_activity(minute_dir, day, "002731.SZ") + _write_daily(daily_dir, day, "600519.SH", 1000) + part = minute_dir / f"date={day}" / "part.parquet" + before = part.read_bytes() + monkeypatch.setattr(kline_api, "cn_now", lambda: _dt.datetime(2026, 10, 23, 20)) + monkeypatch.setattr(kline_api, "cn_today", lambda: _BJ) + repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)) + + result = kline_api.get_minute_gaps(_fake_req(repo), symbols="002731.SZ", dates=day, limit=500) + + assert result["gaps"] == [] + assert result["complete_dates"] == [] + assert result["inactive_symbol_days"] == 1 + assert part.read_bytes() == before + + def test_get_minute_returns_partial_instead_of_dropping(tmp_path, monkeypatch): """本地 171 根必须原样返回 (source=local_partial), 不能丢弃去问源。 diff --git a/backend/tests/test_pipeline_capacity.py b/backend/tests/test_pipeline_capacity.py index 8905c9d5..86509a33 100644 --- a/backend/tests/test_pipeline_capacity.py +++ b/backend/tests/test_pipeline_capacity.py @@ -174,6 +174,8 @@ def test_in_process_backtests_share_capacity(capacity, monkeypatch, kind): ("/api/kline/rebuild_enriched", {}, "app.indicators.pipeline", "run_pipeline", 3), ("/api/kline/sync_minute", {"days": 1}, "app.services.kline_sync", "sync_and_persist_minute", 3), + ("/api/kline/repair_minute_gaps", {"symbols": ["600000.SH"]}, + "app.services.kline_sync", "sync_and_persist_minute", 3), ]) def test_api_jobs_wait_before_computing( capacity, monkeypatch, tmp_path, endpoint, body, module_name, function_name, result, @@ -205,6 +207,7 @@ def test_api_jobs_wait_before_computing( clear_cache=lambda: None, refresh_cache=lambda: None, get_index_symbol_set=lambda: set(), + resolve_asset_type=lambda _: "stock", # rebuild_enriched 收尾经 repo.refresh_view 重建视图 (不再直连 db.execute) refresh_view=lambda *args, **kwargs: True, ) diff --git a/frontend/src/components/Layout.realtime.test.tsx b/frontend/src/components/Layout.realtime.test.tsx index e4461949..dea8f087 100644 --- a/frontend/src/components/Layout.realtime.test.tsx +++ b/frontend/src/components/Layout.realtime.test.tsx @@ -19,6 +19,7 @@ vi.mock('@/lib/api', () => ({ api: { watchlistList: vi.fn(async () => ({ symbols: [] })), watchlistEnriched: vi.fn(async () => ({ rows: [] })), pipelineJobs: vi.fn(async () => ({ active_id: null, items: [] })), + dataIntegrity: vi.fn(async () => ({ report: null, blocking_assets: [], data_revisions: {} })), alertsList: vi.fn(async () => ({ total: 0 })), quoteStatus: vi.fn(), updateRealtimeQuotes: vi.fn(async (enabled: boolean) => ({ @@ -100,3 +101,22 @@ it('当前源已不可用时不复用旧可用状态开启', async () => { expect(api.updateRealtimeQuotes).not.toHaveBeenCalled() expect(toast).toHaveBeenCalledWith(expect.stringContaining('数据源'), 'error') }) + +it.each(['succeeded', 'failed'] as const)('离开数据页后, 分钟补拉 %s 仍刷新体检和分钟缓存', async status => { + vi.mocked(api.pipelineJobs).mockResolvedValue({ active_id: null, jobs: [{ + id: 'repair-job', status, stage: status === 'succeeded' ? 'done' : 'sync_minute', progress: 100, + stage_pct: 100, started_at: null, finished_at: null, duration_s: 1, + result: status === 'succeeded' ? { minute_rows: 480, universe_size: 2 } : null, + error: status === 'failed' ? '部分补拉失败' : null, + }] }) + const keys = [QK.minuteGaps, QK.minuteBatch(), QK.klineMinute('600000.SH', '2026-10-20'), QK.klineMinuteRange('600000.SH', 10)] + keys.forEach(key => client.setQueryData(key, {})) + const unaffected = QK.indexMinute('000001.SH', '2026-10-20') + client.setQueryData(unaffected, {}) + await act(async () => root.render( + , + )) + await act(async () => { await new Promise(r => setTimeout(r, 20)) }) + keys.forEach(key => expect(client.getQueryState(key)?.isInvalidated).toBe(true)) + expect(client.getQueryState(unaffected)?.isInvalidated).toBe(false) +}) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d39796de..93b5aadf 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -21,7 +21,7 @@ import { useUpdateCheck } from '@/lib/updateCheck' import { useToggleRealtimeQuotes, } from '@/lib/useSharedMutations' -import { QK } from '@/lib/queryKeys' +import { QK, MINUTE_DATA_QUERY_PREFIXES } from '@/lib/queryKeys' import { setUiPref } from '@/lib/uiPrefs' import { getPageScale } from '@/lib/pageZoom' import { setKlineBatchLimits } from '@/lib/klineBatchLimits' @@ -493,6 +493,17 @@ export function Layout() { }, [isDataSyncing]) const qc = useQueryClient() + const latestDataJob = pipelineJobs?.jobs?.[0] + const refreshedMinuteJob = useRef(null) + useEffect(() => { + if (!latestDataJob || !['succeeded', 'failed'].includes(latestDataJob.status)) return + if (latestDataJob.result?.minute_rows == null && !['sync_minute', 'extend_minute'].includes(latestDataJob.stage)) return + if (refreshedMinuteJob.current === latestDataJob.id) return + refreshedMinuteJob.current = latestDataJob.id + // 复用常驻的任务轮询, 离开数据页后补拉结束也会刷新; 取消/失败可能已写入部分数据。 + qc.invalidateQueries({ predicate: q => MINUTE_DATA_QUERY_PREFIXES.has(String(q.queryKey[0])) }) + qc.invalidateQueries({ queryKey: QK.dataStatus }) + }, [latestDataJob?.id, latestDataJob?.status, qc]) const navigate = useNavigate() const version = versionData?.version const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false diff --git a/frontend/src/components/data/MinuteGapPanel.css b/frontend/src/components/data/MinuteGapPanel.css new file mode 100644 index 00000000..da012e57 --- /dev/null +++ b/frontend/src/components/data/MinuteGapPanel.css @@ -0,0 +1,82 @@ +/* 按面板实际可用宽度切换, 同时兼容侧栏、窄窗口和应用内界面缩放。 */ +.minute-gap-list { + container-type: inline-size; +} + +.minute-gap-columns { + display: none; +} + +.minute-gap-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.75rem; +} + +.minute-gap-identity { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.25rem; +} + +.minute-gap-metrics { + display: flex; + grid-column: 1 / -1; + grid-row: 2; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; +} + +.minute-gap-metric { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.minute-gap-repair { + grid-column: 2; + grid-row: 1; +} + +@container (min-width: 42rem) { + .minute-gap-columns, + .minute-gap-row { + display: grid; + grid-template-columns: minmax(6.5rem, 1fr) 6.25rem 5.5rem 4rem 7rem 5rem; + align-items: center; + column-gap: 0.75rem; + } + + .minute-gap-identity, + .minute-gap-metrics { + display: contents; + } + + .minute-gap-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + .minute-gap-metric { + align-items: flex-end; + } + + .minute-gap-range { + align-items: flex-start; + } + + .minute-gap-repair { + grid-column: auto; + grid-row: auto; + justify-self: end; + } +} diff --git a/frontend/src/components/data/MinuteGapPanel.tsx b/frontend/src/components/data/MinuteGapPanel.tsx index 8fc0d2af..8e47fd55 100644 --- a/frontend/src/components/data/MinuteGapPanel.tsx +++ b/frontend/src/components/data/MinuteGapPanel.tsx @@ -1,21 +1,22 @@ /** * 分钟数据缺口体检面板 (数据页 · 分钟 K 卡片 → 设置弹窗内)。 * - * 只做「发现」和「按需补拉单只」, 不批量承诺补齐: + * 发现缺口后可按单只或当前筛选结果批量补拉, 不承诺所有缺口均可补齐: * 上游分钟源通常只提供最近 N 天, 超出窗口的历史缺口补不回来 —— 盲目重试会 * 白等几十秒且返回空。因此这里把缺口如实列出、明确标注可尝试补齐, 让用户 * 知道「同步完成」不等于「数据完整」。 */ import { useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { AlertTriangle, CheckCircle2, Loader2, RefreshCw, Search } from 'lucide-react' +import { AlertTriangle, CheckCircle2, Download, Loader2, RefreshCw, Search } from 'lucide-react' import { api, type MinuteGapRow } from '@/lib/api' -import { QK } from '@/lib/queryKeys' +import { QK, MINUTE_DATA_QUERY_PREFIXES } from '@/lib/queryKeys' import { toast } from '@/components/Toast' +import './MinuteGapPanel.css' -const PAGE = 50 +const PAGE = 10 -export function MinuteGapPanel() { +export function MinuteGapPanel({ hasCap = true, onJobStart }: { hasCap?: boolean; onJobStart?: (jobId: string) => void }) { const qc = useQueryClient() const [filter, setFilter] = useState('') const [page, setPage] = useState(0) @@ -32,8 +33,8 @@ export function MinuteGapPanel() { onSuccess: (r, symbol) => { const rows = (r as { rows?: number })?.rows ?? 0 toast(rows > 0 ? `${symbol} 已拉取 ${rows} 行分钟数据` : `${symbol} 未拉到新数据(源可能不提供该历史窗口)`, rows > 0 ? 'success' : 'error') - qc.invalidateQueries({ queryKey: QK.minuteGaps }) - qc.invalidateQueries({ queryKey: ['kline-minute'] }) + qc.invalidateQueries({ predicate: q => MINUTE_DATA_QUERY_PREFIXES.has(String(q.queryKey[0])) }) + qc.invalidateQueries({ queryKey: QK.dataStatus }) }, onError: (e: unknown) => toast(`补齐失败: ${String((e as Error)?.message ?? e)}`, 'error'), onSettled: () => setBusySymbol(null), @@ -44,8 +45,18 @@ export function MinuteGapPanel() { const kw = filter.trim().toUpperCase() return kw ? all.filter(g => g.symbol.toUpperCase().includes(kw) || g.date.includes(kw)) : all }, [all, filter]) - const pageRows = filtered.slice(page * PAGE, page * PAGE + PAGE) + const repairSymbols = useMemo(() => [...new Set(filtered.map(g => g.symbol))], [filtered]) + const batchRepair = useMutation({ + mutationFn: (symbols: string[]) => api.repairMinuteGaps(symbols), + onSuccess: (result, symbols) => { + qc.invalidateQueries({ queryKey: QK.pipelineJobs }) + toast(`已启动 ${symbols.length} 只标的的缺口补拉,可在数据页查看进度`, 'success') + onJobStart?.(result.job_id) + }, + }) const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE)) + const currentPage = Math.min(page, pageCount - 1) + const pageRows = filtered.slice(currentPage * PAGE, (currentPage + 1) * PAGE) if (gaps.isLoading) { return ( @@ -67,33 +78,66 @@ export function MinuteGapPanel() { const totalMissing = summary.reduce((n, c) => n + c.missing_bars, 0) return ( -
-
- - 扫描 {gaps.data?.scanned_dates ?? 0} 个交易日 · {gaps.data?.scanned_symbols ?? 0} 只标的 - - {filtered.length === 0 ? ( - - - 未发现分钟数据缺口 +
+
+
+ + 扫描 {gaps.data?.scanned_dates ?? 0} 个交易日 · {gaps.data?.scanned_symbols ?? 0} 只标的 - ) : ( - - - {filtered.length} 条缺口 · 合计缺 {totalMissing.toLocaleString()} 根 - - )} - + {all.length === 0 ? ( + + + 未发现分钟数据缺口 + + ) : ( + + + {all.length} 条缺口 · 合计缺 {totalMissing.toLocaleString()} 根 + + )} +
+
+ {all.length > 0 && ( + + )} + +
+ {(gaps.data?.inactive_symbol_days ?? 0) > 0 && ( +

+ 另有 {gaps.data!.inactive_symbol_days} 组整日零成交记录(日线核对无成交,可能停牌),未计入缺口,无需补拉。 +

+ )} + + {batchRepair.isError && ( +
+ 批量补拉启动失败:{String((batchRepair.error as Error)?.message ?? batchRepair.error)} +
+ )} + {all.length > 0 && ( +

+ 一键补齐处理{filter.trim() ? '当前筛选的' : '已列出的全部'} {filtered.length} 条缺口,跨页按 {repairSymbols.length} 只标的去重补拉;任务结束后更新体检结果。 + {gaps.data?.truncated && 本次体检列表已截断,仅处理已列出的记录。} +

+ )} + {summary.length > 0 && (
{summary.map(c => ( @@ -104,81 +148,102 @@ export function MinuteGapPanel() {
)} - {filtered.length > 0 && ( + {all.length > 0 && ( <> -
- +
+ { setFilter(e.target.value); setPage(0) }} placeholder="按代码 / 日期过滤" - className="h-7 flex-1 rounded border border-border bg-surface px-2 text-[11px] text-foreground outline-none focus:border-accent" + aria-label="按代码或日期过滤缺口" + className="h-9 min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none" />
-
-
- 代码 - 日期 - 实有/应有 - 缺 - 数据范围 - 操作 + {filtered.length === 0 ? ( +
+ 没有匹配的缺口记录,请尝试其他代码或日期。
-
+ ) : ( +
+ +
    {pageRows.map((g: MinuteGapRow) => ( -
    - {g.symbol} - {g.date} - {g.actual_bars}/{g.expected_bars} - -{g.missing_bars} - - {g.first_time ?? '—'} ~ {g.last_time ?? '—'} - - +
    + {g.symbol} + {g.date} +
    +
    +
    +
    实有 / 应有
    +
    {g.actual_bars}/{g.expected_bars}
    +
    +
    +
    缺失
    +
    {g.missing_bars} 根
    +
    +
    +
    数据范围
    +
    {g.first_time ?? '—'} ~ {g.last_time ?? '—'}
    +
    +
    +
    - -
    +
    + ))} -
+
+ )} - {pageCount > 1 && ( -
- - {page + 1} / {pageCount} - + {filtered.length > 0 && ( +
+ 第 {currentPage * PAGE + 1}–{Math.min((currentPage + 1) * PAGE, filtered.length)} 条,共 {filtered.length} 条 + {pageCount > 1 && ( +
+ + {currentPage + 1} / {pageCount} + +
+ )}
)} )} -

- 「尝试补齐」会向当前分钟数据源重新拉取该标的最近 10 个交易日的分钟 K。 + {!hasCap &&

当前分钟数据源不可用,可查看本地缺口;配置可用数据源后可尝试补齐。

} +

+ 补齐会向当前分钟数据源重新拉取标的最近 10 个交易日窗口的分钟 K,批量操作沿用同一规则。 分钟源通常只提供最近一段时间的分钟数据,超出窗口的历史缺口无法补齐, 此时应视为永久残缺 —— 不影响已有数据的查看。

diff --git a/frontend/src/components/data/MinuteSyncConfig.test.tsx b/frontend/src/components/data/MinuteSyncConfig.test.tsx new file mode 100644 index 00000000..f4d6d476 --- /dev/null +++ b/frontend/src/components/data/MinuteSyncConfig.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { api, type MinuteGapRow, type Preferences } from '@/lib/api' +import { QK } from '@/lib/queryKeys' +import { MinuteSyncConfig } from './MinuteSyncConfig' + +vi.mock('@/lib/api', () => ({ api: { + preferences: vi.fn(), updateMinuteSync: vi.fn(), syncMinute: vi.fn(), + clearMinute: vi.fn(), minuteGaps: vi.fn(), syncMinuteSingle: vi.fn(), repairMinuteGaps: vi.fn(), +} })) +vi.mock('@/components/Toast', () => ({ toast: vi.fn() })) + +let host: HTMLDivElement +let root: Root +let client: QueryClient +const onJobStart = vi.fn() +const rows: MinuteGapRow[] = Array.from({ length: 24 }, (_, i) => ({ + symbol: `${String(i + 1).padStart(6, '0')}.SZ`, date: '2026-10-20', + actual_bars: 171, expected_bars: 240, missing_bars: 69, + first_time: '09:30', last_time: '13:49', +})) +const report = { + status: 'ok', scanned_dates: 10, scanned_symbols: 5580, complete_dates: [], + inactive_symbol_days: 0, + gaps: rows, summary_by_date: [{ date: '2026-10-20', gap_symbols: 24, missing_bars: 1656, expected_bars: 240 }], + truncated: false, +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.resetAllMocks() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const prefs = { minute_sync_enabled: false, minute_sync_days: 5, minute_sync_segment_days: 20 } as Preferences + vi.mocked(api.preferences).mockResolvedValue(prefs) + vi.mocked(api.updateMinuteSync).mockResolvedValue(prefs) + vi.mocked(api.minuteGaps).mockResolvedValue(report) + vi.mocked(api.syncMinute).mockResolvedValue({ job_id: 'minute-job', status: 'started' }) + vi.mocked(api.repairMinuteGaps).mockResolvedValue({ job_id: 'repair-job', status: 'started' }) +}) + +it('零成交残留单独说明, 不提供无效补拉按钮', async () => { + vi.mocked(api.minuteGaps).mockResolvedValue({ ...report, gaps: [], summary_by_date: [], inactive_symbol_days: 2 }) + await render() + await click('数据体检') + expect(host.textContent).toContain('未发现分钟数据缺口') + expect(host.textContent).toContain('另有 2 组整日零成交记录') + expect(host.textContent).not.toContain('一键补齐') + expect(api.repairMinuteGaps).not.toHaveBeenCalled() +}) +afterEach(async () => { + await act(async () => root.unmount()) + client.clear() + host.remove() +}) + +async function render(hasCap = true) { + await act(async () => root.render( + + )) + await settle() +} +async function settle() { + await act(async () => { await new Promise(r => setTimeout(r, 20)) }) +} +async function click(text: string) { + const button = [...host.querySelectorAll('button')].find(b => b.textContent?.includes(text))! + expect(button).toBeTruthy() + await act(async () => button.click()) + await settle() +} +async function filter(value: string) { + const input = host.querySelector('input[placeholder="按代码 / 日期过滤"]')! + expect(input).toBeTruthy() + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +it('同步设置和体检分开展示, 切换页签不丢失筛选条件', async () => { + await render() + const panels = host.querySelectorAll('[role="tabpanel"]') + expect(panels).toHaveLength(2) + expect(panels[0].hidden).toBe(false) + expect(panels[1].hidden).toBe(true) + await click('数据体检') + expect(panels[0].hidden).toBe(true) + expect(panels[1].hidden).toBe(false) + expect(panels[1].textContent).toContain('000001.SZ') + await filter('000001') + await click('同步与获取') + await click('数据体检') + expect(host.querySelector('input')?.value).toBe('000001') +}) + +it('页签支持方向键切换并把键盘焦点移到选中项', async () => { + await render() + const tabs = host.querySelectorAll('[role="tab"]') + await act(async () => { + tabs[0].focus() + tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })) + }) + expect(tabs[1].getAttribute('aria-selected')).toBe('true') + expect(document.activeElement).toBe(tabs[1]) + await act(async () => tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }))) + expect(tabs[0].getAttribute('aria-selected')).toBe('true') + expect(document.activeElement).toBe(tabs[0]) +}) + +it('无匹配记录时保留搜索框和真实缺口状态, 可清除筛选', async () => { + await render() + await click('数据体检') + await filter('不存在') + expect(host.querySelector('input')).not.toBeNull() + expect(host.textContent).toContain('没有匹配的缺口记录') + expect(host.textContent).toContain('24 条缺口') + expect(host.textContent).not.toContain('未发现分钟数据缺口') + await filter('') + expect(host.textContent).toContain('000001.SZ') +}) + +it('分页限制单页记录, 刷新后记录减少仍能显示有效页', async () => { + await render() + await click('数据体检') + expect(host.textContent).toContain('000010.SZ') + expect(host.textContent).not.toContain('000011.SZ') + await click('下一页') + await click('下一页') + expect(host.textContent).toContain('000024.SZ') + await act(async () => { client.setQueryData(QK.minuteGaps, { ...report, gaps: rows.slice(0, 1) }) }) + await settle() + expect(host.textContent).toContain('000001.SZ') +}) + +it('手动获取仍使用分段天数并通知任务, 清空必须经过二次确认', async () => { + await render() + await click('单次获取 20 天') + expect(api.syncMinute).toHaveBeenCalledWith(20, true) + expect(onJobStart).toHaveBeenCalledWith('minute-job') + await click('清空分钟K数据') + expect(api.clearMinute).not.toHaveBeenCalled() + expect(host.textContent).toContain('确认清空分钟K数据?') + await click('取消') + expect(api.clearMinute).not.toHaveBeenCalled() +}) + +it('无分钟能力时禁用拉取和补齐, 仍能查看本地体检结果', async () => { + await render(false) + expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('单次获取'))?.disabled).toBe(true) + await click('数据体检') + const repairs = [...host.querySelectorAll('button')].filter(b => b.textContent?.includes('尝试补齐')) + expect(repairs.length).toBeGreaterThan(0) + expect(repairs.every(b => b.disabled)).toBe(true) + expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('一键补齐'))?.disabled).toBe(true) +}) + +it('一键补齐覆盖当前筛选的全部分页, 同一标的只提交一次', async () => { + const repeated = rows.map((r, i) => ({ ...r, symbol: rows[i % 12].symbol, date: i < 12 ? '2026-10-20' : '2026-10-21' })) + vi.mocked(api.minuteGaps).mockResolvedValue({ ...report, gaps: repeated }) + await render() + await click('数据体检') + await click('一键补齐') + expect(api.repairMinuteGaps).toHaveBeenCalledTimes(1) + expect(api.repairMinuteGaps).toHaveBeenCalledWith(rows.slice(0, 12).map(r => r.symbol)) + expect(api.syncMinuteSingle).not.toHaveBeenCalled() + expect(onJobStart).toHaveBeenCalledWith('repair-job') +}) + +it('一键补齐遵守代码筛选, 无匹配项时不能发起任务', async () => { + await render() + await click('数据体检') + await filter('不存在') + expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('一键补齐'))?.disabled).toBe(true) + await filter('000001') + await click('一键补齐') + expect(api.repairMinuteGaps).toHaveBeenCalledWith(['000001.SZ']) +}) + +it('批量启动期间禁用重复操作, 失败后显示原因并恢复按钮', async () => { + let reject!: (error: Error) => void + vi.mocked(api.repairMinuteGaps).mockImplementation(() => new Promise((_, fail) => { reject = fail })) + await render() + await click('数据体检') + await click('一键补齐') + const repairs = [...host.querySelectorAll('button')].filter(b => b.textContent?.includes('尝试补齐')) + expect(repairs.every(b => b.disabled)).toBe(true) + await act(async () => reject(new Error('已有数据任务正在运行'))) + await settle() + expect(host.querySelector('[role="alert"]')?.textContent).toContain('已有数据任务正在运行') + expect([...host.querySelectorAll('button')].find(b => b.textContent?.includes('一键补齐'))?.disabled).toBe(false) + expect(onJobStart).not.toHaveBeenCalled() +}) + +it('体检加载时显示扫描进度', async () => { + vi.mocked(api.minuteGaps).mockImplementationOnce(() => new Promise(() => {})) + await render() + await click('数据体检') + expect(host.textContent).toContain('正在扫描分钟分区') +}) + +it('体检覆盖失败和空结果状态', async () => { + vi.mocked(api.minuteGaps).mockResolvedValueOnce({ ...report, gaps: [], summary_by_date: [] }) + await render() + await click('数据体检') + expect(host.textContent).toContain('未发现分钟数据缺口') + vi.mocked(api.minuteGaps).mockRejectedValueOnce(new Error('无法读取分钟分区')) + await click('重新体检') + expect(host.textContent).toContain('体检失败') + expect(host.textContent).toContain('无法读取分钟分区') +}) diff --git a/frontend/src/components/data/MinuteSyncConfig.tsx b/frontend/src/components/data/MinuteSyncConfig.tsx index d1d11078..5e4fd956 100644 --- a/frontend/src/components/data/MinuteSyncConfig.tsx +++ b/frontend/src/components/data/MinuteSyncConfig.tsx @@ -1,13 +1,17 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useId, useRef } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Loader2, Trash2, Download, Calendar } from 'lucide-react' +import { Loader2, Trash2, Download, Calendar, SlidersHorizontal, Activity } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { MissingCapChip } from '@/lib/capability-labels' import { MinuteGapPanel } from '@/components/data/MinuteGapPanel' // hasCap: 分钟K能力当前是否可用 (路由矩阵判定, 生效源含插件/自定义源) -export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJobStart?: (jobId: string) => void }) { +export function MinuteSyncConfig({ hasCap, onJobStart, onClose }: { hasCap: boolean; onJobStart?: (jobId: string) => void; onClose?: () => void }) { + const [tab, setTab] = useState<'sync' | 'health'>('sync') + const tabId = useId() + const syncTabRef = useRef(null) + const healthTabRef = useRef(null) const qc = useQueryClient() const prefs = useQuery({ queryKey: QK.preferences, @@ -76,12 +80,54 @@ export function MinuteSyncConfig({ hasCap, onJobStart }: { hasCap: boolean; onJo } return ( -
+
+
{ + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return + e.preventDefault() + const next = e.key === 'Home' ? 'sync' : e.key === 'End' ? 'health' : tab === 'sync' ? 'health' : 'sync' + setTab(next) + ;(next === 'sync' ? syncTabRef : healthTabRef).current?.focus() + }} + > + {([ + { key: 'sync', label: '同步与获取', icon: SlidersHorizontal, ref: syncTabRef }, + { key: 'health', label: '数据体检', icon: Activity, ref: healthTabRef }, + ] as const).map(({ key, label, icon: Icon, ref }) => ( + + ))} +
+