feat(web): 按原型调整响应式研究工作台

This commit is contained in:
yuxuanhui
2026-08-10 10:07:55 +08:00
parent 5b650166b5
commit ed7bdda515
16 changed files with 1319 additions and 350 deletions
@@ -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(" · ")
}