feat(sector-radar): add IME-safe sector autocomplete and detail lookup
This commit is contained in:
@@ -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,22 +373,137 @@ 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 />)
|
||||
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))
|
||||
})
|
||||
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()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), {
|
||||
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")
|
||||
})
|
||||
|
||||
const searchCall = navigate.mock.calls[0]?.[0].search as (
|
||||
previous: SectorRadarRouteSearch,
|
||||
) => SectorRadarRouteSearch
|
||||
expect(searchCall({ ...routeSearch, page: 3 })).toMatchObject({
|
||||
page: 1,
|
||||
search: "机器人",
|
||||
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}
|
||||
<RadarSectorSearch
|
||||
key={search.tradeDate ?? "latest"}
|
||||
tradeDate={search.tradeDate}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user