feat(web): 完善选股详情图表与案例图
This commit is contained in:
@@ -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") {
|
||||
return <Badge variant="destructive">评分暂不可用</Badge>
|
||||
}
|
||||
if (score.status === "below_threshold") {
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
未匹配到 {formatScore(score.threshold)} 分以上案例
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="space-y-0.5">
|
||||
<strong className="block tabular-nums text-primary">
|
||||
@@ -33,6 +26,9 @@ export function PatternScoreSummary({ score }: PatternScoreProps) {
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
{score.case?.name ?? "最佳案例未知"}
|
||||
</span>
|
||||
{score.status === "below_threshold" ? (
|
||||
<Badge variant="outline">低于阈值</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -53,16 +49,6 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {
|
||||
</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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -74,7 +60,9 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {
|
||||
最佳案例:{score.case?.name ?? "未知"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="default">已匹配</Badge>
|
||||
<Badge variant={score.status === "matched" ? "default" : "outline"}>
|
||||
{score.status === "matched" ? "已匹配" : "低于匹配阈值"}
|
||||
</Badge>
|
||||
</div>
|
||||
{score.breakdown ? (
|
||||
<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 (
|
||||
<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">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { lazy, Suspense } from "react"
|
||||
import { Database, Search } from "lucide-react"
|
||||
import { useSelectionChart } from "../api/selection.query"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Card } from "@/shared/ui/card"
|
||||
@@ -7,12 +9,21 @@ import type { SelectionStockResult } from "../api/selection.types"
|
||||
import { PatternScoreDetails } from "./pattern-score"
|
||||
import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
|
||||
import { SignalDetails } from "./signal-details"
|
||||
import { PatternCaseImage } from "./pattern-case-image"
|
||||
|
||||
const SelectionChart = lazy(() =>
|
||||
import("./selection-chart").then((module) => ({
|
||||
default: module.SelectionChart,
|
||||
})),
|
||||
)
|
||||
|
||||
interface SignalDetailPanelProps {
|
||||
stock: SelectionStockResult | undefined
|
||||
}
|
||||
|
||||
export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
const chartQuery = useSelectionChart(stock?.ts_code, stock?.target_trade_date)
|
||||
|
||||
if (!stock) {
|
||||
return (
|
||||
<Card
|
||||
@@ -95,6 +106,32 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
<PatternScoreDetails score={stock.score} />
|
||||
</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">
|
||||
<h3
|
||||
className="mb-2 text-xs font-medium text-muted-foreground"
|
||||
|
||||
Reference in New Issue
Block a user