feat(web): 选股列表改为滚动加载并精简操作区

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuxuanhui
2026-09-01 19:27:41 +08:00
parent c944b219d9
commit af1e1eaa66
10 changed files with 732 additions and 384 deletions
@@ -1,12 +1,13 @@
import { requestJson } from "@/shared/api/request-json"
import type {
SelectionChart,
SelectionResults,
SelectionResultsQuery,
SelectionRunAccepted,
SelectionRunRequest,
SelectionStrategy,
import {
selectionResultPageSize,
type SelectionChart,
type SelectionResults,
type SelectionResultsQuery,
type SelectionRunAccepted,
type SelectionRunRequest,
type SelectionStrategy,
} from "./selection.types"
export function getSelectionChart(
@@ -26,7 +27,7 @@ export function getSelectionChart(
export function getSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
query: SelectionResultsQuery = { page: 1, pageSize: selectionResultPageSize },
signal?: AbortSignal,
) {
const params = buildSelectionQueryParams(query)
@@ -40,7 +41,7 @@ export function getSelectionResults(
export function getSelectionRun(
runId: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
query: SelectionResultsQuery = { page: 1, pageSize: selectionResultPageSize },
signal?: AbortSignal,
) {
const params = buildSelectionQueryParams(query)
@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const useQuery = vi.hoisted(() => vi.fn())
const useInfiniteQuery = vi.hoisted(() => vi.fn())
const api = vi.hoisted(() => ({
getSelectionChart: vi.fn(),
getSelectionResults: vi.fn(),
getSelectionRun: vi.fn(),
triggerSelectionRun: vi.fn(),
}))
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
)
return {
...actual,
useInfiniteQuery,
useQuery,
}
})
vi.mock("./selection.api", () => api)
import {
flattenSelectionResults,
selectionResultsQueryKey,
useSelectionResults,
} from "./selection.query"
import type { SelectionResults } from "./selection.types"
const query = {
pageSize: 20,
search: "平安",
category: "pullback" as const,
sort: "score_desc" as const,
}
function pageResult(overrides: {
page?: number
selected_stock_count?: number
status?: SelectionResults["status"]
stocks: ReadonlyArray<unknown>
stocks_total?: number
}) {
return {
page: 1,
status: "success" as const,
stocks_total: 40,
selected_stock_count: 40,
...overrides,
} as SelectionResults
}
describe("selection query hooks", () => {
beforeEach(() => {
vi.clearAllMocks()
useQuery.mockImplementation((options) => options)
useInfiniteQuery.mockImplementation((options) => options)
})
it("keeps filters in one results key without splitting cached pages", () => {
expect(selectionResultsQueryKey("zhixing_b1", "2026-08-08", query)).toEqual(
[
"selection",
"results",
"zhixing_b1",
"2026-08-08",
20,
"平安",
"pullback",
"score_desc",
],
)
expect(
selectionResultsQueryKey("zhixing_b1", "2026-08-08", {
...query,
}),
).toEqual(selectionResultsQueryKey("zhixing_b1", "2026-08-08", query))
})
it("forwards React Query cancellation signals to the results adapter", async () => {
useSelectionResults("zhixing_b1", "2026-08-08", query)
const options = useInfiniteQuery.mock.calls[0]?.[0] as {
queryFn: (context: { pageParam: number; signal: AbortSignal }) => unknown
}
const signal = new AbortController().signal
await options.queryFn({ pageParam: 3, signal })
expect(api.getSelectionResults).toHaveBeenCalledWith(
"zhixing_b1",
"2026-08-08",
{ ...query, page: 3 },
signal,
)
})
it("starts from page one and stops after loaded stocks cover total", () => {
useSelectionResults("zhixing_b1", "2026-08-08", query)
const options = useInfiniteQuery.mock.calls[0]?.[0] as {
getNextPageParam: (
lastPage: ReturnType<typeof pageResult>,
pages: ReturnType<typeof pageResult>[],
lastPageParam: number,
) => number | undefined
initialPageParam: number
}
const firstPage = pageResult({
page: 1,
stocks: Array(20).fill(null),
stocks_total: 35,
})
const secondPage = pageResult({
page: 2,
stocks: Array(15).fill(null),
stocks_total: 35,
})
expect(options.initialPageParam).toBe(1)
expect(options.getNextPageParam(firstPage, [firstPage], 1)).toBe(2)
expect(
options.getNextPageParam(secondPage, [firstPage, secondPage], 2),
).toBeUndefined()
})
it("stops when a page is empty, running, or does not match its page param", () => {
useSelectionResults("zhixing_b1", "2026-08-08", query)
const options = useInfiniteQuery.mock.calls[0]?.[0] as {
getNextPageParam: (
lastPage: ReturnType<typeof pageResult>,
pages: ReturnType<typeof pageResult>[],
lastPageParam: number,
) => number | undefined
}
const firstPage = pageResult({
page: 1,
stocks: Array(20).fill(null),
stocks_total: 35,
})
const emptyPage = pageResult({
page: 2,
stocks: [],
stocks_total: 35,
})
const mismatchedPage = pageResult({
page: 1,
stocks: Array(15).fill(null),
stocks_total: 35,
})
const runningPage = pageResult({
page: 1,
status: "running",
stocks: [],
stocks_total: 0,
})
expect(
options.getNextPageParam(emptyPage, [firstPage, emptyPage], 2),
).toBeUndefined()
expect(
options.getNextPageParam(mismatchedPage, [firstPage, mismatchedPage], 2),
).toBeUndefined()
expect(
options.getNextPageParam(runningPage, [runningPage], 1),
).toBeUndefined()
})
it("flattens loaded pages into one stock list", () => {
expect(flattenSelectionResults(undefined)).toBeUndefined()
expect(
flattenSelectionResults({
pageParams: [1, 2],
pages: [
pageResult({
page: 1,
stocks: [{ ts_code: "000001.SZ" }],
}),
pageResult({
page: 2,
stocks: [{ ts_code: "600036.SH" }],
}),
],
})?.stocks,
).toEqual([{ ts_code: "000001.SZ" }, { ts_code: "600036.SH" }])
})
})
@@ -1,7 +1,9 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
type InfiniteData,
type QueryClient,
} from "@tanstack/react-query"
import { useEffect } from "react"
@@ -12,12 +14,20 @@ import {
getSelectionRun,
triggerSelectionRun,
} from "./selection.api"
import type {
SelectionResultsQuery,
SelectionRunRequest,
SelectionStrategy,
import {
selectionResultPageSize,
type SelectionResults,
type SelectionResultsQuery,
type SelectionRunRequest,
type SelectionStrategy,
} from "./selection.types"
export type SelectionResultsListQuery = Omit<SelectionResultsQuery, "page">
const defaultListQuery: SelectionResultsListQuery = {
pageSize: selectionResultPageSize,
}
export const selectionChartQueryKey = (
tsCode?: string,
targetTradeDate?: string,
@@ -27,14 +37,13 @@ export const selectionChartQueryKey = (
export const selectionResultsQueryKey = (
strategy: SelectionStrategy,
targetTradeDate?: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
query: SelectionResultsListQuery = defaultListQuery,
) =>
[
"selection",
"results",
strategy,
targetTradeDate,
query.page,
query.pageSize,
query.search ?? "",
query.category ?? "all",
@@ -43,27 +52,50 @@ export const selectionResultsQueryKey = (
export const selectionRunQueryKey = (
runId: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
query: SelectionResultsListQuery = defaultListQuery,
) =>
[
"selection",
"runs",
runId,
query.page,
query.pageSize,
query.search ?? "",
query.category ?? "all",
query.sort ?? "code",
] as const
export function flattenSelectionResults(
data: InfiniteData<SelectionResults> | undefined,
): SelectionResults | undefined {
const firstPage = data?.pages[0]
if (!firstPage) return undefined
return {
...firstPage,
stocks: data.pages.flatMap((page) => page.stocks),
}
}
export function useSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
query: SelectionResultsListQuery = defaultListQuery,
) {
return useQuery({
queryFn: ({ signal }) =>
getSelectionResults(strategy, targetTradeDate, query, signal),
return useInfiniteQuery<
SelectionResults,
Error,
InfiniteData<SelectionResults>,
ReturnType<typeof selectionResultsQueryKey>,
number
>({
getNextPageParam: nextSelectionPageParam,
initialPageParam: 1,
queryFn: ({ pageParam, signal }) =>
getSelectionResults(
strategy,
targetTradeDate,
{ ...query, page: pageParam },
signal,
),
queryKey: selectionResultsQueryKey(strategy, targetTradeDate, query),
})
}
@@ -79,19 +111,28 @@ export function useSelectionChart(tsCode?: string, targetTradeDate?: string) {
export function useSelectionRun(
runId: string | null,
resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 },
resultQuery: SelectionResultsListQuery = defaultListQuery,
) {
const queryClient = useQueryClient()
const query = useQuery({
const query = useInfiniteQuery<
SelectionResults,
Error,
InfiniteData<SelectionResults>,
ReturnType<typeof selectionRunQueryKey>,
number
>({
enabled: Boolean(runId),
queryFn: ({ signal }) => getSelectionRun(runId ?? "", resultQuery, signal),
getNextPageParam: nextSelectionPageParam,
initialPageParam: 1,
queryFn: ({ pageParam, signal }) =>
getSelectionRun(runId ?? "", { ...resultQuery, page: pageParam }, signal),
queryKey: selectionRunQueryKey(runId ?? "none", resultQuery),
refetchInterval: (query) =>
query.state.data?.status === "running" ? 1500 : false,
refetchInterval: (currentQuery) =>
currentQuery.state.data?.pages[0]?.status === "running" ? 1500 : false,
})
useEffect(() => {
const result = query.data
const result = query.data?.pages[0]
if (!runId || !result || result.status === "running") return
void invalidateSelectionResults(queryClient, result.strategy)
@@ -125,3 +166,26 @@ export function invalidateSelectionResults(
queryKey: ["selection", "results", strategy],
})
}
function nextSelectionPageParam(
lastPage: SelectionResults,
pages: SelectionResults[],
lastPageParam: number,
) {
const loadedStockCount = pages.reduce(
(count, page) => count + page.stocks.length,
0,
)
const total = lastPage.stocks_total ?? lastPage.selected_stock_count
if (
(lastPage.status !== "success" && lastPage.status !== "partial_success") ||
lastPage.stocks.length === 0 ||
lastPage.page !== lastPageParam ||
loadedStockCount >= total
) {
return undefined
}
const nextPageParam = lastPageParam + 1
return Number.isSafeInteger(nextPageParam) ? nextPageParam : undefined
}
@@ -14,6 +14,8 @@ export type SelectionSort = "code" | "score_desc" | "score_asc"
export const selectionSorts = ["code", "score_desc", "score_asc"] as const
export const selectionResultPageSize = 20
export interface SelectionResultsQuery {
page: number
pageSize: number