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 SelectionResultsQuery,
type SelectionRunAccepted, type SelectionRunAccepted,
type SelectionRunRequest, type SelectionRunRequest,
type SelectionSectors,
type SelectionStrategy, type SelectionStrategy,
} from "./selection.types" } from "./selection.types"
@@ -63,9 +64,27 @@ function buildSelectionQueryParams(query: SelectionResultsQuery) {
if (query.search) params.set("search", query.search) if (query.search) params.set("search", query.search)
if (query.category) params.set("category", query.category) if (query.category) params.set("category", query.category)
if (query.sort) params.set("sort", query.sort) if (query.sort) params.set("sort", query.sort)
if (query.sector) params.set("sector", query.sector)
return params 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( export function triggerSelectionRun(
request: SelectionRunRequest, request: SelectionRunRequest,
signal?: AbortSignal, signal?: AbortSignal,
@@ -69,6 +69,7 @@ describe("selection query hooks", () => {
"平安", "平安",
"pullback", "pullback",
"score_desc", "score_desc",
"",
], ],
) )
expect( expect(
@@ -10,6 +10,7 @@ import { useEffect } from "react"
import { import {
getSelectionChart, getSelectionChart,
getSelectionResultSectors,
getSelectionResults, getSelectionResults,
getSelectionRun, getSelectionRun,
triggerSelectionRun, triggerSelectionRun,
@@ -19,6 +20,7 @@ import {
type SelectionResults, type SelectionResults,
type SelectionResultsQuery, type SelectionResultsQuery,
type SelectionRunRequest, type SelectionRunRequest,
type SelectionSectors,
type SelectionStrategy, type SelectionStrategy,
} from "./selection.types" } from "./selection.types"
@@ -55,6 +57,7 @@ export const selectionResultsQueryKey = (
query.search ?? "", query.search ?? "",
query.category ?? "all", query.category ?? "all",
query.sort ?? "code", query.sort ?? "code",
query.sector ?? "",
] as const ] as const
export const selectionRunQueryKey = ( export const selectionRunQueryKey = (
@@ -69,8 +72,15 @@ export const selectionRunQueryKey = (
query.search ?? "", query.search ?? "",
query.category ?? "all", query.category ?? "all",
query.sort ?? "code", query.sort ?? "code",
query.sector ?? "",
] as const ] as const
export const selectionSectorsQueryKey = (
strategy: SelectionStrategy,
targetTradeDate?: string,
sectorType: "concept" | "industry" = "concept",
) => ["selection", "sectors", strategy, targetTradeDate, sectorType] as const
export function flattenSelectionResults( export function flattenSelectionResults(
data: InfiniteData<SelectionResults> | undefined, data: InfiniteData<SelectionResults> | undefined,
): 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( export function useSelectionRun(
runId: string | null, runId: string | null,
resultQuery: SelectionResultsListQuery = defaultListQuery, resultQuery: SelectionResultsListQuery = defaultListQuery,
@@ -173,9 +195,14 @@ export function invalidateSelectionResults(
queryClient: QueryClient, queryClient: QueryClient,
strategy: SelectionStrategy, strategy: SelectionStrategy,
) { ) {
return queryClient.invalidateQueries({ return Promise.all([
queryClient.invalidateQueries({
queryKey: ["selection", "results", strategy], queryKey: ["selection", "results", strategy],
}) }),
queryClient.invalidateQueries({
queryKey: ["selection", "sectors", strategy],
}),
])
} }
function nextSelectionPageParam( function nextSelectionPageParam(
@@ -25,6 +25,27 @@ export interface SelectionResultsQuery {
search?: string search?: string
category?: Exclude<SelectionCategoryFilter, "all"> category?: Exclude<SelectionCategoryFilter, "all">
sort?: SelectionSort 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 = 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 { useNavigate, useSearch } from "@tanstack/react-router"
import { Input } from "@/shared/ui/input" import { Input } from "@/shared/ui/input"
@@ -11,7 +11,11 @@ import {
SelectValue, SelectValue,
} from "@/shared/ui/select" } 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 { SignalDetailPanel } from "./signal-detail-panel"
import { SignalRecordList } from "./signal-record-list" import { SignalRecordList } from "./signal-record-list"
import { import {
@@ -30,12 +34,15 @@ const SCORE_SORT_OPTIONS: ReadonlyArray<{
{ label: "评分从低到高", value: "score_asc" }, { label: "评分从低到高", value: "score_asc" },
] ]
const ALL_SECTORS_VALUE = "all"
interface SelectionResultsWorkbenchProps { interface SelectionResultsWorkbenchProps {
hasNextPage: boolean hasNextPage: boolean
isFetchNextPageError: boolean isFetchNextPageError: boolean
isFetchingNextPage: boolean isFetchingNextPage: boolean
onLoadMore: () => void | Promise<unknown> onLoadMore: () => void | Promise<unknown>
result: SelectionResults result: SelectionResults
sectorOptions: ReadonlyArray<SelectionSectorAggregate>
} }
export function SelectionResultsWorkbench({ export function SelectionResultsWorkbench({
@@ -44,12 +51,14 @@ export function SelectionResultsWorkbench({
isFetchingNextPage, isFetchingNextPage,
onLoadMore, onLoadMore,
result, result,
sectorOptions,
}: SelectionResultsWorkbenchProps) { }: SelectionResultsWorkbenchProps) {
const search = useSearch({ from: "/_workspace/selection" }) const search = useSearch({ from: "/_workspace/selection" })
const navigate = useNavigate({ from: "/selection" }) const navigate = useNavigate({ from: "/selection" })
const query = search.search ?? "" const query = search.search ?? ""
const category = search.category ?? "all" const category = search.category ?? "all"
const sort = search.sort ?? "code" const sort = search.sort ?? "code"
const sector = search.sector
const [selectedKey, setSelectedKey] = useState<string | null>(null) const [selectedKey, setSelectedKey] = useState<string | null>(null)
const loadMoreRequestPending = useRef(false) const loadMoreRequestPending = useRef(false)
@@ -68,11 +77,32 @@ export function SelectionResultsWorkbench({
const selectedStock = const selectedStock =
visibleStocks.find((stock) => getStockKey(stock) === selectedKey) ?? visibleStocks.find((stock) => getStockKey(stock) === selectedKey) ??
visibleStocks[0] 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: { function updateSearch(next: {
search?: string search?: string
category?: SignalCategoryFilter category?: SignalCategoryFilter
sort?: SelectionSort sort?: SelectionSort
sector?: string
}) { }) {
void navigate({ search: (previous) => ({ ...previous, ...next }) }) void navigate({ search: (previous) => ({ ...previous, ...next }) })
} }
@@ -89,6 +119,10 @@ export function SelectionResultsWorkbench({
updateSearch({ sort: value }) updateSearch({ sort: value })
} }
function handleSectorChange(value: string) {
updateSearch({ sector: value === ALL_SECTORS_VALUE ? undefined : value })
}
function requestLoadMore() { function requestLoadMore() {
if ( if (
loadMoreRequestPending.current || loadMoreRequestPending.current ||
@@ -105,9 +139,9 @@ export function SelectionResultsWorkbench({
} }
return ( 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)]"> <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden">
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border/80 bg-card"> <section className="shrink-0 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 flex-wrap items-center gap-1.5 p-2.5">
<Input <Input
aria-label="搜索命中股票" aria-label="搜索命中股票"
className="h-11 min-w-0 flex-1 bg-background text-sm sm:max-w-[260px] md:h-8" 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" type="search"
value={query} 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 <Select
items={signalCategoryOptions} items={signalCategoryOptions}
onValueChange={(value) => { onValueChange={(value) => {
@@ -170,7 +236,10 @@ export function SelectionResultsWorkbench({
筛选结果 {stocksTotal} 只 筛选结果 {stocksTotal} 只
</span> </span>
</div> </div>
</section>
<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"> <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<SignalRecordList <SignalRecordList
isFetchNextPageError={isFetchNextPageError} isFetchNextPageError={isFetchNextPageError}
@@ -189,5 +258,6 @@ export function SelectionResultsWorkbench({
<SignalDetailPanel stock={selectedStock} /> <SignalDetailPanel stock={selectedStock} />
</div> </div>
</div>
) )
} }
@@ -16,6 +16,7 @@ import { SelectionResultsPage } from "./selection-results-page"
const routerNavigate = vi.hoisted(() => vi.fn()) const routerNavigate = vi.hoisted(() => vi.fn())
const useSelectionResults = vi.fn() const useSelectionResults = vi.fn()
const useSelectionResultSectors = vi.fn()
const useSelectionChart = vi.fn() const useSelectionChart = vi.fn()
const useStockSectorMembership = vi.fn() const useStockSectorMembership = vi.fn()
const useSelectionRun = vi.fn() const useSelectionRun = vi.fn()
@@ -31,6 +32,8 @@ vi.mock("@/features/selection/api/selection.query", async () => {
return { return {
...actual, ...actual,
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args), useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionResultSectors: (...args: unknown[]) =>
useSelectionResultSectors(...args),
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args), useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args), useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(), useTriggerSelectionRun: () => useTriggerSelectionRun(),
@@ -169,6 +172,11 @@ describe("SelectionResultsPage", () => {
isPending: false, isPending: false,
}) })
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult)) useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
useSelectionResultSectors.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue(emptySelectionQuery()) useSelectionRun.mockReturnValue(emptySelectionQuery())
useTriggerSelectionRun.mockReturnValue({ useTriggerSelectionRun.mockReturnValue({
isError: false, 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 () => { it("requests database-backed score sorting", async () => {
render(<SelectionResultsPage />) render(<SelectionResultsPage />)
@@ -5,6 +5,7 @@ import { useRef, useState, type RefObject } from "react"
import { PageLayout } from "@/app/layout/page-layout" import { PageLayout } from "@/app/layout/page-layout"
import { import {
flattenSelectionResults, flattenSelectionResults,
useSelectionResultSectors,
useSelectionResults, useSelectionResults,
useSelectionRun, useSelectionRun,
useTriggerSelectionRun, useTriggerSelectionRun,
@@ -14,6 +15,8 @@ import {
selectionStrategies, selectionStrategies,
type SelectionResults, type SelectionResults,
type SelectionResultsQuery, type SelectionResultsQuery,
type SelectionSectorAggregate,
type SelectionSectors,
type SelectionStrategy, type SelectionStrategy,
} from "@/features/selection/api/selection.types" } from "@/features/selection/api/selection.types"
import { Button } from "@/shared/ui/button" import { Button } from "@/shared/ui/button"
@@ -71,6 +74,7 @@ export function SelectionResultsPage() {
pageSize: selectionResultPageSize, pageSize: selectionResultPageSize,
...(search.search ? { search: search.search } : {}), ...(search.search ? { search: search.search } : {}),
...(search.category !== "all" ? { category: search.category } : {}), ...(search.category !== "all" ? { category: search.category } : {}),
...(search.sector ? { sector: search.sector } : {}),
sort: search.sort, sort: search.sort,
} }
@@ -79,6 +83,10 @@ export function SelectionResultsPage() {
targetTradeDate || undefined, targetTradeDate || undefined,
resultQuery, resultQuery,
) )
const sectorsQuery = useSelectionResultSectors(
strategy,
targetTradeDate || undefined,
)
const resultsSnapshot = flattenSelectionResults(results.data) const resultsSnapshot = flattenSelectionResults(results.data)
const persistedRunningRunId = const persistedRunningRunId =
resultsSnapshot?.status === "running" ? resultsSnapshot.run_id : null resultsSnapshot?.status === "running" ? resultsSnapshot.run_id : null
@@ -156,7 +164,11 @@ export function SelectionResultsPage() {
setRerunDialogOpen(false) setRerunDialogOpen(false)
trigger.reset() trigger.reset()
void navigate({ 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)} isFetchingNextPage={Boolean(listQuery.isFetchingNextPage)}
onLoadMore={() => listQuery.fetchNextPage()} onLoadMore={() => listQuery.fetchNextPage()}
result={displayedResult} result={displayedResult}
sectors={sectorsQuery.data}
/> />
) : null} ) : null}
</div> </div>
@@ -376,8 +389,11 @@ interface ResultStateProps {
isFetchingNextPage: boolean isFetchingNextPage: boolean
onLoadMore: () => void | Promise<unknown> onLoadMore: () => void | Promise<unknown>
result: SelectionResults result: SelectionResults
sectors: SelectionSectors | undefined
} }
const EMPTY_SECTOR_OPTIONS: ReadonlyArray<SelectionSectorAggregate> = []
function parseLocalTradeDate(value: string): Date | undefined { function parseLocalTradeDate(value: string): Date | undefined {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return undefined if (!match) return undefined
@@ -511,6 +527,7 @@ function ResultState({
isFetchingNextPage, isFetchingNextPage,
onLoadMore, onLoadMore,
result, result,
sectors,
}: ResultStateProps) { }: ResultStateProps) {
return result.signal_count === 0 ? ( return result.signal_count === 0 ? (
<NoSignalState /> <NoSignalState />
@@ -521,6 +538,7 @@ function ResultState({
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
onLoadMore={onLoadMore} onLoadMore={onLoadMore}
result={result} result={result}
sectorOptions={sectors?.sectors ?? EMPTY_SECTOR_OPTIONS}
/> />
) )
} }
+5 -1
View File
@@ -124,7 +124,11 @@ const selectionRoute = createRoute({
) )
? (rawSort as SelectionSort) ? (rawSort as SelectionSort)
: "code" : "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, component: SelectionResultsPage,
}) })