From 8e96e64151b4996da41ccf7af2671c5a165a3dd7 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sat, 29 Aug 2026 20:14:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(sector-radar):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=8E=92=E5=90=8D=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../08-28-sector-capital-radar/implement.md | 12 +- zhixing-web/src/app/layout/app-layout.tsx | 1 + zhixing-web/src/app/layout/navigation.test.ts | 21 + zhixing-web/src/app/layout/navigation.ts | 17 +- .../sector-radar/api/sector-radar.api.test.ts | 163 +++++ .../sector-radar/api/sector-radar.api.ts | 422 ++++++++++++ .../api/sector-radar.query.test.ts | 71 ++ .../sector-radar/api/sector-radar.query.ts | 35 + .../sector-radar/api/sector-radar.types.ts | 113 +++ .../pages/sector-radar-page.test.tsx | 330 +++++++++ .../sector-radar/pages/sector-radar-page.tsx | 648 ++++++++++++++++++ zhixing-web/src/routes/route-tree.tsx | 91 +++ 12 files changed, 1913 insertions(+), 11 deletions(-) create mode 100644 zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts create mode 100644 zhixing-web/src/features/sector-radar/api/sector-radar.api.ts create mode 100644 zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts create mode 100644 zhixing-web/src/features/sector-radar/api/sector-radar.query.ts create mode 100644 zhixing-web/src/features/sector-radar/api/sector-radar.types.ts create mode 100644 zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx create mode 100644 zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx diff --git a/.trellis/tasks/08-28-sector-capital-radar/implement.md b/.trellis/tasks/08-28-sector-capital-radar/implement.md index c099539..97cfc76 100644 --- a/.trellis/tasks/08-28-sector-capital-radar/implement.md +++ b/.trellis/tasks/08-28-sector-capital-radar/implement.md @@ -47,11 +47,13 @@ ## 5. 前端 MVP -- [ ] 新建 feature API types、adapter 与 React Query hooks;API 边界校验稳定枚举和关键字段。 -- [ ] 新增 `/sector-radar` 路由、导航、URL search 校验和活动路由映射。 -- [ ] 实现状态摘要、筛选工具栏、排名表和分页;显示单位、质量状态、数据日期、last-good/stale 和独立指标版本。 -- [ ] 页面测试覆盖成功、筛选、rank-change、loading、error、no-data、stale/partial;adapter 测试覆盖 URL、参数和 AbortSignal。 -- [ ] 不引入图表依赖,不实现成分详情、历史轨迹或导出。 +- [x] 新建 feature API types、adapter 与 React Query hooks;API 边界校验稳定枚举和关键字段。 +- [x] 新增 `/sector-radar` 路由、导航、URL search 校验和活动路由映射。 +- [x] 实现状态摘要、筛选工具栏、排名表和分页;显示单位、质量状态、数据日期、last-good/stale 和独立指标版本。 +- [x] 页面测试覆盖成功、筛选、rank-change、loading、error、no-data、stale/partial;adapter 测试覆盖 URL、参数和 AbortSignal。 +- [x] 不引入图表依赖,不实现成分详情、历史轨迹或导出。 + +阶段结果(2026-08-29):新增独立 `features/sector-radar` API、运行时契约解析、React Query hooks、URL search 驱动的筛选与分页、桌面/移动排名表、发布来源与质量摘要,以及 `/sector-radar` 导航入口。页面明确区分初次加载、致命错误、无数据、后台刷新、后台刷新失败、partial/failed/running 新尝试和 success,并始终保留“知行独立实现”与版本声明;排名变化缺少历史时显示“暂无可比历史”。前端 lint、typecheck、全量 63 项 Vitest 和生产 build 通过,变更文件的 Prettier 检查通过;完整 `pnpm format:check` 仍被未修改的既有 `zhixing-web/DESIGN.md` 格式问题阻挡。浏览器已在默认桌面视口与 390×844 移动视口验证导航、筛选布局、错误降级和 rank-change URL 状态;本地后端未运行,因此成功数据态的视觉行为由页面测试覆盖,未声称真实数据库页面已验证。 ## 6. 全量验证与审查 diff --git a/zhixing-web/src/app/layout/app-layout.tsx b/zhixing-web/src/app/layout/app-layout.tsx index 71f7d68..d81b6c8 100644 --- a/zhixing-web/src/app/layout/app-layout.tsx +++ b/zhixing-web/src/app/layout/app-layout.tsx @@ -23,6 +23,7 @@ import { function useActiveRoutePath() { const matchRoute = useMatchRoute() + if (matchRoute({ to: "/sector-radar", fuzzy: true })) return "/sector-radar" if (matchRoute({ to: "/selection", fuzzy: true })) return "/selection" if (matchRoute({ to: "/sync", fuzzy: true })) return "/sync" if (matchRoute({ to: "/components", fuzzy: true })) return "/components" diff --git a/zhixing-web/src/app/layout/navigation.test.ts b/zhixing-web/src/app/layout/navigation.test.ts index 812e50b..6899d80 100644 --- a/zhixing-web/src/app/layout/navigation.test.ts +++ b/zhixing-web/src/app/layout/navigation.test.ts @@ -26,3 +26,24 @@ describe("sync navigation", () => { }) }) }) + +describe("sector radar navigation", () => { + it("exposes the radar route in desktop and mobile navigation", () => { + const radar = primaryNavigation.find((item) => item.id === "sector-radar") + + expect(radar).toMatchObject({ + availability: "available", + label: "资金雷达", + to: "/sector-radar", + }) + expect(mobilePrimaryNavigation).toContain(radar) + }) + + it("provides the active route presentation for the radar", () => { + expect(routePresentation["/sector-radar"]).toEqual({ + breadcrumb: "研究工作台", + id: "sector-radar", + title: "板块资金雷达", + }) + }) +}) diff --git a/zhixing-web/src/app/layout/navigation.ts b/zhixing-web/src/app/layout/navigation.ts index 065f1cd..34d8d18 100644 --- a/zhixing-web/src/app/layout/navigation.ts +++ b/zhixing-web/src/app/layout/navigation.ts @@ -1,9 +1,9 @@ import { - BarChart3, BrainCircuit, ClipboardCheck, Component, LayoutDashboard, + Radar, type LucideIcon, } from "lucide-react" @@ -26,11 +26,11 @@ export const primaryNavigation: readonly NavigationItem[] = [ availability: "available", }, { - id: "market", - label: "行情数据", - to: null, - icon: BarChart3, - availability: "unavailable", + id: "sector-radar", + label: "资金雷达", + to: "/sector-radar", + icon: Radar, + availability: "available", }, { id: "sync", @@ -79,6 +79,11 @@ export const routePresentation: Record = { breadcrumb: "研究工作台", title: "知行 B1 执行结果", }, + "/sector-radar": { + id: "sector-radar", + breadcrumb: "研究工作台", + title: "板块资金雷达", + }, "/sync": { id: "sync", breadcrumb: "研究工作台", diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts new file mode 100644 index 0000000..561ddf2 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const requestJson = vi.hoisted(() => vi.fn()) + +vi.mock("@/shared/api/request-json", () => ({ requestJson })) + +import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api" + +const publication = { + publication_id: "publication-1", + target_trade_date: "2026-08-28", + status: "success", + source_version: "tushare-pro-v1", + universe_version: "eastmoney-dc-v1", + metric_versions: ["zhixing_amount_net_bn_v1"], + input_hash: "a".repeat(64), + coverage: "0.99", + started_at: "2026-08-28T08:00:00Z", + finished_at: "2026-08-28T08:05:00Z", + error_summary: null, +} + +const rankingPayload = { + status: "success", + requested_trade_date: "2026-08-28", + sector_type: "concept", + view: "rank_change", + rank_change_metric: "amount", + rank_change_days: 5, + side: "top", + search: "机器人", + publication, + definition: { + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + label: "主力净流入(知行独立实现)", + unit: "CNY_100M", + implementation_kind: "independent", + disclaimer: "知行独立实现,非 OneChartLab 原站公式。", + }, + page: 2, + page_size: 10, + total: 1, + rows: [ + { + trade_date: "2026-08-28", + sector_type: "concept", + sector_code: "BK0001.DC", + sector_name: "机器人", + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + implementation_kind: "independent", + unit: "CNY_100M", + metric_value: "12.5", + quality: "available", + member_count: 20, + valid_sample_count: 19, + membership_coverage: "1", + moneyflow_coverage: "0.95", + rank_position: 1, + rank_percentile: "100", + rank_change_days: 5, + rank_change: 3, + }, + ], +} + +describe("sector radar API adapters", () => { + beforeEach(() => { + requestJson.mockReset() + }) + + it("forwards AbortSignal and normalizes decimal publication fields", async () => { + const signal = new AbortController().signal + requestJson.mockResolvedValue({ + status: "success", + available_dates: ["2026-08-28"], + current_attempt: publication, + last_good: publication, + }) + + const result = await getSectorRadarDates(signal) + + expect(requestJson).toHaveBeenCalledWith("/api/v1/sector-radar/dates", { + signal, + }) + expect(result.last_good?.coverage).toBe(0.99) + }) + + it("maps URL-backed filters to the ranking HTTP contract", async () => { + const signal = new AbortController().signal + requestJson.mockResolvedValue(rankingPayload) + + const result = await getSectorRadarRankings( + { + tradeDate: "2026-08-28", + sectorType: "concept", + view: "rank_change", + rankChangeMetric: "amount", + rankChangeDays: 5, + side: "top", + search: " 机器人 ", + page: 2, + pageSize: 10, + }, + signal, + ) + + const [input, init] = requestJson.mock.calls[0] as [ + string, + { signal?: AbortSignal }, + ] + const params = new URL(input, "http://localhost").searchParams + expect(input).toContain("/api/v1/sector-radar/rankings?") + expect(params.get("trade_date")).toBe("2026-08-28") + expect(params.get("sector_type")).toBe("concept") + expect(params.get("view")).toBe("rank_change") + expect(params.get("rank_change_metric")).toBe("amount") + expect(params.get("rank_change_days")).toBe("5") + expect(params.get("side")).toBe("top") + expect(params.get("search")).toBe("机器人") + expect(params.get("page")).toBe("2") + expect(params.get("page_size")).toBe("10") + expect(init).toEqual({ signal }) + expect(result.rows[0]?.metric_value).toBe(12.5) + expect(result.rows[0]?.rank_percentile).toBe(100) + }) + + it("rejects unknown stable enum values at the feature boundary", async () => { + requestJson.mockResolvedValue({ ...rankingPayload, view: "private_score" }) + + await expect( + getSectorRadarRankings({ + sectorType: "concept", + view: "amount", + rankChangeMetric: "amount", + rankChangeDays: 1, + side: "all", + page: 1, + pageSize: 20, + }), + ).rejects.toThrow("Invalid sector radar response: rankings.view") + }) + + it("rejects non-finite decimal strings instead of rendering them", async () => { + requestJson.mockResolvedValue({ + ...rankingPayload, + rows: [{ ...rankingPayload.rows[0], metric_value: "NaN" }], + }) + + await expect( + getSectorRadarRankings({ + sectorType: "concept", + view: "amount", + rankChangeMetric: "amount", + rankChangeDays: 1, + side: "all", + page: 1, + pageSize: 20, + }), + ).rejects.toThrow("rankings.rows[0].metric_value must be finite") + }) +}) diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts new file mode 100644 index 0000000..9b9ead4 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts @@ -0,0 +1,422 @@ +import { requestJson } from "@/shared/api/request-json" + +import { + radarMetricKinds, + radarMetricQualities, + radarMetricUnits, + radarPublicationStatuses, + radarRankSides, + radarViews, + sectorTypes, + type RadarDatesResponse, + type RadarMetricDefinition, + type RadarPublication, + type RadarRankingRow, + type RadarRankingsQuery, + type RadarRankingsResponse, +} from "./sector-radar.types" + +type JsonRecord = Record + +/** + * Fetch and validate the available sector-radar publication dates. + * + * @param signal - React Query cancellation signal forwarded to fetch. + * @returns A normalized response whose decimal fields are finite numbers. + * @throws Error when the server response violates the stable HTTP contract. + */ +export async function getSectorRadarDates(signal?: AbortSignal) { + const payload = await requestJson("/api/v1/sector-radar/dates", { + signal, + }) + return parseRadarDatesResponse(payload) +} + +/** + * Fetch one persisted ranking page using the backend's snake-case query names. + * + * @param query - URL-backed ranking filters owned by the sector-radar feature. + * @param signal - React Query cancellation signal forwarded to fetch. + * @returns A validated and normalized ranking page. + * @throws Error when the server response violates the stable HTTP contract. + */ +export async function getSectorRadarRankings( + query: RadarRankingsQuery, + signal?: AbortSignal, +) { + const params = new URLSearchParams({ + sector_type: query.sectorType, + view: query.view, + rank_change_metric: query.rankChangeMetric, + rank_change_days: String(query.rankChangeDays), + side: query.side, + page: String(query.page), + page_size: String(query.pageSize), + }) + if (query.tradeDate) params.set("trade_date", query.tradeDate) + if (query.search?.trim()) params.set("search", query.search.trim()) + + const payload = await requestJson( + `/api/v1/sector-radar/rankings?${params.toString()}`, + { signal }, + ) + return parseRadarRankingsResponse(payload) +} + +/** + * Validate the dates response at the feature boundary. + * + * The shared transport intentionally does not own feature schemas. Decimal + * strings emitted by Pydantic are converted here so the page never handles + * mixed string/number arithmetic. + */ +export function parseRadarDatesResponse(value: unknown): RadarDatesResponse { + const record = readRecord(value, "dates") + return { + status: readEnum(record.status, ["success", "no_data"], "dates.status"), + available_dates: readArray( + record.available_dates, + "dates.available_dates", + ).map((item, index) => readDate(item, `dates.available_dates[${index}]`)), + current_attempt: readNullablePublication( + record.current_attempt, + "dates.current_attempt", + ), + last_good: readNullablePublication(record.last_good, "dates.last_good"), + } +} + +/** Validate and normalize one ranking response from the same-repository API. */ +export function parseRadarRankingsResponse( + value: unknown, +): RadarRankingsResponse { + const record = readRecord(value, "rankings") + return { + status: readEnum(record.status, ["success", "no_data"], "rankings.status"), + requested_trade_date: readNullableDate( + record.requested_trade_date, + "rankings.requested_trade_date", + ), + sector_type: readEnum( + record.sector_type, + sectorTypes, + "rankings.sector_type", + ), + view: readEnum(record.view, radarViews, "rankings.view"), + rank_change_metric: readEnum( + record.rank_change_metric, + radarMetricKinds, + "rankings.rank_change_metric", + ), + rank_change_days: readIntegerInRange( + record.rank_change_days, + 1, + 5, + "rankings.rank_change_days", + ), + side: readEnum(record.side, radarRankSides, "rankings.side"), + search: readNullableString(record.search, "rankings.search"), + publication: readNullablePublication( + record.publication, + "rankings.publication", + ), + definition: readMetricDefinition(record.definition), + page: readIntegerInRange( + record.page, + 1, + Number.MAX_SAFE_INTEGER, + "rankings.page", + ), + page_size: readIntegerInRange( + record.page_size, + 1, + 100, + "rankings.page_size", + ), + total: readIntegerInRange( + record.total, + 0, + Number.MAX_SAFE_INTEGER, + "rankings.total", + ), + rows: readArray(record.rows, "rankings.rows").map((item, index) => + readRankingRow(item, index), + ), + } +} + +function readNullablePublication( + value: unknown, + path: string, +): RadarPublication | null { + if (value === null) return null + const record = readRecord(value, path) + return { + publication_id: readNonEmptyString( + record.publication_id, + `${path}.publication_id`, + ), + target_trade_date: readDate( + record.target_trade_date, + `${path}.target_trade_date`, + ), + status: readEnum(record.status, radarPublicationStatuses, `${path}.status`), + source_version: readNonEmptyString( + record.source_version, + `${path}.source_version`, + ), + universe_version: readNonEmptyString( + record.universe_version, + `${path}.universe_version`, + ), + metric_versions: readArray( + record.metric_versions, + `${path}.metric_versions`, + ).map((item, index) => + readNonEmptyString(item, `${path}.metric_versions[${index}]`), + ), + input_hash: readNullableString(record.input_hash, `${path}.input_hash`), + coverage: readFraction(record.coverage, `${path}.coverage`), + started_at: readDateTime(record.started_at, `${path}.started_at`), + finished_at: readNullableDateTime( + record.finished_at, + `${path}.finished_at`, + ), + error_summary: readNullableString( + record.error_summary, + `${path}.error_summary`, + ), + } +} + +function readMetricDefinition(value: unknown): RadarMetricDefinition { + const path = "rankings.definition" + const record = readRecord(value, path) + return { + metric_kind: readEnum( + record.metric_kind, + radarMetricKinds, + `${path}.metric_kind`, + ), + metric_version: readNonEmptyString( + record.metric_version, + `${path}.metric_version`, + ), + label: readNonEmptyString(record.label, `${path}.label`), + unit: readEnum(record.unit, radarMetricUnits, `${path}.unit`), + implementation_kind: readEnum( + record.implementation_kind, + ["independent"], + `${path}.implementation_kind`, + ), + disclaimer: readNonEmptyString(record.disclaimer, `${path}.disclaimer`), + } +} + +function readRankingRow(value: unknown, index: number): RadarRankingRow { + const path = `rankings.rows[${index}]` + const record = readRecord(value, path) + return { + trade_date: readDate(record.trade_date, `${path}.trade_date`), + sector_type: readEnum( + record.sector_type, + sectorTypes, + `${path}.sector_type`, + ), + sector_code: readNonEmptyString(record.sector_code, `${path}.sector_code`), + sector_name: readNonEmptyString(record.sector_name, `${path}.sector_name`), + metric_kind: readEnum( + record.metric_kind, + radarMetricKinds, + `${path}.metric_kind`, + ), + metric_version: readNonEmptyString( + record.metric_version, + `${path}.metric_version`, + ), + implementation_kind: readEnum( + record.implementation_kind, + ["independent"], + `${path}.implementation_kind`, + ), + unit: readEnum(record.unit, radarMetricUnits, `${path}.unit`), + metric_value: readNullableFiniteNumber( + record.metric_value, + `${path}.metric_value`, + ), + quality: readEnum(record.quality, radarMetricQualities, `${path}.quality`), + member_count: readIntegerInRange( + record.member_count, + 0, + Number.MAX_SAFE_INTEGER, + `${path}.member_count`, + ), + valid_sample_count: readIntegerInRange( + record.valid_sample_count, + 0, + Number.MAX_SAFE_INTEGER, + `${path}.valid_sample_count`, + ), + membership_coverage: readFraction( + record.membership_coverage, + `${path}.membership_coverage`, + ), + moneyflow_coverage: readFraction( + record.moneyflow_coverage, + `${path}.moneyflow_coverage`, + ), + rank_position: readNullableInteger( + record.rank_position, + 1, + `${path}.rank_position`, + ), + rank_percentile: readNullableNumberInRange( + record.rank_percentile, + 0, + 100, + `${path}.rank_percentile`, + ), + rank_change_days: readIntegerInRange( + record.rank_change_days, + 1, + 5, + `${path}.rank_change_days`, + ), + rank_change: readNullableInteger( + record.rank_change, + Number.MIN_SAFE_INTEGER, + `${path}.rank_change`, + ), + } +} + +function readRecord(value: unknown, path: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw contractError(path, "must be an object") + } + return value as JsonRecord +} + +function readArray(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) throw contractError(path, "must be an array") + return value +} + +function readEnum( + value: unknown, + allowed: Values, + path: string, +): Values[number] { + if (typeof value !== "string" || !allowed.includes(value)) { + throw contractError(path, `must be one of ${allowed.join(", ")}`) + } + return value as Values[number] +} + +function readNonEmptyString(value: unknown, path: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw contractError(path, "must be a non-empty string") + } + return value +} + +function readNullableString(value: unknown, path: string): string | null { + if (value === null) return null + if (typeof value !== "string") + throw contractError(path, "must be a string or null") + return value +} + +function readDate(value: unknown, path: string): string { + const text = readNonEmptyString(value, path) + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) + if (!match) throw contractError(path, "must be an ISO trade date") + const parsed = new Date(`${text}T00:00:00Z`) + if ( + Number.isNaN(parsed.getTime()) || + parsed.getUTCFullYear() !== Number(match[1]) || + parsed.getUTCMonth() + 1 !== Number(match[2]) || + parsed.getUTCDate() !== Number(match[3]) + ) { + throw contractError(path, "must be a valid trade date") + } + return text +} + +function readNullableDate(value: unknown, path: string): string | null { + return value === null ? null : readDate(value, path) +} + +function readDateTime(value: unknown, path: string): string { + const text = readNonEmptyString(value, path) + if (!Number.isFinite(Date.parse(text))) { + throw contractError(path, "must be an ISO date-time") + } + return text +} + +function readNullableDateTime(value: unknown, path: string): string | null { + return value === null ? null : readDateTime(value, path) +} + +function readFiniteNumber(value: unknown, path: string): number { + const number = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number(value) + : Number.NaN + if (!Number.isFinite(number)) throw contractError(path, "must be finite") + return number +} + +function readNullableFiniteNumber(value: unknown, path: string): number | null { + return value === null ? null : readFiniteNumber(value, path) +} + +function readFraction(value: unknown, path: string): number { + const number = readFiniteNumber(value, path) + if (number < 0 || number > 1) + throw contractError(path, "must be between 0 and 1") + return number +} + +function readIntegerInRange( + value: unknown, + min: number, + max: number, + path: string, +): number { + const number = readFiniteNumber(value, path) + if (!Number.isInteger(number) || number < min || number > max) { + throw contractError(path, `must be an integer between ${min} and ${max}`) + } + return number +} + +function readNullableInteger( + value: unknown, + min: number, + path: string, +): number | null { + if (value === null) return null + return readIntegerInRange(value, min, Number.MAX_SAFE_INTEGER, path) +} + +function readNullableNumberInRange( + value: unknown, + min: number, + max: number, + path: string, +): number | null { + if (value === null) return null + const number = readFiniteNumber(value, path) + if (number < min || number > max) { + throw contractError(path, `must be between ${min} and ${max}`) + } + return number +} + +function contractError(path: string, reason: string): Error { + return new Error(`Invalid sector radar response: ${path} ${reason}`) +} 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 new file mode 100644 index 0000000..3166f20 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const useQuery = vi.hoisted(() => vi.fn()) +const api = vi.hoisted(() => ({ + getSectorRadarDates: vi.fn(), + getSectorRadarRankings: vi.fn(), +})) + +vi.mock("@tanstack/react-query", () => ({ useQuery })) +vi.mock("./sector-radar.api", () => api) + +import { + sectorRadarDatesQueryKey, + sectorRadarRankingsQueryKey, + useSectorRadarDates, + useSectorRadarRankings, +} from "./sector-radar.query" + +const query = { + tradeDate: "2026-08-28", + sectorType: "industry" as const, + view: "rank_change" as const, + rankChangeMetric: "ratio" as const, + rankChangeDays: 3, + side: "bottom" as const, + search: "银行", + page: 2, + pageSize: 50, +} + +describe("sector radar query hooks", () => { + beforeEach(() => { + vi.clearAllMocks() + useQuery.mockImplementation((options) => options) + }) + + it("keeps every server-affecting filter in the ranking query key", () => { + expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"]) + expect(sectorRadarRankingsQueryKey(query)).toEqual([ + "sectorRadar", + "rankings", + "2026-08-28", + "industry", + "rank_change", + "ratio", + 3, + "bottom", + "银行", + 2, + 50, + ]) + }) + + it("forwards React Query cancellation signals to both adapters", async () => { + useSectorRadarDates() + useSectorRadarRankings(query) + const datesOptions = useQuery.mock.calls[0]?.[0] as { + queryFn: (context: { signal: AbortSignal }) => unknown + } + const rankingsOptions = useQuery.mock.calls[1]?.[0] as { + queryFn: (context: { signal: AbortSignal }) => unknown + } + const signal = new AbortController().signal + + await datesOptions.queryFn({ signal }) + await rankingsOptions.queryFn({ signal }) + + expect(api.getSectorRadarDates).toHaveBeenCalledWith(signal) + expect(api.getSectorRadarRankings).toHaveBeenCalledWith(query, signal) + }) +}) 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 new file mode 100644 index 0000000..37d4d05 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query" + +import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api" +import type { RadarRankingsQuery } from "./sector-radar.types" + +export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const + +export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) => + [ + "sectorRadar", + "rankings", + query.tradeDate ?? "latest", + query.sectorType, + query.view, + query.rankChangeMetric, + query.rankChangeDays, + query.side, + query.search ?? "", + query.page, + query.pageSize, + ] as const + +export function useSectorRadarDates() { + return useQuery({ + queryFn: ({ signal }) => getSectorRadarDates(signal), + queryKey: sectorRadarDatesQueryKey, + }) +} + +export function useSectorRadarRankings(query: RadarRankingsQuery) { + return useQuery({ + queryFn: ({ signal }) => getSectorRadarRankings(query, signal), + queryKey: sectorRadarRankingsQueryKey(query), + }) +} diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts new file mode 100644 index 0000000..1300fca --- /dev/null +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.types.ts @@ -0,0 +1,113 @@ +export const sectorTypes = ["concept", "industry"] as const +export type SectorType = (typeof sectorTypes)[number] + +export const radarViews = ["amount", "ratio", "swing", "rank_change"] as const +export type RadarView = (typeof radarViews)[number] + +export const radarMetricKinds = ["amount", "ratio", "swing"] as const +export type RadarMetricKind = (typeof radarMetricKinds)[number] + +export const radarRankSides = ["top", "bottom", "all"] as const +export type RadarRankSide = (typeof radarRankSides)[number] + +export const radarPublicationStatuses = [ + "running", + "success", + "partial", + "failed", +] as const +export type RadarPublicationStatus = (typeof radarPublicationStatuses)[number] + +export const radarMetricQualities = [ + "available", + "available_limited_sample", + "unavailable", +] as const +export type RadarMetricQuality = (typeof radarMetricQualities)[number] + +export const radarMetricUnits = ["CNY_100M", "ratio"] as const +export type RadarMetricUnit = (typeof radarMetricUnits)[number] + +export interface RadarPublication { + publication_id: string + target_trade_date: string + status: RadarPublicationStatus + source_version: string + universe_version: string + metric_versions: string[] + input_hash: string | null + coverage: number + started_at: string + finished_at: string | null + error_summary: string | null +} + +export interface RadarDatesResponse { + status: "success" | "no_data" + available_dates: string[] + current_attempt: RadarPublication | null + last_good: RadarPublication | null +} + +export interface RadarMetricDefinition { + metric_kind: RadarMetricKind + metric_version: string + label: string + unit: RadarMetricUnit + implementation_kind: "independent" + disclaimer: string +} + +export interface RadarRankingRow { + trade_date: string + sector_type: SectorType + sector_code: string + sector_name: string + metric_kind: RadarMetricKind + metric_version: string + implementation_kind: "independent" + unit: RadarMetricUnit + metric_value: number | null + quality: RadarMetricQuality + member_count: number + valid_sample_count: number + membership_coverage: number + moneyflow_coverage: number + rank_position: number | null + rank_percentile: number | null + rank_change_days: number + rank_change: number | null +} + +export interface RadarRankingsResponse { + status: "success" | "no_data" + requested_trade_date: string | null + sector_type: SectorType + view: RadarView + rank_change_metric: RadarMetricKind + rank_change_days: number + side: RadarRankSide + search: string | null + publication: RadarPublication | null + definition: RadarMetricDefinition + page: number + page_size: number + total: number + rows: RadarRankingRow[] +} + +export interface RadarRankingsQuery { + tradeDate?: string + sectorType: SectorType + view: RadarView + rankChangeMetric: RadarMetricKind + rankChangeDays: number + side: RadarRankSide + search?: string + page: number + pageSize: number +} + +export interface SectorRadarRouteSearch extends RadarRankingsQuery { + search: string +} 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 new file mode 100644 index 0000000..79c7f23 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx @@ -0,0 +1,330 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { + RadarDatesResponse, + RadarRankingsResponse, + SectorRadarRouteSearch, +} from "../api/sector-radar.types" + +import { SectorRadarPage } from "./sector-radar-page" + +const navigate = vi.hoisted(() => vi.fn()) +const useSectorRadarDates = vi.fn() +const useSectorRadarRankings = vi.fn() +const refetchDates = vi.fn() +const refetchRankings = vi.fn() + +let routeSearch: SectorRadarRouteSearch + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => navigate, + useSearch: () => routeSearch, +})) + +vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({ + useSectorRadarDates: () => useSectorRadarDates(), + useSectorRadarRankings: (...args: unknown[]) => + useSectorRadarRankings(...args), +})) + +const successPublication = { + publication_id: "publication-success", + target_trade_date: "2026-08-28", + status: "success" as const, + source_version: "tushare-pro-v1", + universe_version: "eastmoney-dc-v1", + metric_versions: ["zhixing_amount_net_bn_v1"], + input_hash: "a".repeat(64), + coverage: 0.99, + started_at: "2026-08-28T08:00:00Z", + finished_at: "2026-08-28T08:05:00Z", + error_summary: null, +} + +const datesResponse: RadarDatesResponse = { + status: "success", + available_dates: ["2026-08-28", "2026-08-27"], + current_attempt: successPublication, + last_good: successPublication, +} + +const rankingsResponse: RadarRankingsResponse = { + status: "success", + requested_trade_date: null, + sector_type: "concept", + view: "amount", + rank_change_metric: "amount", + rank_change_days: 1, + side: "all", + search: null, + publication: successPublication, + definition: { + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + label: "主力净流入(知行独立实现)", + unit: "CNY_100M", + implementation_kind: "independent", + disclaimer: "知行独立实现,非 OneChartLab 原站公式。", + }, + page: 1, + page_size: 20, + total: 2, + rows: [ + { + trade_date: "2026-08-28", + sector_type: "concept", + sector_code: "BK0001.DC", + sector_name: "机器人", + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + implementation_kind: "independent", + unit: "CNY_100M", + metric_value: 12.5, + quality: "available", + member_count: 20, + valid_sample_count: 19, + membership_coverage: 1, + moneyflow_coverage: 0.95, + rank_position: 1, + rank_percentile: 100, + rank_change_days: 1, + rank_change: 3, + }, + { + trade_date: "2026-08-28", + sector_type: "concept", + sector_code: "BK0002.DC", + sector_name: "低空经济", + metric_kind: "amount", + metric_version: "zhixing_amount_net_bn_v1", + implementation_kind: "independent", + unit: "CNY_100M", + metric_value: null, + quality: "available_limited_sample", + member_count: 4, + valid_sample_count: 3, + membership_coverage: 1, + moneyflow_coverage: 0.75, + rank_position: null, + rank_percentile: null, + rank_change_days: 1, + rank_change: null, + }, + ], +} + +describe("SectorRadarPage", () => { + beforeEach(() => { + routeSearch = { + tradeDate: undefined, + sectorType: "concept", + view: "amount", + rankChangeMetric: "amount", + rankChangeDays: 1, + side: "all", + search: "", + page: 1, + pageSize: 20, + } + useSectorRadarDates.mockReturnValue({ + data: datesResponse, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchDates, + }) + useSectorRadarRankings.mockReturnValue({ + data: rankingsResponse, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + navigate.mockReset() + refetchDates.mockReset() + refetchRankings.mockReset() + }) + + it("renders independent metric provenance, units, quality, and ranking rows", () => { + render() + + expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0) + expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument() + expect(screen.getByText("12.5 亿元")).toBeInTheDocument() + expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.getByText("样本有限")).toBeInTheDocument() + expect(screen.getByText("95%")).toBeInTheDocument() + }) + + it("stores search and pagination changes in router search state", () => { + render() + + fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), { + target: { value: "机器人" }, + }) + fireEvent.click(screen.getByRole("button", { name: "第 1 页" })) + + const searchCall = navigate.mock.calls[0]?.[0].search as ( + previous: SectorRadarRouteSearch, + ) => SectorRadarRouteSearch + expect(searchCall({ ...routeSearch, page: 3 })).toMatchObject({ + page: 1, + search: "机器人", + }) + }) + + it("shows rank changes without inventing missing history", () => { + routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 } + useSectorRadarRankings.mockReturnValue({ + data: { + ...rankingsResponse, + view: "rank_change", + rank_change_days: 5, + rows: rankingsResponse.rows.map((row, index) => ({ + ...row, + rank_change_days: 5, + rank_change: index === 0 ? 3 : null, + })), + }, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + + render() + + expect( + screen.getByRole("columnheader", { name: "5 日排名变化" }), + ).toBeInTheDocument() + expect(screen.getByText("+3")).toBeInTheDocument() + expect(screen.getByText("暂无可比历史")).toBeInTheDocument() + }) + + it("warns when a partial attempt has not replaced last-good", () => { + useSectorRadarDates.mockReturnValue({ + data: { + ...datesResponse, + current_attempt: { + ...successPublication, + publication_id: "publication-partial", + target_trade_date: "2026-08-29", + status: "partial", + coverage: 0.8, + input_hash: null, + }, + }, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchDates, + }) + + render() + + expect(screen.getByRole("status")).toHaveTextContent("新一期数据不完整") + expect(screen.getByRole("status")).toHaveTextContent( + "当前仍展示最近有效发布 2026-08-28", + ) + }) + + it("keeps the last response visible while React Query refreshes it", () => { + useSectorRadarRankings.mockReturnValue({ + data: rankingsResponse, + isError: false, + isFetching: true, + isPending: false, + refetch: refetchRankings, + }) + + render() + + expect(screen.getByRole("status")).toHaveTextContent( + "当前继续展示上一次已读取的有效结果", + ) + expect(screen.getByText("机器人")).toBeInTheDocument() + }) + + it("keeps stale data visible when a background refresh fails", () => { + useSectorRadarRankings.mockReturnValue({ + data: rankingsResponse, + isError: true, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + + render() + + expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败") + expect(screen.getByText("机器人")).toBeInTheDocument() + expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument() + }) + + it("renders loading, error with retry, and no-data states", () => { + useSectorRadarDates.mockReturnValue({ + data: undefined, + isError: false, + isFetching: true, + isPending: true, + refetch: refetchDates, + }) + useSectorRadarRankings.mockReturnValue({ + data: undefined, + isError: false, + isFetching: true, + isPending: true, + refetch: refetchRankings, + }) + const { rerender } = render() + expect(screen.getByLabelText("正在加载板块资金雷达")).toBeInTheDocument() + + useSectorRadarDates.mockReturnValue({ + data: undefined, + isError: true, + isFetching: false, + isPending: false, + refetch: refetchDates, + }) + useSectorRadarRankings.mockReturnValue({ + data: undefined, + isError: true, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + rerender() + fireEvent.click(screen.getByRole("button", { name: "重试" })) + expect(refetchDates).toHaveBeenCalledOnce() + expect(refetchRankings).toHaveBeenCalledOnce() + + useSectorRadarDates.mockReturnValue({ + data: { + status: "no_data", + available_dates: [], + current_attempt: null, + last_good: null, + }, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchDates, + }) + useSectorRadarRankings.mockReturnValue({ + data: { + ...rankingsResponse, + status: "no_data", + publication: null, + rows: [], + total: 0, + }, + isError: false, + isFetching: false, + isPending: false, + refetch: refetchRankings, + }) + rerender() + expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument() + }) +}) 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 new file mode 100644 index 0000000..53710e1 --- /dev/null +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx @@ -0,0 +1,648 @@ +import { AlertTriangle, Database, RefreshCw } from "lucide-react" +import { useEffect } from "react" +import { useNavigate, useSearch } from "@tanstack/react-router" + +import { PageLayout } from "@/app/layout/page-layout" +import { + useSectorRadarDates, + useSectorRadarRankings, +} from "@/features/sector-radar/api/sector-radar.query" +import type { + RadarMetricQuality, + RadarMetricUnit, + RadarPublication, + RadarRankingRow, + RadarRankingsQuery, + SectorRadarRouteSearch, +} from "@/features/sector-radar/api/sector-radar.types" +import { Badge } from "@/shared/ui/badge" +import { Button } from "@/shared/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/ui/card" +import { Input } from "@/shared/ui/input" +import { Pagination } from "@/shared/ui/pagination" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui/select" +import { Skeleton } from "@/shared/ui/skeleton" + +const PAGE_SIZE_OPTIONS = [10, 20, 50] as const + +const sectorTypeOptions = [ + { label: "概念板块", value: "concept" }, + { label: "行业板块", value: "industry" }, +] as const + +const viewOptions = [ + { label: "主力净额", value: "amount" }, + { label: "单日资金率", value: "ratio" }, + { label: "3—10 日波段资金率", value: "swing" }, + { label: "排名变化", value: "rank_change" }, +] as const + +const metricOptions = [ + { label: "主力净额", value: "amount" }, + { label: "单日资金率", value: "ratio" }, + { label: "3—10 日波段资金率", value: "swing" }, +] as const + +const sideOptions = [ + { label: "全部板块", value: "all" }, + { label: "强榜", value: "top" }, + { label: "弱榜", value: "bottom" }, +] as const + +const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({ + label: `${value} 日变化`, + value: String(value), +})) + +export function SectorRadarPage() { + const search = useSearch({ from: "/_workspace/sector-radar" }) + const navigate = useNavigate({ from: "/sector-radar" }) + const query: RadarRankingsQuery = { + tradeDate: search.tradeDate, + sectorType: search.sectorType, + view: search.view, + rankChangeMetric: search.rankChangeMetric, + rankChangeDays: search.rankChangeDays, + side: search.side, + search: search.search || undefined, + page: search.page, + pageSize: search.pageSize, + } + const dates = useSectorRadarDates() + const rankings = useSectorRadarRankings(query) + const total = rankings.data?.total ?? 0 + const pageCount = Math.max(1, Math.ceil(total / search.pageSize)) + + useEffect(() => { + if (rankings.data?.status === "success" && search.page > pageCount) { + void navigate({ + search: (previous) => ({ ...previous, page: pageCount }), + }) + } + }, [navigate, pageCount, rankings.data?.status, search.page]) + + function updateSearch(next: Partial) { + void navigate({ search: (previous) => ({ ...previous, ...next }) }) + } + + const loading = dates.isPending || rankings.isPending + const refreshing = + !loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching)) + const fatalError = + (dates.isError && !dates.data) || (rankings.isError && !rankings.data) + const refreshFailed = + !fatalError && (Boolean(dates.isError) || Boolean(rankings.isError)) + const noData = + dates.data?.status === "no_data" || rankings.data?.status === "no_data" + + return ( + + } + mode="bounded-workspace" + > +
+ {loading ? : null} + {!loading && fatalError ? ( + { + void dates.refetch() + void rankings.refetch() + }} + /> + ) : null} + {!loading && !fatalError && noData ? : null} + {!loading && + !fatalError && + !noData && + dates.data && + rankings.data?.status === "success" && + rankings.data.publication ? ( + <> + {refreshFailed ? ( + + ) : refreshing ? ( + + ) : null} + + updateSearch({ page })} + onPageSizeChange={(pageSize) => + updateSearch({ page: 1, pageSize }) + } + page={Math.min(search.page, pageCount)} + pageSize={search.pageSize} + response={rankings.data} + /> + + ) : null} +
+
+ ) +} + +interface RadarFiltersProps { + availableDates: string[] + onChange: (next: Partial) => void + search: SectorRadarRouteSearch +} + +function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) { + const dateOptions = [ + { label: "最近有效交易日", value: "latest" }, + ...availableDates.map((value) => ({ label: value, value })), + ] + + return ( +
+ + onChange({ + page: 1, + tradeDate: value === "latest" ? undefined : value, + }) + } + /> + + onChange({ + page: 1, + sectorType: value as SectorRadarRouteSearch["sectorType"], + }) + } + /> + + onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] }) + } + /> + {search.view === "rank_change" ? ( + <> + + onChange({ + page: 1, + rankChangeMetric: + value as SectorRadarRouteSearch["rankChangeMetric"], + }) + } + /> + + onChange({ page: 1, rankChangeDays: Number(value) }) + } + /> + + ) : null} + + onChange({ page: 1, side: value as SectorRadarRouteSearch["side"] }) + } + /> + +
+ ) +} + +function FilterSelect({ + className, + label, + onValueChange, + options, + value, +}: { + className?: string + label: string + onValueChange: (value: string) => void + options: readonly { label: string; value: string }[] + value: string +}) { + return ( + + ) +} + +function RadarStatusSummary({ + currentAttempt, + disclaimer, + lastGood, + metricVersion, + publication, +}: { + currentAttempt: RadarPublication | null + disclaimer: string + lastGood: RadarPublication | null + metricVersion: string + publication: RadarPublication +}) { + const degradedAttempt = + currentAttempt && + currentAttempt.status !== "success" && + currentAttempt.publication_id !== lastGood?.publication_id + + return ( + + +
+
+ 收盘后板块资金排名 + {disclaimer} +
+ 知行独立实现 + {metricVersion} +
+ {degradedAttempt ? ( +
+ {attemptStatusLabel(currentAttempt.status)}:目标交易日{" "} + {currentAttempt.target_trade_date} + ,当前仍展示最近有效发布{" "} + {lastGood?.target_trade_date ?? publication.target_trade_date}。 +
+ ) : null} +
+ + + + + + + +
+ ) +} + +function SummaryItem({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

+ {value} +

+
+ ) +} + +function RadarTable({ + onPageChange, + onPageSizeChange, + page, + pageSize, + response, +}: { + onPageChange: (page: number) => void + onPageSizeChange: (pageSize: number) => void + page: number + pageSize: number + response: Exclude< + ReturnType["data"], + undefined + > +}) { + const rankChangeView = response.view === "rank_change" + return ( +
+
+ + + + + + + + {rankChangeView ? ( + + ) : null} + + + + + + + + {response.rows.map((row) => ( + + ))} + {response.rows.length === 0 ? ( + + + + ) : null} + +
+ {response.sector_type === "concept" ? "概念" : "行业"}板块资金排名 +
+ 名次 + + 板块 + + {rankChangeView + ? `${response.rank_change_days} 日排名变化` + : response.definition.label} + + 参考指标值 + + 排名百分位 + + 样本 + + 资金覆盖率 + + 质量 +
+ 没有符合当前筛选条件的板块。 +
+
+ +
+ ) +} + +function RadarTableRow({ + rankChangeView, + row, +}: { + rankChangeView: boolean + row: RadarRankingRow +}) { + return ( + + + {row.rank_position ?? "—"} + + +

{row.sector_name}

+

+ {row.sector_code} +

+ + + {rankChangeView + ? formatRankChange(row.rank_change) + : formatMetricValue(row.metric_value, row.unit)} + + {rankChangeView ? ( + + {formatMetricValue(row.metric_value, row.unit)} + + ) : null} + + {row.rank_percentile === null + ? "—" + : `${formatNumber(row.rank_percentile, 2)}%`} + + + {row.valid_sample_count}/{row.member_count} + + + {formatCoverage(row.moneyflow_coverage)} + + + + + + ) +} + +function QualityBadge({ quality }: { quality: RadarMetricQuality }) { + if (quality === "available") return 可用 + if (quality === "available_limited_sample") { + return ( + + 样本有限 + + ) + } + return 不可用 +} + +function RadarLoading() { + return ( + + + 正在加载板块资金雷达 + 正在读取最近有效发布和板块排名。 + + + + + {Array.from({ length: 7 }, (_, index) => ( + + ))} + + + ) +} + +function RadarRefreshStatus() { + return ( +
+ 雷达数据正在刷新,当前继续展示上一次已读取的有效结果。 +
+ ) +} + +function RadarRefreshFailureStatus() { + return ( +
+ 雷达数据刷新失败,当前继续展示上一次已读取的有效结果。请稍后重试。 +
+ ) +} + +function RadarError({ onRetry }: { onRetry: () => void }) { + return ( + + + + + + 请求或响应校验失败,可能是服务、网络或接口契约暂时异常。 + + + + + + + ) +} + +function RadarNoData() { + return ( + + + + + + 完成第一次收盘后构建并通过完整性门槛后,这里会显示最近有效排名。 + + + + ) +} + +function formatMetricValue(value: number | null, unit: RadarMetricUnit) { + if (value === null) return "—" + return unit === "CNY_100M" + ? `${formatNumber(value, 2)} 亿元` + : `${formatNumber(value * 100, 2)}%` +} + +function formatRankChange(value: number | null) { + if (value === null) return "暂无可比历史" + if (value > 0) return `+${value}` + return String(value) +} + +function formatCoverage(value: number) { + return `${formatNumber(value * 100, 1)}%` +} + +function formatNumber(value: number, maximumFractionDigits: number) { + return value.toLocaleString("zh-CN", { maximumFractionDigits }) +} + +function formatDateTime(value: string | null) { + if (!value) return "—" + return new Intl.DateTimeFormat("zh-CN", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Asia/Shanghai", + }).format(new Date(value)) +} + +function attemptStatusLabel(status: RadarPublication["status"]) { + if (status === "running") return "新一期仍在构建" + if (status === "partial") return "新一期数据不完整" + if (status === "failed") return "新一期构建失败" + return "新一期已发布" +} diff --git a/zhixing-web/src/routes/route-tree.tsx b/zhixing-web/src/routes/route-tree.tsx index fbcdc0b..2821f36 100644 --- a/zhixing-web/src/routes/route-tree.tsx +++ b/zhixing-web/src/routes/route-tree.tsx @@ -3,6 +3,17 @@ import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router" import { AppLayout } from "@/app/layout/app-layout" import { ComponentsPreviewPage } from "@/features/components/pages/components-preview-page" import { HomePage } from "@/features/home/pages/home-page" +import { + radarMetricKinds, + radarRankSides, + radarViews, + sectorTypes, + type RadarMetricKind, + type RadarRankSide, + type RadarView, + type SectorType, +} from "@/features/sector-radar/api/sector-radar.types" +import { SectorRadarPage } from "@/features/sector-radar/pages/sector-radar-page" import { selectionCategoryFilters, type SelectionCategoryFilter, @@ -26,6 +37,63 @@ const indexRoute = createRoute({ component: HomePage, }) +const sectorRadarRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: "/sector-radar", + validateSearch: (search: Record) => { + const rawPage = Number(search.page) + const rawPageSize = Number(search.pageSize) + const rawRankChangeDays = Number(search.rankChangeDays) + const page = + Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1 + const pageSize = [10, 20, 50].includes(rawPageSize) ? rawPageSize : 20 + const rankChangeDays = + Number.isFinite(rawRankChangeDays) && + rawRankChangeDays >= 1 && + rawRankChangeDays <= 5 + ? Math.floor(rawRankChangeDays) + : 1 + const searchValue = + typeof search.search === "string" ? search.search.slice(0, 100) : "" + const tradeDate = + typeof search.tradeDate === "string" && isValidTradeDate(search.tradeDate) + ? search.tradeDate + : undefined + const sectorType = validatedSearchValue( + search.sectorType, + sectorTypes, + "concept", + ) + const view = validatedSearchValue( + search.view, + radarViews, + "amount", + ) + const rankChangeMetric = validatedSearchValue( + search.rankChangeMetric, + radarMetricKinds, + "amount", + ) + const side = validatedSearchValue( + search.side, + radarRankSides, + "all", + ) + return { + tradeDate, + sectorType, + view, + rankChangeMetric, + rankChangeDays, + side, + search: searchValue, + page, + pageSize, + } + }, + component: SectorRadarPage, +}) + const selectionRoute = createRoute({ getParentRoute: () => workspaceRoute, path: "/selection", @@ -64,8 +132,31 @@ const componentsRoute = createRoute({ export const routeTree = rootRoute.addChildren([ workspaceRoute.addChildren([ indexRoute, + sectorRadarRoute, selectionRoute, syncRoute, componentsRoute, ]), ]) + +function validatedSearchValue( + value: unknown, + allowed: readonly Value[], + fallback: Value, +): Value { + return typeof value === "string" && allowed.includes(value as Value) + ? (value as Value) + : fallback +} + +function isValidTradeDate(value: string) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) return false + const parsed = new Date(`${value}T00:00:00Z`) + return ( + !Number.isNaN(parsed.getTime()) && + parsed.getUTCFullYear() === Number(match[1]) && + parsed.getUTCMonth() + 1 === Number(match[2]) && + parsed.getUTCDate() === Number(match[3]) + ) +}