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
+282
View File
@@ -0,0 +1,282 @@
import { BarChart3, Search } from "lucide-react"
import { useState } from "react"
import { Link, Outlet, useMatchRoute } from "@tanstack/react-router"
import { Avatar, AvatarFallback } from "@/shared/ui/avatar"
import { Button } from "@/shared/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog"
import { Input } from "@/shared/ui/input"
import {
type NavigationItem,
mobilePrimaryNavigation,
mobileOverflowNavigation,
primaryNavigation,
routePresentation,
} from "./navigation"
function useActiveRoutePath() {
const matchRoute = useMatchRoute()
if (matchRoute({ to: "/selection", fuzzy: true })) return "/selection"
if (matchRoute({ to: "/", fuzzy: false })) return "/"
return "/"
}
export function AppLayout() {
const activePath = useActiveRoutePath()
const presentation = routePresentation[activePath] ?? routePresentation["/"]
const [moreOpen, setMoreOpen] = useState(false)
return (
<div className="flex h-svh min-h-0 flex-col overflow-hidden bg-background text-foreground md:grid md:grid-cols-[64px_minmax(0,1fr)] lg:grid-cols-[232px_minmax(0,1fr)]">
<DesktopSidebar activePath={activePath} />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<TopBar
breadcrumb={presentation.breadcrumb}
title={presentation.title}
/>
<main
id="main-content"
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
>
<Outlet />
</main>
</div>
<MobileBottomNav
activePath={activePath}
onMore={() => setMoreOpen(true)}
/>
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
</div>
)
}
function DesktopSidebar({ activePath }: { activePath: string }) {
return (
<aside
aria-label="主导航"
className="hidden shrink-0 flex-col border-r border-border bg-card/95 px-2 py-4 md:flex md:w-16 lg:w-[232px] lg:px-3.5"
>
<Brand />
<p className="mt-7 mb-2 hidden px-2 text-[10px] font-bold tracking-[0.09em] text-muted-foreground uppercase lg:block">
导航
</p>
<nav className="grid gap-0.5">
{primaryNavigation.map((item) => (
<DesktopNavItem
key={item.id}
item={item}
active={item.to === activePath}
/>
))}
</nav>
<div className="mt-auto border-t border-border pt-3.5">
<p className="hidden px-2 text-[11px] leading-[1.5] text-muted-foreground lg:block">
知行量化研究工作台,从市场数据概览开始。
</p>
</div>
</aside>
)
}
function Brand() {
return (
<div className="flex min-h-10 items-center justify-center gap-2.5 px-0 lg:justify-start lg:px-1.5">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-primary text-primary-foreground">
<BarChart3 className="size-[17px]" aria-hidden="true" />
</span>
<div className="hidden min-w-0 lg:block">
<p className="truncate text-sm font-semibold tracking-[-0.01em]">
知行量化
</p>
<p className="text-[11px] text-muted-foreground">研究工作台</p>
</div>
</div>
)
}
function DesktopNavItem({
item,
active,
}: {
item: NavigationItem
active: boolean
}) {
const Icon = item.icon
if (item.to) {
return (
<Link
to={item.to}
aria-current={active ? "page" : undefined}
className={[
"flex min-h-9 w-full items-center justify-center gap-2.5 rounded-md px-0 text-left text-[13px] transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50 lg:justify-start lg:px-2.5",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
].join(" ")}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden truncate lg:inline">{item.label}</span>
</Link>
)
}
return (
<Button
disabled
variant="ghost"
className="min-h-9 w-full justify-center gap-2.5 px-0 text-[13px] text-muted-foreground lg:justify-start lg:px-2.5"
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden truncate lg:inline">{item.label}</span>
</Button>
)
}
function TopBar({ breadcrumb, title }: { breadcrumb: string; title: string }) {
return (
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-background/90 px-4 backdrop-blur md:px-6">
<div className="min-w-0">
<p className="text-[11px] text-muted-foreground">{breadcrumb}</p>
<p className="truncate text-[13px] font-semibold">{title}</p>
</div>
<div className="ml-auto flex items-center gap-2">
<div className="relative hidden w-[min(330px,28vw)] md:block">
<Search
className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
aria-label="搜索"
className="h-8 pl-8 text-xs"
placeholder="搜索股票、数据或功能"
readOnly
/>
</div>
<Avatar aria-label="用户头像" size="sm">
<AvatarFallback>研</AvatarFallback>
</Avatar>
</div>
</header>
)
}
function MobileBottomNav({
activePath,
onMore,
}: {
activePath: string
onMore: () => void
}) {
const visible = mobilePrimaryNavigation
return (
<nav
aria-label="移动端主导航"
className="fixed inset-x-0 bottom-0 z-30 flex items-stretch gap-1 border-t border-border bg-card px-1 pb-[env(safe-area-inset-bottom)] md:hidden"
style={{ minHeight: "60px" }}
>
{visible.map((item) => {
const Icon = item.icon
const active = item.to === activePath
if (item.to) {
return (
<Link
key={item.id}
to={item.to}
aria-current={active ? "page" : undefined}
className="flex min-h-[60px] min-w-[44px] flex-1 flex-col items-center justify-center gap-0.5 rounded-md text-[10px] text-muted-foreground outline-none active:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Icon className="size-5" aria-hidden="true" />
<span>{item.label}</span>
</Link>
)
}
return (
<button
key={item.id}
type="button"
disabled
className="flex min-h-[60px] min-w-[44px] flex-1 flex-col items-center justify-center gap-0.5 rounded-md text-[10px] text-muted-foreground/50"
>
<Icon className="size-5" aria-hidden="true" />
<span>{item.label}</span>
</button>
)
})}
<button
type="button"
onClick={onMore}
aria-label="更多功能"
className="flex min-h-[60px] min-w-[44px] flex-1 flex-col items-center justify-center gap-0.5 rounded-md text-[10px] text-muted-foreground outline-none active:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
>
<span
className="grid h-5 w-5 place-items-center text-base leading-none"
aria-hidden="true"
>
···
</span>
<span>更多</span>
</button>
</nav>
)
}
function MoreSheet({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const overflow = mobileOverflowNavigation
if (overflow.length === 0) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>更多功能</DialogTitle>
<DialogDescription>
行情数据与同步任务尚未上线,暂无更多可用入口。
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>更多功能</DialogTitle>
<DialogDescription>
以下入口尚未上线,仅作占位说明。
</DialogDescription>
</DialogHeader>
<ul className="space-y-1">
{overflow.map((item) => {
const Icon = item.icon
return (
<li key={item.id}>
<Button
disabled
variant="ghost"
className="w-full justify-start gap-2.5"
>
<Icon className="size-4" aria-hidden="true" />
{item.label}
</Button>
</li>
)
})}
</ul>
</DialogContent>
</Dialog>
)
}
+74
View File
@@ -0,0 +1,74 @@
import {
BarChart3,
BrainCircuit,
ClipboardCheck,
LayoutDashboard,
type LucideIcon,
} from "lucide-react"
export type NavigationAvailability = "available" | "unavailable"
export interface NavigationItem {
id: string
label: string
to: string | null
icon: LucideIcon
availability: NavigationAvailability
}
export const primaryNavigation: readonly NavigationItem[] = [
{
id: "home",
label: "首页",
to: "/",
icon: LayoutDashboard,
availability: "available",
},
{
id: "market",
label: "行情数据",
to: null,
icon: BarChart3,
availability: "unavailable",
},
{
id: "sync",
label: "同步任务",
to: null,
icon: ClipboardCheck,
availability: "unavailable",
},
{
id: "selection",
label: "选股策略",
to: "/selection",
icon: BrainCircuit,
availability: "available",
},
] as const
export const mobileNavigationMaxItems = 4
export const mobilePrimaryNavigation: readonly NavigationItem[] =
primaryNavigation.slice(0, mobileNavigationMaxItems)
export const mobileOverflowNavigation: readonly NavigationItem[] = []
export interface RoutePresentation {
id: string
breadcrumb: string
title: string
}
export const routePresentation: Record<string, RoutePresentation> = {
"/": {
id: "home",
breadcrumb: "研究工作台",
title: "市场数据概览",
},
"/selection": {
id: "selection",
breadcrumb: "研究工作台",
title: "知行 B1 执行结果",
},
}
@@ -0,0 +1,49 @@
import type { ReactNode } from "react"
import { cn } from "@/shared/ui/utils"
interface PageLayoutProps {
mode?: "document" | "bounded-workspace"
actions?: ReactNode
metrics?: ReactNode
children: ReactNode
}
/**
* PageLayout 只承载页面内容节奏与滚动边界,不渲染页面大标题、眉题或说明。
* - document: 自然页面滚动,适合首页等普通详情页。
* - bounded-workspace: 连续高度链,只有内部数据区滚动,用于选股主从研究台。
*/
export function PageLayout({
mode = "document",
actions,
metrics,
children,
}: PageLayoutProps) {
const bounded = mode === "bounded-workspace"
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{actions ? (
<div className="shrink-0 border-b border-border px-4 py-3 md:px-6 md:py-3">
{actions}
</div>
) : null}
{metrics ? (
<div className="shrink-0 border-b border-border px-4 py-3 md:px-6 md:py-3">
{metrics}
</div>
) : null}
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col",
bounded ? "overflow-y-auto md:overflow-hidden" : "overflow-y-auto",
"px-4 pb-6 pt-4 md:px-6 md:pb-8 md:pt-5",
// 移动端底部为底部导航预留安全区
"pb-[calc(5.25rem+env(safe-area-inset-bottom))] md:pb-8",
)}
>
{children}
</div>
</div>
)
}
@@ -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 />) render(<HomePage />)
expect(screen.getByRole("heading", { name: "首页" })).toBeInTheDocument()
expect(screen.getByText("市场数据概览")).toBeInTheDocument() expect(screen.getByText("市场数据概览")).toBeInTheDocument()
expect(screen.getByText("部分更新")).toBeInTheDocument() expect(screen.getByText("部分更新")).toBeInTheDocument()
expect(screen.getByText("当前目标股票池")).toBeInTheDocument() expect(screen.getByText("当前目标股票池")).toBeInTheDocument()
expect(screen.getByText("33.33%")).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", () => { it("opens and closes the accessible failed-stock dialog", () => {
@@ -142,7 +135,7 @@ describe("HomePage", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument() expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
}) })
it("keeps the shell and explains the no-data state", () => { it("explains the no-data state", () => {
useHomeOverview.mockReturnValue({ useHomeOverview.mockReturnValue({
data: { status: "no_data", stock_pool_count: 7, latest_update: null }, data: { status: "no_data", stock_pool_count: 7, latest_update: null },
isError: false, isError: false,
@@ -1,6 +1,7 @@
import { AlertTriangle, Database, RefreshCw } from "lucide-react" import { AlertTriangle, Database, RefreshCw } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { PageLayout } from "@/app/layout/page-layout"
import { useHomeOverview } from "@/features/home/api/home.query" import { useHomeOverview } from "@/features/home/api/home.query"
import { Button } from "@/shared/ui/button" import { Button } from "@/shared/ui/button"
import { import {
@@ -13,7 +14,6 @@ import {
import { Skeleton } from "@/shared/ui/skeleton" import { Skeleton } from "@/shared/ui/skeleton"
import { FailedStocksDialog } from "../components/failed-stocks-dialog" import { FailedStocksDialog } from "../components/failed-stocks-dialog"
import { HomeShell } from "../components/home-shell"
import { MarketDataOverviewCard } from "../components/market-data-overview-card" import { MarketDataOverviewCard } from "../components/market-data-overview-card"
export function HomePage() { export function HomePage() {
@@ -22,16 +22,8 @@ export function HomePage() {
const update = overview.data?.latest_update const update = overview.data?.latest_update
return ( return (
<HomeShell activeSection="home"> <PageLayout>
<div className="space-y-8"> <div className="mx-auto w-full max-w-6xl space-y-6">
<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>
{overview.isPending ? <OverviewLoading /> : null} {overview.isPending ? <OverviewLoading /> : null}
{overview.isError ? ( {overview.isError ? (
@@ -60,7 +52,7 @@ export function HomePage() {
/> />
) : null} ) : null}
</div> </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" import { SelectionResultsPage } from "./selection-results-page"
const routerNavigate = vi.hoisted(() => vi.fn())
const useSelectionResults = vi.fn() const useSelectionResults = vi.fn()
const useSelectionRun = vi.fn() const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn() const useTriggerSelectionRun = vi.fn()
@@ -17,6 +18,11 @@ vi.mock("@/features/selection/api/selection.query", () => ({
useTriggerSelectionRun: () => useTriggerSelectionRun(), useTriggerSelectionRun: () => useTriggerSelectionRun(),
})) }))
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => routerNavigate,
useSearch: () => ({ page: 1, pageSize: 5 }),
}))
const selectedResult: SelectionResults = { const selectedResult: SelectionResults = {
created_at: "2026-08-08T09:00:00+08:00", created_at: "2026-08-08T09:00:00+08:00",
coverage: 1, coverage: 1,
@@ -77,18 +83,75 @@ describe("SelectionResultsPage", () => {
}) })
mutate.mockReset() mutate.mockReset()
reset.mockReset() reset.mockReset()
routerNavigate.mockReset()
}) })
it("renders persisted results and keeps multiple categories visible", () => { it("renders persisted results and keeps multiple categories visible", () => {
render(<SelectionResultsPage />) 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( expect(
screen.getByRole("heading", { name: "选股策略" }), within(
screen.getByRole("complementary", { name: "当前股票详情" }),
).getByText("招商银行"),
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByText("知行 B1 执行结果")).toBeInTheDocument()
expect(screen.getByText("原始 B1")).toBeInTheDocument() const expandButton = screen.getAllByRole("button", { name: "查看详情" })[0]
expect(screen.getByText("回踩白线 B")).toBeInTheDocument() fireEvent.click(expandButton)
expect(screen.getAllByText("平安银行")).toHaveLength(2) 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", () => { it("requires confirmation before a rerun and does not trigger on cancel", () => {
@@ -7,6 +7,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { PageLayout } from "@/app/layout/page-layout"
import { import {
useSelectionResults, useSelectionResults,
useSelectionRun, useSelectionRun,
@@ -15,7 +16,6 @@ import {
import type { import type {
SelectionResults, SelectionResults,
SelectionRunStatus, SelectionRunStatus,
SelectionSignal,
} from "@/features/selection/api/selection.types" } from "@/features/selection/api/selection.types"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button" import { Button } from "@/shared/ui/button"
@@ -37,7 +37,7 @@ import {
import { Input } from "@/shared/ui/input" import { Input } from "@/shared/ui/input"
import { Skeleton } from "@/shared/ui/skeleton" 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 const STRATEGY = "zhixing_b1" as const
@@ -50,16 +50,6 @@ const statusPresentation: Record<
success: { label: "执行完成", variant: "default" }, 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() { export function SelectionResultsPage() {
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null) const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
const [activeRunId, setActiveRunId] = useState<string | null>(null) const [activeRunId, setActiveRunId] = useState<string | null>(null)
@@ -123,78 +113,30 @@ export function SelectionResultsPage() {
trigger.reset() trigger.reset()
} }
const resultMetrics =
displayedResult &&
displayedResult.status !== "no_data" &&
displayedResult.status !== "running" ? (
<ResultMetrics result={displayedResult} />
) : null
return ( return (
<HomeShell activeSection="selection"> <PageLayout
<div className="space-y-8"> actions={
<section className="space-y-2"> <ExecutionToolbar
<p className="text-sm font-medium text-primary">研究策略执行</p> hasExistingResult={hasExistingResult}
<h1 className="text-3xl font-semibold tracking-tight">选股策略</h1> isFailedResult={displayedResult?.status === "failed"}
<p className="max-w-2xl text-muted-foreground"> isRunning={isRunning}
查看知行 B1 在指定交易日的持久化执行结果,或重新执行当天策略。 onDateChange={handleDateChange}
</p> onExecute={handleExecute}
</section> selectedTargetTradeDate={selectedTargetTradeDate}
triggerError={trigger.isError}
<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 metrics={resultMetrics}
className="flex-1 space-y-2 text-sm font-medium" mode="bounded-workspace"
htmlFor="strategy-name"
> >
策略 <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 md:overflow-y-auto">
<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>
{isRunning ? <RunningState /> : null} {isRunning ? <RunningState /> : null}
{!isRunning && {!isRunning &&
!hasQueryError && !hasQueryError &&
@@ -249,10 +191,128 @@ export function SelectionResultsPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) : null} ) : 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() { function LoadingState() {
return ( return (
<Card aria-label="正在加载策略执行结果"> <Card aria-label="正在加载策略执行结果">
@@ -355,47 +415,20 @@ function FailureState({ result }: { result: SelectionResults }) {
} }
function ResultState({ 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 ( return (
<div className="space-y-6"> <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
<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>
{result.signal_count === 0 ? ( {result.signal_count === 0 ? (
<NoSignalState />
) : (
<SelectionResultsWorkbench result={result} />
)}
{result.failures.length > 0 ? <FailureList result={result} /> : null}
</div>
)
}
function NoSignalState() {
return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-xl">本次没有命中信号</CardTitle> <CardTitle className="text-xl">本次没有命中信号</CardTitle>
@@ -404,63 +437,6 @@ function ResultState({ result }: { result: SelectionResults }) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
) : (
<SignalTable signals={result.signals} />
)}
{result.failures.length > 0 ? <FailureList result={result} /> : null}
</div>
)
}
function SignalTable({ signals }: { signals: SelectionSignal[] }) {
return (
<Card>
<CardHeader>
<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 }) { function Metric({ label, value }: { label: string; value: number | string }) {
return ( return (
<div className="space-y-1"> <div className="space-y-0.5">
<p className="text-sm text-muted-foreground">{label}</p> <p className="text-[11px] text-muted-foreground">{label}</p>
<p className="text-lg font-semibold tracking-tight">{value}</p> <p className="text-base font-semibold tracking-tight tabular-nums">
{value}
</p>
</div> </div>
) )
} }
function formatDetails(details: SelectionSignal["details"]) {
return Object.entries(details)
.map(([key, value]) => `${key}: ${value ?? "—"}`)
.join(" · ")
}
+20 -3
View File
@@ -1,5 +1,6 @@
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router" import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
import { AppLayout } from "@/app/layout/app-layout"
import { HomePage } from "@/features/home/pages/home-page" import { HomePage } from "@/features/home/pages/home-page"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page" import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
@@ -7,16 +8,32 @@ const rootRoute = createRootRoute({
component: () => <Outlet />, component: () => <Outlet />,
}) })
const indexRoute = createRoute({ const workspaceRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
id: "_workspace",
component: AppLayout,
})
const indexRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "/", path: "/",
component: HomePage, component: HomePage,
}) })
const selectionRoute = createRoute({ const selectionRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => workspaceRoute,
path: "/selection", path: "/selection",
validateSearch: (search: Record<string, unknown>) => {
const rawPage = Number(search.page)
const rawPageSize = Number(search.pageSize)
const page =
Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
const pageSize = [5, 10, 20].includes(rawPageSize) ? rawPageSize : 10
return { page, pageSize }
},
component: SelectionResultsPage, component: SelectionResultsPage,
}) })
export const routeTree = rootRoute.addChildren([indexRoute, selectionRoute]) export const routeTree = rootRoute.addChildren([
workspaceRoute.addChildren([indexRoute, selectionRoute]),
])
+138
View File
@@ -0,0 +1,138 @@
import { ChevronLeft, ChevronRight } from "lucide-react"
import type { ChangeEvent } from "react"
import { Button } from "@/shared/ui/button"
import { cn } from "@/shared/ui/utils"
interface PaginationProps {
page: number
pageSize: number
total: number
pageSizeOptions?: readonly number[]
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
}
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50] as const
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function rangeLabel(page: number, pageSize: number, total: number) {
if (total <= 0) return "共 0 条"
const start = (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
return `第 ${start}–${end} 条,共 ${total} 条`
}
function buildPageItems(page: number, pageCount: number) {
if (pageCount <= 1) return [1]
const items: Array<number | "ellipsis"> = [1]
const start = Math.max(2, page - 1)
const end = Math.min(pageCount - 1, page + 1)
if (start > 2) items.push("ellipsis")
for (let p = start; p <= end; p += 1) items.push(p)
if (end < pageCount - 1) items.push("ellipsis")
if (pageCount > 1) items.push(pageCount)
return items
}
export function Pagination({
page,
pageSize,
total,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
onPageChange,
onPageSizeChange,
}: PaginationProps) {
const pageCount = Math.max(1, Math.ceil(total / pageSize))
const currentPage = clamp(page, 1, pageCount)
const hasPrev = currentPage > 1
const hasNext = currentPage < pageCount
const items = buildPageItems(currentPage, pageCount)
function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
onPageSizeChange(Number(event.target.value))
}
return (
<footer
aria-label="表格分页"
className="flex min-h-12 flex-wrap items-center gap-3 border-t border-border/60 px-3 py-2 text-xs text-muted-foreground"
>
<span className="tabular-nums" aria-live="polite">
{rangeLabel(currentPage, pageSize, total)}
</span>
<label className="ml-auto flex items-center gap-1.5">
每页
<select
className="h-11 rounded-md border border-input bg-card px-2 text-xs text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:h-7"
aria-label="每页数量"
value={pageSize}
onChange={handlePageSizeChange}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
条
</label>
<div className="flex items-center gap-1">
<Button
aria-label="上一页"
disabled={!hasPrev}
onClick={() => onPageChange(currentPage - 1)}
className="size-11 md:size-7"
size="icon-sm"
variant="ghost"
>
<ChevronLeft aria-hidden="true" />
</Button>
{items.map((item, index) => {
if (item === "ellipsis") {
return (
<span
key={`ellipsis-${index}`}
className="px-1.5 text-muted-foreground/70"
aria-hidden="true"
>
…
</span>
)
}
const active = item === currentPage
return (
<button
key={item}
type="button"
aria-current={active ? "page" : undefined}
aria-label={`第 ${item} 页`}
onClick={() => onPageChange(item)}
className={cn(
"size-11 rounded-md border border-transparent text-xs tabular-nums transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:size-7",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{item}
</button>
)
})}
<Button
aria-label="下一页"
disabled={!hasNext}
onClick={() => onPageChange(currentPage + 1)}
className="size-11 md:size-7"
size="icon-sm"
variant="ghost"
>
<ChevronRight aria-hidden="true" />
</Button>
</div>
</footer>
)
}
+41 -14
View File
@@ -9,11 +9,20 @@
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -21,17 +30,26 @@
} }
:root { :root {
--background: oklch(0.985 0.002 247.839); --background: #f5f1ec;
--foreground: oklch(0.145 0.015 252.417); --foreground: #111111;
--card: oklch(1 0 0); --card: #ffffff;
--card-foreground: oklch(0.145 0.015 252.417); --card-foreground: #111111;
--primary: oklch(0.488 0.243 264.376); --primary: #111111;
--primary-foreground: oklch(0.97 0.014 254.604); --primary-foreground: #ffffff;
--muted: oklch(0.968 0.007 247.896); --secondary: #ebe7e1;
--muted-foreground: oklch(0.554 0.046 257.417); --secondary-foreground: #111111;
--accent: oklch(0.932 0.032 255.585); --muted: #ebe7e1;
--accent-foreground: oklch(0.279 0.041 260.031); --muted-foreground: #626260;
--destructive: oklch(0.577 0.245 27.325); --accent: #ebe7e1;
--accent-foreground: #111111;
--destructive: #b3261e;
--success: #147a46;
--success-foreground: #e5f4eb;
--warning: #9b5607;
--warning-foreground: #fff0d8;
--border: #d3cec6;
--input: #d3cec6;
--ring: #111111;
--radius: 0.75rem; --radius: 0.75rem;
} }
@@ -40,18 +58,27 @@
--foreground: oklch(0.984 0.003 247.858); --foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755); --card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858); --card-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.707 0.165 254.624); --primary: oklch(0.984 0.003 247.858);
--primary-foreground: oklch(0.208 0.042 265.755); --primary-foreground: oklch(0.129 0.042 264.695);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031); --muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788); --muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031); --accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858); --accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.6 0.13 150);
--success-foreground: oklch(0.15 0.05 150);
--warning: oklch(0.7 0.15 75);
--warning-foreground: oklch(0.2 0.08 75);
--border: oklch(0.35 0.02 260);
--input: oklch(0.35 0.02 260);
--ring: oklch(0.984 0.003 247.858);
} }
@layer base { @layer base {
* { * {
@apply border-slate-200 dark:border-slate-800; @apply border-border;
} }
body { body {