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
@@ -10,18 +10,20 @@
## Requirements
1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和详情展开能力。列表容器应继续支持内部滚动和空结果提示,分页与筛选行为保持不变。
1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和右侧详情面板。列表容器应继续支持内部滚动和空结果提示;筛选、排序保留,不再展示底部分页,改为滚动加载更多。
2. 资金雷达看板保留核心排名、板块、指标值、排名百分位和样本信息,移除“资金覆盖率”和“质量”两列及其单元格内容;表头和数据行改为更紧凑的垂直间距,在不改变滚动加载、错误/空数据状态和排名变化视图的前提下提升可见行数。
3. 现有筛选、排序、加载更多和选中股票详情的数据契约不变;仅调整展示层结构和样式。
3. 现有筛选、排序和选中股票详情的数据契约不变;仅调整展示层结构和样式。执行状态入口放到工具栏“重新执行”按钮右侧。
## Acceptance Criteria
- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`<table>`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。
- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项;“查看详情/收起详情”可展开信号细节。
- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`<table>`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。卡片上不再出现“查看详情”。
- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项。
- [ ] 选股结果不再渲染底部分页控件;列表滚到底部会加载下一页,加载中和失败重试提示保持可见。
- [ ] 执行状态入口(如“部分成功”)出现在工具栏“重新执行/重试执行/执行策略”按钮右侧。
- [ ] 资金雷达表头不再包含“资金覆盖率”和“质量”,对应行内容也不再渲染;“排名百分位”和“样本”仍可见。
- [ ] 资金雷达数据行垂直间距小于当前实现,滚动到底部仍能触发一次加载更多,加载中和失败重试提示保持可见。
- [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 在 `zhixing-web/` 下通过。
## Out of scope
不调整后端接口、指标计算、筛选/排序语义、分页参数、详情面板字段或全局主题 token。
不调整后端接口、指标计算、筛选/排序语义、详情面板字段或全局主题 token。
@@ -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
@@ -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<unknown>
result: SelectionResults
triggerRef: RefObject<HTMLButtonElement | null>
}
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<string | null>(null)
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
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 (
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,3fr)] md:overflow-hidden">
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,3fr)]">
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border/80 bg-card">
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
<Input
aria-label="搜索命中股票"
@@ -185,34 +169,22 @@ export function SelectionResultsWorkbench({
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
筛选结果 {stocksTotal} 只
</span>
<ExecutionStatusTrigger
drawerOpen={drawerOpen}
onOpen={onOpenExecutionStatus}
result={result}
triggerRef={triggerRef}
/>
</div>
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<SignalRecordList
expandedKeys={expandedKeys}
isFetchNextPageError={isFetchNextPageError}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={requestLoadMore}
onRetryLoadMore={() => {
loadMoreRequestPending.current = false
onLoadMore()
}}
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
onToggleExpanded={handleToggleExpanded}
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
stocks={currentStocks}
stocks={visibleStocks}
/>
</div>
<Pagination
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
onPageSizeChange={(nextPageSize) =>
updateSearch({ page: 1, pageSize: nextPageSize })
}
page={currentPage}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={stocksTotal}
/>
</section>
<SignalDetailPanel stock={selectedStock} />
@@ -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<string>
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<HTMLButtonElement>,
@@ -48,24 +50,38 @@ export function SignalRecordList({
}
return (
<div className="min-h-0 flex-1 overflow-auto">
<div
aria-label="选股结果滚动区域"
className="min-h-0 flex-1 overflow-auto"
onScroll={(event) => {
const container = event.currentTarget
if (
container.scrollHeight -
container.scrollTop -
container.clientHeight <=
160
) {
onLoadMore()
}
}}
role="region"
tabIndex={0}
>
<div className="divide-y divide-border/60">
{stocks.map((stock) => {
const key = getStockKey(stock)
const selected = selectedKey === key
const expanded = expandedKeys.has(key)
return (
<article
key={key}
className={cn(
"space-y-1 px-2.5 py-1.5 transition-colors hover:bg-muted/50",
"px-2.5 py-1.5 transition-colors hover:bg-muted/50",
selected && "bg-muted/70",
)}
data-selected={selected}
>
<div className="flex items-start gap-2">
<button
className="min-h-11 min-w-0 flex-1 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
className="min-h-11 w-full text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
onClick={() => onSelect(stock)}
onKeyDown={(event) => handleSelectKeyDown(event, stock)}
type="button"
@@ -102,45 +118,30 @@ export function SignalRecordList({
</span>
</span>
</button>
<Button
aria-expanded={expanded}
className="min-h-11 shrink-0 gap-1 px-2 text-xs text-muted-foreground"
onClick={() => onToggleExpanded(stock)}
variant="ghost"
>
{expanded ? "收起详情" : "查看详情"}
{expanded ? (
<ChevronUp aria-hidden="true" />
) : (
<ChevronDown aria-hidden="true" />
)}
</Button>
</div>
{expanded ? (
<div className="space-y-3">
<section className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
图形相似度评分
</p>
<PatternScoreDetails score={stock.score} />
</section>
{stock.signals.map((signal) => (
<section key={signal.category} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{getCategoryLabel(signal.category)}
</p>
<SignalDetails
className="sm:grid-cols-3"
details={signal.details}
/>
</section>
))}
</div>
) : null}
</article>
)
})}
</div>
{isFetchingNextPage ? (
<div
aria-live="polite"
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
role="status"
>
正在加载更多股票…
</div>
) : null}
{isFetchNextPageError ? (
<div
className="flex flex-wrap items-center justify-center gap-2 border-t border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
role="alert"
>
<span>加载更多失败,已加载结果仍保留。</span>
<Button onClick={onRetryLoadMore} size="sm" variant="outline">
重试
</Button>
</div>
) : null}
</div>
)
}
@@ -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", () => ({
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<string, unknown> = {},
) {
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(<SelectionResultsPage />)
@@ -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(<SelectionResultsPage />)
@@ -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(<SelectionResultsPage />)
@@ -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(<SelectionResultsPage />)
@@ -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(<SelectionResultsPage />)
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(<SelectionResultsPage />)
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(<SelectionResultsPage />)
@@ -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(<SelectionResultsPage />)
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(<SelectionResultsPage />)
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(<SelectionResultsPage />)
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(<SelectionResultsPage />)
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(<SelectionResultsPage />)
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(<SelectionResultsPage />)
@@ -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(<SelectionResultsPage />)
@@ -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(<SelectionResultsPage />)
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(<SelectionResultsPage />)
@@ -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<HTMLButtonElement>(null)
const search = useSearch({ from: "/_workspace/selection" })
const resultQuery: SelectionResultsQuery = {
page: search.page,
pageSize: search.pageSize,
const resultQuery: Omit<SelectionResultsQuery, "page"> = {
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() {
<PageLayout
actions={
<ExecutionToolbar
drawerOpen={executionStatusDrawerOpen}
hasExistingResult={hasExistingResult}
isFailedResult={displayedResult?.status === "failed"}
isRunning={isRunning}
onDateChange={handleDateChange}
onExecute={handleExecute}
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
result={displayedResult}
selectedTargetTradeDate={selectedTargetTradeDate}
triggerError={trigger.isError}
triggerRef={executionStatusTriggerRef}
/>
}
mode="bounded-workspace"
>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden">
{isRunning ? <RunningState /> : null}
{!isRunning &&
!hasQueryError &&
@@ -163,12 +173,7 @@ export function SelectionResultsPage() {
{!isRunning &&
!hasQueryError &&
displayedResult?.status === "failed" ? (
<FailureState
drawerOpen={executionStatusDrawerOpen}
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
result={displayedResult}
triggerRef={executionStatusTriggerRef}
/>
<FailureState result={displayedResult} />
) : null}
{!isRunning &&
!hasQueryError &&
@@ -176,10 +181,11 @@ export function SelectionResultsPage() {
displayedResult.status !== "no_data" &&
displayedResult.status !== "failed" ? (
<ResultState
drawerOpen={executionStatusDrawerOpen}
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
hasNextPage={Boolean(listQuery.hasNextPage)}
isFetchNextPageError={Boolean(listQuery.isFetchNextPageError)}
isFetchingNextPage={Boolean(listQuery.isFetchingNextPage)}
onLoadMore={() => listQuery.fetchNextPage()}
result={displayedResult}
triggerRef={executionStatusTriggerRef}
/>
) : null}
</div>
@@ -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<HTMLButtonElement | null>
}
function ExecutionToolbar({
drawerOpen,
hasExistingResult,
isFailedResult,
isRunning,
onDateChange,
onExecute,
onOpenExecutionStatus,
result,
selectedTargetTradeDate,
triggerError,
triggerRef,
}: ExecutionToolbarProps) {
return (
<div className="w-full space-y-1.5">
@@ -278,8 +292,9 @@ function ExecutionToolbar({
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-1.5 sm:items-end">
<Button
className="h-11 w-full sm:w-auto md:h-8"
className="h-11 shrink-0 md:h-8"
disabled={!selectedTargetTradeDate || isRunning}
onClick={onExecute}
>
@@ -298,6 +313,15 @@ function ExecutionToolbar({
: "重新执行"
: "执行策略"}
</Button>
{result ? (
<ExecutionStatusTrigger
drawerOpen={drawerOpen}
onOpen={onOpenExecutionStatus}
result={result}
triggerRef={triggerRef}
/>
) : null}
</div>
</div>
{triggerError ? (
<p className="text-sm text-destructive" role="alert">
@@ -309,10 +333,11 @@ function ExecutionToolbar({
}
interface ResultStateProps {
drawerOpen: boolean
onOpenExecutionStatus: () => void
hasNextPage: boolean
isFetchNextPageError: boolean
isFetchingNextPage: boolean
onLoadMore: () => void | Promise<unknown>
result: SelectionResults
triggerRef: RefObject<HTMLButtonElement | null>
}
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 (
<Card>
<CardHeader className="gap-1.5 p-4">
@@ -431,7 +451,6 @@ function FailureState({
</CardDescription>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm text-muted-foreground">
目标交易日:
<span className="font-medium text-foreground">
@@ -442,64 +461,39 @@ function FailureState({
{result.failed_count}
</span>
</p>
<ExecutionStatusTrigger
drawerOpen={drawerOpen}
onOpen={onOpenExecutionStatus}
result={result}
triggerRef={triggerRef}
/>
</div>
</CardContent>
</Card>
)
}
function ResultState({
drawerOpen,
onOpenExecutionStatus,
hasNextPage,
isFetchNextPageError,
isFetchingNextPage,
onLoadMore,
result,
triggerRef,
}: ResultStateProps) {
return result.signal_count === 0 ? (
<NoSignalState
drawerOpen={drawerOpen}
onOpenExecutionStatus={onOpenExecutionStatus}
result={result}
triggerRef={triggerRef}
/>
<NoSignalState />
) : (
<SelectionResultsWorkbench
drawerOpen={drawerOpen}
onOpenExecutionStatus={onOpenExecutionStatus}
hasNextPage={hasNextPage}
isFetchNextPageError={isFetchNextPageError}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={onLoadMore}
result={result}
triggerRef={triggerRef}
/>
)
}
function NoSignalState({
drawerOpen,
onOpenExecutionStatus,
result,
triggerRef,
}: ResultStateProps) {
function NoSignalState() {
return (
<Card>
<CardHeader className="gap-1.5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1.5">
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
<CardDescription>
目标交易日数据完整,但没有符合公式条件的股票。
</CardDescription>
</div>
<ExecutionStatusTrigger
drawerOpen={drawerOpen}
onOpen={onOpenExecutionStatus}
result={result}
triggerRef={triggerRef}
/>
</div>
</CardHeader>
</Card>
)
+5 -1
View File
@@ -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 =