refactor(selection): update component descriptions and improve layout for stock detail panel
This commit is contained in:
@@ -85,7 +85,7 @@ export function ComponentsPreviewPage() {
|
||||
</PreviewCard>
|
||||
|
||||
<PreviewCard
|
||||
description="Select 使用 Base UI 的弹层和键盘语义,字段值由页面状态控制。"
|
||||
description="Select 默认在触发器下方展开菜单;字段值由页面状态控制。"
|
||||
title="Select & Input"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
|
||||
@@ -312,7 +312,7 @@ function FilterSelect({
|
||||
<SelectTrigger aria-label={label} className="bg-background">
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
|
||||
@@ -6,13 +6,6 @@ interface PatternScoreProps {
|
||||
score: SelectionPatternScore | null
|
||||
}
|
||||
|
||||
const breakdownLabels = {
|
||||
trend_structure: "趋势",
|
||||
kdj_state: "KDJ",
|
||||
volume_pattern: "量能",
|
||||
price_shape: "价格形态",
|
||||
} as const
|
||||
|
||||
export function PatternScoreSummary({ score }: PatternScoreProps) {
|
||||
if (!score) return <span className="text-muted-foreground">未评分</span>
|
||||
if (score.status === "failed") {
|
||||
@@ -47,34 +40,13 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{formatScore(score.value)} 分
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
最佳案例:{score.case?.name ?? "未知"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={score.status === "matched" ? "default" : "outline"}>
|
||||
{score.status === "matched" ? "已匹配" : "低于匹配阈值"}
|
||||
</Badge>
|
||||
</div>
|
||||
{score.breakdown ? (
|
||||
<dl className="grid grid-cols-2 gap-2 text-xs">
|
||||
{Object.entries(breakdownLabels).map(([key, label]) => (
|
||||
<div key={key} className="rounded-md border border-border/60 p-2">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-1 font-semibold tabular-nums">
|
||||
{formatScore(
|
||||
score.breakdown?.[key as keyof typeof breakdownLabels],
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xl font-semibold tabular-nums">
|
||||
{formatScore(score.value)} 分
|
||||
</p>
|
||||
<Badge variant={score.status === "matched" ? "default" : "outline"}>
|
||||
{score.status === "matched" ? "已匹配" : "低于匹配阈值"}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import * as echarts from "echarts/core"
|
||||
import { CanvasRenderer } from "echarts/renderers"
|
||||
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
import type { SelectionChart as SelectionChartData } from "../api/selection.types"
|
||||
import { buildSelectionChartOption } from "./selection-chart-option"
|
||||
|
||||
@@ -29,9 +31,10 @@ echarts.use([
|
||||
|
||||
interface SelectionChartProps {
|
||||
chart: SelectionChartData
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SelectionChart({ chart }: SelectionChartProps) {
|
||||
export function SelectionChart({ chart, className }: SelectionChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const instanceRef = useRef<echarts.EChartsType | null>(null)
|
||||
|
||||
@@ -62,7 +65,7 @@ export function SelectionChart({ chart }: SelectionChartProps) {
|
||||
return (
|
||||
<div
|
||||
aria-label={`${chart.name} 日线、知行黄白线、成交量和 KDJ 图表`}
|
||||
className="h-[620px] min-h-[520px] w-full"
|
||||
className={cn("h-full min-h-70 w-full", className)}
|
||||
ref={containerRef}
|
||||
role="img"
|
||||
/>
|
||||
|
||||
@@ -86,16 +86,44 @@ export function getSignalDetailItems(details: SelectionSignal["details"]) {
|
||||
}))
|
||||
}
|
||||
|
||||
export const stockBasicMetricKeys = [
|
||||
"j",
|
||||
"rsi",
|
||||
"daily_change",
|
||||
"daily_amplitude",
|
||||
] as const
|
||||
|
||||
export function getSignalJValue(details: SelectionSignal["details"]) {
|
||||
return formatSignalDetailValue("j", details.j)
|
||||
}
|
||||
|
||||
export function getStockJValue(stock: SelectionStockResult) {
|
||||
const signal = stock.signals.find(
|
||||
({ details }) =>
|
||||
details.j !== null && details.j !== undefined && details.j !== "",
|
||||
return getStockDetailValue(stock, "j")
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one stock-level metric from the first signal that carries it.
|
||||
*
|
||||
* Zhixing B1 writes the same target-row metrics onto every matched category,
|
||||
* so the detail pane must not group these values by signal.
|
||||
*/
|
||||
export function getStockDetailValue(stock: SelectionStockResult, key: string) {
|
||||
const signal = stock.signals.find(({ details }) =>
|
||||
hasDetailValue(details[key]),
|
||||
)
|
||||
return getSignalJValue(signal?.details ?? {})
|
||||
return formatSignalDetailValue(key, signal?.details[key])
|
||||
}
|
||||
|
||||
export function getStockBasicMetrics(stock: SelectionStockResult) {
|
||||
return stockBasicMetricKeys.map((key) => ({
|
||||
key,
|
||||
label: detailLabels[key],
|
||||
value: getStockDetailValue(stock, key),
|
||||
}))
|
||||
}
|
||||
|
||||
function hasDetailValue(value: number | string | null | undefined) {
|
||||
return value !== null && value !== undefined && value !== ""
|
||||
}
|
||||
|
||||
function formatSignalDetailValue(
|
||||
|
||||
@@ -131,7 +131,7 @@ export function SelectionResultsWorkbench({
|
||||
>
|
||||
<SelectValue placeholder="筛选信号分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{signalCategoryOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
@@ -156,7 +156,7 @@ export function SelectionResultsWorkbench({
|
||||
>
|
||||
<SelectValue placeholder="排序命中股票" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" alignItemWithTrigger={false}>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{SCORE_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { lazy, Suspense } from "react"
|
||||
import { Database, Search } from "lucide-react"
|
||||
import { Search } from "lucide-react"
|
||||
import { useSelectionChart } from "../api/selection.query"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Card } from "@/shared/ui/card"
|
||||
|
||||
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"
|
||||
import { getStockBasicMetrics } from "./selection-presentation"
|
||||
|
||||
const SelectionChart = lazy(() =>
|
||||
import("./selection-chart").then((module) => ({
|
||||
@@ -44,111 +42,95 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const metricRows = [
|
||||
...getStockBasicMetrics(stock),
|
||||
{
|
||||
key: "target_trade_date",
|
||||
label: "目标交易日",
|
||||
value: stock.target_trade_date,
|
||||
},
|
||||
{ key: "strategy", label: "策略", value: "知行 B1" },
|
||||
]
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="当前股票详情"
|
||||
className="hidden min-h-0 overflow-auto rounded-md border border-border/80 bg-card p-3 md:block"
|
||||
className="hidden min-h-0 overflow-hidden rounded-md border border-border/80 bg-card md:flex"
|
||||
>
|
||||
<p className="text-[10px] font-bold tracking-[0.08em] text-muted-foreground uppercase">
|
||||
Selected stock
|
||||
</p>
|
||||
<div className="mt-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-xl font-semibold tracking-tight">
|
||||
{stock.name}
|
||||
</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{stock.ts_code}
|
||||
</p>
|
||||
</div>
|
||||
<Database
|
||||
className="mt-1 size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-3xl font-semibold tabular-nums tracking-tight">
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{stock.signals.map((signal) => (
|
||||
<Badge
|
||||
key={signal.category}
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 divide-y divide-border/60 border-y border-border/60 text-xs">
|
||||
<div className="flex items-center justify-between gap-3 py-2.5">
|
||||
<dt className="text-muted-foreground">目标交易日</dt>
|
||||
<dd className="font-medium tabular-nums">
|
||||
{stock.target_trade_date}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 py-2.5">
|
||||
<dt className="text-muted-foreground">策略</dt>
|
||||
<dd className="font-medium">知行 B1</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section className="mt-4" aria-labelledby="pattern-score-details">
|
||||
<section
|
||||
aria-labelledby="technical-chart"
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden p-3"
|
||||
>
|
||||
<h3
|
||||
className="mb-2 text-xs font-medium text-muted-foreground"
|
||||
id="pattern-score-details"
|
||||
>
|
||||
图形相似度评分
|
||||
</h3>
|
||||
<PatternScoreDetails score={stock.score} />
|
||||
</section>
|
||||
|
||||
<section className="mt-4" aria-labelledby="technical-chart">
|
||||
<h3
|
||||
className="mb-2 text-xs font-medium text-muted-foreground"
|
||||
className="mb-2 shrink-0 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>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PatternCaseImage score={stock.score} />
|
||||
</div>
|
||||
</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"
|
||||
id="signal-detail-metrics"
|
||||
<section
|
||||
aria-labelledby="selected-stock-heading"
|
||||
className="flex w-50 shrink-0 flex-col overflow-auto border-l border-border/60 p-3"
|
||||
>
|
||||
<h2
|
||||
className="truncate text-base font-semibold tracking-tight"
|
||||
id="selected-stock-heading"
|
||||
>
|
||||
关键指标
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{stock.signals.map((signal) => (
|
||||
<section key={signal.category} className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-foreground">
|
||||
{getCategoryLabel(signal.category)}
|
||||
</h4>
|
||||
<SignalDetails details={signal.details} />
|
||||
</section>
|
||||
{stock.name}
|
||||
</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{stock.ts_code}
|
||||
</p>
|
||||
<p className="mt-3 text-2xl font-semibold tabular-nums tracking-tight">
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</p>
|
||||
|
||||
<dl className="mt-3 divide-y divide-border/60 border-y border-border/60 text-xs">
|
||||
{metricRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className="flex items-center justify-between gap-2 py-2"
|
||||
>
|
||||
<dt className="shrink-0 text-muted-foreground">{row.label}</dt>
|
||||
<dd className="min-w-0 truncate text-right font-medium tabular-nums">
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section className="mt-3" aria-labelledby="pattern-score-details">
|
||||
<h3
|
||||
className="mb-2 text-xs font-medium text-muted-foreground"
|
||||
id="pattern-score-details"
|
||||
>
|
||||
图形相似度评分
|
||||
</h3>
|
||||
<PatternScoreDetails score={stock.score} />
|
||||
</section>
|
||||
</section>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -90,7 +90,12 @@ const selectedResult: SelectionResults = {
|
||||
{
|
||||
category: "zhixing_b1_original_b1",
|
||||
close: 10.5,
|
||||
details: { j: 12, rsi: 20 },
|
||||
details: {
|
||||
daily_amplitude: 3.4,
|
||||
daily_change: 1.2,
|
||||
j: 12,
|
||||
rsi: 20,
|
||||
},
|
||||
name: "平安银行",
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-08",
|
||||
@@ -199,17 +204,22 @@ describe("SelectionResultsPage", () => {
|
||||
const detailPanel = screen.getByRole("complementary", {
|
||||
name: "当前股票详情",
|
||||
})
|
||||
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
|
||||
expect(within(detailPanel).queryByText("关键指标")).not.toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("图形相似度评分")).toBeInTheDocument()
|
||||
expect(
|
||||
within(detailPanel).getByText("暂无可用行情数据。"),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(detailPanel).getByText("最佳案例:华纳药厂"),
|
||||
within(detailPanel).getByText("最接近案例:华纳药厂"),
|
||||
).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("价格形态")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2)
|
||||
expect(within(detailPanel).queryByText("价格形态")).not.toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("J 值")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("日涨跌幅")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("1.2%")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("日振幅")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("3.4%")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("20")).toBeInTheDocument()
|
||||
const caseImages = within(detailPanel).getAllByRole("img", {
|
||||
name: "华纳药厂 B1 参考案例图",
|
||||
})
|
||||
@@ -590,8 +600,10 @@ describe("SelectionResultsPage", () => {
|
||||
name: "当前股票详情",
|
||||
})
|
||||
expect(within(detailPanel).getByText("招商银行")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("MACD")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("3.2")).toBeInTheDocument()
|
||||
expect(within(detailPanel).queryByText("MACD")).not.toBeInTheDocument()
|
||||
expect(within(detailPanel).queryByText("3.2")).not.toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("J 值")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "查看详情" }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
Reference in New Issue
Block a user