Files
EPEEAIKit/art-agent/frontend/src/components/layout/top-nav.tsx
Nostars Developer b12e55a776 交互原型大版本
2026-04-17 19:30:23 +08:00

267 lines
12 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";
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)} />}
</>
);
}