feat(selection): 重构信号详情展示,增加指标项和格式化功能 #13
@@ -28,6 +28,20 @@ export const categoryLabels: Record<string, string> = {
|
|||||||
zhixing_b1_pullback_yellow: "回踩黄线 B",
|
zhixing_b1_pullback_yellow: "回踩黄线 B",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detailLabels: Record<string, string> = {
|
||||||
|
daily_amplitude: "日振幅",
|
||||||
|
daily_change: "日涨跌幅",
|
||||||
|
j: "J 值",
|
||||||
|
macd: "MACD",
|
||||||
|
rsi: "RSI",
|
||||||
|
sub_signal: "命中信号",
|
||||||
|
trend_white: "知行白线",
|
||||||
|
trend_yellow: "知行黄线",
|
||||||
|
volume: "成交量",
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentageDetailKeys = new Set(["daily_amplitude", "daily_change"])
|
||||||
|
|
||||||
export type SignalCategoryFilter = SelectionCategoryFilter
|
export type SignalCategoryFilter = SelectionCategoryFilter
|
||||||
|
|
||||||
export const signalCategoryOptions: ReadonlyArray<{
|
export const signalCategoryOptions: ReadonlyArray<{
|
||||||
@@ -59,10 +73,32 @@ export function getSignalKey(signal: SelectionSignal) {
|
|||||||
return `${signal.ts_code}-${signal.category}`
|
return `${signal.ts_code}-${signal.category}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDetails(details: SelectionSignal["details"]) {
|
export function getSignalDetailItems(details: SelectionSignal["details"]) {
|
||||||
return Object.entries(details)
|
return Object.entries(details).map(([key, value]) => ({
|
||||||
.map(([key, value]) => `${key}: ${value ?? "—"}`)
|
key,
|
||||||
.join(" · ")
|
label: detailLabels[key] ?? key,
|
||||||
|
value: formatSignalDetailValue(key, value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSignalJValue(details: SelectionSignal["details"]) {
|
||||||
|
return formatSignalDetailValue("j", details.j)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignalDetailValue(
|
||||||
|
key: string,
|
||||||
|
value: number | string | null | undefined,
|
||||||
|
) {
|
||||||
|
if (value === null || value === undefined || value === "") return "—"
|
||||||
|
if (typeof value !== "number") return value
|
||||||
|
if (key === "volume") {
|
||||||
|
return value.toLocaleString("zh-CN", { maximumFractionDigits: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = value.toLocaleString("zh-CN", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})
|
||||||
|
return percentageDetailKeys.has(key) ? `${formatted}%` : formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchesCategory(
|
export function matchesCategory(
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ import { Badge } from "@/shared/ui/badge"
|
|||||||
import { Card } from "@/shared/ui/card"
|
import { Card } from "@/shared/ui/card"
|
||||||
|
|
||||||
import type { SelectionSignal } from "../api/selection.types"
|
import type { SelectionSignal } from "../api/selection.types"
|
||||||
import {
|
import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
|
||||||
categoryToneClass,
|
import { SignalDetails } from "./signal-details"
|
||||||
formatDetails,
|
|
||||||
getCategoryLabel,
|
|
||||||
} from "./selection-presentation"
|
|
||||||
|
|
||||||
interface SignalDetailPanelProps {
|
interface SignalDetailPanelProps {
|
||||||
signal: SelectionSignal | undefined
|
signal: SelectionSignal | undefined
|
||||||
@@ -69,7 +66,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
|||||||
{getCategoryLabel(signal.category)}
|
{getCategoryLabel(signal.category)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<dl className="mt-4 divide-y divide-border/60 border-t border-border/60 text-xs">
|
<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">
|
<div className="flex items-center justify-between gap-3 py-2.5">
|
||||||
<dt className="text-muted-foreground">目标交易日</dt>
|
<dt className="text-muted-foreground">目标交易日</dt>
|
||||||
<dd className="font-medium tabular-nums">
|
<dd className="font-medium tabular-nums">
|
||||||
@@ -80,13 +77,17 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
|||||||
<dt className="text-muted-foreground">策略</dt>
|
<dt className="text-muted-foreground">策略</dt>
|
||||||
<dd className="font-medium">知行 B1</dd>
|
<dd className="font-medium">知行 B1</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 py-2.5">
|
|
||||||
<dt className="text-muted-foreground">关键依据</dt>
|
|
||||||
<dd className="leading-5 text-foreground">
|
|
||||||
{formatDetails(signal.details)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<section className="mt-4" aria-labelledby="signal-detail-metrics">
|
||||||
|
<h3
|
||||||
|
className="mb-2 text-xs font-medium text-muted-foreground"
|
||||||
|
id="signal-detail-metrics"
|
||||||
|
>
|
||||||
|
关键指标
|
||||||
|
</h3>
|
||||||
|
<SignalDetails details={signal.details} />
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { cn } from "@/shared/ui/utils"
|
||||||
|
|
||||||
|
import type { SelectionSignal } from "../api/selection.types"
|
||||||
|
import { getSignalDetailItems } from "./selection-presentation"
|
||||||
|
|
||||||
|
interface SignalDetailsProps {
|
||||||
|
className?: string
|
||||||
|
details: SelectionSignal["details"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignalDetails({ className, details }: SignalDetailsProps) {
|
||||||
|
const items = getSignalDetailItems(details)
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="text-xs text-muted-foreground">暂无指标详情</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className={cn("grid grid-cols-2 gap-2", className)}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="min-w-0 rounded-md border border-border/60 bg-muted/30 px-2.5 py-2"
|
||||||
|
>
|
||||||
|
<dt className="truncate text-[10px] font-medium text-muted-foreground">
|
||||||
|
{item.label}
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className="mt-1 truncate text-sm font-semibold tabular-nums text-foreground"
|
||||||
|
title={item.value}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,10 +6,11 @@ import { Button } from "@/shared/ui/button"
|
|||||||
import type { SelectionSignal } from "../api/selection.types"
|
import type { SelectionSignal } from "../api/selection.types"
|
||||||
import {
|
import {
|
||||||
categoryToneClass,
|
categoryToneClass,
|
||||||
formatDetails,
|
|
||||||
getCategoryLabel,
|
getCategoryLabel,
|
||||||
|
getSignalJValue,
|
||||||
getSignalKey,
|
getSignalKey,
|
||||||
} from "./selection-presentation"
|
} from "./selection-presentation"
|
||||||
|
import { SignalDetails } from "./signal-details"
|
||||||
|
|
||||||
interface SignalRecordListProps {
|
interface SignalRecordListProps {
|
||||||
signals: SelectionSignal[]
|
signals: SelectionSignal[]
|
||||||
@@ -63,24 +64,33 @@ export function SignalRecordList({
|
|||||||
>
|
>
|
||||||
{getCategoryLabel(signal.category)}
|
{getCategoryLabel(signal.category)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
aria-expanded={expanded}
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
|
J
|
||||||
onClick={() => onToggleExpanded(signal)}
|
<strong className="font-semibold text-foreground">
|
||||||
variant="ghost"
|
{getSignalJValue(signal.details)}
|
||||||
>
|
</strong>
|
||||||
{expanded ? "收起详情" : "查看详情"}
|
</span>
|
||||||
{expanded ? (
|
<Button
|
||||||
<ChevronUp aria-hidden="true" />
|
aria-expanded={expanded}
|
||||||
) : (
|
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
|
||||||
<ChevronDown aria-hidden="true" />
|
onClick={() => onToggleExpanded(signal)}
|
||||||
)}
|
variant="ghost"
|
||||||
</Button>
|
>
|
||||||
|
{expanded ? "收起详情" : "查看详情"}
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<p className="text-xs leading-5 text-muted-foreground">
|
<SignalDetails
|
||||||
{formatDetails(signal.details)}
|
className="sm:grid-cols-3"
|
||||||
</p>
|
details={signal.details}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Badge } from "@/shared/ui/badge"
|
|||||||
import type { SelectionSignal } from "../api/selection.types"
|
import type { SelectionSignal } from "../api/selection.types"
|
||||||
import {
|
import {
|
||||||
categoryToneClass,
|
categoryToneClass,
|
||||||
formatDetails,
|
|
||||||
getCategoryLabel,
|
getCategoryLabel,
|
||||||
|
getSignalJValue,
|
||||||
getSignalKey,
|
getSignalKey,
|
||||||
} from "./selection-presentation"
|
} from "./selection-presentation"
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ export function SignalTable({
|
|||||||
<thead className="sticky top-0 z-10 bg-card text-[11px] text-muted-foreground">
|
<thead className="sticky top-0 z-10 bg-card text-[11px] text-muted-foreground">
|
||||||
<tr className="border-b border-border/70">
|
<tr className="border-b border-border/70">
|
||||||
<th className="h-9 px-3 font-medium">股票</th>
|
<th className="h-9 px-3 font-medium">股票</th>
|
||||||
<th className="h-9 px-3 font-medium">子信号</th>
|
<th className="h-9 px-3 font-medium">信号类型</th>
|
||||||
<th className="h-9 px-3 font-medium">收盘价</th>
|
<th className="h-9 px-3 text-right font-medium">J 值</th>
|
||||||
<th className="h-9 px-3 font-medium">关键详情</th>
|
<th className="h-9 px-3 text-right font-medium">收盘价</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -70,13 +70,11 @@ export function SignalTable({
|
|||||||
{getCategoryLabel(signal.category)}
|
{getCategoryLabel(signal.category)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 align-middle font-medium tabular-nums text-foreground">
|
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
|
||||||
¥ {signal.close.toFixed(2)}
|
{getSignalJValue(signal.details)}
|
||||||
</td>
|
</td>
|
||||||
<td className="max-w-[360px] px-3 align-middle text-muted-foreground">
|
<td className="px-3 text-right align-middle font-medium tabular-nums text-foreground">
|
||||||
<span className="line-clamp-2">
|
¥ {signal.close.toFixed(2)}
|
||||||
{formatDetails(signal.details)}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -102,9 +102,26 @@ describe("SelectionResultsPage", () => {
|
|||||||
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
|
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
|
||||||
const table = screen.getByRole("table")
|
const table = screen.getByRole("table")
|
||||||
expect(table).toBeInTheDocument()
|
expect(table).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
within(table).getByRole("columnheader", { name: "信号类型" }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
within(table).getByRole("columnheader", { name: "J 值" }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
within(table).queryByRole("columnheader", { name: "关键详情" }),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||||
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
|
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
|
||||||
|
expect(within(table).getAllByText("12")).toHaveLength(2)
|
||||||
expect(within(table).getAllByText("平安银行")).toHaveLength(2)
|
expect(within(table).getAllByText("平安银行")).toHaveLength(2)
|
||||||
|
|
||||||
|
const detailPanel = screen.getByRole("complementary", {
|
||||||
|
name: "当前股票详情",
|
||||||
|
})
|
||||||
|
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
|
||||||
|
expect(within(detailPanel).getByText("J 值")).toBeInTheDocument()
|
||||||
|
expect(within(detailPanel).getByText("RSI")).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 () => {
|
||||||
@@ -330,10 +347,11 @@ describe("SelectionResultsPage", () => {
|
|||||||
).getByText("招商银行"),
|
).getByText("招商银行"),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|
||||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[0]
|
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[2]
|
||||||
fireEvent.click(expandButton)
|
fireEvent.click(expandButton)
|
||||||
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
||||||
expect(screen.getAllByText("macd: 3.2").length).toBeGreaterThan(1)
|
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
|
||||||
|
expect(screen.getAllByText("3.2").length).toBeGreaterThan(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("navigates to another page when the result list is paginated", () => {
|
it("navigates to another page when the result list is paginated", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user