feat(selection): 支持选股结果服务端分页
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user