feat(sector-radar): add IME-safe sector autocomplete and detail lookup

This commit is contained in:
yuxuanhui
2026-09-21 22:37:07 +08:00
parent 2f1c4f36c2
commit 669e89d3c3
6 changed files with 356 additions and 30 deletions
@@ -18,6 +18,7 @@ import {
useSectorRadarHistory,
sectorRadarDatesQueryKey,
sectorRadarRankingsQueryKey,
useSectorRadarSuggestions,
useSectorRadarDates,
useSectorRadarRankings,
} from "./sector-radar.query"
@@ -41,6 +42,37 @@ describe("sector radar query hooks", () => {
useInfiniteQuery.mockImplementation((options) => options)
})
it("searches both complete pools at the requested date with cancellation", async () => {
api.getSectorRadarRankings.mockResolvedValue({ rows: [], total: 0 })
useSectorRadarSuggestions(" 医疗 ", "2026-08-28")
const options = useQuery.mock.calls.at(-1)?.[0]
const signal = new AbortController().signal
expect(options.enabled).toBe(true)
expect(options.queryKey).toEqual([
"sectorRadar",
"suggestions",
"2026-08-28",
"医疗",
])
await options.queryFn({ signal })
for (const sectorType of ["concept", "industry"]) {
expect(api.getSectorRadarRankings).toHaveBeenCalledWith(
expect.objectContaining({
sectorType,
tradeDate: "2026-08-28",
side: "all",
search: "医疗",
page: 1,
}),
signal,
)
}
useSectorRadarSuggestions(" ")
expect(useQuery.mock.calls.at(-1)?.[0].enabled).toBe(false)
useSectorRadarSuggestions("医疗", undefined, false)
expect(useQuery.mock.calls.at(-1)?.[0].enabled).toBe(false)
})
it("isolates sector resources by type, code and date and forwards cancellation", () => {
const sector = {
sectorType: "concept" as const,
@@ -127,3 +127,45 @@ export function useSectorRadarDetail(query: RadarSectorQuery) {
queryFn: ({ signal }) => getSectorRadarDetail(query, signal),
})
}
/** Search both published sector pools, independently of the visible top/bottom lists. */
export function useSectorRadarSuggestions(
search: string,
tradeDate?: string,
enabled = true,
) {
const term = search.trim()
return useQuery({
queryKey: [
"sectorRadar",
"suggestions",
tradeDate ?? "latest",
term,
] as const,
enabled: enabled && Boolean(term),
queryFn: async ({ signal }) => {
const pages = await Promise.all(
(["concept", "industry"] as const).map((sectorType) =>
getSectorRadarRankings(
{
tradeDate,
sectorType,
view: "amount",
rankChangeMetric: "amount",
rankChangeDays: 1,
side: "all",
search: term,
page: 1,
pageSize: 10,
},
signal,
),
),
)
return {
rows: pages.flatMap((page) => page.rows),
total: pages.reduce((sum, page) => sum + page.total, 0),
}
},
})
}
@@ -53,7 +53,7 @@ export function RadarDetailDialog({ row }: { row: RadarRankingRow }) {
)
}
function RadarDetail({ row }: { row: RadarRankingRow }) {
export function RadarDetail({ row }: { row: RadarRankingRow }) {
const query: RadarSectorQuery = {
sectorType: row.sector_type,
sectorCode: row.sector_code,
@@ -0,0 +1,133 @@
import { Autocomplete } from "@base-ui/react/autocomplete"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/shared/ui/button"
import { Dialog, DialogContent } from "@/shared/ui/dialog"
import { Input } from "@/shared/ui/input"
import { useSectorRadarSuggestions } from "../api/sector-radar.query"
import type { RadarRankingRow } from "../api/sector-radar.types"
import { RadarDetail } from "./radar-detail-dialog"
/** Keep IME drafts local; only completed input may trigger a debounced lookup. */
export function RadarSectorSearch({ tradeDate }: { tradeDate?: string }) {
const [value, setValue] = useState("")
const [term, setTerm] = useState("")
const [composing, setComposing] = useState(false)
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<RadarRankingRow | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (composing) return
const timer = setTimeout(() => setTerm(value.trim()), 250)
return () => clearTimeout(timer)
}, [value, composing])
const ready = !composing && Boolean(term) && term === value.trim()
const suggestions = useSectorRadarSuggestions(term, tradeDate, ready && open)
const rows = ready ? (suggestions.data?.rows ?? []) : []
const popupOpen = open && !composing && Boolean(value.trim()) && !detailOpen
return (
<div className="grid gap-1 text-xs font-medium text-muted-foreground lg:col-span-2">
<span>搜索板块</span>
<Autocomplete.Root
items={rows}
filter={null}
value={value}
onValueChange={setValue}
open={popupOpen}
onOpenChange={setOpen}
openOnInputClick
itemToStringValue={(row: RadarRankingRow) => row.sector_name}
>
<Autocomplete.Input
ref={inputRef}
aria-label="搜索板块"
placeholder="名称或代码"
maxLength={100}
render={<Input className="bg-background" />}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={(event) => {
setValue(event.currentTarget.value)
setComposing(false)
setOpen(true)
}}
/>
<Autocomplete.Portal>
<Autocomplete.Positioner sideOffset={4} align="end" className="z-50">
<Autocomplete.Popup className="w-[max(var(--anchor-width),20rem)] max-w-[calc(100vw-2rem)] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md">
{!ready || suggestions.isPending ? (
<p
role="status"
className="px-3 py-3 text-sm text-muted-foreground"
>
正在搜索板块…
</p>
) : suggestions.isError ? (
<div
role="alert"
className="flex items-center justify-between gap-2 px-3 py-2 text-sm"
>
搜索失败,请重试。
<Button
size="sm"
variant="outline"
onClick={() => void suggestions.refetch()}
>
重试搜索
</Button>
</div>
) : rows.length === 0 ? (
<p
role="status"
className="px-3 py-3 text-sm text-muted-foreground"
>
未找到匹配的板块。
</p>
) : null}
<Autocomplete.List className="max-h-72 overflow-y-auto">
{(row: RadarRankingRow) => (
<Autocomplete.Item
key={`${row.sector_type}:${row.sector_code}`}
value={row}
onClick={() => {
setSelected(row)
setDetailOpen(true)
setOpen(false)
}}
className="flex cursor-pointer items-center gap-3 border-b border-border/60 px-3 py-3 text-sm outline-none last:border-0 data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
<span className="min-w-0 flex-1">
<span className="font-medium">{row.sector_name}</span>{" "}
<span className="text-xs text-muted-foreground">
{row.sector_code}
</span>
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{row.sector_type === "concept" ? "概念" : "行业"}
</span>
</Autocomplete.Item>
)}
</Autocomplete.List>
{ready &&
suggestions.data &&
suggestions.data.total > rows.length ? (
<p className="border-t border-border px-3 py-2 text-xs text-muted-foreground">
仅展示部分结果,请补充名称或代码缩小范围。
</p>
) : null}
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Portal>
</Autocomplete.Root>
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent
finalFocus={inputRef}
className="max-h-[90dvh] grid-cols-1 overflow-y-auto sm:max-w-4xl"
>
{detailOpen && selected ? <RadarDetail row={selected} /> : null}
</DialogContent>
</Dialog>
</div>
)
}
@@ -1,4 +1,11 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type {
@@ -14,6 +21,7 @@ const useSectorRadarDates = vi.fn()
const useSectorRadarRankings = vi.fn()
const useSectorRadarHistory = vi.fn()
const useSectorRadarDetail = vi.fn()
const useSectorRadarSuggestions = vi.fn()
const refetchDates = vi.fn()
const refetchRankings = vi.fn()
const fetchNextPage = vi.fn()
@@ -26,6 +34,8 @@ vi.mock("@tanstack/react-router", () => ({
}))
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
useSectorRadarSuggestions: (...args: unknown[]) =>
useSectorRadarSuggestions(...args),
useSectorRadarDetail: (...args: unknown[]) => useSectorRadarDetail(...args),
useSectorRadarHistory: (...args: unknown[]) => useSectorRadarHistory(...args),
useSectorRadarDates: () => useSectorRadarDates(),
@@ -183,6 +193,11 @@ describe("SectorRadarPage", () => {
data: { status: "no_data" },
})
useSectorRadarDetail.mockReturnValue({ isPending: true })
useSectorRadarSuggestions.mockReturnValue({
isPending: false,
isError: false,
data: { rows: [], total: 0 },
})
navigate.mockReset()
refetchDates.mockReset()
refetchRankings.mockReset()
@@ -358,23 +373,138 @@ describe("SectorRadarPage", () => {
},
)
it("resets filters to the first batch in router search state", () => {
routeSearch = { ...routeSearch, page: 3 }
it("keeps Chinese composition local and searches only after committing it", async () => {
render(<SectorRadarPage />)
fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), {
target: { value: "机器人" },
const input = screen.getByRole("combobox", { name: "搜索板块" })
fireEvent.focus(input)
fireEvent.compositionStart(input)
fireEvent.change(input, { target: { value: "yi liao" } })
expect(input).toHaveValue("yi liao")
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300))
})
const searchCall = navigate.mock.calls[0]?.[0].search as (
previous: SectorRadarRouteSearch,
) => SectorRadarRouteSearch
expect(searchCall({ ...routeSearch, page: 3 })).toMatchObject({
page: 1,
search: "机器人",
expect(useSectorRadarSuggestions).toHaveBeenLastCalledWith(
"",
undefined,
false,
)
expect(navigate).not.toHaveBeenCalled()
fireEvent.input(input, {
target: { value: "医疗" },
inputType: "insertText",
})
fireEvent.compositionEnd(input, { data: "医疗" })
expect(input).toHaveValue("医疗")
await waitFor(() =>
expect(useSectorRadarSuggestions).toHaveBeenLastCalledWith(
"医疗",
undefined,
true,
),
)
expect(navigate).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: "" } })
expect(input).toHaveValue("")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("hides stale suggestions while the next search is waiting", async () => {
useSectorRadarSuggestions.mockReturnValue({
isPending: false,
isError: false,
data: { rows: [rankingsResponse.rows[0]], total: 1 },
})
render(<SectorRadarPage />)
const input = screen.getByRole("combobox", { name: "搜索板块" })
fireEvent.input(input, {
target: { value: "机器人" },
inputType: "insertText",
})
expect(await screen.findByRole("option")).toHaveTextContent("机器人")
fireEvent.input(input, {
target: { value: "医疗" },
inputType: "insertText",
})
expect(screen.queryByRole("option")).not.toBeInTheDocument()
expect(screen.getByText("正在搜索板块…")).toBeInTheDocument()
fireEvent.keyDown(input, { key: "Escape" })
expect(input).toHaveAttribute("aria-expanded", "false")
})
it("shows empty and failed searches with retry", async () => {
const retry = vi.fn()
const { rerender } = render(<SectorRadarPage />)
const input = screen.getByRole("combobox", { name: "搜索板块" })
fireEvent.input(input, {
target: { value: "医疗" },
inputType: "insertText",
})
expect(await screen.findByText("未找到匹配的板块。")).toBeInTheDocument()
useSectorRadarSuggestions.mockReturnValue({
isPending: false,
isError: true,
refetch: retry,
})
rerender(<SectorRadarPage />)
expect(screen.getByRole("alert")).toHaveTextContent("搜索失败")
fireEvent.click(screen.getByRole("button", { name: "重试搜索" }))
expect(retry).toHaveBeenCalledOnce()
})
it.each(["pointer", "keyboard"])(
"opens mixed sector suggestions using %s and restores focus",
async (method) => {
useSectorRadarSuggestions.mockReturnValue({
isPending: false,
isError: false,
data: {
total: 2,
rows: [
{
...rankingsResponse.rows[0],
sector_name: "创新医疗服务",
sector_code: "BK1658.DC",
},
{
...rankingsResponse.rows[0],
sector_name: "医疗服务",
sector_code: "BK0727.DC",
sector_type: "industry",
},
],
},
})
render(<SectorRadarPage />)
const input = screen.getByRole("combobox", { name: "搜索板块" })
fireEvent.focus(input)
fireEvent.input(input, {
target: { value: "医疗" },
inputType: "insertText",
})
const option = await screen.findByRole("option", {
name: "医疗服务 BK0727.DC 行业",
})
expect(
screen.getByRole("option", { name: "创新医疗服务 BK1658.DC 概念" }),
).toBeInTheDocument()
if (method === "pointer") fireEvent.click(option)
else {
fireEvent.keyDown(input, { key: "ArrowDown" })
fireEvent.keyDown(input, { key: "ArrowDown" })
fireEvent.keyDown(input, { key: "Enter" })
}
const dialog = await screen.findByRole("dialog", { name: "医疗服务" })
expect(useSectorRadarDetail).toHaveBeenLastCalledWith({
sectorType: "industry",
sectorCode: "BK0727.DC",
tradeDate: "2026-08-28",
})
expect(navigate).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole("button", { name: "Close" }))
await waitFor(() => expect(input).toHaveFocus())
},
)
it("renders accessible tabs, writes tab state to the URL, and queries both sides", () => {
render(<SectorRadarPage />)
@@ -24,7 +24,6 @@ import {
CardHeader,
CardTitle,
} from "@/shared/ui/card"
import { Input } from "@/shared/ui/input"
import {
Select,
SelectContent,
@@ -35,6 +34,7 @@ import {
} from "@/shared/ui/select"
import { Skeleton } from "@/shared/ui/skeleton"
import { RadarSectorSearch } from "../components/radar-sector-search"
import { RadarDetailDialog } from "../components/radar-detail-dialog"
import { RadarHistory } from "../components/radar-history"
import { formatRadarValue, radarValueTone } from "../components/radar-format"
@@ -68,7 +68,6 @@ export function SectorRadarPage() {
view: search.view,
rankChangeMetric: search.rankChangeMetric,
rankChangeDays: search.rankChangeDays,
search: search.search || undefined,
page: search.page,
pageSize: search.pageSize,
}
@@ -229,20 +228,10 @@ function RadarFilters({
/>
</>
) : null}
<label className="grid gap-1 text-xs font-medium text-muted-foreground lg:col-span-2">
搜索板块
<Input
aria-label="搜索板块"
className="bg-background"
maxLength={100}
onChange={(event) =>
onChange({ page: 1, search: event.target.value })
}
placeholder="名称或代码"
type="search"
value={search.search}
/>
</label>
<RadarSectorSearch
key={search.tradeDate ?? "latest"}
tradeDate={search.tradeDate}
/>
</div>
)
}