feat(selection): rework results layout and add sector filter

- move the filter bar out of the list column into a full-width top section;
  list and detail stay side by side below on md+, stacking search > list >
  detail on small screens
- add sector select fed by the run's concept-board aggregates; options show
  per-sector stock counts ordered by count desc, stale selections reset when
  the aggregate list changes
- persist sector in the route search, results/runs query keys, and refresh
  sector aggregates when a selection run finishes
This commit is contained in:
yuxuanhui
2026-09-05 19:48:41 +08:00
parent 7e0f13d678
commit 12642f3c2d
8 changed files with 233 additions and 26 deletions
@@ -7,6 +7,7 @@ import {
type SelectionResultsQuery,
type SelectionRunAccepted,
type SelectionRunRequest,
type SelectionSectors,
type SelectionStrategy,
} from "./selection.types"
@@ -63,9 +64,27 @@ function buildSelectionQueryParams(query: SelectionResultsQuery) {
if (query.search) params.set("search", query.search)
if (query.category) params.set("category", query.category)
if (query.sort) params.set("sort", query.sort)
if (query.sector) params.set("sector", query.sector)
return params
}
export function getSelectionResultSectors(
strategy: SelectionStrategy,
targetTradeDate?: string,
sectorType: "concept" | "industry" = "concept",
signal?: AbortSignal,
) {
const params = new URLSearchParams({
strategy,
sector_type: sectorType,
})
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
return requestJson<SelectionSectors>(
`/api/v1/selection/sectors?${params.toString()}`,
{ signal },
)
}
export function triggerSelectionRun(
request: SelectionRunRequest,
signal?: AbortSignal,
@@ -69,6 +69,7 @@ describe("selection query hooks", () => {
"平安",
"pullback",
"score_desc",
"",
],
)
expect(
@@ -10,6 +10,7 @@ import { useEffect } from "react"
import {
getSelectionChart,
getSelectionResultSectors,
getSelectionResults,
getSelectionRun,
triggerSelectionRun,
@@ -19,6 +20,7 @@ import {
type SelectionResults,
type SelectionResultsQuery,
type SelectionRunRequest,
type SelectionSectors,
type SelectionStrategy,
} from "./selection.types"
@@ -55,6 +57,7 @@ export const selectionResultsQueryKey = (
query.search ?? "",
query.category ?? "all",
query.sort ?? "code",
query.sector ?? "",
] as const
export const selectionRunQueryKey = (
@@ -69,8 +72,15 @@ export const selectionRunQueryKey = (
query.search ?? "",
query.category ?? "all",
query.sort ?? "code",
query.sector ?? "",
] as const
export const selectionSectorsQueryKey = (
strategy: SelectionStrategy,
targetTradeDate?: string,
sectorType: "concept" | "industry" = "concept",
) => ["selection", "sectors", strategy, targetTradeDate, sectorType] as const
export function flattenSelectionResults(
data: InfiniteData<SelectionResults> | undefined,
): SelectionResults | undefined {
@@ -120,6 +130,18 @@ export function useSelectionChart(
})
}
export function useSelectionResultSectors(
strategy: SelectionStrategy,
targetTradeDate?: string,
sectorType: "concept" | "industry" = "concept",
) {
return useQuery<SelectionSectors, Error>({
queryFn: ({ signal }) =>
getSelectionResultSectors(strategy, targetTradeDate, sectorType, signal),
queryKey: selectionSectorsQueryKey(strategy, targetTradeDate, sectorType),
})
}
export function useSelectionRun(
runId: string | null,
resultQuery: SelectionResultsListQuery = defaultListQuery,
@@ -173,9 +195,14 @@ export function invalidateSelectionResults(
queryClient: QueryClient,
strategy: SelectionStrategy,
) {
return queryClient.invalidateQueries({
queryKey: ["selection", "results", strategy],
})
return Promise.all([
queryClient.invalidateQueries({
queryKey: ["selection", "results", strategy],
}),
queryClient.invalidateQueries({
queryKey: ["selection", "sectors", strategy],
}),
])
}
function nextSelectionPageParam(
@@ -25,6 +25,27 @@ export interface SelectionResultsQuery {
search?: string
category?: Exclude<SelectionCategoryFilter, "all">
sort?: SelectionSort
sector?: string
}
export type SelectionSectorType = "concept" | "industry"
export const selectionSectorTypes = ["concept", "industry"] as const
export interface SelectionSectorAggregate {
sector_code: string
sector_name: string
stock_count: number
}
export interface SelectionSectors {
strategy: SelectionStrategy
target_trade_date: string | null
run_id: string | null
status: SelectionRunStatus
snapshot_trade_date: string | null
sector_type: SelectionSectorType
sectors: SelectionSectorAggregate[]
}
export type SelectionRunStatus =
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { Input } from "@/shared/ui/input"
@@ -11,7 +11,11 @@ import {
SelectValue,
} from "@/shared/ui/select"
import type { SelectionResults, SelectionSort } from "../api/selection.types"
import type {
SelectionResults,
SelectionSectorAggregate,
SelectionSort,
} from "../api/selection.types"
import { SignalDetailPanel } from "./signal-detail-panel"
import { SignalRecordList } from "./signal-record-list"
import {
@@ -30,12 +34,15 @@ const SCORE_SORT_OPTIONS: ReadonlyArray<{
{ label: "评分从低到高", value: "score_asc" },
]
const ALL_SECTORS_VALUE = "all"
interface SelectionResultsWorkbenchProps {
hasNextPage: boolean
isFetchNextPageError: boolean
isFetchingNextPage: boolean
onLoadMore: () => void | Promise<unknown>
result: SelectionResults
sectorOptions: ReadonlyArray<SelectionSectorAggregate>
}
export function SelectionResultsWorkbench({
@@ -44,12 +51,14 @@ export function SelectionResultsWorkbench({
isFetchingNextPage,
onLoadMore,
result,
sectorOptions,
}: SelectionResultsWorkbenchProps) {
const search = useSearch({ from: "/_workspace/selection" })
const navigate = useNavigate({ from: "/selection" })
const query = search.search ?? ""
const category = search.category ?? "all"
const sort = search.sort ?? "code"
const sector = search.sector
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const loadMoreRequestPending = useRef(false)
@@ -68,11 +77,32 @@ export function SelectionResultsWorkbench({
const selectedStock =
visibleStocks.find((stock) => getStockKey(stock) === selectedKey) ??
visibleStocks[0]
const sectorSelectItems = useMemo(
() => [
{ label: "全部板块", value: ALL_SECTORS_VALUE },
...sectorOptions.map((option) => ({
label: option.sector_name,
value: option.sector_code,
})),
],
[sectorOptions],
)
// The aggregate list is the source of truth for valid sectors; drop a stale
// value left over from another strategy or trade date.
useEffect(() => {
if (!sector) return
if (sectorOptions.some((option) => option.sector_code === sector)) return
void navigate({
search: (previous) => ({ ...previous, sector: undefined }),
})
}, [navigate, sector, sectorOptions])
function updateSearch(next: {
search?: string
category?: SignalCategoryFilter
sort?: SelectionSort
sector?: string
}) {
void navigate({ search: (previous) => ({ ...previous, ...next }) })
}
@@ -89,6 +119,10 @@ export function SelectionResultsWorkbench({
updateSearch({ sort: value })
}
function handleSectorChange(value: string) {
updateSearch({ sector: value === ALL_SECTORS_VALUE ? undefined : value })
}
function requestLoadMore() {
if (
loadMoreRequestPending.current ||
@@ -105,9 +139,9 @@ export function SelectionResultsWorkbench({
}
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden md:grid md:grid-cols-[320px_minmax(0,1fr)]">
<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">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden">
<section className="shrink-0 rounded-md border border-border/80 bg-card">
<div className="flex flex-wrap items-center gap-1.5 p-2.5">
<Input
aria-label="搜索命中股票"
className="h-11 min-w-0 flex-1 bg-background text-sm sm:max-w-[260px] md:h-8"
@@ -116,6 +150,38 @@ export function SelectionResultsWorkbench({
type="search"
value={query}
/>
<Select
items={sectorSelectItems}
onValueChange={(value) => {
if (typeof value === "string") handleSectorChange(value)
}}
value={sector ?? ALL_SECTORS_VALUE}
>
<SelectTrigger
aria-label="筛选板块"
className="h-11 w-full bg-background text-sm sm:w-auto sm:min-w-36 md:h-8"
>
<SelectValue placeholder="筛选板块" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={ALL_SECTORS_VALUE}>全部板块</SelectItem>
{sectorOptions.map((option) => (
<SelectItem
key={option.sector_code}
value={option.sector_code}
>
<span className="flex w-full items-center justify-between gap-3">
<span>{option.sector_name}</span>
<span className="tabular-nums text-muted-foreground">
{option.stock_count}
</span>
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
items={signalCategoryOptions}
onValueChange={(value) => {
@@ -170,24 +236,28 @@ export function SelectionResultsWorkbench({
筛选结果 {stocksTotal} 只
</span>
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<SignalRecordList
isFetchNextPageError={isFetchNextPageError}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={requestLoadMore}
onRetryLoadMore={() => {
loadMoreRequestPending.current = false
onLoadMore()
}}
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
stocks={visibleStocks}
/>
</div>
</section>
<SignalDetailPanel stock={selectedStock} />
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden md:grid md:grid-cols-[320px_minmax(0,1fr)]">
<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 min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<SignalRecordList
isFetchNextPageError={isFetchNextPageError}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={requestLoadMore}
onRetryLoadMore={() => {
loadMoreRequestPending.current = false
onLoadMore()
}}
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
stocks={visibleStocks}
/>
</div>
</section>
<SignalDetailPanel stock={selectedStock} />
</div>
</div>
)
}
@@ -16,6 +16,7 @@ import { SelectionResultsPage } from "./selection-results-page"
const routerNavigate = vi.hoisted(() => vi.fn())
const useSelectionResults = vi.fn()
const useSelectionResultSectors = vi.fn()
const useSelectionChart = vi.fn()
const useStockSectorMembership = vi.fn()
const useSelectionRun = vi.fn()
@@ -31,6 +32,8 @@ vi.mock("@/features/selection/api/selection.query", async () => {
return {
...actual,
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionResultSectors: (...args: unknown[]) =>
useSelectionResultSectors(...args),
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(),
@@ -169,6 +172,11 @@ describe("SelectionResultsPage", () => {
isPending: false,
})
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
useSelectionResultSectors.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue(emptySelectionQuery())
useTriggerSelectionRun.mockReturnValue({
isError: false,
@@ -535,6 +543,45 @@ describe("SelectionResultsPage", () => {
})
})
it("filters by sector and shows per-sector counts", async () => {
useSelectionResultSectors.mockReturnValue({
data: {
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
run_id: "run-1",
status: "success",
snapshot_trade_date: "2026-08-08",
sector_type: "concept",
sectors: [
{ sector_code: "BK0001.DC", sector_name: "机器人", stock_count: 3 },
{ sector_code: "BK0003.DC", sector_name: "数字经济", stock_count: 2 },
],
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
const sectorTrigger = screen.getByRole("combobox", { name: "筛选板块" })
expect(sectorTrigger).toHaveTextContent("全部板块")
fireEvent.click(sectorTrigger)
const robotOption = await screen.findByRole("option", {
name: /机器人/,
})
expect(robotOption).toHaveTextContent("3")
fireEvent.pointerDown(robotOption, { pointerType: "mouse" })
fireEvent.click(robotOption)
expect(routerNavigate).toHaveBeenCalled()
const lastCall =
routerNavigate.mock.calls[routerNavigate.mock.calls.length - 1]
const searchUpdate = lastCall[0].search as (previous: {
sector?: string
}) => Record<string, unknown>
expect(searchUpdate({})).toEqual({ sector: "BK0001.DC" })
})
it("requests database-backed score sorting", async () => {
render(<SelectionResultsPage />)
@@ -5,6 +5,7 @@ import { useRef, useState, type RefObject } from "react"
import { PageLayout } from "@/app/layout/page-layout"
import {
flattenSelectionResults,
useSelectionResultSectors,
useSelectionResults,
useSelectionRun,
useTriggerSelectionRun,
@@ -14,6 +15,8 @@ import {
selectionStrategies,
type SelectionResults,
type SelectionResultsQuery,
type SelectionSectorAggregate,
type SelectionSectors,
type SelectionStrategy,
} from "@/features/selection/api/selection.types"
import { Button } from "@/shared/ui/button"
@@ -71,6 +74,7 @@ export function SelectionResultsPage() {
pageSize: selectionResultPageSize,
...(search.search ? { search: search.search } : {}),
...(search.category !== "all" ? { category: search.category } : {}),
...(search.sector ? { sector: search.sector } : {}),
sort: search.sort,
}
@@ -79,6 +83,10 @@ export function SelectionResultsPage() {
targetTradeDate || undefined,
resultQuery,
)
const sectorsQuery = useSelectionResultSectors(
strategy,
targetTradeDate || undefined,
)
const resultsSnapshot = flattenSelectionResults(results.data)
const persistedRunningRunId =
resultsSnapshot?.status === "running" ? resultsSnapshot.run_id : null
@@ -156,7 +164,11 @@ export function SelectionResultsPage() {
setRerunDialogOpen(false)
trigger.reset()
void navigate({
search: (previous) => ({ ...previous, category: "all" }),
search: (previous) => ({
...previous,
category: "all",
sector: undefined,
}),
})
}
@@ -212,6 +224,7 @@ export function SelectionResultsPage() {
isFetchingNextPage={Boolean(listQuery.isFetchingNextPage)}
onLoadMore={() => listQuery.fetchNextPage()}
result={displayedResult}
sectors={sectorsQuery.data}
/>
) : null}
</div>
@@ -376,8 +389,11 @@ interface ResultStateProps {
isFetchingNextPage: boolean
onLoadMore: () => void | Promise<unknown>
result: SelectionResults
sectors: SelectionSectors | undefined
}
const EMPTY_SECTOR_OPTIONS: ReadonlyArray<SelectionSectorAggregate> = []
function parseLocalTradeDate(value: string): Date | undefined {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return undefined
@@ -511,6 +527,7 @@ function ResultState({
isFetchingNextPage,
onLoadMore,
result,
sectors,
}: ResultStateProps) {
return result.signal_count === 0 ? (
<NoSignalState />
@@ -521,6 +538,7 @@ function ResultState({
isFetchingNextPage={isFetchingNextPage}
onLoadMore={onLoadMore}
result={result}
sectorOptions={sectors?.sectors ?? EMPTY_SECTOR_OPTIONS}
/>
)
}
+5 -1
View File
@@ -124,7 +124,11 @@ const selectionRoute = createRoute({
)
? (rawSort as SelectionSort)
: "code"
return { page, pageSize, search: searchValue, category, sort }
const sector =
typeof search.sector === "string" && search.sector.trim()
? search.sector.trim().slice(0, 60)
: undefined
return { page, pageSize, search: searchValue, category, sort, sector }
},
component: SelectionResultsPage,
})