diff --git a/zhixing-server/src/zhixing_server/modules/selection/application/run.py b/zhixing-server/src/zhixing_server/modules/selection/application/run.py index eb6b913..6d19ecc 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/application/run.py +++ b/zhixing-server/src/zhixing_server/modules/selection/application/run.py @@ -11,6 +11,7 @@ from ..domain.models import SelectionEvaluation from ..domain.runs import ( SelectionExecutionSource, SelectionRerunRequired, + SelectionResultQuery, SelectionRun, SelectionRunInProgress, SelectionRunItem, @@ -135,19 +136,26 @@ class RunZhixingB1: except Exception: # noqa: BLE001 - preserve the original worker failure logger.exception("selection_run_failure_persist_failed run_id=%s", prepared.run.id) - def get_run(self, run_id: str) -> SelectionRun | None: + def get_run( + self, + run_id: str, + *, + query: SelectionResultQuery | None = None, + ) -> SelectionRun | None: """Read one persisted run for polling.""" - return self.store.get_run(run_id) + return self.store.get_run(run_id, query=query) def get_latest( self, strategy: StrategyName, target_trade_date: date | None = None, + *, + query: SelectionResultQuery | None = None, ) -> SelectionRun | None: """Read the current result by date or the latest result for a strategy.""" - return self.store.get_latest_run(strategy, target_trade_date) + return self.store.get_latest_run(strategy, target_trade_date, query=query) def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem: diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py index 88eedb4..9f7826d 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py @@ -11,6 +11,17 @@ from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory SelectionRunStatus = Literal["running", "success", "partial_success", "failed"] SelectionRunItemStatus = SelectionEvaluationStatus +SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original"] + + +@dataclass(frozen=True, slots=True) +class SelectionResultQuery: + """Validated query options for a paged selection-result read.""" + + page: int = 1 + page_size: int = 10 + search: str | None = None + category: SelectionSignalCategoryFilter | None = None @dataclass(frozen=True, slots=True) @@ -67,6 +78,7 @@ class SelectionRun: finished_at: datetime | None = None items: tuple[SelectionRunItem, ...] = field(default_factory=tuple) signals: tuple[SelectionSignal, ...] = field(default_factory=tuple) + signals_total: int | None = None class SelectionRunError(RuntimeError): @@ -112,12 +124,19 @@ class SelectionRunStore(Protocol): error_message: str | None = None, ) -> None: ... - def get_run(self, run_id: str) -> SelectionRun | None: ... + def get_run( + self, + run_id: str, + *, + query: SelectionResultQuery | None = None, + ) -> SelectionRun | None: ... def get_latest_run( self, strategy: Literal["zhixing_b1"], target_trade_date: date | None = None, + *, + query: SelectionResultQuery | None = None, ) -> SelectionRun | None: ... diff --git a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py index 214ea5a..2a9fcb9 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py +++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py @@ -18,6 +18,7 @@ from ..domain.models import SelectionSignal, ZhixingB1Category from ..domain.runs import ( SelectionExecutionSource, SelectionRerunRequired, + SelectionResultQuery, SelectionRun, SelectionRunError, SelectionRunInProgress, @@ -29,6 +30,19 @@ from ..domain.runs import ( from ..domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER _SIGNAL_PRIORITY = {category: index for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)} +_CATEGORY_PREFIXES = { + "pullback": "zhixing_b1_pullback_", + "oversold": "zhixing_b1_oversold_", + "original": "zhixing_b1_original_b1", +} +_SIGNAL_ORDER_SQL = ( + "CASE category " + + " ".join( + f"WHEN '{category.value}' THEN {index}" + for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER) + ) + + f" ELSE {len(ZHIXING_B1_SIGNAL_ORDER)} END" +) class PostgresSelectionRunRepository(SelectionRunStore): @@ -224,12 +238,17 @@ class PostgresSelectionRunRepository(SelectionRunStore): except psycopg.Error as exc: raise SelectionRunStoreError(f"failed to finish selection run {run_id}") from exc - def get_run(self, run_id: str) -> SelectionRun | None: - """Read one run with its item failures and signal details.""" + def get_run( + self, + run_id: str, + *, + query: SelectionResultQuery | None = None, + ) -> SelectionRun | None: + """Read one run with filtered, paged signals and item failures.""" try: with self._connection() as connection: - return self._load_run(connection, run_id) + return self._load_run(connection, run_id, query or SelectionResultQuery()) except psycopg.Error as exc: raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc @@ -237,6 +256,8 @@ class PostgresSelectionRunRepository(SelectionRunStore): self, strategy: Literal["zhixing_b1"], target_trade_date: date | None = None, + *, + query: SelectionResultQuery | None = None, ) -> SelectionRun | None: """Read the current run for a date or the latest date for a strategy.""" @@ -263,12 +284,20 @@ class PostgresSelectionRunRepository(SelectionRunStore): """, (strategy, target_trade_date), ).fetchone() - return self._load_run(connection, str(row[0])) if row else None + return ( + self._load_run(connection, str(row[0]), query or SelectionResultQuery()) + if row + else None + ) except psycopg.Error as exc: raise SelectionRunStoreError("failed to load latest selection run") from exc @staticmethod - def _load_run(connection: Any, run_id: str) -> SelectionRun | None: + def _load_run( + connection: Any, + run_id: str, + query: SelectionResultQuery, + ) -> SelectionRun | None: row = connection.execute( """ SELECT @@ -292,15 +321,23 @@ class PostgresSelectionRunRepository(SelectionRunStore): """, (run_id,), ).fetchall() + signal_filter, signal_parameters = _signal_filter(query, run_id) + signal_total_row = connection.execute( + f"SELECT COUNT(*) FROM selection_signal WHERE {signal_filter}", + tuple(signal_parameters), + ).fetchone() + signal_total = int(signal_total_row[0] or 0) if signal_total_row else 0 + offset = (query.page - 1) * query.page_size signal_rows = connection.execute( - """ + f""" SELECT ts_code, name, target_trade_date, strategy, category, close, details FROM selection_signal - WHERE run_id = %s - ORDER BY ts_code, category + WHERE {signal_filter} + ORDER BY ts_code, {_SIGNAL_ORDER_SQL} + LIMIT %s OFFSET %s """, - (run_id,), + tuple((*signal_parameters, query.page_size, offset)), ).fetchall() signals = tuple( sorted( @@ -353,6 +390,7 @@ class PostgresSelectionRunRepository(SelectionRunStore): finished_at=cast(datetime | None, row[15]), items=items, signals=signals, + signals_total=signal_total, ) @contextmanager @@ -380,6 +418,27 @@ def _signal_from_row(row: tuple[object, ...]) -> SelectionSignal: ) +def _signal_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[object]]: + """Build the parameterized WHERE clause shared by count and page reads.""" + + clauses = ["run_id = %s"] + parameters: list[object] = [run_id] + if query.search: + pattern = f"%{_escape_like(query.search)}%" + clauses.append("(name ILIKE %s ESCAPE '\\' OR ts_code ILIKE %s ESCAPE '\\')") + parameters.extend((pattern, pattern)) + if query.category: + clauses.append("category LIKE %s") + parameters.append(f"{_CATEGORY_PREFIXES[query.category]}%") + return " AND ".join(clauses), parameters + + +def _escape_like(value: str) -> str: + """Escape user wildcards before placing text inside a SQL LIKE pattern.""" + + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def _details(value: object) -> dict[str, float | str | None]: """Normalize JSONB details into the domain's scalar-only mapping.""" diff --git a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py index 81452db..3356164 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py +++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py @@ -3,7 +3,7 @@ from datetime import date, datetime from typing import Annotated, Literal -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from zhixing_server.bootstrap.config import Settings, get_settings @@ -12,6 +12,7 @@ from zhixing_server.modules.selection.application.run import ( ) from zhixing_server.modules.selection.domain.runs import ( SelectionRerunRequired, + SelectionResultQuery, SelectionRun, SelectionRunInProgress, SelectionRunStoreError, @@ -88,7 +89,7 @@ def _empty_signals() -> list[SelectionSignalResponse]: class SelectionResultsResponse(BaseModel): - """Batch summary and materialized signals consumed by the Web feature.""" + """Batch summary and one filtered page of signals consumed by the Web feature.""" strategy: StrategyValue target_trade_date: date | None @@ -106,6 +107,9 @@ class SelectionResultsResponse(BaseModel): error_message: str | None = None created_at: datetime | None = None finished_at: datetime | None = None + page: int = Field(default=1, ge=1) + page_size: int = Field(default=10, ge=1, le=100) + signals_total: int = Field(default=0, ge=0) failures: list[SelectionFailureResponse] = Field(default_factory=_empty_failures) signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals) @@ -160,16 +164,21 @@ def trigger_selection_run( def get_selection_run( run_id: str, service: Annotated[RunZhixingB1, Depends(get_selection_service)], + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 10, + search: Annotated[str | None, Query(max_length=100)] = None, + category: Literal["pullback", "oversold", "original"] | None = None, ) -> SelectionResultsResponse: """Return one run for asynchronous polling.""" try: - run = service.get_run(run_id) + query = _result_query(page, page_size, search, category) + run = service.get_run(run_id, query=query) except SelectionRunStoreError as exc: raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc if run is None: raise _http_error(404, "run_not_found", f"selection run not found: {run_id}") - return _run_response(run) + return _run_response(run, query=query) @selection_router.get("/results", response_model=SelectionResultsResponse) @@ -177,11 +186,16 @@ def get_selection_results( service: Annotated[RunZhixingB1, Depends(get_selection_service)], strategy: StrategyValue = "zhixing_b1", target_trade_date: date | None = None, + page: Annotated[int, Query(ge=1)] = 1, + page_size: Annotated[int, Query(ge=1, le=100)] = 10, + search: Annotated[str | None, Query(max_length=100)] = None, + category: Literal["pullback", "oversold", "original"] | None = None, ) -> SelectionResultsResponse: """Return the current persisted result for a strategy and optional date.""" try: - run = service.get_latest(strategy, target_trade_date) + query = _result_query(page, page_size, search, category) + run = service.get_latest(strategy, target_trade_date, query=query) except SelectionRunStoreError as exc: raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc if run is None: @@ -192,11 +206,14 @@ def get_selection_results( market_sync_batch_id=None, status="no_data", coverage=0, + page=query.page, + page_size=query.page_size, + signals_total=0, ) - return _run_response(run) + return _run_response(run, query=query) -def _run_response(run: SelectionRun) -> SelectionResultsResponse: +def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> SelectionResultsResponse: """Translate a domain run without exposing storage-specific fields.""" return SelectionResultsResponse( @@ -216,6 +233,9 @@ def _run_response(run: SelectionRun) -> SelectionResultsResponse: error_message=run.error_message, created_at=run.created_at, finished_at=run.finished_at, + page=query.page, + page_size=query.page_size, + signals_total=run.signals_total if run.signals_total is not None else run.signal_count, failures=[ SelectionFailureResponse( ts_code=item.ts_code, @@ -241,6 +261,23 @@ def _run_response(run: SelectionRun) -> SelectionResultsResponse: ) +def _result_query( + page: int, + page_size: int, + search: str | None, + category: Literal["pullback", "oversold", "original"] | None, +) -> SelectionResultQuery: + """Normalize HTTP query values before handing them to the selection port.""" + + normalized_search = search.strip() if search else None + return SelectionResultQuery( + page=page, + page_size=page_size, + search=normalized_search or None, + category=category, + ) + + def _http_error(code: int, error_type: str, message: str) -> HTTPException: """Create the project's explicit, safe error envelope.""" diff --git a/zhixing-server/tests/test_selection_http.py b/zhixing-server/tests/test_selection_http.py index bfca991..b8ff818 100644 --- a/zhixing-server/tests/test_selection_http.py +++ b/zhixing-server/tests/test_selection_http.py @@ -11,6 +11,7 @@ from zhixing_server.modules.selection.domain.models import SelectionSignal, Zhix from zhixing_server.modules.selection.domain.runs import ( SelectionExecutionSource, SelectionRerunRequired, + SelectionResultQuery, SelectionRun, SelectionRunInProgress, SelectionStock, @@ -28,6 +29,7 @@ class FakeSelectionService: self.run = run self.executed = False self.mode = "ok" + self.last_query: SelectionResultQuery | None = None def prepare( self, @@ -58,14 +60,23 @@ class FakeSelectionService: def execute(self, prepared: PreparedSelectionRun) -> None: self.executed = True - def get_run(self, run_id: str) -> SelectionRun | None: + def get_run( + self, + run_id: str, + *, + query: SelectionResultQuery | None = None, + ) -> SelectionRun | None: + self.last_query = query return self.run if self.run and self.run.id == run_id else None def get_latest( self, strategy: str, target_trade_date: date | None = None, + *, + query: SelectionResultQuery | None = None, ) -> SelectionRun | None: + self.last_query = query if self.run is None: return None if target_trade_date is not None and self.run.target_trade_date != target_trade_date: @@ -193,10 +204,47 @@ def test_query_returns_persisted_signal_details() -> None: body = response.json() assert body["run_id"] == "run-http" assert body["signal_count"] == 1 + assert body["page"] == 1 + assert body["page_size"] == 10 + assert body["signals_total"] == 1 assert body["signals"][0]["category"] == "zhixing_b1_original_b1" assert body["signals"][0]["details"] == {"j": 12.0} +def test_query_forwards_pagination_and_filters() -> None: + service = FakeSelectionService(_run("run-http", "success")) + + response = _client(service).get( + "/api/v1/selection/results", + params={ + "strategy": "zhixing_b1", + "page": 2, + "page_size": 5, + "search": " 平安银行 ", + "category": "original", + }, + ) + + assert response.status_code == 200 + assert service.last_query == SelectionResultQuery( + page=2, + page_size=5, + search="平安银行", + category="original", + ) + assert response.json()["page"] == 2 + assert response.json()["page_size"] == 5 + + +def test_query_rejects_invalid_page_size() -> None: + response = _client(FakeSelectionService()).get( + "/api/v1/selection/results", + params={"strategy": "zhixing_b1", "page_size": 101}, + ) + + assert response.status_code == 422 + + def test_run_polling_returns_the_persisted_terminal_result() -> None: response = _client(FakeSelectionService(_run("run-http", "success"))).get( "/api/v1/selection/runs/run-http" diff --git a/zhixing-server/tests/unit/selection/test_postgres_runs.py b/zhixing-server/tests/unit/selection/test_postgres_runs.py index 5fc7895..1029c8d 100644 --- a/zhixing-server/tests/unit/selection/test_postgres_runs.py +++ b/zhixing-server/tests/unit/selection/test_postgres_runs.py @@ -11,6 +11,7 @@ from zhixing_server.modules.selection.domain.models import SelectionSignal, Zhix from zhixing_server.modules.selection.domain.runs import ( SelectionExecutionSource, SelectionRerunRequired, + SelectionResultQuery, SelectionRunInProgress, SelectionRunItem, SelectionStock, @@ -163,6 +164,9 @@ def test_record_item_persists_independent_signals_as_jsonb(monkeypatch: pytest.M class LoadConnection: + def __init__(self) -> None: + self.statements: list[tuple[str, tuple[object, ...]]] = [] + def __enter__(self) -> "LoadConnection": return self @@ -170,6 +174,7 @@ class LoadConnection: return None def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult": + self.statements.append((query, parameters)) if "FROM selection_run\n" in query: return LoadResult( row=( @@ -193,6 +198,8 @@ class LoadConnection: ) if "FROM selection_run_item" in query: return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)]) + if "COUNT(*) FROM selection_signal" in query: + return LoadResult(row=(2,)) return LoadResult( rows=[ ( @@ -242,10 +249,32 @@ def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPa return connection monkeypatch.setattr(psycopg, "connect", connect) - run = PostgresSelectionRunRepository("postgresql://test").get_run("run-1") + run = PostgresSelectionRunRepository("postgresql://test").get_run( + "run-1", + query=SelectionResultQuery( + page=2, + page_size=1, + search="100%", + category="pullback", + ), + ) assert run is not None assert [signal.category for signal in run.signals] == [ ZHIXING_B1_SIGNAL_ORDER[0], ZHIXING_B1_SIGNAL_ORDER[-1], ] + count_query, count_parameters = next( + (query, parameters) + for query, parameters in connection.statements + if "COUNT(*) FROM selection_signal" in query + ) + assert "name ILIKE %s ESCAPE" in count_query + assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%") + page_query, page_parameters = next( + (query, parameters) + for query, parameters in connection.statements + if "LIMIT %s OFFSET %s" in query + ) + assert "ORDER BY ts_code, CASE category" in page_query + assert page_parameters[-2:] == (1, 1) diff --git a/zhixing-server/tests/unit/selection/test_run.py b/zhixing-server/tests/unit/selection/test_run.py index 461edec..2fe51d9 100644 --- a/zhixing-server/tests/unit/selection/test_run.py +++ b/zhixing-server/tests/unit/selection/test_run.py @@ -15,6 +15,7 @@ from zhixing_server.modules.selection.domain.models import ( ) from zhixing_server.modules.selection.domain.runs import ( SelectionExecutionSource, + SelectionResultQuery, SelectionRun, SelectionRunItem, SelectionRunStatus, @@ -100,10 +101,16 @@ class FakeStore: kwargs["error_message"] = error_message self.finished = (run_id, status, kwargs) - def get_run(self, run_id: str): + def get_run(self, run_id: str, *, query: SelectionResultQuery | None = None): return None - def get_latest_run(self, strategy: str, target_trade_date: date | None = None): + def get_latest_run( + self, + strategy: str, + target_trade_date: date | None = None, + *, + query: SelectionResultQuery | None = None, + ): return None diff --git a/zhixing-web/src/features/selection/api/selection.api.test.ts b/zhixing-web/src/features/selection/api/selection.api.test.ts new file mode 100644 index 0000000..24609fd --- /dev/null +++ b/zhixing-web/src/features/selection/api/selection.api.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const requestJson = vi.hoisted(() => vi.fn()) + +vi.mock("@/shared/api/request-json", () => ({ requestJson })) + +import { getSelectionResults, getSelectionRun } from "./selection.api" + +describe("selection API adapters", () => { + beforeEach(() => { + requestJson.mockReset() + }) + + it("sends paged result filters using the HTTP field names", async () => { + await getSelectionResults("zhixing_b1", "2026-08-08", { + category: "pullback", + page: 2, + pageSize: 15, + search: "平安银行", + }) + + const [input, init] = requestJson.mock.calls[0] as [ + string, + { signal?: AbortSignal }, + ] + const params = new URL(input, "http://localhost").searchParams + expect(params.get("strategy")).toBe("zhixing_b1") + expect(params.get("target_trade_date")).toBe("2026-08-08") + expect(params.get("page")).toBe("2") + expect(params.get("page_size")).toBe("15") + expect(params.get("search")).toBe("平安银行") + expect(params.get("category")).toBe("pullback") + expect(init).toEqual({ signal: undefined }) + }) + + it("uses the same query contract while polling a run", async () => { + await getSelectionRun("run-1", { page: 3, pageSize: 5 }) + + const [input] = requestJson.mock.calls[0] as [string] + const params = new URL(input, "http://localhost").searchParams + expect(input).toContain("/api/v1/selection/runs/run-1?") + expect(params.get("page")).toBe("3") + expect(params.get("page_size")).toBe("5") + expect(params.has("search")).toBe(false) + expect(params.has("category")).toBe(false) + }) +}) diff --git a/zhixing-web/src/features/selection/api/selection.api.ts b/zhixing-web/src/features/selection/api/selection.api.ts index b306a1d..689f0b5 100644 --- a/zhixing-web/src/features/selection/api/selection.api.ts +++ b/zhixing-web/src/features/selection/api/selection.api.ts @@ -2,6 +2,7 @@ import { requestJson } from "@/shared/api/request-json" import type { SelectionResults, + SelectionResultsQuery, SelectionRunAccepted, SelectionRunRequest, SelectionStrategy, @@ -10,9 +11,11 @@ import type { export function getSelectionResults( strategy: SelectionStrategy, targetTradeDate?: string, + query: SelectionResultsQuery = { page: 1, pageSize: 10 }, signal?: AbortSignal, ) { - const params = new URLSearchParams({ strategy }) + const params = buildSelectionQueryParams(query) + params.set("strategy", strategy) if (targetTradeDate) params.set("target_trade_date", targetTradeDate) return requestJson( `/api/v1/selection/results?${params.toString()}`, @@ -20,10 +23,28 @@ export function getSelectionResults( ) } -export function getSelectionRun(runId: string, signal?: AbortSignal) { - return requestJson(`/api/v1/selection/runs/${runId}`, { - signal, +export function getSelectionRun( + runId: string, + query: SelectionResultsQuery = { page: 1, pageSize: 10 }, + signal?: AbortSignal, +) { + const params = buildSelectionQueryParams(query) + return requestJson( + `/api/v1/selection/runs/${runId}?${params.toString()}`, + { + signal, + }, + ) +} + +function buildSelectionQueryParams(query: SelectionResultsQuery) { + const params = new URLSearchParams({ + page: String(query.page), + page_size: String(query.pageSize), }) + if (query.search) params.set("search", query.search) + if (query.category) params.set("category", query.category) + return params } export function triggerSelectionRun( diff --git a/zhixing-web/src/features/selection/api/selection.query.ts b/zhixing-web/src/features/selection/api/selection.query.ts index 2f4e136..3ffc178 100644 --- a/zhixing-web/src/features/selection/api/selection.query.ts +++ b/zhixing-web/src/features/selection/api/selection.query.ts @@ -11,33 +11,63 @@ import { getSelectionRun, triggerSelectionRun, } from "./selection.api" -import type { SelectionRunRequest, SelectionStrategy } from "./selection.types" +import type { + SelectionResultsQuery, + SelectionRunRequest, + SelectionStrategy, +} from "./selection.types" export const selectionResultsQueryKey = ( strategy: SelectionStrategy, targetTradeDate?: string, -) => ["selection", "results", strategy, targetTradeDate] as const + query: SelectionResultsQuery = { page: 1, pageSize: 10 }, +) => + [ + "selection", + "results", + strategy, + targetTradeDate, + query.page, + query.pageSize, + query.search ?? "", + query.category ?? "all", + ] as const -export const selectionRunQueryKey = (runId: string) => - ["selection", "runs", runId] as const +export const selectionRunQueryKey = ( + runId: string, + query: SelectionResultsQuery = { page: 1, pageSize: 10 }, +) => + [ + "selection", + "runs", + runId, + query.page, + query.pageSize, + query.search ?? "", + query.category ?? "all", + ] as const export function useSelectionResults( strategy: SelectionStrategy, targetTradeDate?: string, + query: SelectionResultsQuery = { page: 1, pageSize: 10 }, ) { return useQuery({ queryFn: ({ signal }) => - getSelectionResults(strategy, targetTradeDate, signal), - queryKey: selectionResultsQueryKey(strategy, targetTradeDate), + getSelectionResults(strategy, targetTradeDate, query, signal), + queryKey: selectionResultsQueryKey(strategy, targetTradeDate, query), }) } -export function useSelectionRun(runId: string | null) { +export function useSelectionRun( + runId: string | null, + resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 }, +) { const queryClient = useQueryClient() const query = useQuery({ enabled: Boolean(runId), - queryFn: ({ signal }) => getSelectionRun(runId ?? "", signal), - queryKey: selectionRunQueryKey(runId ?? "none"), + queryFn: ({ signal }) => getSelectionRun(runId ?? "", resultQuery, signal), + queryKey: selectionRunQueryKey(runId ?? "none", resultQuery), refetchInterval: (query) => query.state.data?.status === "running" ? 1500 : false, }) diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts index 9012921..61cd8db 100644 --- a/zhixing-web/src/features/selection/api/selection.types.ts +++ b/zhixing-web/src/features/selection/api/selection.types.ts @@ -1,5 +1,22 @@ export type SelectionStrategy = "zhixing_b1" +export type SelectionCategoryFilter = + "all" | "pullback" | "oversold" | "original" + +export const selectionCategoryFilters = [ + "all", + "pullback", + "oversold", + "original", +] as const + +export interface SelectionResultsQuery { + page: number + pageSize: number + search?: string + category?: Exclude +} + export type SelectionRunStatus = "no_data" | "running" | "success" | "partial_success" | "failed" @@ -50,6 +67,9 @@ export interface SelectionResults { error_message: string | null created_at: string | null finished_at: string | null + page: number + page_size: number + signals_total: number failures: SelectionFailure[] signals: SelectionSignal[] } diff --git a/zhixing-web/src/features/selection/components/selection-presentation.ts b/zhixing-web/src/features/selection/components/selection-presentation.ts index 3a6c45d..7a21a70 100644 --- a/zhixing-web/src/features/selection/components/selection-presentation.ts +++ b/zhixing-web/src/features/selection/components/selection-presentation.ts @@ -1,4 +1,7 @@ -import type { SelectionSignal } from "../api/selection.types" +import type { + SelectionCategoryFilter, + SelectionSignal, +} from "../api/selection.types" export const categoryLabels: Record = { zhixing_b1_extreme_volume: "超卖超缩量 B", @@ -10,7 +13,7 @@ export const categoryLabels: Record = { zhixing_b1_pullback_yellow: "回踩黄线 B", } -export type SignalCategoryFilter = "all" | "pullback" | "oversold" | "original" +export type SignalCategoryFilter = SelectionCategoryFilter export const signalCategoryOptions: ReadonlyArray<{ label: string diff --git a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx index 482d939..a98ecf4 100644 --- a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx +++ b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx @@ -15,7 +15,7 @@ import { } from "./selection-presentation" import { SignalTable } from "./signal-table" -const PAGE_SIZE_OPTIONS = [5, 10, 20] as const +const PAGE_SIZE_OPTIONS = [5, 10, 15] as const interface SelectionResultsWorkbenchProps { result: SelectionResults @@ -26,14 +26,14 @@ export function SelectionResultsWorkbench({ }: SelectionResultsWorkbenchProps) { const search = useSearch({ from: "/_workspace/selection" }) const navigate = useNavigate({ from: "/selection" }) - const [query, setQuery] = useState("") - const [category, setCategory] = useState("all") + const query = search.search ?? "" + const category = search.category ?? "all" const [selectedKey, setSelectedKey] = useState(null) const [expandedKeys, setExpandedKeys] = useState>( new Set(), ) - const filteredSignals = useMemo(() => { + const visibleSignals = useMemo(() => { const normalized = query.trim().toLowerCase() return result.signals.filter((signal) => { const matchesQuery = @@ -46,12 +46,10 @@ export function SelectionResultsWorkbench({ const page = search.page const pageSize = search.pageSize - const pageCount = Math.max(1, Math.ceil(filteredSignals.length / pageSize)) + const signalsTotal = result.signals_total ?? result.signal_count + const pageCount = Math.max(1, Math.ceil(signalsTotal / pageSize)) const currentPage = Math.min(page, pageCount) - const currentSignals = filteredSignals.slice( - (currentPage - 1) * pageSize, - currentPage * pageSize, - ) + const currentSignals = visibleSignals const selectedSignal = currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ?? currentSignals[0] @@ -64,18 +62,21 @@ export function SelectionResultsWorkbench({ } }, [navigate, page, pageCount]) - function updateSearch(next: { page?: number; pageSize?: number }) { + function updateSearch(next: { + page?: number + pageSize?: number + search?: string + category?: SignalCategoryFilter + }) { void navigate({ search: (previous) => ({ ...previous, ...next }) }) } function handleQueryChange(value: string) { - setQuery(value) - updateSearch({ page: 1 }) + updateSearch({ page: 1, search: value }) } function handleCategoryChange(value: SignalCategoryFilter) { - setCategory(value) - updateSearch({ page: 1 }) + updateSearch({ category: value, page: 1 }) } function handleToggleExpanded(signal: SelectionSignal) { @@ -115,7 +116,7 @@ export function SelectionResultsWorkbench({ ))} - 筛选结果 {filteredSignals.length} 条 + 筛选结果 {signalsTotal} 条 @@ -141,7 +142,7 @@ export function SelectionResultsWorkbench({ page={currentPage} pageSize={pageSize} pageSizeOptions={PAGE_SIZE_OPTIONS} - total={filteredSignals.length} + total={signalsTotal} /> diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx index 71d094c..be3d41b 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx @@ -20,7 +20,7 @@ vi.mock("@/features/selection/api/selection.query", () => ({ vi.mock("@tanstack/react-router", () => ({ useNavigate: () => routerNavigate, - useSearch: () => ({ page: 1, pageSize: 5 }), + useSearch: () => ({ page: 1, pageSize: 5, search: "", category: "all" }), })) const selectedResult: SelectionResults = { @@ -34,9 +34,12 @@ const selectedResult: SelectionResults = { failures: [], finished_at: "2026-08-08T09:02:00+08:00", market_sync_batch_id: "market-run-1", + page: 1, + page_size: 5, run_id: "run-1", selected_stock_count: 1, signal_count: 2, + signals_total: 2, signals: [ { category: "zhixing_b1_original_b1", @@ -142,7 +145,12 @@ describe("SelectionResultsPage", () => { ts_code: `00000${index + 1}.SZ`, })) useSelectionResults.mockReturnValue({ - data: { ...selectedResult, signal_count: signals.length, signals }, + data: { + ...selectedResult, + signal_count: signals.length, + signals, + signals_total: signals.length, + }, isError: false, isPending: false, }) @@ -262,7 +270,10 @@ describe("SelectionResultsPage", () => { expect(screen.getByText("策略执行中")).toBeInTheDocument() expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled() - expect(useSelectionRun).toHaveBeenCalledWith("run-1") + expect(useSelectionRun).toHaveBeenCalledWith("run-1", { + page: 1, + pageSize: 5, + }) }) it("renders query failures explicitly", () => { diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.tsx index b9bcdfa..6e61297 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.tsx @@ -5,6 +5,7 @@ import { RefreshCw, Search, } from "lucide-react" +import { useSearch } from "@tanstack/react-router" import { useState } from "react" import { PageLayout } from "@/app/layout/page-layout" @@ -15,6 +16,7 @@ import { } from "@/features/selection/api/selection.query" import type { SelectionResults, + SelectionResultsQuery, SelectionRunStatus, } from "@/features/selection/api/selection.types" import { Badge } from "@/shared/ui/badge" @@ -54,12 +56,23 @@ export function SelectionResultsPage() { const [targetTradeDate, setTargetTradeDate] = useState(null) const [activeRunId, setActiveRunId] = useState(null) const [rerunDialogOpen, setRerunDialogOpen] = useState(false) + const search = useSearch({ from: "/_workspace/selection" }) + const resultQuery: SelectionResultsQuery = { + page: search.page, + pageSize: search.pageSize, + ...(search.search ? { search: search.search } : {}), + ...(search.category !== "all" ? { category: search.category } : {}), + } - const results = useSelectionResults(STRATEGY, targetTradeDate || undefined) + const results = useSelectionResults( + STRATEGY, + targetTradeDate || undefined, + resultQuery, + ) const persistedRunningRunId = results.data?.status === "running" ? results.data.run_id : null const polledRunId = activeRunId ?? persistedRunningRunId - const activeRun = useSelectionRun(polledRunId) + const activeRun = useSelectionRun(polledRunId, resultQuery) const trigger = useTriggerSelectionRun() const selectedTargetTradeDate = @@ -420,7 +433,9 @@ function ResultState({ result }: { result: SelectionResults }) { {result.signal_count === 0 ? ( ) : ( - +
+ +
)} {result.failures.length > 0 ? : null} diff --git a/zhixing-web/src/routes/route-tree.tsx b/zhixing-web/src/routes/route-tree.tsx index c0cde86..a772187 100644 --- a/zhixing-web/src/routes/route-tree.tsx +++ b/zhixing-web/src/routes/route-tree.tsx @@ -2,6 +2,10 @@ import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router" import { AppLayout } from "@/app/layout/app-layout" import { HomePage } from "@/features/home/pages/home-page" +import { + selectionCategoryFilters, + type SelectionCategoryFilter, +} from "@/features/selection/api/selection.types" import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page" const rootRoute = createRootRoute({ @@ -28,8 +32,17 @@ const selectionRoute = createRoute({ const rawPageSize = Number(search.pageSize) const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1 - const pageSize = [5, 10, 20].includes(rawPageSize) ? rawPageSize : 10 - return { page, pageSize } + const pageSize = [5, 10, 15].includes(rawPageSize) ? rawPageSize : 10 + const searchValue = + typeof search.search === "string" ? search.search.slice(0, 100) : "" + const rawCategory = + typeof search.category === "string" ? search.category : "all" + const category: SelectionCategoryFilter = selectionCategoryFilters.includes( + rawCategory as SelectionCategoryFilter, + ) + ? (rawCategory as SelectionCategoryFilter) + : "all" + return { page, pageSize, search: searchValue, category } }, component: SelectionResultsPage, })