- Sidebar: 信号/模拟盘 section headers - Three paper trade entries: 全部持仓, V5.1模拟盘, V5.2模拟盘 (NEW badge) - Paper page reads strategy from URL query params - Suspense boundary for useSearchParams
154 lines
7.3 KiB
TypeScript
154 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { useAuth } from "@/lib/auth";
|
|
import {
|
|
LayoutDashboard, Info,
|
|
Menu, X, Zap, LogIn, UserPlus,
|
|
ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart, Sparkles, FlaskConical
|
|
} from "lucide-react";
|
|
|
|
const navItems = [
|
|
{ href: "/", label: "仪表盘", icon: LayoutDashboard },
|
|
{ href: "/trades", label: "成交流", icon: Activity },
|
|
{ href: "/signals", label: "信号引擎", icon: Crosshair, section: "信号" },
|
|
{ href: "/paper?strategy=all", label: "全部持仓", icon: LineChart, section: "模拟盘" },
|
|
{ href: "/paper?strategy=v51_baseline", label: "V5.1 模拟盘", icon: FlaskConical },
|
|
{ href: "/paper?strategy=v52_8signals", label: "V5.2 模拟盘", icon: Sparkles, badge: "NEW" },
|
|
{ href: "/server", label: "服务器", icon: Monitor },
|
|
{ href: "/about", label: "说明", icon: Info },
|
|
];
|
|
|
|
export default function Sidebar() {
|
|
const pathname = usePathname();
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const { user, isLoggedIn, logout } = useAuth();
|
|
|
|
const SidebarContent = ({ mobile = false }: { mobile?: boolean }) => (
|
|
<div className={`flex flex-col h-full ${mobile ? "w-64" : collapsed ? "w-16" : "w-60"} transition-all duration-200`}>
|
|
{/* Logo */}
|
|
<div className={`flex items-center gap-2 px-4 py-5 border-b border-slate-100 ${collapsed && !mobile ? "justify-center" : ""}`}>
|
|
<Zap className="w-6 h-6 text-blue-600 shrink-0" />
|
|
{(!collapsed || mobile) && (
|
|
<span className="font-bold text-slate-800 text-base leading-tight">Arbitrage<br/>Engine</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Nav */}
|
|
<nav className="flex-1 py-4 space-y-1 px-2">
|
|
{navItems.map(({ href, label, icon: Icon, section, badge }, idx) => {
|
|
const active = pathname === href || (href.includes("?") && pathname === href.split("?")[0] && typeof window !== "undefined" && window.location.search === "?" + href.split("?")[1]);
|
|
return (
|
|
<div key={href}>
|
|
{section && (
|
|
<div className={`px-3 pt-3 pb-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wider ${idx > 0 ? "mt-2 border-t border-slate-100 pt-4" : ""}`}>
|
|
{section}
|
|
</div>
|
|
)}
|
|
<Link href={href}
|
|
onClick={() => setMobileOpen(false)}
|
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors
|
|
${active ? "bg-blue-50 text-blue-700 font-medium" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"}
|
|
${collapsed && !mobile ? "justify-center" : ""}`}>
|
|
<Icon className={`shrink-0 ${active ? "text-blue-600" : "text-slate-400"}`} size={18} />
|
|
{(!collapsed || mobile) && (
|
|
<span className="flex items-center gap-1.5">
|
|
{label}
|
|
{badge && <span className="text-[9px] bg-emerald-500 text-white px-1 py-0.5 rounded font-bold">{badge}</span>}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</div>
|
|
);
|
|
})}
|
|
{/* 手机端:登录/登出放在菜单里 */}
|
|
{mobile && (
|
|
<div className="pt-4 border-t border-slate-100 mt-4 space-y-1">
|
|
{isLoggedIn ? (
|
|
<>
|
|
{user?.email && (
|
|
<div className="px-3 py-2 text-xs text-slate-400">{user.email}</div>
|
|
)}
|
|
<button onClick={() => { logout(); setMobileOpen(false); }}
|
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-red-500 hover:bg-red-50 transition-colors w-full">
|
|
<LogOut className="shrink-0" size={18} /><span>退出登录</span>
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link href="/login" onClick={() => setMobileOpen(false)}
|
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-slate-600 hover:bg-slate-100 transition-colors">
|
|
<LogIn className="shrink-0 text-slate-400" size={18} /><span>登录</span>
|
|
</Link>
|
|
<Link href="/register" onClick={() => setMobileOpen(false)}
|
|
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-blue-600 hover:bg-blue-700 transition-colors">
|
|
<UserPlus className="shrink-0" size={18} /><span>注册</span>
|
|
</Link>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</nav>
|
|
|
|
{/* Collapse toggle (desktop only) */}
|
|
{!mobile && (
|
|
<button onClick={() => setCollapsed(!collapsed)}
|
|
className="flex items-center justify-center p-3 border-t border-slate-100 text-slate-400 hover:text-slate-600 hover:bg-slate-50 transition-colors">
|
|
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* Desktop sidebar */}
|
|
<aside className={`hidden md:flex flex-col bg-white border-r border-slate-200 min-h-screen sticky top-0 shrink-0 transition-all duration-200 ${collapsed ? "w-16" : "w-60"}`}>
|
|
<SidebarContent />
|
|
</aside>
|
|
|
|
{/* Mobile top bar */}
|
|
<div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-white border-b border-slate-200 flex items-center justify-between px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="w-5 h-5 text-blue-600" />
|
|
<span className="font-bold text-slate-800 text-sm">Arbitrage Engine</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isLoggedIn ? (
|
|
<button onClick={logout} className="text-xs text-slate-500 hover:text-red-500 border border-slate-200 px-2 py-1 rounded">退出</button>
|
|
) : (
|
|
<>
|
|
<Link href="/login" className="text-xs text-slate-600 border border-slate-200 px-2 py-1 rounded">登录</Link>
|
|
<Link href="/register" className="text-xs text-white bg-blue-600 px-2 py-1 rounded">注册</Link>
|
|
</>
|
|
)}
|
|
<button onClick={() => setMobileOpen(!mobileOpen)} className="text-slate-600 p-1 ml-1 border border-slate-200 rounded">
|
|
{mobileOpen ? <X size={18} /> : <Menu size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile drawer */}
|
|
{mobileOpen && (
|
|
<div className="md:hidden fixed inset-0 z-30" onClick={() => setMobileOpen(false)}>
|
|
<div className="absolute inset-0 bg-black/30" />
|
|
<aside className="absolute top-0 left-0 bottom-0 bg-white border-r border-slate-200 z-40 flex flex-col"
|
|
onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="w-5 h-5 text-blue-600" />
|
|
<span className="font-bold text-slate-800 text-sm">Arbitrage Engine</span>
|
|
</div>
|
|
<button onClick={() => setMobileOpen(false)} className="text-slate-400 p-1"><X size={18} /></button>
|
|
</div>
|
|
<SidebarContent mobile />
|
|
</aside>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|