541 lines
20 KiB
TypeScript
541 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useRouter } from "next/navigation";
|
||
import Link from "next/link";
|
||
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, X } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { useTheme } from "next-themes";
|
||
import { useState, useEffect, useRef } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { ImageAnnotator } from "@/components/image-annotator";
|
||
import { VideoPlayer } from "@/components/video-player";
|
||
|
||
type RankingItem = {
|
||
rank: number;
|
||
userId: string;
|
||
userName: string;
|
||
department: string;
|
||
featuredCount: number;
|
||
};
|
||
|
||
type CustomLink = {
|
||
id: string;
|
||
name: string;
|
||
url: string;
|
||
order: number;
|
||
toolboxGroup?: number; // 1 或 2
|
||
};
|
||
|
||
type TutorialVideo = {
|
||
id: string;
|
||
title: string;
|
||
videoUrl: string;
|
||
coverUrl: string | null;
|
||
description: string | null;
|
||
order: number;
|
||
};
|
||
|
||
type TopNavbarProps = {
|
||
siteName?: string;
|
||
siteLogo?: string;
|
||
siteLogoDark?: string;
|
||
isAdmin?: boolean;
|
||
customLinks?: CustomLink[];
|
||
toolbox1Name?: string;
|
||
toolbox1Enabled?: boolean;
|
||
toolbox2Name?: string;
|
||
toolbox2Enabled?: boolean;
|
||
tutorialVideos?: TutorialVideo[];
|
||
tutorialEnabled?: boolean;
|
||
rankingEnabled?: boolean;
|
||
};
|
||
|
||
export function TopNavbar({
|
||
siteName = "AI Chatbot",
|
||
siteLogo,
|
||
siteLogoDark,
|
||
isAdmin = false,
|
||
customLinks = [],
|
||
toolbox1Name = "工具箱",
|
||
toolbox1Enabled = true,
|
||
toolbox2Name = "工具箱2",
|
||
toolbox2Enabled = false,
|
||
tutorialVideos = [],
|
||
tutorialEnabled = false,
|
||
rankingEnabled = true,
|
||
}: TopNavbarProps) {
|
||
const router = useRouter();
|
||
const { resolvedTheme } = useTheme();
|
||
const [mounted, setMounted] = useState(false);
|
||
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
||
const [rankingOpen, setRankingOpen] = useState(false);
|
||
const [tutorialMenuOpen, setTutorialMenuOpen] = useState(false);
|
||
const [showTutorialModal, setShowTutorialModal] = useState(false);
|
||
const [currentTutorialVideo, setCurrentTutorialVideo] = useState<TutorialVideo | null>(null);
|
||
const [showImageEditor, setShowImageEditor] = useState(false);
|
||
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
|
||
// 移除 fileInputRef,不再需要外部上传
|
||
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
// 获取精选榜单数据
|
||
const fetchRanking = async () => {
|
||
try {
|
||
const res = await fetch("/api/featured/ranking");
|
||
const data = await res.json();
|
||
if (data.ranking) {
|
||
setRanking(data.ranking);
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to fetch ranking:", error);
|
||
}
|
||
};
|
||
|
||
// 当下拉菜单打开时获取数据
|
||
useEffect(() => {
|
||
if (rankingOpen) {
|
||
fetchRanking();
|
||
}
|
||
}, [rankingOpen]);
|
||
|
||
// 获取排名图标
|
||
const getRankIcon = (rank: number) => {
|
||
switch (rank) {
|
||
case 1:
|
||
return <Crown className="h-4 w-4 text-yellow-500" />;
|
||
case 2:
|
||
return <Medal className="h-4 w-4 text-gray-400" />;
|
||
case 3:
|
||
return <Award className="h-4 w-4 text-amber-600" />;
|
||
default:
|
||
return <span className="w-4 h-4 text-center text-xs font-bold text-gray-500">{rank}</span>;
|
||
}
|
||
};
|
||
|
||
// 决定显示哪个 LOGO
|
||
const displayLogo = (siteLogoDark && siteLogoDark.trim() !== "") ? siteLogoDark : siteLogo;
|
||
|
||
// 打开图片编辑器 - 直接打开,不触发上传
|
||
const handleOpenImageEditor = () => {
|
||
setEditorImageUrl(""); // 确保初始为空
|
||
setShowImageEditor(true);
|
||
};
|
||
|
||
// 处理图片编辑完成
|
||
const handleImageEditorConfirm = (annotatedImageUrl: string) => {
|
||
// 将编辑后的图片存储到 sessionStorage,以便在聊天页面中使用
|
||
try {
|
||
sessionStorage.setItem("pending-edited-image", annotatedImageUrl);
|
||
} catch (error) {
|
||
// sessionStorage不可用(如隐私模式),使用URL参数作为备选方案
|
||
console.warn("sessionStorage不可用,尝试使用URL参数:", error);
|
||
// 如果sessionStorage不可用,我们可以将图片数据编码到URL中
|
||
// 但由于数据可能很大,这里只记录警告,让用户手动添加图片
|
||
// 或者可以显示提示信息
|
||
}
|
||
// 导航到新聊天页面(无论sessionStorage是否成功)
|
||
router.push("/");
|
||
setShowImageEditor(false);
|
||
setEditorImageUrl("");
|
||
};
|
||
|
||
// 取消图片编辑
|
||
const handleImageEditorCancel = () => {
|
||
setShowImageEditor(false);
|
||
setEditorImageUrl("");
|
||
};
|
||
|
||
const navContent = (
|
||
<nav className="fixed top-0 left-0 right-0 z-[100] flex h-12 items-center justify-between bg-black/90 backdrop-blur-sm px-4 text-white border-b border-white/10 shadow-sm">
|
||
{/* 左侧:LOGO + 网站名称 */}
|
||
<a
|
||
href="/"
|
||
className="flex items-center gap-2 transition-opacity hover:opacity-80"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
window.location.href = "/";
|
||
}}
|
||
>
|
||
{displayLogo && (
|
||
<img
|
||
src={displayLogo}
|
||
alt={siteName}
|
||
className="h-6 w-6 object-contain"
|
||
/>
|
||
)}
|
||
<span className="text-sm font-semibold">{siteName}</span>
|
||
</a>
|
||
|
||
{/* 右侧区域:自定义链接 + 系统设置 */}
|
||
<div className="flex items-center gap-2">
|
||
{/* 工具箱1 (如果启用且有链接) */}
|
||
{toolbox1Enabled && customLinks.filter(l => (l.toolboxGroup || 1) === 1).length > 0 && (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
|
||
>
|
||
<Briefcase className="h-4 w-4" />
|
||
<span className="text-sm font-medium">{toolbox1Name}</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-[320px] p-2">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{customLinks
|
||
.filter(l => (l.toolboxGroup || 1) === 1)
|
||
.sort((a, b) => a.order - b.order)
|
||
.map((link) => (
|
||
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
|
||
<a
|
||
href={link.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 hover:bg-accent hover:text-accent-foreground text-center h-20 transition-colors"
|
||
>
|
||
<ExternalLink className="h-5 w-5 opacity-70" />
|
||
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
|
||
</a>
|
||
</DropdownMenuItem>
|
||
))}
|
||
</div>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
|
||
{/* 工具箱2 (如果启用且有链接) */}
|
||
{toolbox2Enabled && customLinks.filter(l => l.toolboxGroup === 2).length > 0 && (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
|
||
>
|
||
<Briefcase className="h-4 w-4" />
|
||
<span className="text-sm font-medium">{toolbox2Name}</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-[320px] p-2">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{customLinks
|
||
.filter(l => l.toolboxGroup === 2)
|
||
.sort((a, b) => a.order - b.order)
|
||
.map((link) => (
|
||
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
|
||
<a
|
||
href={link.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 hover:bg-accent hover:text-accent-foreground text-center h-20 transition-colors"
|
||
>
|
||
<ExternalLink className="h-5 w-5 opacity-70" />
|
||
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
|
||
</a>
|
||
</DropdownMenuItem>
|
||
))}
|
||
</div>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
|
||
{/* AI智能精选 */}
|
||
<a
|
||
href="/featured"
|
||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors text-sm font-medium"
|
||
title="AI智能精选"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
window.location.href = "/featured";
|
||
}}
|
||
>
|
||
<Sparkles className="h-4 w-4 text-yellow-400" />
|
||
<span>AI精选</span>
|
||
</a>
|
||
|
||
{/* 功能教程 */}
|
||
{tutorialEnabled && tutorialVideos.length > 0 && (
|
||
<DropdownMenu open={tutorialMenuOpen} onOpenChange={setTutorialMenuOpen}>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
|
||
type="button"
|
||
>
|
||
<PlayCircle className="h-4 w-4 text-green-400" />
|
||
<span className="text-sm font-medium">功能教程</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-[320px] p-2">
|
||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||
{tutorialVideos
|
||
.sort((a, b) => a.order - b.order)
|
||
.map((video) => (
|
||
<DropdownMenuItem
|
||
key={video.id}
|
||
className="cursor-pointer p-0"
|
||
onClick={() => {
|
||
setCurrentTutorialVideo(video);
|
||
setShowTutorialModal(true);
|
||
setTutorialMenuOpen(false);
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-3 p-2 w-full hover:bg-accent rounded-md">
|
||
{/* 封面 */}
|
||
<div className="w-16 h-10 bg-muted rounded overflow-hidden flex-shrink-0">
|
||
{video.coverUrl ? (
|
||
<img
|
||
src={video.coverUrl}
|
||
alt={video.title}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-green-500/20 to-blue-500/20">
|
||
<PlayCircle className="h-5 w-5 text-green-500" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* 标题和描述 */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-medium text-sm truncate">{video.title}</div>
|
||
{video.description && (
|
||
<div className="text-xs text-muted-foreground truncate">{video.description}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</DropdownMenuItem>
|
||
))}
|
||
</div>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
|
||
{/* 精选榜单 */}
|
||
{rankingEnabled && (
|
||
<DropdownMenu open={rankingOpen} onOpenChange={setRankingOpen}>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
|
||
type="button"
|
||
>
|
||
<Trophy className="h-4 w-4 text-orange-400" />
|
||
<span className="text-sm font-medium">精选榜</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-[280px] p-3">
|
||
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
|
||
<Trophy className="h-5 w-5 text-orange-500" />
|
||
<span className="font-semibold">精选榜单 TOP5</span>
|
||
</div>
|
||
{ranking.length === 0 ? (
|
||
<div className="text-center text-muted-foreground py-4 text-sm">
|
||
暂无精选数据
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{ranking.map((item) => (
|
||
<div
|
||
key={item.userId}
|
||
className={`flex items-center gap-3 p-2 rounded-lg transition-colors ${
|
||
item.rank === 1 ? "bg-gradient-to-r from-yellow-50 to-orange-50 dark:from-yellow-900/20 dark:to-orange-900/20" :
|
||
item.rank === 2 ? "bg-gray-50 dark:bg-gray-800/50" :
|
||
item.rank === 3 ? "bg-amber-50 dark:bg-amber-900/20" :
|
||
"hover:bg-muted"
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-center w-6">
|
||
{getRankIcon(item.rank)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-medium text-sm truncate">{item.userName}</p>
|
||
<p className="text-xs text-muted-foreground flex items-center gap-1 truncate">
|
||
<Building2 className="h-3 w-3" />
|
||
{item.department}
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<span className="text-lg font-bold text-orange-500">{item.featuredCount}</span>
|
||
<span className="text-xs text-muted-foreground ml-1">精选</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
|
||
{/* 图片编辑 */}
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
|
||
onClick={handleOpenImageEditor}
|
||
type="button"
|
||
title="图片编辑"
|
||
>
|
||
<Edit className="h-4 w-4" />
|
||
<span className="text-sm font-medium">图片编辑</span>
|
||
</Button>
|
||
|
||
{/* 无限画布 */}
|
||
<button
|
||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors text-sm font-medium"
|
||
onClick={() => {
|
||
const newId = crypto.randomUUID();
|
||
window.location.href = `/canvas/${newId}`;
|
||
}}
|
||
type="button"
|
||
title="无限画布"
|
||
>
|
||
<LayoutGrid className="h-4 w-4 text-purple-400" />
|
||
<span>无限画布</span>
|
||
</button>
|
||
|
||
{/* 我的图册 */}
|
||
<a
|
||
href="/gallery"
|
||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors text-sm font-medium"
|
||
title="我的图册"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
window.location.href = "/gallery";
|
||
}}
|
||
>
|
||
<Images className="h-4 w-4" />
|
||
<span>我的图册</span>
|
||
</a>
|
||
|
||
{/* 分隔线 (如果有链接或图册按钮,且是管理员) */}
|
||
{isAdmin && (
|
||
<div className="hidden h-4 w-px bg-white/20 mx-1 sm:block" />
|
||
)}
|
||
|
||
{/* 系统设置(仅管理员可见)*/}
|
||
{isAdmin && (
|
||
<a
|
||
href="/settings"
|
||
className="flex items-center justify-center h-8 w-8 text-white hover:bg-white/15 hover:text-white rounded-full"
|
||
title="系统设置"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
window.location.href = "/settings";
|
||
}}
|
||
>
|
||
<Settings className="h-4 w-4" />
|
||
</a>
|
||
)}
|
||
</div>
|
||
|
||
{/* 图片编辑器 */}
|
||
{showImageEditor && (
|
||
<ImageAnnotator
|
||
imageUrl={editorImageUrl}
|
||
onConfirm={handleImageEditorConfirm}
|
||
onCancel={handleImageEditorCancel}
|
||
/>
|
||
)}
|
||
</nav>
|
||
);
|
||
|
||
// 使用 Portal 将视频弹窗渲染到 body,确保它在最顶层且不受 nav 的限制
|
||
const videoModal = showTutorialModal && currentTutorialVideo && typeof document !== 'undefined' ? createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[99999] bg-black/95"
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
margin: 0,
|
||
padding: '20px',
|
||
}}
|
||
onClick={() => {
|
||
setShowTutorialModal(false);
|
||
setCurrentTutorialVideo(null);
|
||
}}
|
||
>
|
||
<div
|
||
className="relative w-full max-w-[1200px]"
|
||
style={{ maxHeight: 'calc(100vh - 80px)' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 标题栏 - 包含标题和关闭按钮 */}
|
||
<div className="flex items-center justify-between mb-3 px-2">
|
||
<div className="w-8" /> {/* 占位符保持标题居中 */}
|
||
<h3 className="text-white text-lg font-medium truncate flex-1 text-center">{currentTutorialVideo.title}</h3>
|
||
<button
|
||
onClick={() => {
|
||
setShowTutorialModal(false);
|
||
setCurrentTutorialVideo(null);
|
||
}}
|
||
className="text-white hover:text-red-400 transition-colors z-10 p-1"
|
||
title="关闭 (ESC)"
|
||
>
|
||
<X className="h-8 w-8" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* 视频播放器 */}
|
||
{currentTutorialVideo.videoUrl.includes('youtube.com') || currentTutorialVideo.videoUrl.includes('youtu.be') ? (
|
||
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
|
||
<iframe
|
||
src={`${currentTutorialVideo.videoUrl.replace('watch?v=', 'embed/').replace('youtu.be/', 'youtube.com/embed/')}?autoplay=1&rel=0&modestbranding=1`}
|
||
className="w-full h-full"
|
||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||
allowFullScreen
|
||
loading="eager"
|
||
/>
|
||
</div>
|
||
) : currentTutorialVideo.videoUrl.includes('bilibili.com') ? (
|
||
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
|
||
<iframe
|
||
src={`${currentTutorialVideo.videoUrl.replace('www.bilibili.com/video/', 'player.bilibili.com/player.html?bvid=')}&autoplay=1&high_quality=1`}
|
||
className="w-full h-full"
|
||
allowFullScreen
|
||
loading="eager"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<VideoPlayer
|
||
src={currentTutorialVideo.videoUrl}
|
||
poster={currentTutorialVideo.coverUrl || undefined}
|
||
title={currentTutorialVideo.title}
|
||
autoPlay={true}
|
||
className="shadow-2xl"
|
||
/>
|
||
)}
|
||
|
||
{/* 视频描述 */}
|
||
{currentTutorialVideo.description && (
|
||
<p className="text-gray-300 text-sm mt-3 text-center">{currentTutorialVideo.description}</p>
|
||
)}
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
) : null;
|
||
|
||
return (
|
||
<>
|
||
{navContent}
|
||
{videoModal}
|
||
</>
|
||
);
|
||
}
|