feat(web): 完善选股详情图表与案例图
@@ -23,6 +23,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
|
echarts:
|
||||||
|
specifier: ^6.1.0
|
||||||
|
version: 6.1.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.28.0
|
specifier: ^1.28.0
|
||||||
version: 1.28.0(react@19.2.8)
|
version: 1.28.0(react@19.2.8)
|
||||||
@@ -857,6 +860,9 @@ packages:
|
|||||||
dom-accessibility-api@0.6.3:
|
dom-accessibility-api@0.6.3:
|
||||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
|
||||||
|
echarts@6.1.0:
|
||||||
|
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.399:
|
electron-to-chromium@1.5.399:
|
||||||
resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==}
|
resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==}
|
||||||
|
|
||||||
@@ -1484,6 +1490,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -1674,6 +1683,9 @@ packages:
|
|||||||
zod@4.4.3:
|
zod@4.4.3:
|
||||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||||
|
|
||||||
|
zrender@6.1.0:
|
||||||
|
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||||
|
|
||||||
zustand@5.0.14:
|
zustand@5.0.14:
|
||||||
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
|
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
@@ -2414,6 +2426,11 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.6.3: {}
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
|
echarts@6.1.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 6.1.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.399: {}
|
electron-to-chromium@1.5.399: {}
|
||||||
|
|
||||||
enhanced-resolve@5.24.5:
|
enhanced-resolve@5.24.5:
|
||||||
@@ -2958,6 +2975,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3078,6 +3097,10 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.4.3: {}
|
zod@4.4.3: {}
|
||||||
|
|
||||||
|
zrender@6.1.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
|
||||||
zustand@5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)):
|
zustand@5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
|
|||||||
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 225 KiB |
@@ -4,7 +4,11 @@ const requestJson = vi.hoisted(() => vi.fn())
|
|||||||
|
|
||||||
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
||||||
|
|
||||||
import { getSelectionResults, getSelectionRun } from "./selection.api"
|
import {
|
||||||
|
getSelectionChart,
|
||||||
|
getSelectionResults,
|
||||||
|
getSelectionRun,
|
||||||
|
} from "./selection.api"
|
||||||
|
|
||||||
describe("selection API adapters", () => {
|
describe("selection API adapters", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -47,4 +51,18 @@ describe("selection API adapters", () => {
|
|||||||
expect(params.has("category")).toBe(false)
|
expect(params.has("category")).toBe(false)
|
||||||
expect(params.has("sort")).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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { requestJson } from "@/shared/api/request-json"
|
import { requestJson } from "@/shared/api/request-json"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
SelectionChart,
|
||||||
SelectionResults,
|
SelectionResults,
|
||||||
SelectionResultsQuery,
|
SelectionResultsQuery,
|
||||||
SelectionRunAccepted,
|
SelectionRunAccepted,
|
||||||
@@ -8,6 +9,20 @@ import type {
|
|||||||
SelectionStrategy,
|
SelectionStrategy,
|
||||||
} from "./selection.types"
|
} from "./selection.types"
|
||||||
|
|
||||||
|
export function getSelectionChart(
|
||||||
|
tsCode: string,
|
||||||
|
targetTradeDate: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
target_trade_date: targetTradeDate,
|
||||||
|
})
|
||||||
|
return requestJson<SelectionChart>(
|
||||||
|
`/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`,
|
||||||
|
{ signal },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function getSelectionResults(
|
export function getSelectionResults(
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
targetTradeDate?: string,
|
targetTradeDate?: string,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getSelectionChart,
|
||||||
getSelectionResults,
|
getSelectionResults,
|
||||||
getSelectionRun,
|
getSelectionRun,
|
||||||
triggerSelectionRun,
|
triggerSelectionRun,
|
||||||
@@ -17,6 +18,12 @@ import type {
|
|||||||
SelectionStrategy,
|
SelectionStrategy,
|
||||||
} from "./selection.types"
|
} from "./selection.types"
|
||||||
|
|
||||||
|
export const selectionChartQueryKey = (
|
||||||
|
tsCode?: string,
|
||||||
|
targetTradeDate?: string,
|
||||||
|
) =>
|
||||||
|
["selection", "chart", tsCode ?? "none", targetTradeDate ?? "none"] as const
|
||||||
|
|
||||||
export const selectionResultsQueryKey = (
|
export const selectionResultsQueryKey = (
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
targetTradeDate?: string,
|
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(
|
export function useSelectionRun(
|
||||||
runId: string | null,
|
runId: string | null,
|
||||||
resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 },
|
||||||
|
|||||||
@@ -58,6 +58,26 @@ export interface SelectionStockResult {
|
|||||||
signals: SelectionSignal[]
|
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 {
|
export interface SelectionPatternScore {
|
||||||
status: "matched" | "below_threshold" | "failed"
|
status: "matched" | "below_threshold" | "failed"
|
||||||
value: number | null
|
value: number | null
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(<PatternCaseImage score={score()} />)
|
||||||
|
|
||||||
|
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(<PatternCaseImage score={score("case_001", "华纳药厂")} />)
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not guess an asset for an unknown case id", () => {
|
||||||
|
render(<PatternCaseImage score={score("case_unknown", "未知案例")} />)
|
||||||
|
|
||||||
|
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(<PatternCaseImage score={null} />)
|
||||||
|
expect(screen.queryByRole("img")).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
view.rerender(<PatternCaseImage score={failed} />)
|
||||||
|
expect(screen.queryByRole("img")).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText("案例图片暂不可用。")).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string | null>(null)
|
||||||
|
const matchedCase = score?.status === "failed" ? null : score?.case
|
||||||
|
if (!matchedCase) return null
|
||||||
|
|
||||||
|
const source =
|
||||||
|
patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="best-pattern-case" className="mt-4">
|
||||||
|
<div className="mb-2 flex items-baseline justify-between gap-3">
|
||||||
|
<h3 className="text-xs font-medium" id="best-pattern-case">
|
||||||
|
最接近案例:{matchedCase.name}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{formatScore(score?.value)} 分
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!source || failedSource === source ? (
|
||||||
|
<p className="rounded-md border border-border/60 p-3 text-xs text-muted-foreground">
|
||||||
|
案例图片暂不可用。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
alt={`${matchedCase.name} B1 参考案例图`}
|
||||||
|
className="aspect-2/1 w-full rounded-md border border-border/60 object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setFailedSource(source)}
|
||||||
|
src={source}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatScore(value: number | null | undefined) {
|
||||||
|
return value === null || value === undefined ? "—" : value.toFixed(2)
|
||||||
|
}
|
||||||
@@ -18,13 +18,6 @@ export function PatternScoreSummary({ score }: PatternScoreProps) {
|
|||||||
if (score.status === "failed") {
|
if (score.status === "failed") {
|
||||||
return <Badge variant="destructive">评分暂不可用</Badge>
|
return <Badge variant="destructive">评分暂不可用</Badge>
|
||||||
}
|
}
|
||||||
if (score.status === "below_threshold") {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline">
|
|
||||||
未匹配到 {formatScore(score.threshold)} 分以上案例
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<span className="space-y-0.5">
|
<span className="space-y-0.5">
|
||||||
<strong className="block tabular-nums text-primary">
|
<strong className="block tabular-nums text-primary">
|
||||||
@@ -33,6 +26,9 @@ export function PatternScoreSummary({ score }: PatternScoreProps) {
|
|||||||
<span className="block text-[10px] text-muted-foreground">
|
<span className="block text-[10px] text-muted-foreground">
|
||||||
{score.case?.name ?? "最佳案例未知"}
|
{score.case?.name ?? "最佳案例未知"}
|
||||||
</span>
|
</span>
|
||||||
|
{score.status === "below_threshold" ? (
|
||||||
|
<Badge variant="outline">低于阈值</Badge>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -53,16 +49,6 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (score.status === "below_threshold") {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Badge variant="outline">低于匹配阈值</Badge>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
未匹配到 {formatScore(score.threshold)} 分以上案例。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -74,7 +60,9 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {
|
|||||||
最佳案例:{score.case?.name ?? "未知"}
|
最佳案例:{score.case?.name ?? "未知"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="default">已匹配</Badge>
|
<Badge variant={score.status === "matched" ? "default" : "outline"}>
|
||||||
|
{score.status === "matched" ? "已匹配" : "低于匹配阈值"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{score.breakdown ? (
|
{score.breakdown ? (
|
||||||
<dl className="grid grid-cols-2 gap-2 text-xs">
|
<dl className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import type { SelectionChartPoint } from "../api/selection.types"
|
||||||
|
import { buildSelectionChartOption } from "./selection-chart-option"
|
||||||
|
|
||||||
|
function point(index: number): SelectionChartPoint {
|
||||||
|
return {
|
||||||
|
close: index + 10.5,
|
||||||
|
d: index + 20,
|
||||||
|
high: index + 11,
|
||||||
|
j: index + 30,
|
||||||
|
k: index + 10,
|
||||||
|
low: index + 9,
|
||||||
|
open: index + 10,
|
||||||
|
trade_date: `2026-${String(Math.floor(index / 28) + 1).padStart(2, "0")}-${String((index % 28) + 1).padStart(2, "0")}`,
|
||||||
|
volume: index + 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("selection chart option", () => {
|
||||||
|
it("aligns all three grids and opens on exactly the last 120 points", () => {
|
||||||
|
const points = Array.from({ length: 250 }, (_, index) => point(index))
|
||||||
|
const option = buildSelectionChartOption(points)
|
||||||
|
const dataZoom = option.dataZoom as Array<{
|
||||||
|
endValue: string
|
||||||
|
startValue: string
|
||||||
|
xAxisIndex: number[]
|
||||||
|
}>
|
||||||
|
const series = option.series as Array<{
|
||||||
|
data: unknown[]
|
||||||
|
xAxisIndex: number
|
||||||
|
yAxisIndex: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
expect(dataZoom).toHaveLength(2)
|
||||||
|
expect(dataZoom[0]).toMatchObject({
|
||||||
|
endValue: points[249].trade_date,
|
||||||
|
startValue: points[130].trade_date,
|
||||||
|
xAxisIndex: [0, 1, 2],
|
||||||
|
})
|
||||||
|
expect(dataZoom[1].xAxisIndex).toEqual([0, 1, 2])
|
||||||
|
expect(series[0]).toMatchObject({ xAxisIndex: 0, yAxisIndex: 0 })
|
||||||
|
expect(series[1]).toMatchObject({ xAxisIndex: 1, yAxisIndex: 1 })
|
||||||
|
expect(series.slice(2)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ xAxisIndex: 2, yAxisIndex: 2 }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(series[0].data[0]).toEqual([10, 10.5, 9, 11])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps missing candles and indicator values empty", () => {
|
||||||
|
const incomplete = {
|
||||||
|
...point(0),
|
||||||
|
close: null,
|
||||||
|
d: null,
|
||||||
|
high: null,
|
||||||
|
j: null,
|
||||||
|
k: null,
|
||||||
|
volume: null,
|
||||||
|
}
|
||||||
|
const option = buildSelectionChartOption([incomplete])
|
||||||
|
const series = option.series as Array<{
|
||||||
|
connectNulls?: boolean
|
||||||
|
data: unknown[]
|
||||||
|
}>
|
||||||
|
const candle = series[0].data[0] as number[]
|
||||||
|
|
||||||
|
expect(candle).toHaveLength(4)
|
||||||
|
expect(candle.every(Number.isNaN)).toBe(true)
|
||||||
|
expect(series[1].data[0]).toMatchObject({ value: null })
|
||||||
|
for (const indicator of series.slice(2)) {
|
||||||
|
expect(indicator.connectNulls).toBe(false)
|
||||||
|
expect(indicator.data).toEqual([null])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import type {
|
||||||
|
BarSeriesOption,
|
||||||
|
CandlestickSeriesOption,
|
||||||
|
LineSeriesOption,
|
||||||
|
} from "echarts/charts"
|
||||||
|
import type {
|
||||||
|
DataZoomComponentOption,
|
||||||
|
GridComponentOption,
|
||||||
|
LegendComponentOption,
|
||||||
|
TitleComponentOption,
|
||||||
|
TooltipComponentOption,
|
||||||
|
} from "echarts/components"
|
||||||
|
import type { ComposeOption } from "echarts/core"
|
||||||
|
|
||||||
|
import type { SelectionChartPoint } from "../api/selection.types"
|
||||||
|
|
||||||
|
const DEFAULT_VISIBLE_POINTS = 120
|
||||||
|
|
||||||
|
export type SelectionChartOption = ComposeOption<
|
||||||
|
| BarSeriesOption
|
||||||
|
| CandlestickSeriesOption
|
||||||
|
| DataZoomComponentOption
|
||||||
|
| GridComponentOption
|
||||||
|
| LegendComponentOption
|
||||||
|
| LineSeriesOption
|
||||||
|
| TitleComponentOption
|
||||||
|
| TooltipComponentOption
|
||||||
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the three-grid financial chart configuration from aligned API points.
|
||||||
|
*
|
||||||
|
* @param points Ascending qfq OHLCV and KDJ points from the selection API.
|
||||||
|
* @returns One ECharts option with synchronized axes and an exact 120-point window.
|
||||||
|
*/
|
||||||
|
export function buildSelectionChartOption(
|
||||||
|
points: SelectionChartPoint[],
|
||||||
|
): SelectionChartOption {
|
||||||
|
const dates = points.map((point) => point.trade_date)
|
||||||
|
const startIndex = Math.max(0, dates.length - DEFAULT_VISIBLE_POINTS)
|
||||||
|
const startValue = dates[startIndex]
|
||||||
|
const endValue = dates.at(-1)
|
||||||
|
const zoomRange = { endValue, startValue }
|
||||||
|
|
||||||
|
return {
|
||||||
|
animation: false,
|
||||||
|
dataZoom: [
|
||||||
|
{
|
||||||
|
...zoomRange,
|
||||||
|
type: "inside",
|
||||||
|
xAxisIndex: [0, 1, 2],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...zoomRange,
|
||||||
|
bottom: 4,
|
||||||
|
height: 20,
|
||||||
|
type: "slider",
|
||||||
|
xAxisIndex: [0, 1, 2],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
grid: [
|
||||||
|
{ bottom: "46%", left: 64, right: 24, top: 36 },
|
||||||
|
{ bottom: "28%", height: "14%", left: 64, right: 24 },
|
||||||
|
{ bottom: 48, height: "20%", left: 64, right: 24 },
|
||||||
|
],
|
||||||
|
legend: {
|
||||||
|
data: ["K", "D", "J"],
|
||||||
|
right: 20,
|
||||||
|
top: "73%",
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
data: points.map(toCandlestickValue),
|
||||||
|
itemStyle: {
|
||||||
|
borderColor: "#dc2626",
|
||||||
|
borderColor0: "#16a34a",
|
||||||
|
color: "#dc2626",
|
||||||
|
color0: "#16a34a",
|
||||||
|
},
|
||||||
|
name: "日线",
|
||||||
|
type: "candlestick",
|
||||||
|
xAxisIndex: 0,
|
||||||
|
yAxisIndex: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: points.map((point) => ({
|
||||||
|
itemStyle: {
|
||||||
|
color:
|
||||||
|
point.open !== null &&
|
||||||
|
point.close !== null &&
|
||||||
|
point.close >= point.open
|
||||||
|
? "#dc2626"
|
||||||
|
: "#16a34a",
|
||||||
|
},
|
||||||
|
value: point.volume,
|
||||||
|
})),
|
||||||
|
name: "成交量",
|
||||||
|
type: "bar",
|
||||||
|
xAxisIndex: 1,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
},
|
||||||
|
...(["k", "d", "j"] as const).map((key, index): LineSeriesOption => ({
|
||||||
|
connectNulls: false,
|
||||||
|
data: points.map((point) => point[key]),
|
||||||
|
name: key.toUpperCase(),
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: false,
|
||||||
|
type: "line",
|
||||||
|
xAxisIndex: 2,
|
||||||
|
yAxisIndex: 2,
|
||||||
|
lineStyle: {
|
||||||
|
color: ["#2563eb", "#d97706", "#9333ea"][index],
|
||||||
|
width: 1.5,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
title: [
|
||||||
|
{ left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 },
|
||||||
|
{ left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "54%" },
|
||||||
|
{ left: 72, text: "KDJ", textStyle: { fontSize: 12 }, top: "72%" },
|
||||||
|
],
|
||||||
|
tooltip: {
|
||||||
|
axisPointer: { type: "cross" },
|
||||||
|
trigger: "axis",
|
||||||
|
},
|
||||||
|
xAxis: [
|
||||||
|
buildCategoryAxis(dates, 0, false),
|
||||||
|
buildCategoryAxis(dates, 1, false),
|
||||||
|
buildCategoryAxis(dates, 2, true),
|
||||||
|
],
|
||||||
|
yAxis: [
|
||||||
|
buildValueAxis(0),
|
||||||
|
buildValueAxis(1),
|
||||||
|
buildValueAxis(2, { max: 120, min: -20 }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCandlestickValue(point: SelectionChartPoint) {
|
||||||
|
if (
|
||||||
|
point.open === null ||
|
||||||
|
point.close === null ||
|
||||||
|
point.low === null ||
|
||||||
|
point.high === null
|
||||||
|
) {
|
||||||
|
return [Number.NaN, Number.NaN, Number.NaN, Number.NaN]
|
||||||
|
}
|
||||||
|
return [point.open, point.close, point.low, point.high]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCategoryAxis(
|
||||||
|
dates: string[],
|
||||||
|
gridIndex: number,
|
||||||
|
showLabels: boolean,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
axisLabel: { show: showLabels },
|
||||||
|
axisLine: { onZero: false },
|
||||||
|
boundaryGap: false,
|
||||||
|
data: dates,
|
||||||
|
gridIndex,
|
||||||
|
min: "dataMin",
|
||||||
|
max: "dataMax",
|
||||||
|
scale: true,
|
||||||
|
splitLine: { show: false },
|
||||||
|
type: "category" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildValueAxis(
|
||||||
|
gridIndex: number,
|
||||||
|
bounds: { max?: number; min?: number } = {},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...bounds,
|
||||||
|
axisLabel: { fontSize: 10 },
|
||||||
|
gridIndex,
|
||||||
|
scale: true,
|
||||||
|
splitLine: { lineStyle: { opacity: 0.16 } },
|
||||||
|
type: "value" as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { render, screen } from "@testing-library/react"
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import type { SelectionChart as SelectionChartData } from "../api/selection.types"
|
||||||
|
|
||||||
|
const chartMocks = vi.hoisted(() => ({
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
dispose: vi.fn(),
|
||||||
|
init: vi.fn(),
|
||||||
|
observe: vi.fn(),
|
||||||
|
resize: vi.fn(),
|
||||||
|
setOption: vi.fn(),
|
||||||
|
use: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("echarts/charts", () => ({
|
||||||
|
BarChart: {},
|
||||||
|
CandlestickChart: {},
|
||||||
|
LineChart: {},
|
||||||
|
}))
|
||||||
|
vi.mock("echarts/components", () => ({
|
||||||
|
AxisPointerComponent: {},
|
||||||
|
DataZoomComponent: {},
|
||||||
|
GridComponent: {},
|
||||||
|
LegendComponent: {},
|
||||||
|
TitleComponent: {},
|
||||||
|
TooltipComponent: {},
|
||||||
|
}))
|
||||||
|
vi.mock("echarts/core", () => ({
|
||||||
|
init: chartMocks.init,
|
||||||
|
use: chartMocks.use,
|
||||||
|
}))
|
||||||
|
vi.mock("echarts/renderers", () => ({ CanvasRenderer: {} }))
|
||||||
|
|
||||||
|
import { SelectionChart } from "./selection-chart"
|
||||||
|
|
||||||
|
const chart: SelectionChartData = {
|
||||||
|
name: "药明康德",
|
||||||
|
points: [
|
||||||
|
{
|
||||||
|
close: 10.5,
|
||||||
|
d: 48,
|
||||||
|
high: 11,
|
||||||
|
j: 60,
|
||||||
|
k: 52,
|
||||||
|
low: 9,
|
||||||
|
open: 10,
|
||||||
|
trade_date: "2026-08-08",
|
||||||
|
volume: 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source_adj: "qfq",
|
||||||
|
target_trade_date: "2026-08-08",
|
||||||
|
ts_code: "603259.SH",
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SelectionChart", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
chartMocks.init.mockReturnValue({
|
||||||
|
dispose: chartMocks.dispose,
|
||||||
|
resize: chartMocks.resize,
|
||||||
|
setOption: chartMocks.setOption,
|
||||||
|
})
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
constructor(private readonly callback: ResizeObserverCallback) {}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
chartMocks.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
observe() {
|
||||||
|
chartMocks.observe()
|
||||||
|
this.callback([], this as unknown as ResizeObserver)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("initializes, resizes, updates and disposes one chart instance", () => {
|
||||||
|
const view = render(<SelectionChart chart={chart} />)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", {
|
||||||
|
name: "药明康德 日线、成交量和 KDJ 图表",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(chartMocks.init).toHaveBeenCalledTimes(1)
|
||||||
|
expect(chartMocks.observe).toHaveBeenCalledTimes(1)
|
||||||
|
expect(chartMocks.resize).toHaveBeenCalledTimes(1)
|
||||||
|
expect(chartMocks.setOption).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<SelectionChart
|
||||||
|
chart={{ ...chart, points: [...chart.points, chart.points[0]] }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(chartMocks.setOption).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
expect(chartMocks.disconnect).toHaveBeenCalledTimes(1)
|
||||||
|
expect(chartMocks.dispose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { BarChart, CandlestickChart, LineChart } from "echarts/charts"
|
||||||
|
import {
|
||||||
|
AxisPointerComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
} from "echarts/components"
|
||||||
|
import * as echarts from "echarts/core"
|
||||||
|
import { CanvasRenderer } from "echarts/renderers"
|
||||||
|
|
||||||
|
import type { SelectionChart as SelectionChartData } from "../api/selection.types"
|
||||||
|
import { buildSelectionChartOption } from "./selection-chart-option"
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
AxisPointerComponent,
|
||||||
|
BarChart,
|
||||||
|
CanvasRenderer,
|
||||||
|
CandlestickChart,
|
||||||
|
DataZoomComponent,
|
||||||
|
GridComponent,
|
||||||
|
LegendComponent,
|
||||||
|
LineChart,
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
])
|
||||||
|
|
||||||
|
interface SelectionChartProps {
|
||||||
|
chart: SelectionChartData
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectionChart({ chart }: SelectionChartProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const instanceRef = useRef<echarts.EChartsType | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const instance = echarts.init(container, undefined, {
|
||||||
|
renderer: "canvas",
|
||||||
|
})
|
||||||
|
instanceRef.current = instance
|
||||||
|
const observer = new ResizeObserver(() => instance.resize())
|
||||||
|
observer.observe(container)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
instance.dispose()
|
||||||
|
instanceRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
instanceRef.current?.setOption(buildSelectionChartOption(chart.points), {
|
||||||
|
notMerge: true,
|
||||||
|
})
|
||||||
|
}, [chart.points])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-label={`${chart.name} 日线、成交量和 KDJ 图表`}
|
||||||
|
className="h-[620px] min-h-[520px] w-full"
|
||||||
|
ref={containerRef}
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -122,7 +122,7 @@ export function SelectionResultsWorkbench({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_260px] md:overflow-hidden lg:grid-cols-[minmax(0,1.55fr)_minmax(280px,0.72fr)]">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,3fr)] md:overflow-hidden">
|
||||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden">
|
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden">
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { lazy, Suspense } from "react"
|
||||||
import { Database, Search } from "lucide-react"
|
import { Database, Search } from "lucide-react"
|
||||||
|
import { useSelectionChart } from "../api/selection.query"
|
||||||
|
|
||||||
import { Badge } from "@/shared/ui/badge"
|
import { Badge } from "@/shared/ui/badge"
|
||||||
import { Card } from "@/shared/ui/card"
|
import { Card } from "@/shared/ui/card"
|
||||||
@@ -7,12 +9,21 @@ import type { SelectionStockResult } from "../api/selection.types"
|
|||||||
import { PatternScoreDetails } from "./pattern-score"
|
import { PatternScoreDetails } from "./pattern-score"
|
||||||
import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
|
import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
|
||||||
import { SignalDetails } from "./signal-details"
|
import { SignalDetails } from "./signal-details"
|
||||||
|
import { PatternCaseImage } from "./pattern-case-image"
|
||||||
|
|
||||||
|
const SelectionChart = lazy(() =>
|
||||||
|
import("./selection-chart").then((module) => ({
|
||||||
|
default: module.SelectionChart,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
interface SignalDetailPanelProps {
|
interface SignalDetailPanelProps {
|
||||||
stock: SelectionStockResult | undefined
|
stock: SelectionStockResult | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||||
|
const chartQuery = useSelectionChart(stock?.ts_code, stock?.target_trade_date)
|
||||||
|
|
||||||
if (!stock) {
|
if (!stock) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -95,6 +106,32 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
|||||||
<PatternScoreDetails score={stock.score} />
|
<PatternScoreDetails score={stock.score} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-4" aria-labelledby="technical-chart">
|
||||||
|
<h3
|
||||||
|
className="mb-2 text-xs font-medium text-muted-foreground"
|
||||||
|
id="technical-chart"
|
||||||
|
>
|
||||||
|
技术图表
|
||||||
|
</h3>
|
||||||
|
{chartQuery.isPending ? (
|
||||||
|
<p className="text-xs text-muted-foreground">正在加载行情图表…</p>
|
||||||
|
) : chartQuery.isError ? (
|
||||||
|
<p className="text-xs text-destructive">行情图表暂时不可用。</p>
|
||||||
|
) : chartQuery.data?.points.length ? (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<p className="text-xs text-muted-foreground">正在加载图表组件…</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectionChart chart={chartQuery.data} />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">暂无可用行情数据。</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PatternCaseImage score={stock.score} />
|
||||||
|
|
||||||
<section className="mt-4" aria-labelledby="signal-detail-metrics">
|
<section className="mt-4" aria-labelledby="signal-detail-metrics">
|
||||||
<h3
|
<h3
|
||||||
className="mb-2 text-xs font-medium text-muted-foreground"
|
className="mb-2 text-xs font-medium text-muted-foreground"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { SelectionResultsPage } from "./selection-results-page"
|
|||||||
|
|
||||||
const routerNavigate = vi.hoisted(() => vi.fn())
|
const routerNavigate = vi.hoisted(() => vi.fn())
|
||||||
const useSelectionResults = vi.fn()
|
const useSelectionResults = vi.fn()
|
||||||
|
const useSelectionChart = vi.fn()
|
||||||
const useSelectionRun = vi.fn()
|
const useSelectionRun = vi.fn()
|
||||||
const useTriggerSelectionRun = vi.fn()
|
const useTriggerSelectionRun = vi.fn()
|
||||||
const mutate = vi.fn()
|
const mutate = vi.fn()
|
||||||
@@ -23,6 +24,7 @@ const reset = vi.fn()
|
|||||||
|
|
||||||
vi.mock("@/features/selection/api/selection.query", () => ({
|
vi.mock("@/features/selection/api/selection.query", () => ({
|
||||||
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
|
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
|
||||||
|
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
|
||||||
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
|
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
|
||||||
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
||||||
}))
|
}))
|
||||||
@@ -110,6 +112,11 @@ const selectedResult: SelectionResults = {
|
|||||||
|
|
||||||
describe("SelectionResultsPage", () => {
|
describe("SelectionResultsPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
useSelectionChart.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue({
|
||||||
data: selectedResult,
|
data: selectedResult,
|
||||||
isError: false,
|
isError: false,
|
||||||
@@ -167,12 +174,23 @@ describe("SelectionResultsPage", () => {
|
|||||||
})
|
})
|
||||||
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
|
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
|
||||||
expect(within(detailPanel).getByText("图形相似度评分")).toBeInTheDocument()
|
expect(within(detailPanel).getByText("图形相似度评分")).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
within(detailPanel).getByText("暂无可用行情数据。"),
|
||||||
|
).toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
within(detailPanel).getByText("最佳案例:华纳药厂"),
|
within(detailPanel).getByText("最佳案例:华纳药厂"),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(within(detailPanel).getByText("价格形态")).toBeInTheDocument()
|
expect(within(detailPanel).getByText("价格形态")).toBeInTheDocument()
|
||||||
expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2)
|
expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2)
|
||||||
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
|
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
|
||||||
|
const caseImages = within(detailPanel).getAllByRole("img", {
|
||||||
|
name: "华纳药厂 B1 参考案例图",
|
||||||
|
})
|
||||||
|
expect(caseImages).toHaveLength(1)
|
||||||
|
expect(caseImages[0]).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg",
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each<{
|
it.each<{
|
||||||
@@ -184,14 +202,23 @@ describe("SelectionResultsPage", () => {
|
|||||||
score: null,
|
score: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
expected: "未匹配到 60.00 分以上案例。",
|
expected: "低于匹配阈值",
|
||||||
score: {
|
score: {
|
||||||
breakdown: null,
|
breakdown: {
|
||||||
case: null,
|
kdj_state: 42,
|
||||||
reason: "未匹配到评分阈值以上案例",
|
price_shape: 44,
|
||||||
|
trend_structure: 40,
|
||||||
|
volume_pattern: 43,
|
||||||
|
},
|
||||||
|
case: {
|
||||||
|
breakout_date: "2025-07-23",
|
||||||
|
id: "case_004",
|
||||||
|
name: "方正科技",
|
||||||
|
},
|
||||||
|
reason: null,
|
||||||
status: "below_threshold",
|
status: "below_threshold",
|
||||||
threshold: 60,
|
threshold: 60,
|
||||||
value: null,
|
value: 42.5,
|
||||||
version: "zhixing_b1_pattern_fastdtw_v1",
|
version: "zhixing_b1_pattern_fastdtw_v1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -225,9 +252,51 @@ describe("SelectionResultsPage", () => {
|
|||||||
const table = screen.getByRole("table")
|
const table = screen.getByRole("table")
|
||||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||||
expect(within(table).getByText("12")).toBeInTheDocument()
|
expect(within(table).getByText("12")).toBeInTheDocument()
|
||||||
|
if (score?.status === "below_threshold") {
|
||||||
|
const detailPanel = screen.getByRole("complementary", {
|
||||||
|
name: "当前股票详情",
|
||||||
|
})
|
||||||
|
const images = within(detailPanel).getAllByRole("img", {
|
||||||
|
name: "方正科技 B1 参考案例图",
|
||||||
|
})
|
||||||
|
expect(images).toHaveLength(1)
|
||||||
|
expect(images[0]).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
|
||||||
|
)
|
||||||
|
expect(within(detailPanel).getAllByText("42.50 分")).toHaveLength(2)
|
||||||
|
} else {
|
||||||
|
const detailPanel = screen.getByRole("complementary", {
|
||||||
|
name: "当前股票详情",
|
||||||
|
})
|
||||||
|
expect(within(detailPanel).queryByRole("img")).not.toBeInTheDocument()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it("shows chart loading and error states for the selected stock", () => {
|
||||||
|
useSelectionChart.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isError: false,
|
||||||
|
isPending: true,
|
||||||
|
})
|
||||||
|
const view = render(<SelectionResultsPage />)
|
||||||
|
|
||||||
|
expect(screen.getByText("正在加载行情图表…")).toBeInTheDocument()
|
||||||
|
expect(useSelectionChart).toHaveBeenLastCalledWith(
|
||||||
|
"000001.SZ",
|
||||||
|
"2026-08-08",
|
||||||
|
)
|
||||||
|
|
||||||
|
useSelectionChart.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isError: true,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
|
view.rerender(<SelectionResultsPage />)
|
||||||
|
expect(screen.getByText("行情图表暂时不可用。")).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it("opens execution status details in a drawer and restores trigger focus", async () => {
|
it("opens execution status details in a drawer and restores trigger focus", async () => {
|
||||||
useSelectionResults.mockReturnValue({
|
useSelectionResults.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||