267 lines
12 KiB
TypeScript
267 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import Link from "next/link";
|
||
import { usePathname } from "next/navigation";
|
||
import { useApp } from "@/lib/app-context";
|
||
import { useAuth } from "@/lib/auth-context";
|
||
import { ProfileModal } from "@/components/profile-modal";
|
||
import { ProjectSwitcher } from "./project-switcher";
|
||
import { slideUp } from "@/components/ui/motion-presets";
|
||
|
||
const NAV_ITEMS = [
|
||
{
|
||
href: "/",
|
||
label: "工作台",
|
||
phase: "Phase 0",
|
||
icon: (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||
</svg>
|
||
),
|
||
},
|
||
{
|
||
href: "/gallery",
|
||
label: "资源库",
|
||
phase: "Phase 0",
|
||
icon: (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||
<polyline points="21 15 16 10 5 21" />
|
||
</svg>
|
||
),
|
||
},
|
||
{
|
||
href: "/styles",
|
||
label: "风格库",
|
||
phase: "Phase 1",
|
||
icon: (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="13.5" cy="6.5" r=".5" />
|
||
<circle cx="17.5" cy="10.5" r=".5" />
|
||
<circle cx="8.5" cy="7.5" r=".5" />
|
||
<circle cx="6.5" cy="12.5" r=".5" />
|
||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.55 0 1-.45 1-1v-3.5c0-.55.45-1 1-1h2c2.76 0 5-2.24 5-5 0-5.5-4.5-9.5-10-9.5z" />
|
||
</svg>
|
||
),
|
||
},
|
||
{
|
||
href: "/characters",
|
||
label: "角色库",
|
||
phase: "Phase 1",
|
||
icon: (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||
<circle cx="12" cy="7" r="4" />
|
||
</svg>
|
||
),
|
||
},
|
||
{
|
||
href: "/training",
|
||
label: "训练中心",
|
||
phase: "Phase 2",
|
||
icon: (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M12 2L3 7v5c0 5 3.8 9.7 9 11 5.2-1.3 9-6 9-11V7l-9-5z" />
|
||
</svg>
|
||
),
|
||
},
|
||
] as const;
|
||
|
||
export function TopNav() {
|
||
const pathname = usePathname();
|
||
const { sidebarCollapsed, setSidebarCollapsed } = useApp();
|
||
const { user, logout } = useAuth();
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const [profileOpen, setProfileOpen] = useState(false);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!menuOpen) return;
|
||
function handleClick(e: MouseEvent) {
|
||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||
setMenuOpen(false);
|
||
}
|
||
}
|
||
document.addEventListener("mousedown", handleClick);
|
||
return () => document.removeEventListener("mousedown", handleClick);
|
||
}, [menuOpen]);
|
||
|
||
return (
|
||
<>
|
||
<header className="relative z-10 flex-shrink-0 h-14 border-b border-[var(--border)] surface-1 backdrop-blur-xl flex items-center px-4 md:px-5 gap-3 md:gap-5">
|
||
{/* 移动端汉堡菜单 */}
|
||
<button
|
||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||
className="md:hidden flex-shrink-0 p-1.5 rounded-md
|
||
text-[var(--text-secondary)] hover:text-[var(--accent)]
|
||
transition-colors cursor-pointer"
|
||
title="菜单"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Logo */}
|
||
<Link href="/" className="flex items-center gap-2.5 flex-shrink-0 group">
|
||
<div className="w-8 h-8 rounded-lg bg-[var(--accent)] flex items-center justify-center
|
||
shadow-[0_0_12px_rgba(46,139,122,0.25)]
|
||
group-hover:shadow-[0_0_20px_rgba(46,139,122,0.4)] transition-shadow">
|
||
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M8 22L12.5 10h2.2L19 22h-2.3l-1.1-3h-4.2l-1.1 3H8Zm3.7-5h3.1l-1.5-4.6h-.1L11.7 17Z" fill="white" />
|
||
<circle cx="23" cy="12" r="3.5" stroke="white" strokeWidth="1.8" fill="none" />
|
||
<path d="M23 15.5v5" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
|
||
<circle cx="23" cy="22.5" r="1" fill="white" />
|
||
</svg>
|
||
</div>
|
||
<span className="text-base font-bold tracking-tight text-[var(--text-primary)] hidden sm:inline">
|
||
EPEEKit
|
||
</span>
|
||
</Link>
|
||
|
||
{/* 项目切换器 */}
|
||
<ProjectSwitcher />
|
||
|
||
{/* 导航 Tab */}
|
||
<nav className="flex items-center gap-1 overflow-x-auto hide-scrollbar">
|
||
{NAV_ITEMS.map(({ href, label, icon, phase }) => {
|
||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||
return (
|
||
<Link
|
||
key={href}
|
||
href={href}
|
||
className={`relative flex items-center gap-1.5 px-2.5 md:px-3.5 py-2 text-sm rounded-lg transition-all whitespace-nowrap ${
|
||
active
|
||
? "glow-border text-[var(--accent)] font-medium !bg-[var(--bg-secondary)]"
|
||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-transparent hover:border-[var(--border-glow)]"
|
||
}`}
|
||
title={`${label}(${phase})`}
|
||
>
|
||
{icon}
|
||
<span className="hidden md:inline">{label}</span>
|
||
{phase !== "Phase 0" && (
|
||
<span
|
||
className="hidden lg:inline text-[9px] px-1 py-px rounded font-semibold"
|
||
style={{
|
||
background: "rgba(184, 147, 90, 0.12)",
|
||
color: "var(--gold)",
|
||
}}
|
||
>
|
||
{phase.replace("Phase ", "P")}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
<div className="flex-1" />
|
||
|
||
{/* 用户菜单 */}
|
||
{user && (
|
||
<div className="relative" ref={menuRef}>
|
||
<button
|
||
onClick={() => setMenuOpen(!menuOpen)}
|
||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm
|
||
text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
||
hover:bg-[var(--bg-tertiary)] border border-transparent
|
||
hover:border-[var(--border)] transition-all cursor-pointer"
|
||
>
|
||
<div className="w-7 h-7 rounded-full bg-[var(--accent)]/15 border border-[var(--accent)]/30
|
||
flex items-center justify-center text-[var(--accent)] text-xs font-semibold">
|
||
{(user.display_name || user.username).charAt(0).toUpperCase()}
|
||
</div>
|
||
<span className="hidden sm:inline max-w-[80px] truncate">
|
||
{user.display_name || user.username}
|
||
</span>
|
||
</button>
|
||
<AnimatePresence>
|
||
{menuOpen && (
|
||
<motion.div
|
||
variants={slideUp}
|
||
initial="hidden"
|
||
animate="visible"
|
||
exit={{ opacity: 0, y: 4, transition: { duration: 0.1 } }}
|
||
className="absolute right-0 top-full mt-1.5 w-56 rounded-xl
|
||
surface-3 py-1.5 z-50"
|
||
>
|
||
<div className="px-3.5 py-2 text-xs text-[var(--text-secondary)]
|
||
border-b border-[var(--border)] flex items-center gap-1.5">
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
|
||
<circle cx="12" cy="7" r="4" />
|
||
</svg>
|
||
<span className="truncate">{user.username}</span>
|
||
{user.is_admin && <span className="text-[var(--accent)] font-medium">(管理员)</span>}
|
||
</div>
|
||
<button
|
||
onClick={() => { setMenuOpen(false); setProfileOpen(true); }}
|
||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
|
||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
|
||
flex items-center gap-2"
|
||
>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||
</svg>
|
||
个人信息 / 记忆
|
||
</button>
|
||
<Link
|
||
href="/settings"
|
||
onClick={() => setMenuOpen(false)}
|
||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-primary)]
|
||
hover:bg-[var(--bg-tertiary)] cursor-pointer transition-colors
|
||
flex items-center gap-2"
|
||
>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<circle cx="12" cy="12" r="3" />
|
||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
|
||
</svg>
|
||
项目设置
|
||
<span className="ml-auto text-[9px] px-1 py-px rounded font-semibold"
|
||
style={{ background: "rgba(184, 147, 90, 0.12)", color: "var(--gold)" }}>
|
||
P1
|
||
</span>
|
||
</Link>
|
||
<button
|
||
onClick={() => { setMenuOpen(false); alert("全局设置(Phase 2 规划中)\n- API Key 配置\n- 界面偏好\n- 通知设置"); }}
|
||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
|
||
hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]
|
||
cursor-pointer transition-colors flex items-center gap-2"
|
||
title="Phase 2 占位"
|
||
>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M12 20V10M6 20v-5M18 20V4" />
|
||
</svg>
|
||
全局设置
|
||
<span className="ml-auto text-[9px] px-1 py-px rounded font-semibold"
|
||
style={{ background: "rgba(184, 147, 90, 0.12)", color: "var(--gold)" }}>
|
||
P2
|
||
</span>
|
||
</button>
|
||
<div className="border-t border-[var(--border)] my-1" />
|
||
<button
|
||
onClick={() => { setMenuOpen(false); logout(); }}
|
||
className="w-full text-left px-3.5 py-2 text-sm text-[var(--text-secondary)]
|
||
hover:text-[var(--hot)] hover:bg-[var(--bg-tertiary)]
|
||
transition-colors cursor-pointer flex items-center gap-2"
|
||
>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" />
|
||
</svg>
|
||
退出登录
|
||
</button>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
)}
|
||
</header>
|
||
|
||
{profileOpen && <ProfileModal onClose={() => setProfileOpen(false)} />}
|
||
</>
|
||
);
|
||
}
|