From af1e1eaa6616ba6e81a9b90087726d69a645b7a1 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Tue, 1 Sep 2026 19:27:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=80=89=E8=82=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=B9=E4=B8=BA=E6=BB=9A=E5=8A=A8=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=B9=B6=E7=B2=BE=E7=AE=80=E6=93=8D=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../09-01-optimize-stock-list-radar/prd.md | 12 +- .../features/selection/api/selection.api.ts | 19 +- .../selection/api/selection.query.test.ts | 186 ++++++++++ .../features/selection/api/selection.query.ts | 100 +++++- .../features/selection/api/selection.types.ts | 2 + .../selection-results-workbench.tsx | 106 +++--- .../components/signal-record-list.tsx | 163 ++++----- .../pages/selection-results-page.test.tsx | 322 ++++++++++++------ .../pages/selection-results-page.tsx | 200 ++++++----- zhixing-web/src/routes/route-tree.tsx | 6 +- 10 files changed, 732 insertions(+), 384 deletions(-) create mode 100644 zhixing-web/src/features/selection/api/selection.query.test.ts diff --git a/.trellis/tasks/09-01-optimize-stock-list-radar/prd.md b/.trellis/tasks/09-01-optimize-stock-list-radar/prd.md index 6dce77e..8959887 100644 --- a/.trellis/tasks/09-01-optimize-stock-list-radar/prd.md +++ b/.trellis/tasks/09-01-optimize-stock-list-radar/prd.md @@ -10,18 +10,20 @@ ## Requirements -1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和详情展开能力。列表容器应继续支持内部滚动和空结果提示,分页与筛选行为保持不变。 +1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和右侧详情面板。列表容器应继续支持内部滚动和空结果提示;筛选、排序保留,不再展示底部分页,改为滚动加载更多。 2. 资金雷达看板保留核心排名、板块、指标值、排名百分位和样本信息,移除“资金覆盖率”和“质量”两列及其单元格内容;表头和数据行改为更紧凑的垂直间距,在不改变滚动加载、错误/空数据状态和排名变化视图的前提下提升可见行数。 -3. 现有筛选、排序、加载更多和选中股票详情的数据契约不变;仅调整展示层结构和样式。 +3. 现有筛选、排序和选中股票详情的数据契约不变;仅调整展示层结构和样式。执行状态入口放到工具栏“重新执行”按钮右侧。 ## Acceptance Criteria -- [ ] 选股工作台 DOM 中不再出现 `role="table"`/``,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。 -- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项;“查看详情/收起详情”可展开信号细节。 +- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`
`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。卡片上不再出现“查看详情”。 +- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项。 +- [ ] 选股结果不再渲染底部分页控件;列表滚到底部会加载下一页,加载中和失败重试提示保持可见。 +- [ ] 执行状态入口(如“部分成功”)出现在工具栏“重新执行/重试执行/执行策略”按钮右侧。 - [ ] 资金雷达表头不再包含“资金覆盖率”和“质量”,对应行内容也不再渲染;“排名百分位”和“样本”仍可见。 - [ ] 资金雷达数据行垂直间距小于当前实现,滚动到底部仍能触发一次加载更多,加载中和失败重试提示保持可见。 - [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 在 `zhixing-web/` 下通过。 ## Out of scope -不调整后端接口、指标计算、筛选/排序语义、分页参数、详情面板字段或全局主题 token。 +不调整后端接口、指标计算、筛选/排序语义、详情面板字段或全局主题 token。 diff --git a/zhixing-web/src/features/selection/api/selection.api.ts b/zhixing-web/src/features/selection/api/selection.api.ts index 5c667c3..37ecce7 100644 --- a/zhixing-web/src/features/selection/api/selection.api.ts +++ b/zhixing-web/src/features/selection/api/selection.api.ts @@ -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) diff --git a/zhixing-web/src/features/selection/api/selection.query.test.ts b/zhixing-web/src/features/selection/api/selection.query.test.ts new file mode 100644 index 0000000..8fbce75 --- /dev/null +++ b/zhixing-web/src/features/selection/api/selection.query.test.ts @@ -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( + "@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 + 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, + pages: ReturnType[], + 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, + pages: ReturnType[], + 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" }]) + }) +}) diff --git a/zhixing-web/src/features/selection/api/selection.query.ts b/zhixing-web/src/features/selection/api/selection.query.ts index 085900d..7e13540 100644 --- a/zhixing-web/src/features/selection/api/selection.query.ts +++ b/zhixing-web/src/features/selection/api/selection.query.ts @@ -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 + +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 | 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, + ReturnType, + 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, + ReturnType, + 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 +} diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts index 6358aca..0550081 100644 --- a/zhixing-web/src/features/selection/api/selection.types.ts +++ b/zhixing-web/src/features/selection/api/selection.types.ts @@ -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 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 df25c24..b53d82f 100644 --- a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx +++ b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx @@ -1,8 +1,7 @@ -import { useEffect, useMemo, useState, type RefObject } from "react" +import { useMemo, useRef, useState } from "react" import { useNavigate, useSearch } from "@tanstack/react-router" import { Input } from "@/shared/ui/input" -import { Pagination } from "@/shared/ui/pagination" import { Select, SelectContent, @@ -12,12 +11,7 @@ import { SelectValue, } from "@/shared/ui/select" -import type { - SelectionResults, - SelectionSort, - SelectionStockResult, -} from "../api/selection.types" -import { ExecutionStatusTrigger } from "./execution-status-trigger" +import type { SelectionResults, SelectionSort } from "../api/selection.types" import { SignalDetailPanel } from "./signal-detail-panel" import { SignalRecordList } from "./signal-record-list" import { @@ -27,7 +21,6 @@ import { type SignalCategoryFilter, } from "./selection-presentation" -const PAGE_SIZE_OPTIONS = [5, 10, 15] as const const SCORE_SORT_OPTIONS: ReadonlyArray<{ label: string value: SelectionSort @@ -38,17 +31,19 @@ const SCORE_SORT_OPTIONS: ReadonlyArray<{ ] interface SelectionResultsWorkbenchProps { - drawerOpen: boolean - onOpenExecutionStatus: () => void + hasNextPage: boolean + isFetchNextPageError: boolean + isFetchingNextPage: boolean + onLoadMore: () => void | Promise result: SelectionResults - triggerRef: RefObject } export function SelectionResultsWorkbench({ - drawerOpen, - onOpenExecutionStatus, + hasNextPage, + isFetchNextPageError, + isFetchingNextPage, + onLoadMore, result, - triggerRef, }: SelectionResultsWorkbenchProps) { const search = useSearch({ from: "/_workspace/selection" }) const navigate = useNavigate({ from: "/selection" }) @@ -56,9 +51,7 @@ export function SelectionResultsWorkbench({ const category = search.category ?? "all" const sort = search.sort ?? "code" const [selectedKey, setSelectedKey] = useState(null) - const [expandedKeys, setExpandedKeys] = useState>( - new Set(), - ) + const loadMoreRequestPending = useRef(false) const visibleStocks = useMemo(() => { const normalized = query.trim().toLowerCase() @@ -71,26 +64,12 @@ export function SelectionResultsWorkbench({ }) }, [category, query, result.stocks]) - const page = search.page - const pageSize = search.pageSize const stocksTotal = result.stocks_total ?? result.selected_stock_count - const pageCount = Math.max(1, Math.ceil(stocksTotal / pageSize)) - const currentPage = Math.min(page, pageCount) - const currentStocks = visibleStocks const selectedStock = - currentStocks.find((stock) => getStockKey(stock) === selectedKey) ?? - currentStocks[0] - useEffect(() => { - if (page > pageCount) { - void navigate({ - search: (previous) => ({ ...previous, page: pageCount }), - }) - } - }, [navigate, page, pageCount]) + visibleStocks.find((stock) => getStockKey(stock) === selectedKey) ?? + visibleStocks[0] function updateSearch(next: { - page?: number - pageSize?: number search?: string category?: SignalCategoryFilter sort?: SelectionSort @@ -99,30 +78,35 @@ export function SelectionResultsWorkbench({ } function handleQueryChange(value: string) { - updateSearch({ page: 1, search: value }) + updateSearch({ search: value }) } function handleCategoryChange(value: SignalCategoryFilter) { - updateSearch({ category: value, page: 1 }) + updateSearch({ category: value }) } function handleSortChange(value: SelectionSort) { - updateSearch({ page: 1, sort: value }) + updateSearch({ sort: value }) } - function handleToggleExpanded(stock: SelectionStockResult) { - const key = getStockKey(stock) - setExpandedKeys((previous) => { - const next = new Set(previous) - if (next.has(key)) next.delete(key) - else next.add(key) - return next + function requestLoadMore() { + if ( + loadMoreRequestPending.current || + isFetchingNextPage || + !hasNextPage || + isFetchNextPageError + ) { + return + } + loadMoreRequestPending.current = true + void Promise.resolve(onLoadMore()).finally(() => { + loadMoreRequestPending.current = false }) } return ( -
-
+
+
筛选结果 {stocksTotal} 只 -
-
+
{ + loadMoreRequestPending.current = false + onLoadMore() + }} onSelect={(stock) => setSelectedKey(getStockKey(stock))} - onToggleExpanded={handleToggleExpanded} selectedKey={selectedStock ? getStockKey(selectedStock) : null} - stocks={currentStocks} + stocks={visibleStocks} />
- - updateSearch({ page: nextPage })} - onPageSizeChange={(nextPageSize) => - updateSearch({ page: 1, pageSize: nextPageSize }) - } - page={currentPage} - pageSize={pageSize} - pageSizeOptions={PAGE_SIZE_OPTIONS} - total={stocksTotal} - />
diff --git a/zhixing-web/src/features/selection/components/signal-record-list.tsx b/zhixing-web/src/features/selection/components/signal-record-list.tsx index cd7d0c2..996d5c9 100644 --- a/zhixing-web/src/features/selection/components/signal-record-list.tsx +++ b/zhixing-web/src/features/selection/components/signal-record-list.tsx @@ -1,4 +1,3 @@ -import { ChevronDown, ChevronUp } from "lucide-react" import type { KeyboardEvent } from "react" import { Badge } from "@/shared/ui/badge" @@ -6,29 +5,32 @@ import { Button } from "@/shared/ui/button" import { cn } from "@/shared/ui/utils" import type { SelectionStockResult } from "../api/selection.types" -import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score" +import { PatternScoreSummary } from "./pattern-score" import { categoryToneClass, getCategoryLabel, getStockJValue, getStockKey, } from "./selection-presentation" -import { SignalDetails } from "./signal-details" interface SignalRecordListProps { stocks: SelectionStockResult[] selectedKey: string | null - expandedKeys: ReadonlySet + isFetchingNextPage: boolean + isFetchNextPageError: boolean onSelect: (stock: SelectionStockResult) => void - onToggleExpanded: (stock: SelectionStockResult) => void + onLoadMore: () => void + onRetryLoadMore: () => void } export function SignalRecordList({ stocks, selectedKey, - expandedKeys, + isFetchingNextPage, + isFetchNextPageError, onSelect, - onToggleExpanded, + onLoadMore, + onRetryLoadMore, }: SignalRecordListProps) { function handleSelectKeyDown( event: KeyboardEvent, @@ -48,99 +50,98 @@ export function SignalRecordList({ } return ( -
+
{ + const container = event.currentTarget + if ( + container.scrollHeight - + container.scrollTop - + container.clientHeight <= + 160 + ) { + onLoadMore() + } + }} + role="region" + tabIndex={0} + >
{stocks.map((stock) => { const key = getStockKey(stock) const selected = selectedKey === key - const expanded = expandedKeys.has(key) return (
-
- - -
- {expanded ? ( -
-
-

- 图形相似度评分 -

- -
- {stock.signals.map((signal) => ( -
-

+ + ¥ {stock.close.toFixed(2)} + + + + + {stock.signals.map((signal) => ( + {getCategoryLabel(signal.category)} -

- -
- ))} -
- ) : null} + + ))} + + + + J  + + {getStockJValue(stock)} + + + +
) })}
+ {isFetchingNextPage ? ( +
+ 正在加载更多股票… +
+ ) : null} + {isFetchNextPageError ? ( +
+ 加载更多失败,已加载结果仍保留。 + +
+ ) : null}
) } 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 8491f69..473f227 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 @@ -21,13 +21,20 @@ const useSelectionRun = vi.fn() const useTriggerSelectionRun = vi.fn() const mutate = vi.fn() const reset = vi.fn() +const fetchNextPage = vi.fn() -vi.mock("@/features/selection/api/selection.query", () => ({ - useSelectionResults: (...args: unknown[]) => useSelectionResults(...args), - useSelectionChart: (...args: unknown[]) => useSelectionChart(...args), - useSelectionRun: (...args: unknown[]) => useSelectionRun(...args), - useTriggerSelectionRun: () => useTriggerSelectionRun(), -})) +vi.mock("@/features/selection/api/selection.query", async () => { + const actual = await vi.importActual< + typeof import("@/features/selection/api/selection.query") + >("@/features/selection/api/selection.query") + return { + ...actual, + useSelectionResults: (...args: unknown[]) => useSelectionResults(...args), + useSelectionChart: (...args: unknown[]) => useSelectionChart(...args), + useSelectionRun: (...args: unknown[]) => useSelectionRun(...args), + useTriggerSelectionRun: () => useTriggerSelectionRun(), + } +}) vi.mock("@tanstack/react-router", () => ({ useNavigate: () => routerNavigate, @@ -110,6 +117,34 @@ const selectedResult: SelectionResults = { target_trade_date: "2026-08-08", } +function emptySelectionQuery() { + return { + data: undefined, + fetchNextPage, + hasNextPage: false, + isError: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isPending: false, + } +} + +function selectionQueryResult( + result: SelectionResults, + overrides: Record = {}, +) { + return { + data: { pageParams: [1], pages: [result] }, + fetchNextPage, + hasNextPage: false, + isError: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isPending: false, + ...overrides, + } +} + describe("SelectionResultsPage", () => { beforeEach(() => { useSelectionChart.mockReturnValue({ @@ -117,16 +152,8 @@ describe("SelectionResultsPage", () => { isError: false, isPending: false, }) - useSelectionResults.mockReturnValue({ - data: selectedResult, - isError: false, - isPending: false, - }) - useSelectionRun.mockReturnValue({ - data: undefined, - isError: false, - isPending: false, - }) + useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult)) + useSelectionRun.mockReturnValue(emptySelectionQuery()) useTriggerSelectionRun.mockReturnValue({ isError: false, isPending: false, @@ -136,6 +163,8 @@ describe("SelectionResultsPage", () => { mutate.mockReset() reset.mockReset() routerNavigate.mockReset() + fetchNextPage.mockReset() + fetchNextPage.mockResolvedValue(undefined) }) it("renders persisted results and keeps multiple categories visible", () => { @@ -155,6 +184,18 @@ describe("SelectionResultsPage", () => { expect(within(record).getByText("¥ 10.50")).toBeInTheDocument() expect(within(record).getByText("华纳药厂")).toBeInTheDocument() expect(screen.getByRole("button", { name: /平安银行/ })).toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "查看详情" }), + ).not.toBeInTheDocument() + expect(screen.queryByText(/共 \d+ 条/)).not.toBeInTheDocument() + const rerunButton = screen.getByRole("button", { name: "重新执行" }) + const statusTrigger = screen.getByRole("button", { + name: "查看执行状态:执行完成,未完成评估 0 条", + }) + expect( + rerunButton.compareDocumentPosition(statusTrigger) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() const detailPanel = screen.getByRole("complementary", { name: "当前股票详情", @@ -224,14 +265,12 @@ describe("SelectionResultsPage", () => { ])( "keeps selection signals visible when score state changes", ({ expected, score }) => { - useSelectionResults.mockReturnValue({ - data: { + useSelectionResults.mockReturnValue( + selectionQueryResult({ ...selectedResult, stocks: [{ ...selectedResult.stocks[0], score }], - }, - isError: false, - isPending: false, - }) + }), + ) render() @@ -285,8 +324,8 @@ describe("SelectionResultsPage", () => { }) it("opens execution status details in a drawer and restores trigger focus", async () => { - useSelectionResults.mockReturnValue({ - data: { + useSelectionResults.mockReturnValue( + selectionQueryResult({ ...selectedResult, failed_count: 1, failures: [ @@ -298,10 +337,8 @@ describe("SelectionResultsPage", () => { }, ], status: "partial_success", - }, - isError: false, - isPending: false, - }) + }), + ) render() @@ -376,16 +413,14 @@ describe("SelectionResultsPage", () => { }) it("keeps execution status accessible when no signals are hit", async () => { - useSelectionResults.mockReturnValue({ - data: { + useSelectionResults.mockReturnValue( + selectionQueryResult({ ...selectedResult, signal_count: 0, stocks: [], stocks_total: 0, - }, - isError: false, - isPending: false, - }) + }), + ) render() @@ -401,8 +436,8 @@ describe("SelectionResultsPage", () => { }) it("keeps execution status accessible for a failed result", async () => { - useSelectionResults.mockReturnValue({ - data: { + useSelectionResults.mockReturnValue( + selectionQueryResult({ ...selectedResult, failed_count: 2, failures: [ @@ -422,10 +457,8 @@ describe("SelectionResultsPage", () => { signal_count: 0, stocks: [], status: "failed", - }, - isError: false, - isPending: false, - }) + }), + ) render() @@ -440,7 +473,7 @@ describe("SelectionResultsPage", () => { expect(within(dialog).getByText("执行失败")).toBeInTheDocument() }) - it("uses shared selects and resets pagination when the category changes", async () => { + it("uses shared selects when the category changes", async () => { render() expect(screen.getByRole("combobox", { name: "策略" })).toHaveTextContent( @@ -472,13 +505,13 @@ describe("SelectionResultsPage", () => { searchUpdate({ category: "all", page: 4, pageSize: 5, search: "" }), ).toEqual({ category: "oversold", - page: 1, + page: 4, pageSize: 5, search: "", }) }) - it("requests database-backed score sorting and resets the page", async () => { + it("requests database-backed score sorting", async () => { render() const sortTrigger = screen.getByRole("combobox", { @@ -507,14 +540,14 @@ describe("SelectionResultsPage", () => { }), ).toEqual({ category: "all", - page: 1, + page: 3, pageSize: 5, search: "", sort: "score_desc", }) }) - it("filters signals, updates the detail panel, and expands mobile details", () => { + it("filters signals and updates the detail panel", () => { const extraStock = { ...selectedResult.stocks[0], name: "招商银行", @@ -529,16 +562,14 @@ describe("SelectionResultsPage", () => { ], ts_code: "600036.SH", } - useSelectionResults.mockReturnValue({ - data: { + useSelectionResults.mockReturnValue( + selectionQueryResult({ ...selectedResult, signal_count: 3, stocks: [...selectedResult.stocks, extraStock], stocks_total: 2, - }, - isError: false, - isPending: false, - }) + }), + ) render() @@ -555,24 +586,19 @@ describe("SelectionResultsPage", () => { const selectButton = within(extraRecord).getByRole("button", { name: /招商银行/, }) - fireEvent.keyDown(selectButton, { key: "Enter" }) - expect( - within( - screen.getByRole("complementary", { name: "当前股票详情" }), - ).getByText("招商银行"), - ).toBeInTheDocument() - fireEvent.keyDown(selectButton, { key: " " }) - const expandButton = within(extraRecord).getByRole("button", { - name: "查看详情", + const detailPanel = screen.getByRole("complementary", { + name: "当前股票详情", }) - fireEvent.click(expandButton) - expect(expandButton).toHaveAttribute("aria-expanded", "true") - expect(screen.getAllByText("MACD").length).toBeGreaterThan(1) - expect(screen.getAllByText("3.2").length).toBeGreaterThan(1) + expect(within(detailPanel).getByText("招商银行")).toBeInTheDocument() + expect(within(detailPanel).getByText("MACD")).toBeInTheDocument() + expect(within(detailPanel).getByText("3.2")).toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "查看详情" }), + ).not.toBeInTheDocument() }) - it("navigates to another page when the result list is paginated", () => { + it("shows the full result list without pagination controls", () => { const stocks = Array.from({ length: 6 }, (_, index) => ({ ...selectedResult.stocks[0], name: `股票${index + 1}`, @@ -585,23 +611,121 @@ describe("SelectionResultsPage", () => { ], ts_code: `00000${index + 1}.SZ`, })) - useSelectionResults.mockReturnValue({ - data: { + useSelectionResults.mockReturnValue( + selectionQueryResult({ ...selectedResult, selected_stock_count: stocks.length, signal_count: stocks.length, stocks, stocks_total: stocks.length, - }, - isError: false, - isPending: false, - }) + }), + ) render() - expect(screen.getByText("第 1–5 条,共 6 条")).toBeInTheDocument() - fireEvent.click(screen.getByRole("button", { name: "第 2 页" })) - expect(routerNavigate).toHaveBeenCalled() + expect(screen.getAllByText("股票1").length).toBeGreaterThan(0) + expect(screen.getByText("股票6")).toBeInTheDocument() + expect(screen.queryByText("第 1–5 条,共 6 条")).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: "第 2 页" }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole("combobox", { name: "每页数量" }), + ).not.toBeInTheDocument() + }) + + it("loads the next page once when repeated scroll events reach the bottom", () => { + fetchNextPage.mockReturnValue(new Promise(() => undefined)) + useSelectionResults.mockReturnValue( + selectionQueryResult( + { ...selectedResult, stocks_total: 40 }, + { hasNextPage: true }, + ), + ) + + render() + const scrollRegion = screen.getByRole("region", { + name: "选股结果滚动区域", + }) + Object.defineProperties(scrollRegion, { + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, value: 1_000 }, + scrollTop: { configurable: true, value: 550 }, + }) + + fireEvent.scroll(scrollRegion) + fireEvent.scroll(scrollRegion) + + expect(fetchNextPage).toHaveBeenCalledOnce() + }) + + it("announces that the next page is loading without hiding loaded rows", () => { + useSelectionResults.mockReturnValue( + selectionQueryResult(selectedResult, { + hasNextPage: true, + isFetchingNextPage: true, + }), + ) + + render() + + expect(screen.getByRole("status")).toHaveTextContent("正在加载更多股票") + expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0) + }) + + it("keeps loaded rows and offers retry when loading more fails", () => { + useSelectionResults.mockReturnValue( + selectionQueryResult(selectedResult, { + hasNextPage: true, + isError: true, + isFetchNextPageError: true, + }), + ) + + render() + + expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0) + expect(screen.getByRole("alert")).toHaveTextContent( + "加载更多失败,已加载结果仍保留", + ) + fireEvent.click(screen.getByRole("button", { name: "重试" })) + expect(fetchNextPage).toHaveBeenCalledOnce() + }) + + it("merges stocks from every loaded page", () => { + const nextStock = { + ...selectedResult.stocks[0], + name: "招商银行", + ts_code: "600036.SH", + signals: [ + { + ...selectedResult.stocks[0].signals[0], + name: "招商银行", + ts_code: "600036.SH", + }, + ], + } + useSelectionResults.mockReturnValue( + selectionQueryResult(selectedResult, { + data: { + pageParams: [1, 2], + pages: [ + { ...selectedResult, stocks_total: 2 }, + { + ...selectedResult, + page: 2, + stocks: [nextStock], + stocks_total: 2, + }, + ], + }, + }), + ) + + render() + + expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0) + expect(screen.getAllByText("招商银行").length).toBeGreaterThan(0) }) it("requires confirmation before a rerun and does not trigger on cancel", () => { @@ -634,8 +758,8 @@ describe("SelectionResultsPage", () => { }) it("uses the same confirmation flow for a failed retry", () => { - useSelectionResults.mockReturnValue({ - data: { + useSelectionResults.mockReturnValue( + selectionQueryResult({ ...selectedResult, failed_count: 1, failures: [ @@ -650,10 +774,8 @@ describe("SelectionResultsPage", () => { signal_count: 0, stocks: [], status: "failed", - }, - isError: false, - isPending: false, - }) + }), + ) render() @@ -664,17 +786,15 @@ describe("SelectionResultsPage", () => { }) it("renders a no-data state and triggers an initial run after date selection", async () => { - useSelectionResults.mockReturnValue({ - data: { + useSelectionResults.mockReturnValue( + selectionQueryResult({ ...selectedResult, run_id: null, stocks: [], status: "no_data", target_trade_date: "2026-08-08", - }, - isError: false, - isPending: false, - }) + }), + ) render() @@ -715,33 +835,35 @@ describe("SelectionResultsPage", () => { }) it("continues polling a persisted running run after page reload", () => { - useSelectionResults.mockReturnValue({ - data: { ...selectedResult, stocks: [], status: "running" }, - isError: false, - isPending: false, - }) - useSelectionRun.mockReturnValue({ - data: { ...selectedResult, stocks: [], status: "running" }, - isError: false, - isPending: false, - }) + useSelectionResults.mockReturnValue( + selectionQueryResult({ + ...selectedResult, + stocks: [], + status: "running", + }), + ) + useSelectionRun.mockReturnValue( + selectionQueryResult({ + ...selectedResult, + stocks: [], + status: "running", + }), + ) render() expect(screen.getByText("策略执行中")).toBeInTheDocument() expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled() expect(useSelectionRun).toHaveBeenCalledWith("run-1", { - page: 1, - pageSize: 5, + pageSize: 20, sort: "code", }) }) it("renders query failures explicitly", () => { useSelectionResults.mockReturnValue({ - data: undefined, + ...emptySelectionQuery(), isError: true, - isPending: false, }) render() 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 b49f990..7a21ac9 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.tsx @@ -4,13 +4,15 @@ import { useRef, useState, type RefObject } from "react" import { PageLayout } from "@/app/layout/page-layout" import { + flattenSelectionResults, useSelectionResults, useSelectionRun, useTriggerSelectionRun, } from "@/features/selection/api/selection.query" -import type { - SelectionResults, - SelectionResultsQuery, +import { + selectionResultPageSize, + type SelectionResults, + type SelectionResultsQuery, } from "@/features/selection/api/selection.types" import { Button } from "@/shared/ui/button" import { @@ -56,9 +58,8 @@ export function SelectionResultsPage() { const [rerunDialogOpen, setRerunDialogOpen] = useState(false) const executionStatusTriggerRef = useRef(null) const search = useSearch({ from: "/_workspace/selection" }) - const resultQuery: SelectionResultsQuery = { - page: search.page, - pageSize: search.pageSize, + const resultQuery: Omit = { + pageSize: selectionResultPageSize, ...(search.search ? { search: search.search } : {}), ...(search.category !== "all" ? { category: search.category } : {}), sort: search.sort, @@ -69,33 +70,38 @@ export function SelectionResultsPage() { targetTradeDate || undefined, resultQuery, ) + const resultsSnapshot = flattenSelectionResults(results.data) const persistedRunningRunId = - results.data?.status === "running" ? results.data.run_id : null + resultsSnapshot?.status === "running" ? resultsSnapshot.run_id : null const polledRunId = activeRunId ?? persistedRunningRunId const activeRun = useSelectionRun(polledRunId, resultQuery) const trigger = useTriggerSelectionRun() + const runSnapshot = flattenSelectionResults(activeRun.data) const selectedTargetTradeDate = targetTradeDate === undefined - ? (results.data?.target_trade_date ?? "") + ? (resultsSnapshot?.target_trade_date ?? "") : (targetTradeDate ?? "") const selectedDateResult = targetTradeDate && - results.data?.target_trade_date !== selectedTargetTradeDate + resultsSnapshot?.target_trade_date !== selectedTargetTradeDate ? undefined - : results.data - const displayedResult = activeRun.data ?? selectedDateResult + : resultsSnapshot + const displayedResult = runSnapshot ?? selectedDateResult + const listQuery = activeRun.data ? activeRun : results const isRunning = trigger.isPending || - results.data?.status === "running" || - activeRun.data?.status === "running" || + resultsSnapshot?.status === "running" || + runSnapshot?.status === "running" || (polledRunId !== null && activeRun.isPending) const hasExistingResult = Boolean( displayedResult?.run_id && displayedResult.status !== "no_data", ) const hasQueryError = - activeRunId !== null ? activeRun.isError : results.isError + activeRunId !== null + ? Boolean(activeRun.isError) && !activeRun.isFetchNextPageError + : Boolean(results.isError) && !results.isFetchNextPageError function handleExecute() { if (!selectedTargetTradeDate || isRunning) return @@ -135,18 +141,22 @@ export function SelectionResultsPage() { setExecutionStatusDrawerOpen(true)} + result={displayedResult} selectedTargetTradeDate={selectedTargetTradeDate} triggerError={trigger.isError} + triggerRef={executionStatusTriggerRef} /> } mode="bounded-workspace" > -
+
{isRunning ? : null} {!isRunning && !hasQueryError && @@ -163,12 +173,7 @@ export function SelectionResultsPage() { {!isRunning && !hasQueryError && displayedResult?.status === "failed" ? ( - setExecutionStatusDrawerOpen(true)} - result={displayedResult} - triggerRef={executionStatusTriggerRef} - /> + ) : null} {!isRunning && !hasQueryError && @@ -176,10 +181,11 @@ export function SelectionResultsPage() { displayedResult.status !== "no_data" && displayedResult.status !== "failed" ? ( setExecutionStatusDrawerOpen(true)} + hasNextPage={Boolean(listQuery.hasNextPage)} + isFetchNextPageError={Boolean(listQuery.isFetchNextPageError)} + isFetchingNextPage={Boolean(listQuery.isFetchingNextPage)} + onLoadMore={() => listQuery.fetchNextPage()} result={displayedResult} - triggerRef={executionStatusTriggerRef} /> ) : null}
@@ -228,23 +234,31 @@ export function SelectionResultsPage() { } interface ExecutionToolbarProps { + drawerOpen: boolean hasExistingResult: boolean isFailedResult: boolean isRunning: boolean onDateChange: (value: Date | undefined) => void onExecute: () => void + onOpenExecutionStatus: () => void + result: SelectionResults | undefined selectedTargetTradeDate: string triggerError: boolean + triggerRef: RefObject } function ExecutionToolbar({ + drawerOpen, hasExistingResult, isFailedResult, isRunning, onDateChange, onExecute, + onOpenExecutionStatus, + result, selectedTargetTradeDate, triggerError, + triggerRef, }: ExecutionToolbarProps) { return (
@@ -278,26 +292,36 @@ function ExecutionToolbar({
- +
+ + {result ? ( + + ) : null} +
{triggerError ? (

@@ -309,10 +333,11 @@ function ExecutionToolbar({ } interface ResultStateProps { - drawerOpen: boolean - onOpenExecutionStatus: () => void + hasNextPage: boolean + isFetchNextPageError: boolean + isFetchingNextPage: boolean + onLoadMore: () => void | Promise result: SelectionResults - triggerRef: RefObject } function parseLocalTradeDate(value: string): Date | undefined { @@ -409,12 +434,7 @@ function NoDataState() { ) } -function FailureState({ - drawerOpen, - onOpenExecutionStatus, - result, - triggerRef, -}: ResultStateProps) { +function FailureState({ result }: { result: SelectionResults }) { return ( @@ -431,75 +451,49 @@ function FailureState({ -

-

- 目标交易日: - - {result.target_trade_date} - - ,失败股票: - - {result.failed_count} - -

- -
+

+ 目标交易日: + + {result.target_trade_date} + + ,失败股票: + + {result.failed_count} + +

) } function ResultState({ - drawerOpen, - onOpenExecutionStatus, + hasNextPage, + isFetchNextPageError, + isFetchingNextPage, + onLoadMore, result, - triggerRef, }: ResultStateProps) { return result.signal_count === 0 ? ( - + ) : ( ) } -function NoSignalState({ - drawerOpen, - onOpenExecutionStatus, - result, - triggerRef, -}: ResultStateProps) { +function NoSignalState() { return ( -
-
- 本次没有命中信号 - - 目标交易日数据完整,但没有符合公式条件的股票。 - -
- -
+ 本次没有命中信号 + + 目标交易日数据完整,但没有符合公式条件的股票。 +
) diff --git a/zhixing-web/src/routes/route-tree.tsx b/zhixing-web/src/routes/route-tree.tsx index ab102ad..b06dbf7 100644 --- a/zhixing-web/src/routes/route-tree.tsx +++ b/zhixing-web/src/routes/route-tree.tsx @@ -16,6 +16,7 @@ import { import { SectorRadarPage } from "@/features/sector-radar/pages/sector-radar-page" import { selectionCategoryFilters, + selectionResultPageSize, selectionSorts, type SelectionCategoryFilter, type SelectionSort, @@ -104,7 +105,10 @@ const selectionRoute = createRoute({ const rawPageSize = Number(search.pageSize) const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1 - const pageSize = [5, 10, 15].includes(rawPageSize) ? rawPageSize : 10 + const pageSize = + Number.isFinite(rawPageSize) && rawPageSize >= 1 && rawPageSize <= 100 + ? Math.floor(rawPageSize) + : selectionResultPageSize const searchValue = typeof search.search === "string" ? search.search.slice(0, 100) : "" const rawCategory = -- 2.52.0