feat(selection): 支持选股结果服务端分页

This commit is contained in:
yuxuanhui
2026-08-10 11:09:23 +08:00
parent ed7bdda515
commit 3af97bf65d
16 changed files with 431 additions and 63 deletions
@@ -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)
})
})
@@ -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<SelectionResults>(
`/api/v1/selection/results?${params.toString()}`,
@@ -20,10 +23,28 @@ export function getSelectionResults(
)
}
export function getSelectionRun(runId: string, signal?: AbortSignal) {
return requestJson<SelectionResults>(`/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<SelectionResults>(
`/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(
@@ -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,
})
@@ -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<SelectionCategoryFilter, "all">
}
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[]
}
@@ -1,4 +1,7 @@
import type { SelectionSignal } from "../api/selection.types"
import type {
SelectionCategoryFilter,
SelectionSignal,
} from "../api/selection.types"
export const categoryLabels: Record<string, string> = {
zhixing_b1_extreme_volume: "超卖超缩量 B",
@@ -10,7 +13,7 @@ export const categoryLabels: Record<string, string> = {
zhixing_b1_pullback_yellow: "回踩黄线 B",
}
export type SignalCategoryFilter = "all" | "pullback" | "oversold" | "original"
export type SignalCategoryFilter = SelectionCategoryFilter
export const signalCategoryOptions: ReadonlyArray<{
label: string
@@ -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<SignalCategoryFilter>("all")
const query = search.search ?? ""
const category = search.category ?? "all"
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
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({
))}
</select>
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">
筛选结果 {filteredSignals.length} 条
筛选结果 {signalsTotal} 条
</span>
</div>
@@ -141,7 +142,7 @@ export function SelectionResultsWorkbench({
page={currentPage}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={filteredSignals.length}
total={signalsTotal}
/>
</section>
@@ -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", () => {
@@ -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<string | null>(null)
const [activeRunId, setActiveRunId] = useState<string | null>(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 ? (
<NoSignalState />
) : (
<SelectionResultsWorkbench result={result} />
<div className="min-h-[420px] shrink-0">
<SelectionResultsWorkbench result={result} />
</div>
)}
{result.failures.length > 0 ? <FailureList result={result} /> : null}
</div>