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>
)
}