Develop #6
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(" · ")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
])
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user