feat(web): 选股列表改为滚动加载并精简操作区
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user