Files
qlib/frontend/web/components/app-shell.tsx
T
Simon 24f98e90c6 feat(web): 按 ui-ux-pro-max 设计系统重构前端(侧边导航 + 组件库 + 图标 + 可视化)
- 设计令牌与组件体系:语义色/间距/圆角/状态 + Card/Metric/Pill/Btn/Field/Empty/Skeleton/Progress 等
- 桌面左侧导航 + 窄屏抽屉(usePathname 高亮、ESC/遮罩关闭);修复抽屉/遮罩占据网格把 main 挤到第二行的问题
- 自绘内联 SVG 图标集替代 emoji;图标/表格/按钮等交互态齐全,空态/加载/错误/禁用态补齐
- 因子研究分层柱状、因子组合与回测净值/回撤双图 + 月度收益热力表、实验页任务状态徽标
- LineChart 主题化并按需引入 echarts(回测/组合首载 JS 448k→281k);next.config 放行局域网 dev 来源
2026-09-08 21:48:20 +08:00

147 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/** 应用外壳:桌面左侧导航栏 + 窄屏顶栏与抽屉(usePathname 高亮当前页)。 */
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Icon, type IconName } from "@/components/icons";
interface NavItem {
href: string;
label: string;
icon: IconName;
}
const GROUPS: { title: string; items: NavItem[] }[] = [
{
title: "研究",
items: [
{ href: "/", label: "总览", icon: "grid" },
{ href: "/stocks", label: "股票池", icon: "candles" },
{ href: "/factors", label: "因子研究", icon: "flask" },
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
{ href: "/backtest", label: "选股回测", icon: "gauge" },
],
},
{
title: "归档",
items: [{ href: "/experiments", label: "实验", icon: "archive" }],
},
];
function isActive(href: string, pathname: string): boolean {
if (href === "/") return pathname === "/";
if (href === "/factors") return pathname === "/factors";
if (pathname === href || pathname.startsWith(href + "/")) return true;
return false;
}
function Brand() {
return (
<Link href="/" className="brand" style={{ textDecoration: "none" }}>
<span className="logo">
<Icon name="logo" size={18} />
</span>
<span>
qlib-platform
<small>个人 A 股量化研究台</small>
</span>
</Link>
);
}
function NavLinks({ pathname, onNavigate }: { pathname: string; onNavigate?: () => void }) {
return (
<>
{GROUPS.map((g) => (
<div key={g.title}>
<div className="nav-label">{g.title}</div>
{g.items.map((item) => {
const active = isActive(item.href, pathname);
return (
<Link
key={item.href}
href={item.href}
className={active ? "nav-link active" : "nav-link"}
aria-current={active ? "page" : undefined}
onClick={onNavigate}
>
<span className="nav-ic">
<Icon name={item.icon} size={17} />
</span>
{item.label}
</Link>
);
})}
</div>
))}
</>
);
}
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// 路由变化时收起抽屉
useEffect(() => {
setOpen(false);
}, [pathname]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open]);
return (
<div className="shell">
<aside className="rail nav" aria-label="主导航">
<Brand />
<NavLinks pathname={pathname} />
<div className="rail-foot">
数据源 Tushare 首选 · 新浪校验兜底
<br />
研究引擎 Qlib(本地 SQLite / Parquet)
</div>
</aside>
<div className="topbar">
<button
type="button"
className="icon-btn"
aria-label="打开导航菜单"
aria-expanded={open}
onClick={() => setOpen(true)}
style={{ width: 36, height: 36 }}
>
<Icon name="filter" size={18} />
</button>
<Brand />
</div>
{open ? (
<div className="scrim show" aria-hidden="true" onClick={() => setOpen(false)} />
) : null}
<aside
className={open ? "drawer nav open" : "drawer nav"}
aria-label="移动端导航"
aria-hidden={!open}
>
{open ? <NavLinks pathname={pathname} onNavigate={() => setOpen(false)} /> : null}
</aside>
<main className="content">
<div className="page">{children}</div>
</main>
</div>
);
}