Develop #20
@@ -28,6 +28,20 @@ export const categoryLabels: Record<string, string> = {
|
||||
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 const signalCategoryOptions: ReadonlyArray<{
|
||||
@@ -59,10 +73,32 @@ export function getSignalKey(signal: SelectionSignal) {
|
||||
return `${signal.ts_code}-${signal.category}`
|
||||
}
|
||||
|
||||
export function formatDetails(details: SelectionSignal["details"]) {
|
||||
return Object.entries(details)
|
||||
.map(([key, value]) => `${key}: ${value ?? "—"}`)
|
||||
.join(" · ")
|
||||
export function getSignalDetailItems(details: SelectionSignal["details"]) {
|
||||
return Object.entries(details).map(([key, value]) => ({
|
||||
key,
|
||||
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(
|
||||
|
||||
@@ -4,11 +4,8 @@ import { Badge } from "@/shared/ui/badge"
|
||||
import { Card } from "@/shared/ui/card"
|
||||
|
||||
import type { SelectionSignal } from "../api/selection.types"
|
||||
import {
|
||||
categoryToneClass,
|
||||
formatDetails,
|
||||
getCategoryLabel,
|
||||
} from "./selection-presentation"
|
||||
import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
|
||||
import { SignalDetails } from "./signal-details"
|
||||
|
||||
interface SignalDetailPanelProps {
|
||||
signal: SelectionSignal | undefined
|
||||
@@ -69,7 +66,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
||||
{getCategoryLabel(signal.category)}
|
||||
</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">
|
||||
<dt className="text-muted-foreground">目标交易日</dt>
|
||||
<dd className="font-medium tabular-nums">
|
||||
@@ -80,13 +77,17 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
||||
<dt className="text-muted-foreground">策略</dt>
|
||||
<dd className="font-medium">知行 B1</dd>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
categoryToneClass,
|
||||
formatDetails,
|
||||
getCategoryLabel,
|
||||
getSignalJValue,
|
||||
getSignalKey,
|
||||
} from "./selection-presentation"
|
||||
import { SignalDetails } from "./signal-details"
|
||||
|
||||
interface SignalRecordListProps {
|
||||
signals: SelectionSignal[]
|
||||
@@ -63,6 +64,13 @@ export function SignalRecordList({
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
J
|
||||
<strong className="font-semibold text-foreground">
|
||||
{getSignalJValue(signal.details)}
|
||||
</strong>
|
||||
</span>
|
||||
<Button
|
||||
aria-expanded={expanded}
|
||||
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
|
||||
@@ -77,10 +85,12 @@ export function SignalRecordList({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{formatDetails(signal.details)}
|
||||
</p>
|
||||
<SignalDetails
|
||||
className="sm:grid-cols-3"
|
||||
details={signal.details}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Badge } from "@/shared/ui/badge"
|
||||
import type { SelectionSignal } from "../api/selection.types"
|
||||
import {
|
||||
categoryToneClass,
|
||||
formatDetails,
|
||||
getCategoryLabel,
|
||||
getSignalJValue,
|
||||
getSignalKey,
|
||||
} from "./selection-presentation"
|
||||
|
||||
@@ -37,9 +37,9 @@ export function SignalTable({
|
||||
<thead className="sticky top-0 z-10 bg-card text-[11px] text-muted-foreground">
|
||||
<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 text-right font-medium">收盘价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -70,13 +70,11 @@ export function SignalTable({
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 align-middle font-medium tabular-nums text-foreground">
|
||||
¥ {signal.close.toFixed(2)}
|
||||
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
|
||||
{getSignalJValue(signal.details)}
|
||||
</td>
|
||||
<td className="max-w-[360px] px-3 align-middle text-muted-foreground">
|
||||
<span className="line-clamp-2">
|
||||
{formatDetails(signal.details)}
|
||||
</span>
|
||||
<td className="px-3 text-right align-middle font-medium tabular-nums text-foreground">
|
||||
¥ {signal.close.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -102,9 +102,26 @@ describe("SelectionResultsPage", () => {
|
||||
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
|
||||
const table = screen.getByRole("table")
|
||||
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("回踩白线 B")).toBeInTheDocument()
|
||||
expect(within(table).getAllByText("12")).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 () => {
|
||||
@@ -330,10 +347,11 @@ describe("SelectionResultsPage", () => {
|
||||
).getByText("招商银行"),
|
||||
).toBeInTheDocument()
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[0]
|
||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[2]
|
||||
fireEvent.click(expandButton)
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user