diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts index 89ed354..84d6045 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts @@ -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, diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts index 5af3644..077fbc4 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts @@ -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), + } + }, + }) +} diff --git a/zhixing-web/src/features/sector-radar/components/radar-detail-dialog.tsx b/zhixing-web/src/features/sector-radar/components/radar-detail-dialog.tsx index 95667c3..b1a0cbc 100644 --- a/zhixing-web/src/features/sector-radar/components/radar-detail-dialog.tsx +++ b/zhixing-web/src/features/sector-radar/components/radar-detail-dialog.tsx @@ -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, diff --git a/zhixing-web/src/features/sector-radar/components/radar-sector-search.tsx b/zhixing-web/src/features/sector-radar/components/radar-sector-search.tsx new file mode 100644 index 0000000..47c3d73 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/components/radar-sector-search.tsx @@ -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(null) + const [detailOpen, setDetailOpen] = useState(false) + const inputRef = useRef(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 ( +
+ 搜索板块 + row.sector_name} + > + } + onCompositionStart={() => setComposing(true)} + onCompositionEnd={(event) => { + setValue(event.currentTarget.value) + setComposing(false) + setOpen(true) + }} + /> + + + + {!ready || suggestions.isPending ? ( +

+ 正在搜索板块… +

+ ) : suggestions.isError ? ( +
+ 搜索失败,请重试。 + +
+ ) : rows.length === 0 ? ( +

+ 未找到匹配的板块。 +

+ ) : null} + + {(row: RadarRankingRow) => ( + { + 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" + > + + {row.sector_name}{" "} + + {row.sector_code} + + + + {row.sector_type === "concept" ? "概念" : "行业"} + + + )} + + {ready && + suggestions.data && + suggestions.data.total > rows.length ? ( +

+ 仅展示部分结果,请补充名称或代码缩小范围。 +

+ ) : null} +
+
+
+
+ + + {detailOpen && selected ? : null} + + +
+ ) +} diff --git a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx index aeed80e..0318f0c 100644 --- a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx @@ -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() - - 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() + 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() + 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() + 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() + 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() diff --git a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx index d2ae6e5..b47a129 100644 --- a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx @@ -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} - + ) }