Merge branch 'develop' into codex/point
This commit is contained in:
+59
-37
@@ -263,6 +263,7 @@ The single chromatic accent is **Fin Orange** (`{colors.fin-orange}` #ff5600)
|
||||
The page rhythm is heavy on **product mockups**: every section's payload is a high-fidelity screenshot of Intercom's product UI, framed in white cards with a consistent 6px corner radius. The marketing chrome is intentionally quiet so the product can be the protagonist.
|
||||
|
||||
**Key Characteristics:**
|
||||
|
||||
- **Cream canvas** (`{colors.canvas}` #f5f1ec) is the brand's defining surface — neither white nor gray, deliberately warm.
|
||||
- Product-screenshot-led page rhythm: every section centers a product mockup card, marketing chrome stays minimal.
|
||||
- **Saans** proprietary sans-serif carries the entire hierarchy; SaansMono for code-only contexts.
|
||||
@@ -276,6 +277,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi
|
||||
> Source pages: intercom.com (home), /pricing, /helpdesk, /customers, /helpdesk/inbox.
|
||||
|
||||
### Brand & Accent
|
||||
|
||||
- **Charcoal** ({colors.ink}): The system primary surface. Headlines, body type, and primary CTA background — all charcoal.
|
||||
- **White** ({colors.on-primary}): Text on charcoal CTAs; canvas of floating cards.
|
||||
- **Fin Orange** ({colors.fin-orange}): The AI-product accent. Used on the Fin CTA, Fin badge, and a small set of inline emphasis moments.
|
||||
@@ -283,6 +285,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi
|
||||
- **Brand Blue** ({colors.brand-blue}): Saturated brand blue (#0007cb) — used on a small set of marketing illustrations.
|
||||
|
||||
### Surface
|
||||
|
||||
- **Canvas** ({colors.canvas}): Default page background — soft cream-white #f5f1ec.
|
||||
- **Surface 1** ({colors.surface-1}): Pure white — used for floating cards (pricing, feature, product-mockup).
|
||||
- **Surface 2** ({colors.surface-2}): Slightly darker cream — startup-discount banner, alt-row stripes.
|
||||
@@ -292,6 +295,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi
|
||||
- **Inverse Surface 1** ({colors.inverse-surface-1}): One step lighter — hovered footer items in dark contexts.
|
||||
|
||||
### Text
|
||||
|
||||
- **Ink** ({colors.ink}): All headlines, body type, button labels — charcoal #111111.
|
||||
- **Ink Muted** ({colors.ink-muted}): Secondary type at #626260 — meta info, deselected pricing tabs.
|
||||
- **Ink Subtle** ({colors.ink-subtle}): Tertiary type at #7b7b78 — footer columns, helper text.
|
||||
@@ -300,6 +304,7 @@ The page rhythm is heavy on **product mockups**: every section's payload is a hi
|
||||
- **Inverse Ink Muted** ({colors.inverse-ink-muted}): Light gray on black — quote-strip meta.
|
||||
|
||||
### Semantic & Report Palette (in-product mockups)
|
||||
|
||||
- **Error Red** ({colors.semantic-error}): Form validation, destructive states.
|
||||
- **Success Green** ({colors.semantic-success}): Positive states (also `{colors.report-green}`).
|
||||
- **Report Blue** ({colors.report-blue}): Analytics chart blue.
|
||||
@@ -320,21 +325,21 @@ The same family carries the entire hierarchy. Hierarchy is carried by size + wei
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
||||
|---|---|---|---|---|---|
|
||||
| `{typography.display-xl}` | 72px | 500 | 1.05 | -2.0px | Largest hero headline |
|
||||
| `{typography.display-lg}` | 56px | 500 | 1.10 | -1.4px | Section opener headlines |
|
||||
| `{typography.display-md}` | 40px | 500 | 1.15 | -0.8px | Sub-section headlines |
|
||||
| `{typography.headline}` | 28px | 500 | 1.20 | -0.5px | Pricing tier titles, CTA banner |
|
||||
| `{typography.card-title}` | 22px | 500 | 1.25 | -0.3px | Card title, feature card |
|
||||
| `{typography.subhead}` | 20px | 400 | 1.40 | -0.2px | Lead body, intro paragraphs |
|
||||
| `{typography.body-lg}` | 18px | 400 | 1.50 | -0.1px | Hero subhead, lead paragraphs |
|
||||
| `{typography.body}` | 16px | 400 | 1.50 | 0 | Default body |
|
||||
| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Card body, footer |
|
||||
| `{typography.caption}` | 12px | 400 | 1.40 | 0 | Captions, meta |
|
||||
| `{typography.button}` | 15px | 500 | 1.20 | 0 | Button labels |
|
||||
| `{typography.eyebrow}` | 14px | 500 | 1.30 | 0 | Section eyebrow (sentence case) |
|
||||
| `{typography.mono}` | 13px | 400 | 1.50 | 0 | SaansMono for code in mockups |
|
||||
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
||||
| ------------------------- | ---- | ------ | ----------- | -------------- | ------------------------------- |
|
||||
| `{typography.display-xl}` | 72px | 500 | 1.05 | -2.0px | Largest hero headline |
|
||||
| `{typography.display-lg}` | 56px | 500 | 1.10 | -1.4px | Section opener headlines |
|
||||
| `{typography.display-md}` | 40px | 500 | 1.15 | -0.8px | Sub-section headlines |
|
||||
| `{typography.headline}` | 28px | 500 | 1.20 | -0.5px | Pricing tier titles, CTA banner |
|
||||
| `{typography.card-title}` | 22px | 500 | 1.25 | -0.3px | Card title, feature card |
|
||||
| `{typography.subhead}` | 20px | 400 | 1.40 | -0.2px | Lead body, intro paragraphs |
|
||||
| `{typography.body-lg}` | 18px | 400 | 1.50 | -0.1px | Hero subhead, lead paragraphs |
|
||||
| `{typography.body}` | 16px | 400 | 1.50 | 0 | Default body |
|
||||
| `{typography.body-sm}` | 14px | 400 | 1.50 | 0 | Card body, footer |
|
||||
| `{typography.caption}` | 12px | 400 | 1.40 | 0 | Captions, meta |
|
||||
| `{typography.button}` | 15px | 500 | 1.20 | 0 | Button labels |
|
||||
| `{typography.eyebrow}` | 14px | 500 | 1.30 | 0 | Section eyebrow (sentence case) |
|
||||
| `{typography.mono}` | 13px | 400 | 1.50 | 0 | SaansMono for code in mockups |
|
||||
|
||||
### Principles
|
||||
|
||||
@@ -370,12 +375,12 @@ The cream canvas does the work white space would in another brand. Sections sepa
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|---|---|---|
|
||||
| 0 (flat) | No shadow, no border | Default for body type, hero text, footer |
|
||||
| Level | Treatment | Use |
|
||||
| ----------------- | ---------------------------------------------------------------- | --------------------------------------------- |
|
||||
| 0 (flat) | No shadow, no border | Default for body type, hero text, footer |
|
||||
| 1 (lift on cream) | `{colors.surface-1}` white background on `{colors.canvas}` cream | Pricing cards, feature cards, product mockups |
|
||||
| 2 (hairline lift) | `{colors.surface-1}` + 1px `{colors.hairline}` border | Floating tiles with extra definition |
|
||||
| 3 (deep accent) | `{colors.inverse-canvas}` true black | Quote / testimonial callout strip |
|
||||
| 2 (hairline lift) | `{colors.surface-1}` + 1px `{colors.hairline}` border | Floating tiles with extra definition |
|
||||
| 3 (deep accent) | `{colors.inverse-canvas}` true black | Quote / testimonial callout strip |
|
||||
|
||||
Intercom resists drop shadows. Depth is communicated by the white-on-cream surface change.
|
||||
|
||||
@@ -388,16 +393,16 @@ Intercom resists drop shadows. Depth is communicated by the white-on-cream surfa
|
||||
|
||||
### Border Radius Scale
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `{rounded.xs}` | 6px | Small chips, badges |
|
||||
| `{rounded.sm}` | 6px | Inline tags |
|
||||
| `{rounded.md}` | 6px | All buttons, form inputs |
|
||||
| `{rounded.lg}` | 6px | Pricing cards, feature cards, FAQ rows |
|
||||
| `{rounded.xl}` | 6px | Product mockup cards |
|
||||
| `{rounded.xxl}` | 6px | Oversized CTA banners |
|
||||
| `{rounded.pill}` | 6px | Tab toggles (legacy alias) |
|
||||
| `{rounded.full}` | 6px | Avatar containers (legacy alias) |
|
||||
| Token | Value | Use |
|
||||
| ---------------- | ----- | -------------------------------------- |
|
||||
| `{rounded.xs}` | 6px | Small chips, badges |
|
||||
| `{rounded.sm}` | 6px | Inline tags |
|
||||
| `{rounded.md}` | 6px | All buttons, form inputs |
|
||||
| `{rounded.lg}` | 6px | Pricing cards, feature cards, FAQ rows |
|
||||
| `{rounded.xl}` | 6px | Product mockup cards |
|
||||
| `{rounded.xxl}` | 6px | Oversized CTA banners |
|
||||
| `{rounded.pill}` | 6px | Tab toggles (legacy alias) |
|
||||
| `{rounded.full}` | 6px | Avatar containers (legacy alias) |
|
||||
|
||||
### Photography & Illustration Geometry
|
||||
|
||||
@@ -410,68 +415,85 @@ Intercom resists drop shadows. Depth is communicated by the white-on-cream surfa
|
||||
### Buttons
|
||||
|
||||
**`button-primary`** — Charcoal CTA. The default primary CTA across all pages.
|
||||
|
||||
- Background `{colors.ink}`, text `{colors.on-primary}`, type `{typography.button}`, padding 10px 18px, rounded `{rounded.md}`.
|
||||
- Pressed state lives in `button-primary-pressed`.
|
||||
|
||||
**`button-secondary`** — White button on cream. Used for secondary CTAs.
|
||||
|
||||
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.button}`, padding 10px 18px, rounded `{rounded.md}`. 1px `{colors.hairline}` border.
|
||||
|
||||
**`button-tertiary`** — Plain text button.
|
||||
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.button}`, rounded `{rounded.md}`, padding 10px 18px.
|
||||
|
||||
**`button-fin`** — Fin Orange CTA — reserved for Fin AI product CTAs.
|
||||
|
||||
- Background `{colors.fin-orange}`, text `{colors.on-primary}`, type `{typography.button}`, rounded `{rounded.md}`, padding 10px 18px.
|
||||
|
||||
### Pricing Tabs
|
||||
|
||||
**`pricing-tab-default`** + **`pricing-tab-selected`** — Compact tab toggle on `/pricing`.
|
||||
|
||||
- Default: `{colors.canvas}` background, `{colors.ink-muted}` text, rounded `{rounded.md}` (6px).
|
||||
- Selected: `{colors.surface-1}` white background, `{colors.ink}` text — selected = lift onto white.
|
||||
|
||||
### Cards & Containers
|
||||
|
||||
**`pricing-card`** — Each tier on `/pricing`.
|
||||
|
||||
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px.
|
||||
|
||||
**`pricing-card-featured`** — Featured / recommended tier — inverts to charcoal.
|
||||
|
||||
- Background `{colors.ink}`, text `{colors.on-primary}`, otherwise identical structure.
|
||||
|
||||
**`feature-card`** — Generic feature highlight.
|
||||
|
||||
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 24px.
|
||||
|
||||
**`product-mockup-card`** — The dominant card type — frames a high-fidelity product UI screenshot.
|
||||
|
||||
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.xl}`, padding 24px.
|
||||
|
||||
**`testimonial-card`** — Customer quote with avatar + name + company.
|
||||
|
||||
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body-lg}`, rounded `{rounded.lg}`, padding 32px.
|
||||
|
||||
**`startup-discount-card`** — The "Startups get 90% off" tinted card.
|
||||
|
||||
- Background `{colors.surface-2}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.lg}`, padding 32px.
|
||||
|
||||
**`customer-logo-tile`** — Small tile in the customer marquee.
|
||||
|
||||
- Background `{colors.canvas}`, text `{colors.ink-muted}`, type `{typography.caption}`, rounded `{rounded.xs}`, padding 16px.
|
||||
|
||||
**`cta-banner`** — Closing CTA panel near page bottom.
|
||||
|
||||
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.headline}`, rounded `{rounded.lg}`, padding 48px.
|
||||
|
||||
### Inputs & Forms
|
||||
|
||||
**`text-input`** + **`text-input-focused`** — Form fields on contact and search overlays.
|
||||
|
||||
- Background `{colors.surface-1}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.md}`, padding 10px 14px.
|
||||
|
||||
### FAQ
|
||||
|
||||
**`faq-row`** — Expandable accordion row in the pricing FAQ.
|
||||
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body}`, rounded `{rounded.md}`, padding 24px. 1px `{colors.hairline-soft}` bottom rule.
|
||||
|
||||
### Navigation
|
||||
|
||||
**`top-nav`** — Sticky cream bar with the Intercom wordmark left, nav links centered, log-in + sign-up pair right.
|
||||
|
||||
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-sm}`, height 56px.
|
||||
|
||||
### Footer
|
||||
|
||||
**`footer`** — Dense link grid on `{colors.canvas}` cream with the Intercom wordmark left.
|
||||
|
||||
- Background `{colors.canvas}`, text `{colors.ink-muted}`, type `{typography.caption}`, padding 64px 32px.
|
||||
|
||||
## Do's and Don'ts
|
||||
@@ -501,13 +523,13 @@ Intercom resists drop shadows. Depth is communicated by the white-on-cream surfa
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Name | Width | Key Changes |
|
||||
|---|---|---|
|
||||
| Desktop-XL | 1440px | Default desktop layout |
|
||||
| Desktop | 1280px | Card grid 3-up maintained |
|
||||
| Tablet | 1024px | Card grid 3-up → 2-up |
|
||||
| Mobile-Lg | 768px | Pricing comparison becomes accordion; nav hamburger |
|
||||
| Mobile | 480px | Single-column; display-xl scales 72px → ~32px |
|
||||
| Name | Width | Key Changes |
|
||||
| ---------- | ------ | --------------------------------------------------- |
|
||||
| Desktop-XL | 1440px | Default desktop layout |
|
||||
| Desktop | 1280px | Card grid 3-up maintained |
|
||||
| Tablet | 1024px | Card grid 3-up → 2-up |
|
||||
| Mobile-Lg | 768px | Pricing comparison becomes accordion; nav hamburger |
|
||||
| Mobile | 480px | Single-column; display-xl scales 72px → ~32px |
|
||||
|
||||
### Touch Targets
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "板块资金雷达",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, RoutePresentation> = {
|
||||
breadcrumb: "研究工作台",
|
||||
title: "知行 B1 执行结果",
|
||||
},
|
||||
"/sector-radar": {
|
||||
id: "sector-radar",
|
||||
breadcrumb: "研究工作台",
|
||||
title: "板块资金雷达",
|
||||
},
|
||||
"/sync": {
|
||||
id: "sync",
|
||||
breadcrumb: "研究工作台",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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")
|
||||
})
|
||||
|
||||
it("rejects a zero rank percentile that the domain cannot produce", async () => {
|
||||
requestJson.mockResolvedValue({
|
||||
...rankingPayload,
|
||||
rows: [{ ...rankingPayload.rows[0], rank_percentile: "0" }],
|
||||
})
|
||||
|
||||
await expect(
|
||||
getSectorRadarRankings({
|
||||
sectorType: "concept",
|
||||
view: "amount",
|
||||
rankChangeMetric: "amount",
|
||||
rankChangeDays: 1,
|
||||
side: "all",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
).rejects.toThrow("rankings.rows[0].rank_percentile must be greater than 0")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
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<string, unknown>
|
||||
|
||||
/**
|
||||
* 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<unknown>("/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<unknown>(
|
||||
`/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: readNullablePositiveNumber(
|
||||
record.rank_percentile,
|
||||
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<const Values extends readonly string[]>(
|
||||
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 readNullablePositiveNumber(
|
||||
value: unknown,
|
||||
max: number,
|
||||
path: string,
|
||||
): number | null {
|
||||
if (value === null) return null
|
||||
const number = readFiniteNumber(value, path)
|
||||
if (number <= 0 || number > max) {
|
||||
throw contractError(path, `must be greater than 0 and at most ${max}`)
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
function contractError(path: string, reason: string): Error {
|
||||
return new Error(`Invalid sector radar response: ${path} ${reason}`)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
|
||||
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(<SectorRadarPage />)
|
||||
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(<SectorRadarPage />)
|
||||
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(<SectorRadarPage />)
|
||||
expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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<SectorRadarRouteSearch>) {
|
||||
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 (
|
||||
<PageLayout
|
||||
actions={
|
||||
<RadarFilters
|
||||
availableDates={dates.data?.available_dates ?? []}
|
||||
onChange={updateSearch}
|
||||
search={search}
|
||||
/>
|
||||
}
|
||||
mode="bounded-workspace"
|
||||
>
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-7xl flex-1 flex-col gap-3 md:overflow-hidden">
|
||||
{loading ? <RadarLoading /> : null}
|
||||
{!loading && fatalError ? (
|
||||
<RadarError
|
||||
onRetry={() => {
|
||||
void dates.refetch()
|
||||
void rankings.refetch()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!loading && !fatalError && noData ? <RadarNoData /> : null}
|
||||
{!loading &&
|
||||
!fatalError &&
|
||||
!noData &&
|
||||
dates.data &&
|
||||
rankings.data?.status === "success" &&
|
||||
rankings.data.publication ? (
|
||||
<>
|
||||
{refreshFailed ? (
|
||||
<RadarRefreshFailureStatus />
|
||||
) : refreshing ? (
|
||||
<RadarRefreshStatus />
|
||||
) : null}
|
||||
<RadarStatusSummary
|
||||
currentAttempt={dates.data.current_attempt}
|
||||
lastGood={dates.data.last_good}
|
||||
publication={rankings.data.publication}
|
||||
disclaimer={rankings.data.definition.disclaimer}
|
||||
metricVersion={rankings.data.definition.metric_version}
|
||||
/>
|
||||
<RadarTable
|
||||
onPageChange={(page) => updateSearch({ page })}
|
||||
onPageSizeChange={(pageSize) =>
|
||||
updateSearch({ page: 1, pageSize })
|
||||
}
|
||||
page={Math.min(search.page, pageCount)}
|
||||
pageSize={search.pageSize}
|
||||
response={rankings.data}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
interface RadarFiltersProps {
|
||||
availableDates: string[]
|
||||
onChange: (next: Partial<SectorRadarRouteSearch>) => void
|
||||
search: SectorRadarRouteSearch
|
||||
}
|
||||
|
||||
function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
const dateOptions = [
|
||||
{ label: "最近有效交易日", value: "latest" },
|
||||
...availableDates.map((value) => ({ label: value, value })),
|
||||
]
|
||||
|
||||
return (
|
||||
<div aria-label="板块资金雷达筛选" className="grid gap-2 lg:grid-cols-12">
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="交易日"
|
||||
options={dateOptions}
|
||||
value={search.tradeDate ?? "latest"}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
page: 1,
|
||||
tradeDate: value === "latest" ? undefined : value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="板块类型"
|
||||
options={sectorTypeOptions}
|
||||
value={search.sectorType}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
page: 1,
|
||||
sectorType: value as SectorRadarRouteSearch["sectorType"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="指标视角"
|
||||
options={viewOptions}
|
||||
value={search.view}
|
||||
onValueChange={(value) =>
|
||||
onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] })
|
||||
}
|
||||
/>
|
||||
{search.view === "rank_change" ? (
|
||||
<>
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="变化指标"
|
||||
options={metricOptions}
|
||||
value={search.rankChangeMetric}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
page: 1,
|
||||
rankChangeMetric:
|
||||
value as SectorRadarRouteSearch["rankChangeMetric"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="对比区间"
|
||||
options={rankChangeDayOptions}
|
||||
value={String(search.rankChangeDays)}
|
||||
onValueChange={(value) =>
|
||||
onChange({ page: 1, rankChangeDays: Number(value) })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="榜单范围"
|
||||
options={sideOptions}
|
||||
value={search.side}
|
||||
onValueChange={(value) =>
|
||||
onChange({ page: 1, side: value as SectorRadarRouteSearch["side"] })
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
className,
|
||||
label,
|
||||
onValueChange,
|
||||
options,
|
||||
value,
|
||||
}: {
|
||||
className?: string
|
||||
label: string
|
||||
onValueChange: (value: string) => void
|
||||
options: readonly { label: string; value: string }[]
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={`grid gap-1 text-xs font-medium text-muted-foreground ${className ?? ""}`}
|
||||
>
|
||||
{label}
|
||||
<Select
|
||||
items={options}
|
||||
onValueChange={(next) => {
|
||||
if (typeof next === "string") onValueChange(next)
|
||||
}}
|
||||
value={value}
|
||||
>
|
||||
<SelectTrigger aria-label={label} className="bg-background">
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="shrink-0 shadow-none">
|
||||
<CardHeader className="gap-3 p-4 pb-3">
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">收盘后板块资金排名</CardTitle>
|
||||
<CardDescription className="mt-1">{disclaimer}</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">知行独立实现</Badge>
|
||||
<Badge variant="outline">{metricVersion}</Badge>
|
||||
</div>
|
||||
{degradedAttempt ? (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{attemptStatusLabel(currentAttempt.status)}:目标交易日{" "}
|
||||
{currentAttempt.target_trade_date}
|
||||
,当前仍展示最近有效发布{" "}
|
||||
{lastGood?.target_trade_date ?? publication.target_trade_date}。
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 p-4 pt-0 text-xs sm:grid-cols-2 lg:grid-cols-5">
|
||||
<SummaryItem label="数据交易日" value={publication.target_trade_date} />
|
||||
<SummaryItem
|
||||
label="发布完成"
|
||||
value={formatDateTime(publication.finished_at)}
|
||||
/>
|
||||
<SummaryItem
|
||||
label="全局覆盖率"
|
||||
value={formatCoverage(publication.coverage)}
|
||||
/>
|
||||
<SummaryItem label="事实来源" value={publication.source_version} />
|
||||
<SummaryItem
|
||||
label="板块范围版本"
|
||||
value={publication.universe_version}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 break-all font-medium text-foreground tabular-nums">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarTable({
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
page,
|
||||
pageSize,
|
||||
response,
|
||||
}: {
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (pageSize: number) => void
|
||||
page: number
|
||||
pageSize: number
|
||||
response: Exclude<
|
||||
ReturnType<typeof useSectorRadarRankings>["data"],
|
||||
undefined
|
||||
>
|
||||
}) {
|
||||
const rankChangeView = response.view === "rank_change"
|
||||
return (
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
|
||||
<table className="w-full min-w-[920px] border-collapse text-sm">
|
||||
<caption className="sr-only">
|
||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
|
||||
</caption>
|
||||
<thead className="sticky top-0 z-10 bg-muted/95 text-left text-xs text-muted-foreground backdrop-blur">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
名次
|
||||
</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
板块
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
{rankChangeView
|
||||
? `${response.rank_change_days} 日排名变化`
|
||||
: response.definition.label}
|
||||
</th>
|
||||
{rankChangeView ? (
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
参考指标值
|
||||
</th>
|
||||
) : null}
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
资金覆盖率
|
||||
</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
质量
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{response.rows.map((row) => (
|
||||
<RadarTableRow
|
||||
key={`${row.sector_type}-${row.sector_code}`}
|
||||
rankChangeView={rankChangeView}
|
||||
row={row}
|
||||
/>
|
||||
))}
|
||||
{response.rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-3 py-12 text-center text-muted-foreground"
|
||||
colSpan={rankChangeView ? 8 : 7}
|
||||
>
|
||||
没有符合当前筛选条件的板块。
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
total={response.total}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarTableRow({
|
||||
rankChangeView,
|
||||
row,
|
||||
}: {
|
||||
rankChangeView: boolean
|
||||
row: RadarRankingRow
|
||||
}) {
|
||||
return (
|
||||
<tr className="hover:bg-muted/40">
|
||||
<td className="px-3 py-2.5 font-semibold tabular-nums">
|
||||
{row.rank_position ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-medium text-foreground">{row.sector_name}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{row.sector_code}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-medium tabular-nums">
|
||||
{rankChangeView
|
||||
? formatRankChange(row.rank_change)
|
||||
: formatMetricValue(row.metric_value, row.unit)}
|
||||
</td>
|
||||
{rankChangeView ? (
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-muted-foreground">
|
||||
{formatMetricValue(row.metric_value, row.unit)}
|
||||
</td>
|
||||
) : null}
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
{row.rank_percentile === null
|
||||
? "—"
|
||||
: `${formatNumber(row.rank_percentile, 2)}%`}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
{row.valid_sample_count}/{row.member_count}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
{formatCoverage(row.moneyflow_coverage)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<QualityBadge quality={row.quality} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
||||
if (quality === "available") return <Badge variant="outline">可用</Badge>
|
||||
if (quality === "available_limited_sample") {
|
||||
return (
|
||||
<Badge
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
|
||||
variant="outline"
|
||||
>
|
||||
样本有限
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return <Badge variant="destructive">不可用</Badge>
|
||||
}
|
||||
|
||||
function RadarLoading() {
|
||||
return (
|
||||
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
||||
<CardHeader className="gap-3">
|
||||
<CardTitle className="text-base">正在加载板块资金雷达</CardTitle>
|
||||
<CardDescription>正在读取最近有效发布和板块排名。</CardDescription>
|
||||
<Skeleton className="h-5 w-52" />
|
||||
<Skeleton className="h-4 w-80 max-w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 7 }, (_, index) => (
|
||||
<Skeleton className="h-10 w-full" key={index} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarRefreshStatus() {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="shrink-0 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
雷达数据正在刷新,当前继续展示上一次已读取的有效结果。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarRefreshFailureStatus() {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="shrink-0 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
雷达数据刷新失败,当前继续展示上一次已读取的有效结果。请稍后重试。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarError({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AlertTriangle
|
||||
className="size-5 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
板块资金雷达暂时不可用
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
请求或响应校验失败,可能是服务、网络或接口契约暂时异常。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={onRetry} variant="outline">
|
||||
<RefreshCw aria-hidden="true" />
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarNoData() {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Database className="size-5 text-primary" aria-hidden="true" />
|
||||
暂无板块资金发布
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
完成第一次收盘后构建并通过完整性门槛后,这里会显示最近有效排名。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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 "新一期已发布"
|
||||
}
|
||||
@@ -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,
|
||||
selectionSorts,
|
||||
@@ -28,6 +39,63 @@ const indexRoute = createRoute({
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
const sectorRadarRoute = createRoute({
|
||||
getParentRoute: () => workspaceRoute,
|
||||
path: "/sector-radar",
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
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<SectorType>(
|
||||
search.sectorType,
|
||||
sectorTypes,
|
||||
"concept",
|
||||
)
|
||||
const view = validatedSearchValue<RadarView>(
|
||||
search.view,
|
||||
radarViews,
|
||||
"amount",
|
||||
)
|
||||
const rankChangeMetric = validatedSearchValue<RadarMetricKind>(
|
||||
search.rankChangeMetric,
|
||||
radarMetricKinds,
|
||||
"amount",
|
||||
)
|
||||
const side = validatedSearchValue<RadarRankSide>(
|
||||
search.side,
|
||||
radarRankSides,
|
||||
"all",
|
||||
)
|
||||
return {
|
||||
tradeDate,
|
||||
sectorType,
|
||||
view,
|
||||
rankChangeMetric,
|
||||
rankChangeDays,
|
||||
side,
|
||||
search: searchValue,
|
||||
page,
|
||||
pageSize,
|
||||
}
|
||||
},
|
||||
component: SectorRadarPage,
|
||||
})
|
||||
|
||||
const selectionRoute = createRoute({
|
||||
getParentRoute: () => workspaceRoute,
|
||||
path: "/selection",
|
||||
@@ -72,8 +140,31 @@ const componentsRoute = createRoute({
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
workspaceRoute.addChildren([
|
||||
indexRoute,
|
||||
sectorRadarRoute,
|
||||
selectionRoute,
|
||||
syncRoute,
|
||||
componentsRoute,
|
||||
]),
|
||||
])
|
||||
|
||||
function validatedSearchValue<Value extends string>(
|
||||
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])
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user