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:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user