Merge pull request 'feat(selection): 重构信号详情展示,增加指标项和格式化功能' (#13) from develop into main
Deploy Production / deploy (push) Successful in 54s

Reviewed-on: sakibcc/zhixing-system#13
This commit was merged in pull request #13.
This commit is contained in:
2026-08-27 16:03:18 +08:00
6 changed files with 146 additions and 45 deletions
@@ -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,6 +64,13 @@ export function SignalRecordList({
> >
{getCategoryLabel(signal.category)} {getCategoryLabel(signal.category)}
</Badge> </Badge>
<div className="flex items-center gap-1">
<span className="text-xs tabular-nums text-muted-foreground">
J&nbsp;
<strong className="font-semibold text-foreground">
{getSignalJValue(signal.details)}
</strong>
</span>
<Button <Button
aria-expanded={expanded} aria-expanded={expanded}
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground" className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
@@ -77,10 +85,12 @@ export function SignalRecordList({
)} )}
</Button> </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", () => {