feat(web): 用紧凑卡片替换选股表格并精简资金雷达列
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,7 +26,6 @@ import {
|
||||
signalCategoryOptions,
|
||||
type SignalCategoryFilter,
|
||||
} from "./selection-presentation"
|
||||
import { SignalTable } from "./signal-table"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
|
||||
const SCORE_SORT_OPTIONS: ReadonlyArray<{
|
||||
@@ -195,15 +194,11 @@ export function SelectionResultsWorkbench({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
|
||||
<SignalTable
|
||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
||||
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
||||
stocks={currentStocks}
|
||||
/>
|
||||
<SignalRecordList
|
||||
expandedKeys={expandedKeys}
|
||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
||||
stocks={currentStocks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChevronDown, ChevronUp } from "lucide-react"
|
||||
import type { KeyboardEvent } from "react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { cn } from "@/shared/ui/utils"
|
||||
|
||||
import type { SelectionStockResult } from "../api/selection.types"
|
||||
import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score"
|
||||
@@ -15,6 +17,7 @@ import { SignalDetails } from "./signal-details"
|
||||
|
||||
interface SignalRecordListProps {
|
||||
stocks: SelectionStockResult[]
|
||||
selectedKey: string | null
|
||||
expandedKeys: ReadonlySet<string>
|
||||
onSelect: (stock: SelectionStockResult) => void
|
||||
onToggleExpanded: (stock: SelectionStockResult) => void
|
||||
@@ -22,67 +25,86 @@ interface SignalRecordListProps {
|
||||
|
||||
export function SignalRecordList({
|
||||
stocks,
|
||||
selectedKey,
|
||||
expandedKeys,
|
||||
onSelect,
|
||||
onToggleExpanded,
|
||||
}: SignalRecordListProps) {
|
||||
function handleSelectKeyDown(
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
stock: SelectionStockResult,
|
||||
) {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
onSelect(stock)
|
||||
}
|
||||
|
||||
if (stocks.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-32 items-center justify-center px-4 text-sm text-muted-foreground md:hidden">
|
||||
<div className="flex min-h-32 flex-1 items-center justify-center overflow-auto px-4 text-sm text-muted-foreground">
|
||||
没有符合当前筛选条件的信号。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/60 md:hidden">
|
||||
{stocks.map((stock) => {
|
||||
const key = getStockKey(stock)
|
||||
const expanded = expandedKeys.has(key)
|
||||
return (
|
||||
<article key={key} className="space-y-2 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
onClick={() => onSelect(stock)}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<div className="divide-y divide-border/60">
|
||||
{stocks.map((stock) => {
|
||||
const key = getStockKey(stock)
|
||||
const selected = selectedKey === key
|
||||
const expanded = expandedKeys.has(key)
|
||||
return (
|
||||
<article
|
||||
key={key}
|
||||
className={cn(
|
||||
"space-y-1 px-2.5 py-1.5 transition-colors hover:bg-muted/50",
|
||||
selected && "bg-muted/70",
|
||||
)}
|
||||
data-selected={selected}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-foreground">
|
||||
{stock.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{stock.ts_code}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-medium tabular-nums text-foreground">
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="text-xs">
|
||||
<PatternScoreSummary score={stock.score} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{stock.signals.map((signal) => (
|
||||
<Badge
|
||||
key={signal.category}
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
J
|
||||
<strong className="font-semibold text-foreground">
|
||||
{getStockJValue(stock)}
|
||||
</strong>
|
||||
</span>
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
className="min-h-11 min-w-0 flex-1 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
onClick={() => onSelect(stock)}
|
||||
onKeyDown={(event) => handleSelectKeyDown(event, stock)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="font-medium text-foreground">
|
||||
{stock.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{stock.ts_code}
|
||||
</span>
|
||||
<span className="ml-auto font-medium tabular-nums text-foreground">
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{stock.signals.map((signal) => (
|
||||
<Badge
|
||||
key={signal.category}
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
<PatternScoreSummary score={stock.score} />
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
J
|
||||
<strong className="font-semibold text-foreground">
|
||||
{getStockJValue(stock)}
|
||||
</strong>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
aria-expanded={expanded}
|
||||
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
|
||||
className="min-h-11 shrink-0 gap-1 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => onToggleExpanded(stock)}
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -94,31 +116,31 @@ export function SignalRecordList({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="space-y-3">
|
||||
<section className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
图形相似度评分
|
||||
</p>
|
||||
<PatternScoreDetails score={stock.score} />
|
||||
</section>
|
||||
{stock.signals.map((signal) => (
|
||||
<section key={signal.category} className="space-y-2">
|
||||
{expanded ? (
|
||||
<div className="space-y-3">
|
||||
<section className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{getCategoryLabel(signal.category)}
|
||||
图形相似度评分
|
||||
</p>
|
||||
<SignalDetails
|
||||
className="sm:grid-cols-3"
|
||||
details={signal.details}
|
||||
/>
|
||||
<PatternScoreDetails score={stock.score} />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
{stock.signals.map((signal) => (
|
||||
<section key={signal.category} className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{getCategoryLabel(signal.category)}
|
||||
</p>
|
||||
<SignalDetails
|
||||
className="sm:grid-cols-3"
|
||||
details={signal.details}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { KeyboardEvent } from "react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
|
||||
import type { SelectionStockResult } from "../api/selection.types"
|
||||
import { PatternScoreSummary } from "./pattern-score"
|
||||
import {
|
||||
categoryToneClass,
|
||||
getCategoryLabel,
|
||||
getStockJValue,
|
||||
getStockKey,
|
||||
} from "./selection-presentation"
|
||||
|
||||
interface SignalTableProps {
|
||||
stocks: SelectionStockResult[]
|
||||
selectedKey: string | null
|
||||
onSelect: (stock: SelectionStockResult) => void
|
||||
}
|
||||
|
||||
export function SignalTable({
|
||||
stocks,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
}: SignalTableProps) {
|
||||
function handleRowKeyDown(
|
||||
event: KeyboardEvent<HTMLTableRowElement>,
|
||||
stock: SelectionStockResult,
|
||||
) {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
onSelect(stock)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hidden min-h-0 flex-1 overflow-auto md:block">
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<caption className="sr-only">知行 B1 命中信号明细</caption>
|
||||
<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 text-right 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>
|
||||
{stocks.length > 0 ? (
|
||||
stocks.map((stock) => {
|
||||
const selected = selectedKey === getStockKey(stock)
|
||||
return (
|
||||
<tr
|
||||
key={getStockKey(stock)}
|
||||
aria-selected={selected}
|
||||
className="cursor-pointer border-b border-border/50 outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted/60 data-[selected=true]:bg-muted/70"
|
||||
data-selected={selected}
|
||||
onClick={() => onSelect(stock)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, stock)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
<p className="font-medium text-foreground">{stock.name}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{stock.ts_code}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 align-middle">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{stock.signals.map((signal) => (
|
||||
<Badge
|
||||
key={signal.category}
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 text-right align-middle">
|
||||
<PatternScoreSummary score={stock.score} />
|
||||
</td>
|
||||
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
|
||||
{getStockJValue(stock)}
|
||||
</td>
|
||||
<td className="px-3 text-right align-middle font-medium tabular-nums text-foreground">
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-32 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={5}
|
||||
>
|
||||
没有符合当前筛选条件的信号。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -143,31 +143,18 @@ describe("SelectionResultsPage", () => {
|
||||
|
||||
expect(screen.getByText("执行完成")).toBeInTheDocument()
|
||||
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).getByRole("columnheader", { name: "图形评分" }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(table).queryByRole("columnheader", { name: "关键详情" }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(within(table).getByText("12")).toBeInTheDocument()
|
||||
expect(within(table).queryByText("13")).not.toBeInTheDocument()
|
||||
expect(within(table).getByText("平安银行")).toBeInTheDocument()
|
||||
expect(within(table).getByText("86.40 分")).toBeInTheDocument()
|
||||
expect(within(table).getByText("华纳药厂")).toBeInTheDocument()
|
||||
|
||||
const mobileRecord = screen.getAllByRole("article")[0]
|
||||
expect(within(mobileRecord).getByText("12")).toBeInTheDocument()
|
||||
expect(within(mobileRecord).queryByText("13")).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument()
|
||||
const record = screen.getByRole("article")
|
||||
expect(within(record).getByText("平安银行")).toBeInTheDocument()
|
||||
expect(within(record).getByText("000001.SZ")).toBeInTheDocument()
|
||||
expect(within(record).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(record).getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(within(record).getByText("86.40 分")).toBeInTheDocument()
|
||||
expect(within(record).getByText("12")).toBeInTheDocument()
|
||||
expect(within(record).queryByText("13")).not.toBeInTheDocument()
|
||||
expect(within(record).getByText("¥ 10.50")).toBeInTheDocument()
|
||||
expect(within(record).getByText("华纳药厂")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: /平安银行/ })).toBeInTheDocument()
|
||||
|
||||
const detailPanel = screen.getByRole("complementary", {
|
||||
name: "当前股票详情",
|
||||
@@ -249,9 +236,9 @@ describe("SelectionResultsPage", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getAllByText(expected).length).toBeGreaterThan(0)
|
||||
const table = screen.getByRole("table")
|
||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(table).getByText("12")).toBeInTheDocument()
|
||||
const record = screen.getByRole("article")
|
||||
expect(within(record).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(record).getByText("12")).toBeInTheDocument()
|
||||
if (score?.status === "below_threshold") {
|
||||
const detailPanel = screen.getByRole("complementary", {
|
||||
name: "当前股票详情",
|
||||
@@ -558,16 +545,27 @@ describe("SelectionResultsPage", () => {
|
||||
fireEvent.change(screen.getByLabelText("搜索命中股票"), {
|
||||
target: { value: "招商" },
|
||||
})
|
||||
const table = screen.getByRole("table")
|
||||
expect(within(table).getByText("招商银行")).toBeInTheDocument()
|
||||
fireEvent.click(within(table).getByText("招商银行"))
|
||||
const extraRecord = screen
|
||||
.getAllByRole("article")
|
||||
.find((article) => within(article).queryByText("招商银行"))
|
||||
expect(extraRecord).toBeTruthy()
|
||||
if (!extraRecord) {
|
||||
throw new Error("expected 招商银行 card to be visible")
|
||||
}
|
||||
const selectButton = within(extraRecord).getByRole("button", {
|
||||
name: /招商银行/,
|
||||
})
|
||||
fireEvent.keyDown(selectButton, { key: "Enter" })
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole("complementary", { name: "当前股票详情" }),
|
||||
).getByText("招商银行"),
|
||||
).toBeInTheDocument()
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[1]
|
||||
fireEvent.keyDown(selectButton, { key: " " })
|
||||
const expandButton = within(extraRecord).getByRole("button", {
|
||||
name: "查看详情",
|
||||
})
|
||||
fireEvent.click(expandButton)
|
||||
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
||||
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
|
||||
|
||||
Reference in New Issue
Block a user