2026-08-10 10:07:55 +08:00
|
|
|
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"
|
2026-08-11 11:20:13 +08:00
|
|
|
if (matchRoute({ to: "/sync", fuzzy: true })) return "/sync"
|
2026-08-10 15:59:48 +08:00
|
|
|
if (matchRoute({ to: "/components", fuzzy: true })) return "/components"
|
2026-08-10 10:07:55 +08:00
|
|
|
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">
|
2026-08-10 10:07:55 +08:00
|
|
|
<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>
|
2026-08-11 11:20:13 +08:00
|
|
|
行情数据尚未上线,暂无更多可用入口。
|
2026-08-10 10:07:55 +08:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|