feat(web): 按原型调整响应式研究工作台
This commit is contained in:
@@ -1,124 +0,0 @@
|
||||
import {
|
||||
BarChart3,
|
||||
BrainCircuit,
|
||||
ClipboardCheck,
|
||||
LayoutDashboard,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/shared/ui/avatar"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Separator } from "@/shared/ui/separator"
|
||||
|
||||
interface HomeShellProps {
|
||||
children: React.ReactNode
|
||||
activeSection?: "home" | "selection"
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
label: "首页",
|
||||
icon: LayoutDashboard,
|
||||
section: "home" as const,
|
||||
path: "/" as const,
|
||||
},
|
||||
{ label: "行情数据", icon: BarChart3, section: null, path: null },
|
||||
{ label: "同步任务", icon: ClipboardCheck, section: null, path: null },
|
||||
{
|
||||
label: "选股策略",
|
||||
icon: BrainCircuit,
|
||||
section: "selection" as const,
|
||||
path: "/selection" as const,
|
||||
},
|
||||
]
|
||||
|
||||
export function HomeShell({
|
||||
activeSection = "home",
|
||||
children,
|
||||
}: HomeShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-muted/30 text-foreground">
|
||||
<aside className="flex w-20 shrink-0 flex-col border-r bg-card px-3 py-5 md:w-64 md:px-5">
|
||||
<div className="flex items-center gap-3 px-1 md:px-2">
|
||||
<div className="flex size-9 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
|
||||
<BarChart3 className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="hidden min-w-0 md:block">
|
||||
<p className="truncate text-sm font-semibold tracking-wide">
|
||||
知行量化
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">研究工作台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<nav aria-label="主导航" className="space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = item.section === activeSection
|
||||
const content = (
|
||||
<>
|
||||
<Icon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
</>
|
||||
)
|
||||
if (item.path) {
|
||||
return (
|
||||
<Button
|
||||
key={item.label}
|
||||
className="w-full justify-start gap-3 px-2 md:px-3"
|
||||
render={<a href={item.path} />}
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={item.label}
|
||||
className="w-full justify-start gap-3 px-2 md:px-3"
|
||||
disabled
|
||||
variant="ghost"
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto hidden rounded-xl bg-muted/60 p-4 md:block">
|
||||
<p className="text-xs font-medium">数据研究空间</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
从市场数据概览开始,掌握当前研究基础。
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="flex h-16 items-center justify-between gap-4 border-b bg-card/80 px-4 backdrop-blur md:px-8">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search
|
||||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
aria-label="搜索"
|
||||
className="h-9 bg-muted/40 pl-9"
|
||||
placeholder="搜索股票、数据或功能"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<Avatar aria-label="用户头像">
|
||||
<AvatarFallback>研</AvatarFallback>
|
||||
</Avatar>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-7xl p-4 md:p-8">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,20 +51,13 @@ describe("HomePage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("renders the management shell and partial overview", () => {
|
||||
it("renders the overview card with the partial overview", () => {
|
||||
render(<HomePage />)
|
||||
|
||||
expect(screen.getByRole("heading", { name: "首页" })).toBeInTheDocument()
|
||||
expect(screen.getByText("市场数据概览")).toBeInTheDocument()
|
||||
expect(screen.getByText("部分更新")).toBeInTheDocument()
|
||||
expect(screen.getByText("当前目标股票池")).toBeInTheDocument()
|
||||
expect(screen.getByText("33.33%")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "行情数据" })).toBeDisabled()
|
||||
expect(screen.getByRole("button", { name: "同步任务" })).toBeDisabled()
|
||||
expect(screen.getByRole("link", { name: "选股策略" })).toHaveAttribute(
|
||||
"href",
|
||||
"/selection",
|
||||
)
|
||||
})
|
||||
|
||||
it("opens and closes the accessible failed-stock dialog", () => {
|
||||
@@ -142,7 +135,7 @@ describe("HomePage", () => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("keeps the shell and explains the no-data state", () => {
|
||||
it("explains the no-data state", () => {
|
||||
useHomeOverview.mockReturnValue({
|
||||
data: { status: "no_data", stock_pool_count: 7, latest_update: null },
|
||||
isError: false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { PageLayout } from "@/app/layout/page-layout"
|
||||
import { useHomeOverview } from "@/features/home/api/home.query"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
import { Skeleton } from "@/shared/ui/skeleton"
|
||||
|
||||
import { FailedStocksDialog } from "../components/failed-stocks-dialog"
|
||||
import { HomeShell } from "../components/home-shell"
|
||||
import { MarketDataOverviewCard } from "../components/market-data-overview-card"
|
||||
|
||||
export function HomePage() {
|
||||
@@ -22,16 +22,8 @@ export function HomePage() {
|
||||
const update = overview.data?.latest_update
|
||||
|
||||
return (
|
||||
<HomeShell activeSection="home">
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-2">
|
||||
<p className="text-sm font-medium text-primary">欢迎回到知行量化</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">首页</h1>
|
||||
<p className="max-w-2xl text-muted-foreground">
|
||||
从当前目标股票池和市场数据同步状态开始,快速判断研究数据是否准备就绪。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<PageLayout>
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
{overview.isPending ? <OverviewLoading /> : null}
|
||||
|
||||
{overview.isError ? (
|
||||
@@ -60,7 +52,7 @@ export function HomePage() {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</HomeShell>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { SelectionSignal } from "../api/selection.types"
|
||||
|
||||
export const categoryLabels: Record<string, string> = {
|
||||
zhixing_b1_extreme_volume: "超卖超缩量 B",
|
||||
zhixing_b1_original_b1: "原始 B1",
|
||||
zhixing_b1_oversold_turn: "超卖缩量拐头 B",
|
||||
zhixing_b1_oversold_volume: "超卖缩量 B",
|
||||
zhixing_b1_pullback_super: "回踩超级 B",
|
||||
zhixing_b1_pullback_white: "回踩白线 B",
|
||||
zhixing_b1_pullback_yellow: "回踩黄线 B",
|
||||
}
|
||||
|
||||
export type SignalCategoryFilter = "all" | "pullback" | "oversold" | "original"
|
||||
|
||||
export const signalCategoryOptions: ReadonlyArray<{
|
||||
label: string
|
||||
value: SignalCategoryFilter
|
||||
}> = [
|
||||
{ label: "全部信号", value: "all" },
|
||||
{ label: "回踩类", value: "pullback" },
|
||||
{ label: "超卖类", value: "oversold" },
|
||||
{ label: "原始 B1", value: "original" },
|
||||
]
|
||||
|
||||
export function getCategoryLabel(category: string) {
|
||||
return categoryLabels[category] ?? category
|
||||
}
|
||||
|
||||
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 matchesCategory(
|
||||
signal: SelectionSignal,
|
||||
category: SignalCategoryFilter,
|
||||
) {
|
||||
if (category === "all") return true
|
||||
return signal.category.includes(category)
|
||||
}
|
||||
|
||||
export function categoryToneClass(category: string) {
|
||||
if (category.includes("pullback")) {
|
||||
return "border-warning/30 bg-warning/10 text-warning"
|
||||
}
|
||||
if (category.includes("oversold")) {
|
||||
return "border-primary/15 bg-primary/5 text-primary"
|
||||
}
|
||||
return "border-success/30 bg-success/10 text-success"
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Pagination } from "@/shared/ui/pagination"
|
||||
|
||||
import type { SelectionResults, SelectionSignal } from "../api/selection.types"
|
||||
import { SignalDetailPanel } from "./signal-detail-panel"
|
||||
import { SignalRecordList } from "./signal-record-list"
|
||||
import {
|
||||
getSignalKey,
|
||||
matchesCategory,
|
||||
signalCategoryOptions,
|
||||
type SignalCategoryFilter,
|
||||
} from "./selection-presentation"
|
||||
import { SignalTable } from "./signal-table"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 20] as const
|
||||
|
||||
interface SelectionResultsWorkbenchProps {
|
||||
result: SelectionResults
|
||||
}
|
||||
|
||||
export function SelectionResultsWorkbench({
|
||||
result,
|
||||
}: SelectionResultsWorkbenchProps) {
|
||||
const search = useSearch({ from: "/_workspace/selection" })
|
||||
const navigate = useNavigate({ from: "/selection" })
|
||||
const [query, setQuery] = useState("")
|
||||
const [category, setCategory] = useState<SignalCategoryFilter>("all")
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
||||
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const filteredSignals = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
return result.signals.filter((signal) => {
|
||||
const matchesQuery =
|
||||
normalized.length === 0 ||
|
||||
signal.name.toLowerCase().includes(normalized) ||
|
||||
signal.ts_code.toLowerCase().includes(normalized)
|
||||
return matchesQuery && matchesCategory(signal, category)
|
||||
})
|
||||
}, [category, query, result.signals])
|
||||
|
||||
const page = search.page
|
||||
const pageSize = search.pageSize
|
||||
const pageCount = Math.max(1, Math.ceil(filteredSignals.length / pageSize))
|
||||
const currentPage = Math.min(page, pageCount)
|
||||
const currentSignals = filteredSignals.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
)
|
||||
const selectedSignal =
|
||||
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ??
|
||||
currentSignals[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (page > pageCount) {
|
||||
void navigate({
|
||||
search: (previous) => ({ ...previous, page: pageCount }),
|
||||
})
|
||||
}
|
||||
}, [navigate, page, pageCount])
|
||||
|
||||
function updateSearch(next: { page?: number; pageSize?: number }) {
|
||||
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
||||
}
|
||||
|
||||
function handleQueryChange(value: string) {
|
||||
setQuery(value)
|
||||
updateSearch({ page: 1 })
|
||||
}
|
||||
|
||||
function handleCategoryChange(value: SignalCategoryFilter) {
|
||||
setCategory(value)
|
||||
updateSearch({ page: 1 })
|
||||
}
|
||||
|
||||
function handleToggleExpanded(signal: SelectionSignal) {
|
||||
const key = getSignalKey(signal)
|
||||
setExpandedKeys((previous) => {
|
||||
const next = new Set(previous)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_260px] md:overflow-hidden lg:grid-cols-[minmax(0,1.55fr)_minmax(280px,0.72fr)]">
|
||||
<section className="flex min-h-[420px] min-w-0 flex-1 flex-col overflow-visible rounded-lg border border-border/80 bg-card md:min-h-0 md:overflow-hidden">
|
||||
<div className="grid shrink-0 gap-2 border-b border-border/60 p-3 sm:grid-cols-[minmax(0,260px)_auto_1fr] sm:items-center">
|
||||
<Input
|
||||
aria-label="搜索命中信号"
|
||||
className="h-11 bg-background text-sm md:h-8"
|
||||
onChange={(event) => handleQueryChange(event.target.value)}
|
||||
placeholder="搜索股票名称或代码"
|
||||
type="search"
|
||||
value={query}
|
||||
/>
|
||||
<select
|
||||
aria-label="筛选信号分类"
|
||||
className="h-11 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:h-8"
|
||||
onChange={(event) =>
|
||||
handleCategoryChange(event.target.value as SignalCategoryFilter)
|
||||
}
|
||||
value={category}
|
||||
>
|
||||
{signalCategoryOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">
|
||||
筛选结果 {filteredSignals.length} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col md:min-h-0 md:flex-1 md:overflow-hidden">
|
||||
<SignalTable
|
||||
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
|
||||
selectedKey={selectedSignal ? getSignalKey(selectedSignal) : null}
|
||||
signals={currentSignals}
|
||||
/>
|
||||
<SignalRecordList
|
||||
expandedKeys={expandedKeys}
|
||||
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
signals={currentSignals}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
|
||||
onPageSizeChange={(nextPageSize) =>
|
||||
updateSearch({ page: 1, pageSize: nextPageSize })
|
||||
}
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
total={filteredSignals.length}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<SignalDetailPanel signal={selectedSignal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Database, Search } from "lucide-react"
|
||||
|
||||
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"
|
||||
|
||||
interface SignalDetailPanelProps {
|
||||
signal: SelectionSignal | undefined
|
||||
}
|
||||
|
||||
export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
||||
if (!signal) {
|
||||
return (
|
||||
<Card
|
||||
aria-label="当前股票详情"
|
||||
className="hidden min-h-0 overflow-auto rounded-lg border-border/80 bg-card p-4 md:flex md:flex-col md:justify-center"
|
||||
>
|
||||
<div className="space-y-2 text-center">
|
||||
<Search
|
||||
className="mx-auto size-5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="font-medium">暂无选中信号</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
从左侧列表选择一条信号查看详情。
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="当前股票详情"
|
||||
className="hidden min-h-0 overflow-auto rounded-lg border border-border/80 bg-card p-4 md:block"
|
||||
>
|
||||
<p className="text-[10px] font-bold tracking-[0.08em] text-muted-foreground uppercase">
|
||||
Selected signal
|
||||
</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">
|
||||
{signal.name}
|
||||
</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{signal.ts_code}
|
||||
</p>
|
||||
</div>
|
||||
<Database
|
||||
className="mt-1 size-4 shrink-0 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-3xl font-semibold tabular-nums tracking-tight">
|
||||
¥ {signal.close.toFixed(2)}
|
||||
</p>
|
||||
|
||||
<Badge
|
||||
className={`mt-3 ${categoryToneClass(signal.category)}`}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
|
||||
<dl className="mt-5 divide-y divide-border/60 border-t 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">
|
||||
{signal.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>
|
||||
<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>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { SelectionSignal } from "../api/selection.types"
|
||||
import {
|
||||
categoryToneClass,
|
||||
formatDetails,
|
||||
getCategoryLabel,
|
||||
getSignalKey,
|
||||
} from "./selection-presentation"
|
||||
|
||||
interface SignalRecordListProps {
|
||||
signals: SelectionSignal[]
|
||||
expandedKeys: ReadonlySet<string>
|
||||
onSelect: (signal: SelectionSignal) => void
|
||||
onToggleExpanded: (signal: SelectionSignal) => void
|
||||
}
|
||||
|
||||
export function SignalRecordList({
|
||||
signals,
|
||||
expandedKeys,
|
||||
onSelect,
|
||||
onToggleExpanded,
|
||||
}: SignalRecordListProps) {
|
||||
if (signals.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-32 items-center justify-center px-4 text-sm text-muted-foreground md:hidden">
|
||||
没有符合当前筛选条件的信号。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/60 md:hidden">
|
||||
{signals.map((signal) => {
|
||||
const key = getSignalKey(signal)
|
||||
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(signal)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-foreground">
|
||||
{signal.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{signal.ts_code}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-medium tabular-nums text-foreground">
|
||||
¥ {signal.close.toFixed(2)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
<Button
|
||||
aria-expanded={expanded}
|
||||
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => onToggleExpanded(signal)}
|
||||
variant="ghost"
|
||||
>
|
||||
{expanded ? "收起详情" : "查看详情"}
|
||||
{expanded ? (
|
||||
<ChevronUp aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{formatDetails(signal.details)}
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { KeyboardEvent } from "react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
|
||||
import type { SelectionSignal } from "../api/selection.types"
|
||||
import {
|
||||
categoryToneClass,
|
||||
formatDetails,
|
||||
getCategoryLabel,
|
||||
getSignalKey,
|
||||
} from "./selection-presentation"
|
||||
|
||||
interface SignalTableProps {
|
||||
signals: SelectionSignal[]
|
||||
selectedKey: string | null
|
||||
onSelect: (signal: SelectionSignal) => void
|
||||
}
|
||||
|
||||
export function SignalTable({
|
||||
signals,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
}: SignalTableProps) {
|
||||
function handleRowKeyDown(
|
||||
event: KeyboardEvent<HTMLTableRowElement>,
|
||||
signal: SelectionSignal,
|
||||
) {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
onSelect(signal)
|
||||
}
|
||||
|
||||
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 font-medium">收盘价</th>
|
||||
<th className="h-9 px-3 font-medium">关键详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.length > 0 ? (
|
||||
signals.map((signal) => {
|
||||
const selected = selectedKey === getSignalKey(signal)
|
||||
return (
|
||||
<tr
|
||||
key={getSignalKey(signal)}
|
||||
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(signal)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, signal)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
<p className="font-medium text-foreground">{signal.name}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{signal.ts_code}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 align-middle">
|
||||
<Badge
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 align-middle font-medium tabular-nums text-foreground">
|
||||
¥ {signal.close.toFixed(2)}
|
||||
</td>
|
||||
<td className="max-w-[360px] px-3 align-middle text-muted-foreground">
|
||||
<span className="line-clamp-2">
|
||||
{formatDetails(signal.details)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
className="h-32 px-3 text-center text-sm text-muted-foreground"
|
||||
colSpan={4}
|
||||
>
|
||||
没有符合当前筛选条件的信号。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { SelectionResults } from "../api/selection.types"
|
||||
|
||||
import { SelectionResultsPage } from "./selection-results-page"
|
||||
|
||||
const routerNavigate = vi.hoisted(() => vi.fn())
|
||||
const useSelectionResults = vi.fn()
|
||||
const useSelectionRun = vi.fn()
|
||||
const useTriggerSelectionRun = vi.fn()
|
||||
@@ -17,6 +18,11 @@ vi.mock("@/features/selection/api/selection.query", () => ({
|
||||
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useNavigate: () => routerNavigate,
|
||||
useSearch: () => ({ page: 1, pageSize: 5 }),
|
||||
}))
|
||||
|
||||
const selectedResult: SelectionResults = {
|
||||
created_at: "2026-08-08T09:00:00+08:00",
|
||||
coverage: 1,
|
||||
@@ -77,18 +83,75 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
mutate.mockReset()
|
||||
reset.mockReset()
|
||||
routerNavigate.mockReset()
|
||||
})
|
||||
|
||||
it("renders persisted results and keeps multiple categories visible", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("执行完成")).toBeInTheDocument()
|
||||
const table = screen.getByRole("table")
|
||||
expect(table).toBeInTheDocument()
|
||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(within(table).getAllByText("平安银行")).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("filters signals, updates the detail panel, and expands mobile details", () => {
|
||||
const extraSignal = {
|
||||
...selectedResult.signals[0],
|
||||
category: "zhixing_b1_oversold_volume",
|
||||
details: { macd: 3.2 },
|
||||
name: "招商银行",
|
||||
ts_code: "600036.SH",
|
||||
}
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
signal_count: 3,
|
||||
signals: [...selectedResult.signals, extraSignal],
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText("搜索命中信号"), {
|
||||
target: { value: "招商" },
|
||||
})
|
||||
const table = screen.getByRole("table")
|
||||
expect(within(table).getByText("招商银行")).toBeInTheDocument()
|
||||
fireEvent.click(within(table).getByText("招商银行"))
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "选股策略" }),
|
||||
within(
|
||||
screen.getByRole("complementary", { name: "当前股票详情" }),
|
||||
).getByText("招商银行"),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("知行 B1 执行结果")).toBeInTheDocument()
|
||||
expect(screen.getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(screen.getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("平安银行")).toHaveLength(2)
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[0]
|
||||
fireEvent.click(expandButton)
|
||||
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
||||
expect(screen.getAllByText("macd: 3.2").length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it("navigates to another page when the result list is paginated", () => {
|
||||
const signals = Array.from({ length: 6 }, (_, index) => ({
|
||||
...selectedResult.signals[0],
|
||||
name: `股票${index + 1}`,
|
||||
ts_code: `00000${index + 1}.SZ`,
|
||||
}))
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: { ...selectedResult, signal_count: signals.length, signals },
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("第 1–5 条,共 6 条")).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "第 2 页" }))
|
||||
expect(routerNavigate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("requires confirmation before a rerun and does not trigger on cancel", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { PageLayout } from "@/app/layout/page-layout"
|
||||
import {
|
||||
useSelectionResults,
|
||||
useSelectionRun,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
import type {
|
||||
SelectionResults,
|
||||
SelectionRunStatus,
|
||||
SelectionSignal,
|
||||
} from "@/features/selection/api/selection.types"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Skeleton } from "@/shared/ui/skeleton"
|
||||
|
||||
import { HomeShell } from "@/features/home/components/home-shell"
|
||||
import { SelectionResultsWorkbench } from "../components/selection-results-workbench"
|
||||
|
||||
const STRATEGY = "zhixing_b1" as const
|
||||
|
||||
@@ -50,16 +50,6 @@ const statusPresentation: Record<
|
||||
success: { label: "执行完成", variant: "default" },
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
zhixing_b1_extreme_volume: "超卖超缩量 B",
|
||||
zhixing_b1_original_b1: "原始 B1",
|
||||
zhixing_b1_oversold_turn: "超卖缩量拐头 B",
|
||||
zhixing_b1_oversold_volume: "超卖缩量 B",
|
||||
zhixing_b1_pullback_super: "回踩超级 B",
|
||||
zhixing_b1_pullback_white: "回踩白线 B",
|
||||
zhixing_b1_pullback_yellow: "回踩黄线 B",
|
||||
}
|
||||
|
||||
export function SelectionResultsPage() {
|
||||
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null)
|
||||
@@ -123,78 +113,30 @@ export function SelectionResultsPage() {
|
||||
trigger.reset()
|
||||
}
|
||||
|
||||
const resultMetrics =
|
||||
displayedResult &&
|
||||
displayedResult.status !== "no_data" &&
|
||||
displayedResult.status !== "running" ? (
|
||||
<ResultMetrics result={displayedResult} />
|
||||
) : null
|
||||
|
||||
return (
|
||||
<HomeShell activeSection="selection">
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-2">
|
||||
<p className="text-sm font-medium text-primary">研究策略执行</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">选股策略</h1>
|
||||
<p className="max-w-2xl text-muted-foreground">
|
||||
查看知行 B1 在指定交易日的持久化执行结果,或重新执行当天策略。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">执行条件</CardTitle>
|
||||
<CardDescription>
|
||||
策略只使用已完成且达到选股资格的市场数据同步批次。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<label
|
||||
className="flex-1 space-y-2 text-sm font-medium"
|
||||
htmlFor="target-trade-date"
|
||||
>
|
||||
目标交易日
|
||||
<Input
|
||||
id="target-trade-date"
|
||||
onChange={(event) => handleDateChange(event.target.value)}
|
||||
type="date"
|
||||
value={selectedTargetTradeDate}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="flex-1 space-y-2 text-sm font-medium"
|
||||
htmlFor="strategy-name"
|
||||
>
|
||||
策略
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
disabled
|
||||
id="strategy-name"
|
||||
value={STRATEGY}
|
||||
>
|
||||
<option value={STRATEGY}>知行 B1</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
disabled={!selectedTargetTradeDate || isRunning}
|
||||
onClick={handleExecute}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw aria-hidden="true" className="animate-spin" />
|
||||
) : hasExistingResult ? (
|
||||
<RefreshCw aria-hidden="true" />
|
||||
) : (
|
||||
<Play aria-hidden="true" />
|
||||
)}
|
||||
{isRunning
|
||||
? "执行中"
|
||||
: hasExistingResult
|
||||
? displayedResult?.status === "failed"
|
||||
? "重试执行"
|
||||
: "重新执行"
|
||||
: "执行策略"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
{trigger.isError ? (
|
||||
<p className="px-6 pb-6 text-sm text-destructive" role="alert">
|
||||
执行请求失败,请确认市场数据状态后重试。
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<PageLayout
|
||||
actions={
|
||||
<ExecutionToolbar
|
||||
hasExistingResult={hasExistingResult}
|
||||
isFailedResult={displayedResult?.status === "failed"}
|
||||
isRunning={isRunning}
|
||||
onDateChange={handleDateChange}
|
||||
onExecute={handleExecute}
|
||||
selectedTargetTradeDate={selectedTargetTradeDate}
|
||||
triggerError={trigger.isError}
|
||||
/>
|
||||
}
|
||||
metrics={resultMetrics}
|
||||
mode="bounded-workspace"
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 md:overflow-y-auto">
|
||||
{isRunning ? <RunningState /> : null}
|
||||
{!isRunning &&
|
||||
!hasQueryError &&
|
||||
@@ -249,10 +191,128 @@ export function SelectionResultsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</HomeShell>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
interface ExecutionToolbarProps {
|
||||
hasExistingResult: boolean
|
||||
isFailedResult: boolean
|
||||
isRunning: boolean
|
||||
onDateChange: (value: string) => void
|
||||
onExecute: () => void
|
||||
selectedTargetTradeDate: string
|
||||
triggerError: boolean
|
||||
}
|
||||
|
||||
function ExecutionToolbar({
|
||||
hasExistingResult,
|
||||
isFailedResult,
|
||||
isRunning,
|
||||
onDateChange,
|
||||
onExecute,
|
||||
selectedTargetTradeDate,
|
||||
triggerError,
|
||||
}: ExecutionToolbarProps) {
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
|
||||
<label
|
||||
className="space-y-1 text-xs font-medium"
|
||||
htmlFor="target-trade-date"
|
||||
>
|
||||
目标交易日
|
||||
<Input
|
||||
className="mt-1 h-11 bg-background md:h-8"
|
||||
id="target-trade-date"
|
||||
onChange={(event) => onDateChange(event.target.value)}
|
||||
type="date"
|
||||
value={selectedTargetTradeDate}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="space-y-1 text-xs font-medium"
|
||||
htmlFor="strategy-name"
|
||||
>
|
||||
策略
|
||||
<select
|
||||
className="mt-1 h-11 w-full rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50 md:h-8"
|
||||
disabled
|
||||
id="strategy-name"
|
||||
value={STRATEGY}
|
||||
>
|
||||
<option value={STRATEGY}>知行 B1</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
className="h-11 w-full sm:w-auto md:h-8"
|
||||
disabled={!selectedTargetTradeDate || isRunning}
|
||||
onClick={onExecute}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw aria-hidden="true" className="animate-spin" />
|
||||
) : hasExistingResult ? (
|
||||
<RefreshCw aria-hidden="true" />
|
||||
) : (
|
||||
<Play aria-hidden="true" />
|
||||
)}
|
||||
{isRunning
|
||||
? "执行中"
|
||||
: hasExistingResult
|
||||
? isFailedResult
|
||||
? "重试执行"
|
||||
: "重新执行"
|
||||
: "执行策略"}
|
||||
</Button>
|
||||
</div>
|
||||
{triggerError ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
执行请求失败,请确认市场数据状态后重试。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultMetrics({ result }: { result: SelectionResults }) {
|
||||
const presentation = getStatusPresentation(result.status)
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-2 gap-x-5 gap-y-2 sm:grid-cols-3 lg:grid-cols-[repeat(5,minmax(0,1fr))_auto]">
|
||||
<Metric label="目标股票数" value={result.target_count} />
|
||||
<Metric label="实际参与数" value={result.eligible_count} />
|
||||
<Metric label="命中股票数" value={result.selected_stock_count} />
|
||||
<Metric label="信号条数" value={result.signal_count} />
|
||||
<Metric
|
||||
label="数据覆盖率"
|
||||
value={`${(result.coverage * 100).toFixed(2)}%`}
|
||||
/>
|
||||
{presentation ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] text-muted-foreground">执行状态</p>
|
||||
<Badge className="gap-1" variant={presentation.variant}>
|
||||
{result.status === "success" ? (
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
) : null}
|
||||
{presentation.label}
|
||||
</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusPresentation(status: SelectionRunStatus) {
|
||||
if (
|
||||
status === "failed" ||
|
||||
status === "partial_success" ||
|
||||
status === "success"
|
||||
) {
|
||||
return statusPresentation[status]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<Card aria-label="正在加载策略执行结果">
|
||||
@@ -355,111 +415,27 @@ function FailureState({ result }: { result: SelectionResults }) {
|
||||
}
|
||||
|
||||
function ResultState({ result }: { result: SelectionResults }) {
|
||||
const presentation =
|
||||
result.status === "success" ||
|
||||
result.status === "partial_success" ||
|
||||
result.status === "failed"
|
||||
? statusPresentation[result.status]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="gap-4 border-b md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<CheckCircle2
|
||||
aria-hidden="true"
|
||||
className="size-5 text-primary"
|
||||
/>
|
||||
知行 B1 执行结果
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日:{result.target_trade_date} · 市场同步批次:
|
||||
{result.market_sync_batch_id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{presentation ? (
|
||||
<Badge variant={presentation.variant}>{presentation.label}</Badge>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Metric label="目标股票数" value={result.target_count} />
|
||||
<Metric label="实际参与数" value={result.eligible_count} />
|
||||
<Metric label="命中股票数" value={result.selected_stock_count} />
|
||||
<Metric label="信号条数" value={result.signal_count} />
|
||||
<Metric
|
||||
label="数据覆盖率"
|
||||
value={`${(result.coverage * 100).toFixed(2)}%`}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
{result.signal_count === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日数据完整,但没有符合公式条件的股票。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<NoSignalState />
|
||||
) : (
|
||||
<SignalTable signals={result.signals} />
|
||||
<SelectionResultsWorkbench result={result} />
|
||||
)}
|
||||
|
||||
{result.failures.length > 0 ? <FailureList result={result} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SignalTable({ signals }: { signals: SelectionSignal[] }) {
|
||||
function NoSignalState() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">命中信号明细</CardTitle>
|
||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||
<CardDescription>
|
||||
同一股票同日的多个子信号分别保留,不合并分类。
|
||||
目标交易日数据完整,但没有符合公式条件的股票。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pb-3 pr-4 font-medium">股票</th>
|
||||
<th className="pb-3 pr-4 font-medium">子信号</th>
|
||||
<th className="pb-3 pr-4 font-medium">收盘价</th>
|
||||
<th className="pb-3 font-medium">关键详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.map((signal) => (
|
||||
<tr
|
||||
key={`${signal.ts_code}-${signal.category}`}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-3 pr-4">
|
||||
<p className="font-medium">{signal.name}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{signal.ts_code}
|
||||
</p>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge variant="outline">
|
||||
{categoryLabels[signal.category] ?? signal.category}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums">
|
||||
{signal.close.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-3 text-muted-foreground">
|
||||
{formatDetails(signal.details)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -502,15 +478,11 @@ function FailureList({ result }: { result: SelectionResults }) {
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-semibold tracking-tight">{value}</p>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] text-muted-foreground">{label}</p>
|
||||
<p className="text-base font-semibold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDetails(details: SelectionSignal["details"]) {
|
||||
return Object.entries(details)
|
||||
.map(([key, value]) => `${key}: ${value ?? "—"}`)
|
||||
.join(" · ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user