refactor(selection): replace concept sector filter with sub-industry
- pin the selection sector vocabulary to industry: the /sectors endpoint no longer accepts sector_type, and the port, adapter, and use case all resolve counts and member codes with sector_type="industry" - relabel the results filter to 细分行业 and drop concept-type plumbing from the frontend API, query keys, and types - show only 细分行业 in the signal detail panel; remove the concept-board chips and keep the snapshot-date tooltip on the industry line - update backend and frontend tests to the industry vocabulary and record the revised scope in the task PRD
This commit is contained in:
@@ -71,12 +71,10 @@ function buildSelectionQueryParams(query: SelectionResultsQuery) {
|
||||
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>(
|
||||
|
||||
@@ -78,8 +78,7 @@ export const selectionRunQueryKey = (
|
||||
export const selectionSectorsQueryKey = (
|
||||
strategy: SelectionStrategy,
|
||||
targetTradeDate?: string,
|
||||
sectorType: "concept" | "industry" = "concept",
|
||||
) => ["selection", "sectors", strategy, targetTradeDate, sectorType] as const
|
||||
) => ["selection", "sectors", strategy, targetTradeDate] as const
|
||||
|
||||
export function flattenSelectionResults(
|
||||
data: InfiniteData<SelectionResults> | undefined,
|
||||
@@ -133,12 +132,11 @@ 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),
|
||||
getSelectionResultSectors(strategy, targetTradeDate, signal),
|
||||
queryKey: selectionSectorsQueryKey(strategy, targetTradeDate),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -28,9 +28,7 @@ export interface SelectionResultsQuery {
|
||||
sector?: string
|
||||
}
|
||||
|
||||
export type SelectionSectorType = "concept" | "industry"
|
||||
|
||||
export const selectionSectorTypes = ["concept", "industry"] as const
|
||||
export type SelectionSectorType = "industry"
|
||||
|
||||
export interface SelectionSectorAggregate {
|
||||
sector_code: string
|
||||
|
||||
@@ -79,7 +79,7 @@ export function SelectionResultsWorkbench({
|
||||
visibleStocks[0]
|
||||
const sectorSelectItems = useMemo(
|
||||
() => [
|
||||
{ label: "全部板块", value: ALL_SECTORS_VALUE },
|
||||
{ label: "全部细分行业", value: ALL_SECTORS_VALUE },
|
||||
...sectorOptions.map((option) => ({
|
||||
label: option.sector_name,
|
||||
value: option.sector_code,
|
||||
@@ -158,14 +158,14 @@ export function SelectionResultsWorkbench({
|
||||
value={sector ?? ALL_SECTORS_VALUE}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="筛选板块"
|
||||
aria-label="筛选细分行业"
|
||||
className="h-11 w-full bg-background text-sm sm:w-auto sm:min-w-36 md:h-8"
|
||||
>
|
||||
<SelectValue placeholder="筛选板块" />
|
||||
<SelectValue placeholder="筛选细分行业" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={ALL_SECTORS_VALUE}>全部板块</SelectItem>
|
||||
<SelectItem value={ALL_SECTORS_VALUE}>全部细分行业</SelectItem>
|
||||
{sectorOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.sector_code}
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("SignalDetailPanel", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("shows the stock industry and concept boards from the membership query", () => {
|
||||
it("shows the stock sub-industries from the membership query", () => {
|
||||
useStockSectorMembership.mockReturnValue({
|
||||
data: {
|
||||
status: "success",
|
||||
@@ -69,42 +69,12 @@ describe("SignalDetailPanel", () => {
|
||||
|
||||
render(<SignalDetailPanel stock={stock} />)
|
||||
|
||||
expect(screen.getByText("行业:银行")).toBeInTheDocument()
|
||||
expect(screen.getByText("板块")).toBeInTheDocument()
|
||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||
expect(screen.getByText("人工智能")).toBeInTheDocument()
|
||||
expect(screen.queryByText(/等 \d+ 个/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/细分行业:银行/)).toBeInTheDocument()
|
||||
expect(screen.queryByText("机器人")).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("人工智能")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("caps visible concepts and reports the total", () => {
|
||||
const concepts = Array.from({ length: 10 }, (_, index) => ({
|
||||
code: `BK${String(index + 1).padStart(4, "0")}.DC`,
|
||||
name: `板块${index + 1}`,
|
||||
}))
|
||||
useStockSectorMembership.mockReturnValue({
|
||||
data: {
|
||||
status: "success",
|
||||
ts_code: "000001.SZ",
|
||||
requested_trade_date: "2026-08-28",
|
||||
trade_date: "2026-08-28",
|
||||
industries: [],
|
||||
concepts,
|
||||
concept_total: 10,
|
||||
concept_limit: 20,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SignalDetailPanel stock={stock} />)
|
||||
|
||||
expect(screen.getByText("板块1")).toBeInTheDocument()
|
||||
expect(screen.getByText("板块8")).toBeInTheDocument()
|
||||
expect(screen.queryByText("板块9")).not.toBeInTheDocument()
|
||||
expect(screen.getByText("等 10 个")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("hides the sector rows when the membership query returns no data", () => {
|
||||
it("hides the sub-industry row when the membership query returns no data", () => {
|
||||
useStockSectorMembership.mockReturnValue({
|
||||
data: {
|
||||
status: "no_data",
|
||||
@@ -122,7 +92,6 @@ describe("SignalDetailPanel", () => {
|
||||
|
||||
render(<SignalDetailPanel stock={stock} />)
|
||||
|
||||
expect(screen.queryByText(/行业:/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("板块")).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/细分行业:/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,8 +19,6 @@ const SelectionChart = lazy(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const displayedConceptCount = 8
|
||||
|
||||
interface SignalDetailPanelProps {
|
||||
stock: SelectionStockResult | undefined
|
||||
}
|
||||
@@ -71,16 +69,13 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
]
|
||||
|
||||
const membership =
|
||||
membershipQuery.data?.status === "success" ? membershipQuery.data : undefined
|
||||
membershipQuery.data?.status === "success"
|
||||
? membershipQuery.data
|
||||
: undefined
|
||||
const industries = membership?.industries ?? []
|
||||
const concepts = membership?.concepts ?? []
|
||||
const extraConceptCount = Math.max(
|
||||
0,
|
||||
concepts.length - displayedConceptCount,
|
||||
)
|
||||
const sectorSnapshotTitle =
|
||||
const industrySnapshotTitle =
|
||||
membership?.trade_date != null
|
||||
? `板块成分截至 ${membership.trade_date}`
|
||||
? `细分行业成分截至 ${membership.trade_date}`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
@@ -138,30 +133,13 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
</p>
|
||||
|
||||
{industries.length > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
行业:{industries.map((industry) => industry.name).join("、")}
|
||||
</p>
|
||||
)}
|
||||
{concepts.length > 0 && (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap items-center gap-1"
|
||||
title={sectorSnapshotTitle}
|
||||
<p
|
||||
className="mt-1 text-xs text-muted-foreground"
|
||||
title={industrySnapshotTitle}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">板块</span>
|
||||
{concepts.slice(0, displayedConceptCount).map((concept) => (
|
||||
<span
|
||||
className="rounded bg-muted px-1.5 py-0.5 text-xs"
|
||||
key={concept.code}
|
||||
>
|
||||
{concept.name}
|
||||
</span>
|
||||
))}
|
||||
{extraConceptCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
等 {concepts.length} 个
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
细分行业:
|
||||
{industries.map((industry) => industry.name).join("、")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<dl className="mt-3 divide-y divide-border/60 border-y border-border/60 text-xs">
|
||||
|
||||
@@ -543,7 +543,7 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("filters by sector and shows per-sector counts", async () => {
|
||||
it("filters by sub-industry and shows per-industry counts", async () => {
|
||||
useSelectionResultSectors.mockReturnValue({
|
||||
data: {
|
||||
strategy: "zhixing_b1",
|
||||
@@ -551,10 +551,10 @@ describe("SelectionResultsPage", () => {
|
||||
run_id: "run-1",
|
||||
status: "success",
|
||||
snapshot_trade_date: "2026-08-08",
|
||||
sector_type: "concept",
|
||||
sector_type: "industry",
|
||||
sectors: [
|
||||
{ sector_code: "BK0001.DC", sector_name: "机器人", stock_count: 3 },
|
||||
{ sector_code: "BK0003.DC", sector_name: "数字经济", stock_count: 2 },
|
||||
{ sector_code: "BK0475.DC", sector_name: "银行", stock_count: 3 },
|
||||
{ sector_code: "BK0477.DC", sector_name: "保险", stock_count: 2 },
|
||||
],
|
||||
},
|
||||
isError: false,
|
||||
@@ -562,16 +562,16 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
const sectorTrigger = screen.getByRole("combobox", { name: "筛选板块" })
|
||||
expect(sectorTrigger).toHaveTextContent("全部板块")
|
||||
const sectorTrigger = screen.getByRole("combobox", { name: "筛选细分行业" })
|
||||
expect(sectorTrigger).toHaveTextContent("全部细分行业")
|
||||
|
||||
fireEvent.click(sectorTrigger)
|
||||
const robotOption = await screen.findByRole("option", {
|
||||
name: /机器人/,
|
||||
const bankOption = await screen.findByRole("option", {
|
||||
name: /银行/,
|
||||
})
|
||||
expect(robotOption).toHaveTextContent("3")
|
||||
fireEvent.pointerDown(robotOption, { pointerType: "mouse" })
|
||||
fireEvent.click(robotOption)
|
||||
expect(bankOption).toHaveTextContent("3")
|
||||
fireEvent.pointerDown(bankOption, { pointerType: "mouse" })
|
||||
fireEvent.click(bankOption)
|
||||
|
||||
expect(routerNavigate).toHaveBeenCalled()
|
||||
const lastCall =
|
||||
@@ -579,7 +579,7 @@ describe("SelectionResultsPage", () => {
|
||||
const searchUpdate = lastCall[0].search as (previous: {
|
||||
sector?: string
|
||||
}) => Record<string, unknown>
|
||||
expect(searchUpdate({})).toEqual({ sector: "BK0001.DC" })
|
||||
expect(searchUpdate({})).toEqual({ sector: "BK0475.DC" })
|
||||
})
|
||||
|
||||
it("requests database-backed score sorting", async () => {
|
||||
|
||||
Reference in New Issue
Block a user