Files
zhixing-system/zhixing-web/src/app/layout/app-layout.tsx
T

284 lines
8.9 KiB
TypeScript
Raw Normal View History

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: "/components", fuzzy: true })) return "/components"
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">
2026-08-10 16:58:16 +08:00
<span className="grid size-8 shrink-0 place-items-center rounded-md 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>
)
}