)
}
-
function RadarError({ onRetry }: { onRetry: () => void }) {
return (
@@ -584,7 +747,6 @@ function RadarError({ onRetry }: { onRetry: () => void }) {
)
}
-
function RadarNoData() {
return (
@@ -600,28 +762,18 @@ function RadarNoData() {
)
}
-
-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)
+ if (value > 0) return `+${value} 名`
+ if (value < 0) return `${value} 名`
+ return "0 名"
}
-
function formatCoverage(value: number) {
return `${formatNumber(value * 100, 1)}%`
}
-
function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString("zh-CN", { maximumFractionDigits })
}
-
function attemptStatusLabel(status: RadarPublication["status"]) {
if (status === "running") return "新一期仍在构建"
if (status === "partial") return "新一期数据不完整"
diff --git a/zhixing-web/src/features/selection/api/selection.api.test.ts b/zhixing-web/src/features/selection/api/selection.api.test.ts
index abd96d6..cae1a5e 100644
--- a/zhixing-web/src/features/selection/api/selection.api.test.ts
+++ b/zhixing-web/src/features/selection/api/selection.api.test.ts
@@ -4,7 +4,11 @@ const requestJson = vi.hoisted(() => vi.fn())
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
-import { getSelectionResults, getSelectionRun } from "./selection.api"
+import {
+ getSelectionChart,
+ getSelectionResults,
+ getSelectionRun,
+} from "./selection.api"
describe("selection API adapters", () => {
beforeEach(() => {
@@ -47,4 +51,18 @@ describe("selection API adapters", () => {
expect(params.has("category")).toBe(false)
expect(params.has("sort")).toBe(false)
})
+
+ it("requests a stock chart with an encoded code and target date", async () => {
+ await getSelectionChart("600601.SH", "2026-08-08")
+
+ const [input, init] = requestJson.mock.calls[0] as [
+ string,
+ { signal?: AbortSignal },
+ ]
+ expect(input).toContain("/api/v1/selection/stocks/600601.SH/chart?")
+ expect(
+ new URL(input, "http://localhost").searchParams.get("target_trade_date"),
+ ).toBe("2026-08-08")
+ expect(init).toEqual({ signal: undefined })
+ })
})
diff --git a/zhixing-web/src/features/selection/api/selection.api.ts b/zhixing-web/src/features/selection/api/selection.api.ts
index 121e4bb..5c667c3 100644
--- a/zhixing-web/src/features/selection/api/selection.api.ts
+++ b/zhixing-web/src/features/selection/api/selection.api.ts
@@ -1,6 +1,7 @@
import { requestJson } from "@/shared/api/request-json"
import type {
+ SelectionChart,
SelectionResults,
SelectionResultsQuery,
SelectionRunAccepted,
@@ -8,6 +9,20 @@ import type {
SelectionStrategy,
} from "./selection.types"
+export function getSelectionChart(
+ tsCode: string,
+ targetTradeDate: string,
+ signal?: AbortSignal,
+) {
+ const params = new URLSearchParams({
+ target_trade_date: targetTradeDate,
+ })
+ return requestJson
(
+ `/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`,
+ { signal },
+ )
+}
+
export function getSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
diff --git a/zhixing-web/src/features/selection/api/selection.query.ts b/zhixing-web/src/features/selection/api/selection.query.ts
index 4667739..085900d 100644
--- a/zhixing-web/src/features/selection/api/selection.query.ts
+++ b/zhixing-web/src/features/selection/api/selection.query.ts
@@ -7,6 +7,7 @@ import {
import { useEffect } from "react"
import {
+ getSelectionChart,
getSelectionResults,
getSelectionRun,
triggerSelectionRun,
@@ -17,6 +18,12 @@ import type {
SelectionStrategy,
} from "./selection.types"
+export const selectionChartQueryKey = (
+ tsCode?: string,
+ targetTradeDate?: string,
+) =>
+ ["selection", "chart", tsCode ?? "none", targetTradeDate ?? "none"] as const
+
export const selectionResultsQueryKey = (
strategy: SelectionStrategy,
targetTradeDate?: string,
@@ -61,6 +68,15 @@ export function useSelectionResults(
})
}
+export function useSelectionChart(tsCode?: string, targetTradeDate?: string) {
+ return useQuery({
+ enabled: Boolean(tsCode && targetTradeDate),
+ queryFn: ({ signal }) =>
+ getSelectionChart(tsCode ?? "", targetTradeDate ?? "", signal),
+ queryKey: selectionChartQueryKey(tsCode, targetTradeDate),
+ })
+}
+
export function useSelectionRun(
runId: string | null,
resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 },
diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts
index d947ad6..6358aca 100644
--- a/zhixing-web/src/features/selection/api/selection.types.ts
+++ b/zhixing-web/src/features/selection/api/selection.types.ts
@@ -58,6 +58,26 @@ export interface SelectionStockResult {
signals: SelectionSignal[]
}
+export interface SelectionChartPoint {
+ trade_date: string
+ open: number | null
+ high: number | null
+ low: number | null
+ close: number | null
+ volume: number | null
+ k: number | null
+ d: number | null
+ j: number | null
+}
+
+export interface SelectionChart {
+ ts_code: string
+ name: string
+ target_trade_date: string
+ source_adj: "qfq"
+ points: SelectionChartPoint[]
+}
+
export interface SelectionPatternScore {
status: "matched" | "below_threshold" | "failed"
value: number | null
diff --git a/zhixing-web/src/features/selection/components/pattern-case-assets.ts b/zhixing-web/src/features/selection/components/pattern-case-assets.ts
new file mode 100644
index 0000000..4e91782
--- /dev/null
+++ b/zhixing-web/src/features/selection/components/pattern-case-assets.ts
@@ -0,0 +1,12 @@
+export const patternCaseImageById = {
+ case_001: "/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg",
+ case_002: "/patterns/b1-cases/case_002_600366_宁波韵升_2025-08-06.jpg",
+ case_003: "/patterns/b1-cases/case_003_688321_微芯生物_2025-06-20.jpg",
+ case_004: "/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
+ case_006: "/patterns/b1-cases/case_006_002074_国轩高科_2025-08-04.jpg",
+ case_007: "/patterns/b1-cases/case_007_605378_野马电池_2025-08-01.jpg",
+ case_008: "/patterns/b1-cases/case_008_600184_光电股份_2025-07-10.jpg",
+ case_009: "/patterns/b1-cases/case_009_301076_新瀚新材_2025-08-01.jpg",
+ case_010: "/patterns/b1-cases/case_010_002940_昂利康_2025-07-11.jpg",
+ case_011: "/patterns/b1-cases/case_011_000547_航天发展_2025-11-12.jpg",
+} as const
diff --git a/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx b/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx
new file mode 100644
index 0000000..8ff1958
--- /dev/null
+++ b/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx
@@ -0,0 +1,78 @@
+import { fireEvent, render, screen } from "@testing-library/react"
+import { describe, expect, it } from "vitest"
+
+import type { SelectionPatternScore } from "../api/selection.types"
+import { PatternCaseImage } from "./pattern-case-image"
+
+function score(
+ caseId = "case_004",
+ caseName = "方正科技",
+): SelectionPatternScore {
+ return {
+ breakdown: {
+ kdj_state: 91,
+ price_shape: 92,
+ trend_structure: 86,
+ volume_pattern: 89,
+ },
+ case: {
+ breakout_date: "2025-07-23",
+ id: caseId,
+ name: caseName,
+ },
+ reason: null,
+ status: "matched",
+ threshold: 60,
+ value: 90.17,
+ version: "zhixing_b1_pattern_fastdtw_v1",
+ }
+}
+
+describe("PatternCaseImage", () => {
+ it("renders only the best case image and falls back after a load error", () => {
+ const view = render()
+
+ const image = screen.getByRole("img", {
+ name: "方正科技 B1 参考案例图",
+ })
+ expect(screen.getAllByRole("img")).toHaveLength(1)
+ expect(image).toHaveAttribute(
+ "src",
+ "/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
+ )
+
+ fireEvent.error(image)
+ expect(screen.queryByRole("img")).not.toBeInTheDocument()
+ expect(screen.getByText("案例图片暂不可用。")).toBeInTheDocument()
+
+ view.rerender()
+ expect(
+ screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }),
+ ).toBeInTheDocument()
+ })
+
+ it("does not guess an asset for an unknown case id", () => {
+ render()
+
+ expect(screen.queryByRole("img")).not.toBeInTheDocument()
+ expect(screen.getByText("案例图片暂不可用。")).toBeInTheDocument()
+ })
+
+ it("does not render an image for uncomputed or failed scores", () => {
+ const failed: SelectionPatternScore = {
+ breakdown: null,
+ case: null,
+ reason: "scoring unavailable",
+ status: "failed",
+ threshold: null,
+ value: null,
+ version: null,
+ }
+ const view = render()
+ expect(screen.queryByRole("img")).not.toBeInTheDocument()
+
+ view.rerender()
+ expect(screen.queryByRole("img")).not.toBeInTheDocument()
+ expect(screen.queryByText("案例图片暂不可用。")).not.toBeInTheDocument()
+ })
+})
diff --git a/zhixing-web/src/features/selection/components/pattern-case-image.tsx b/zhixing-web/src/features/selection/components/pattern-case-image.tsx
new file mode 100644
index 0000000..e6d0370
--- /dev/null
+++ b/zhixing-web/src/features/selection/components/pattern-case-image.tsx
@@ -0,0 +1,47 @@
+import { useState } from "react"
+
+import type { SelectionPatternScore } from "../api/selection.types"
+import { patternCaseImageById } from "./pattern-case-assets"
+
+interface PatternCaseImageProps {
+ score: SelectionPatternScore | null
+}
+
+export function PatternCaseImage({ score }: PatternCaseImageProps) {
+ const [failedSource, setFailedSource] = useState(null)
+ const matchedCase = score?.status === "failed" ? null : score?.case
+ if (!matchedCase) return null
+
+ const source =
+ patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById]
+
+ return (
+
+
+
+ 最接近案例:{matchedCase.name}
+
+
+ {formatScore(score?.value)} 分
+
+
+ {!source || failedSource === source ? (
+
+ 案例图片暂不可用。
+
+ ) : (
+
setFailedSource(source)}
+ src={source}
+ />
+ )}
+
+ )
+}
+
+function formatScore(value: number | null | undefined) {
+ return value === null || value === undefined ? "—" : value.toFixed(2)
+}
diff --git a/zhixing-web/src/features/selection/components/pattern-score.tsx b/zhixing-web/src/features/selection/components/pattern-score.tsx
index a782510..e2fec77 100644
--- a/zhixing-web/src/features/selection/components/pattern-score.tsx
+++ b/zhixing-web/src/features/selection/components/pattern-score.tsx
@@ -18,13 +18,6 @@ export function PatternScoreSummary({ score }: PatternScoreProps) {
if (score.status === "failed") {
return 评分暂不可用
}
- if (score.status === "below_threshold") {
- return (
-
- 未匹配到 {formatScore(score.threshold)} 分以上案例
-
- )
- }
return (
@@ -33,6 +26,9 @@ export function PatternScoreSummary({ score }: PatternScoreProps) {
{score.case?.name ?? "最佳案例未知"}
+ {score.status === "below_threshold" ? (
+ 低于阈值
+ ) : null}
)
}
@@ -53,16 +49,6 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {