ai-chatbot/components/top-navbar.tsx
Your Name d88c28356a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add flow board with database drafts
2026-04-11 20:48:46 +08:00

786 lines
26 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 {
Award,
BarChart3,
Box,
Briefcase,
Building2,
Clapperboard,
Crown,
Edit,
ExternalLink,
Images,
LayoutGrid,
Medal,
PlayCircle,
Settings,
Sparkles,
Trophy,
Wand2,
Workflow,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { ImageAnnotator } from "@/components/image-annotator";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { VideoPlayer } from "@/components/video-player";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
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;
settingsHref?: string | null;
customLinks?: CustomLink[];
toolbox1Name?: string;
toolbox1Enabled?: boolean;
toolbox2Name?: string;
toolbox2Enabled?: boolean;
tutorialVideos?: TutorialVideo[];
tutorialEnabled?: boolean;
rankingEnabled?: boolean;
productPlanningEnabled?: boolean;
agentEnabled?: boolean;
};
export function TopNavbar({
siteName = "AI Chatbot",
siteLogo,
siteLogoDark,
isAdmin = false,
settingsHref = null,
customLinks = [],
toolbox1Name = "工具箱",
toolbox1Enabled = true,
toolbox2Name = "工具箱2",
toolbox2Enabled = false,
tutorialVideos = [],
tutorialEnabled = false,
rankingEnabled = true,
productPlanningEnabled = true,
agentEnabled = true,
}: TopNavbarProps) {
const router = useRouter();
const { resolvedTheme } = useTheme();
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(() => {
if (!rankingOpen) {
return;
}
const loadRanking = 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);
}
};
loadRanking();
}, [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="h-4 w-4 text-center font-bold text-gray-500 text-xs">
{rank}
</span>
);
}
};
// 决定显示哪个 LOGO(浅色用普通,深色用暗色)
const displayLogo =
resolvedTheme === "dark"
? siteLogoDark && siteLogoDark.trim() !== ""
? siteLogoDark
: siteLogo
: siteLogo && siteLogo.trim() !== ""
? siteLogo
: siteLogoDark;
const getShortLabel = (label: string) => {
const trimmed = label.trim();
if (!trimmed) {
return "";
}
if (trimmed === "AI精选") {
return "精选";
}
if (trimmed === "功能教程") {
return "教程";
}
if (trimmed === "精选榜") {
return "榜单";
}
if (trimmed === "图片编辑") {
return "编辑";
}
if (trimmed === "风格化") {
return "风格";
}
if (trimmed === "视频画布") {
return "视频";
}
if (trimmed === "无限画布") {
return "画布";
}
if (trimmed === "我的图册") {
return "图册";
}
if (trimmed === "商品策划") {
return "策划";
}
if (trimmed === "AI-CAD") {
return "CAD";
}
if (trimmed === "系统设置") {
return "设置";
}
if (trimmed === "工具箱") {
return "工具";
}
if (trimmed === "工具箱2") {
return "工具";
}
return Array.from(trimmed).slice(0, 2).join("");
};
const navItemBaseClass =
"flex h-12 w-12 flex-col items-center justify-center gap-1 rounded-xl text-gray-800 hover:bg-black/5 transition-colors dark:text-white dark:hover:bg-white/10";
const navLabelClass =
"text-[10px] leading-none font-medium text-gray-700 dark:text-white/90";
// 打开图片编辑器 - 直接打开,不触发上传
const handleOpenImageEditor = () => {
setEditorImageUrl(""); // 确保初始为空
setShowImageEditor(true);
};
// 处理图片编辑完成
const handleImageEditorConfirm = async (annotatedImageUrl: string) => {
// 如果仍然是 base64 格式,先上传到服务器(ImageAnnotator 应该已经处理了,这里作为保护)
let finalUrl = annotatedImageUrl;
if (annotatedImageUrl.startsWith("data:")) {
try {
toast.info("正在上传图片...");
const response = await fetch(annotatedImageUrl);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, `edited-${Date.now()}.png`);
const uploadResponse = await fetchUploadWithRetry(formData);
if (uploadResponse.ok) {
const result = await uploadResponse.json();
finalUrl = result.url;
} else {
toast.error("上传失败");
return;
}
} catch (error) {
console.error("上传图片失败:", error);
toast.error("上传失败");
return;
}
}
// 将编辑后的图片存储到 sessionStorage,以便在聊天页面中使用
try {
sessionStorage.setItem("pending-edited-image", finalUrl);
} 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 bottom-0 left-0 z-[100] flex w-16 flex-col items-center border-gray-200 border-r bg-white/90 text-gray-900 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-black/90 dark:text-white">
{/* 顶部:LOGO + 网站名称 */}
<a
className="mt-3 flex flex-col items-center gap-1 transition-opacity hover:opacity-80"
href="/"
onClick={(e) => {
e.preventDefault();
window.location.href = "/";
}}
>
{displayLogo && (
<img
alt={siteName}
className="h-7 w-7 object-contain"
src={displayLogo}
/>
)}
<span className="line-clamp-2 max-w-[4.5rem] text-center text-[10px] leading-tight">
{siteName}
</span>
</a>
{/* 侧边功能区 */}
<div className="mt-12 flex w-full flex-1 flex-col items-center gap-3 overflow-y-auto px-2 pb-4">
{/* 工具箱1 (如果启用且有链接) */}
{toolbox1Enabled &&
customLinks.filter((l) => (l.toolboxGroup || 1) === 1).length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className={`${navItemBaseClass} p-0`}
size="sm"
title={toolbox1Name}
type="button"
variant="ghost"
>
<Briefcase className="h-5 w-5" />
<span className={navLabelClass}>
{getShortLabel(toolbox1Name)}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-[320px] p-2"
side="right"
>
<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
asChild
className="cursor-pointer"
key={link.id}
>
<a
className="flex h-20 flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 text-center transition-colors hover:bg-accent hover:text-accent-foreground"
href={link.url}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="h-5 w-5 opacity-70" />
<span className="line-clamp-2 font-medium text-xs">
{link.name}
</span>
</a>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 工具箱2 (如果启用且有链接) */}
{toolbox2Enabled &&
customLinks.filter((l) => l.toolboxGroup === 2).length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className={`${navItemBaseClass} p-0`}
size="sm"
title={toolbox2Name}
type="button"
variant="ghost"
>
<Briefcase className="h-5 w-5" />
<span className={navLabelClass}>
{getShortLabel(toolbox2Name)}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-[320px] p-2"
side="right"
>
<div className="grid grid-cols-2 gap-2">
{customLinks
.filter((l) => l.toolboxGroup === 2)
.sort((a, b) => a.order - b.order)
.map((link) => (
<DropdownMenuItem
asChild
className="cursor-pointer"
key={link.id}
>
<a
className="flex h-20 flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 text-center transition-colors hover:bg-accent hover:text-accent-foreground"
href={link.url}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="h-5 w-5 opacity-70" />
<span className="line-clamp-2 font-medium text-xs">
{link.name}
</span>
</a>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* AI智能精选 */}
<a
className={navItemBaseClass}
href="/featured"
onClick={(e) => {
e.preventDefault();
window.location.href = "/featured";
}}
title="AI精选"
>
<Sparkles className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("AI精选")}</span>
</a>
{productPlanningEnabled && (
<a
className={navItemBaseClass}
href="/product-planning"
onClick={(e) => {
e.preventDefault();
window.location.href = "/product-planning";
}}
title="商品策划"
>
<BarChart3 className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("商品策划")}</span>
</a>
)}
<a
className={navItemBaseClass}
href="/ai-cad"
onClick={(e) => {
e.preventDefault();
window.location.href = "/ai-cad";
}}
title="AI-CAD"
>
<Box className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("AI-CAD")}</span>
</a>
{/* 功能教程 */}
{tutorialEnabled && tutorialVideos.length > 0 && (
<DropdownMenu
onOpenChange={setTutorialMenuOpen}
open={tutorialMenuOpen}
>
<DropdownMenuTrigger asChild>
<Button
className={`${navItemBaseClass} p-0`}
size="sm"
title="功能教程"
type="button"
variant="ghost"
>
<PlayCircle className="h-5 w-5" />
<span className={navLabelClass}>
{getShortLabel("功能教程")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-[320px] p-2"
side="right"
>
<div className="max-h-[400px] space-y-1 overflow-y-auto">
{tutorialVideos
.sort((a, b) => a.order - b.order)
.map((video) => (
<DropdownMenuItem
className="cursor-pointer p-0"
key={video.id}
onClick={() => {
setCurrentTutorialVideo(video);
setShowTutorialModal(true);
setTutorialMenuOpen(false);
}}
>
<div className="flex w-full items-center gap-3 rounded-md p-2 hover:bg-accent">
{/* 封面 */}
<div className="h-10 w-16 flex-shrink-0 overflow-hidden rounded bg-muted">
{video.coverUrl ? (
<img
alt={video.title}
className="h-full w-full object-cover"
src={video.coverUrl}
/>
) : (
<div className="flex h-full w-full 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="min-w-0 flex-1">
<div className="truncate font-medium text-sm">
{video.title}
</div>
{video.description && (
<div className="truncate text-muted-foreground text-xs">
{video.description}
</div>
)}
</div>
</div>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 精选榜单 */}
{rankingEnabled && (
<DropdownMenu onOpenChange={setRankingOpen} open={rankingOpen}>
<DropdownMenuTrigger asChild>
<Button
className={`${navItemBaseClass} p-0`}
size="sm"
title="精选榜单"
type="button"
variant="ghost"
>
<Trophy className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("精选榜")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-[280px] p-3"
side="right"
>
<div className="mb-3 flex items-center gap-2 border-b pb-2">
<Trophy className="h-5 w-5 text-orange-500" />
<span className="font-semibold">精选榜单 TOP5</span>
</div>
{ranking.length === 0 ? (
<div className="py-4 text-center text-muted-foreground text-sm">
暂无精选数据
</div>
) : (
<div className="space-y-2">
{ranking.map((item) => (
<div
className={`flex items-center gap-3 rounded-lg p-2 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"
}`}
key={item.userId}
>
<div className="flex w-6 items-center justify-center">
{getRankIcon(item.rank)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">
{item.userName}
</p>
<p className="flex items-center gap-1 truncate text-muted-foreground text-xs">
<Building2 className="h-3 w-3" />
{item.department}
</p>
</div>
<div className="text-right">
<span className="font-bold text-lg text-orange-500">
{item.featuredCount}
</span>
<span className="ml-1 text-muted-foreground text-xs">
精选
</span>
</div>
</div>
))}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 图片编辑 */}
<Button
className={`${navItemBaseClass} p-0`}
onClick={handleOpenImageEditor}
size="sm"
title="图片编辑"
type="button"
variant="ghost"
>
<Edit className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("图片编辑")}</span>
</Button>
{/* 风格化模式 */}
{agentEnabled && (
<button
className={navItemBaseClass}
onClick={() => {
window.location.href = "/canvas?mode=agent";
}}
title="风格化"
type="button"
>
<Wand2 className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("风格化")}</span>
</button>
)}
<button
className={navItemBaseClass}
onClick={() => {
window.location.href = "/video-canvas";
}}
title="视频画布"
type="button"
>
<Clapperboard className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("视频画布")}</span>
</button>
<button
className={navItemBaseClass}
onClick={() => {
window.location.href = "/flow-board";
}}
title="流程白板"
type="button"
>
<Workflow className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("流程白板")}</span>
</button>
{/* 无限画布 */}
<button
className={navItemBaseClass}
onClick={() => {
const newId = crypto.randomUUID();
window.location.href = `/canvas/${newId}`;
}}
title="无限画布"
type="button"
>
<LayoutGrid className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("无限画布")}</span>
</button>
{/* 我的图册 */}
<a
className={navItemBaseClass}
href="/gallery"
onClick={(e) => {
e.preventDefault();
window.location.href = "/gallery";
}}
title="我的图册"
>
<Images className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("我的图册")}</span>
</a>
</div>
{/* 系统设置(管理员 / 风格化管理员可见)*/}
{(isAdmin || settingsHref) && (
<div className="pb-4">
<a
className={navItemBaseClass}
href={settingsHref || "/settings"}
onClick={(e) => {
e.preventDefault();
window.location.href = settingsHref || "/settings";
}}
title="系统设置"
>
<Settings className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("系统设置")}</span>
</a>
</div>
)}
{/* 图片编辑器 */}
{showImageEditor && (
<ImageAnnotator
imageUrl={editorImageUrl}
onCancel={handleImageEditorCancel}
onConfirm={handleImageEditorConfirm}
submitMode="publish-new-canvas"
/>
)}
</nav>
);
// 使用 Portal 将视频弹窗渲染到 body,确保它在最顶层且不受 nav 的限制
const videoModal =
showTutorialModal && currentTutorialVideo && typeof document !== "undefined"
? createPortal(
<div
className="fixed inset-0 z-[99999] bg-black/95"
onClick={() => {
setShowTutorialModal(false);
setCurrentTutorialVideo(null);
}}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: 0,
padding: "20px",
}}
>
<div
className="relative w-full max-w-[1200px]"
onClick={(e) => e.stopPropagation()}
style={{ maxHeight: "calc(100vh - 80px)" }}
>
{/* 标题栏 - 包含标题和关闭按钮 */}
<div className="mb-3 flex items-center justify-between px-2">
<div className="w-8" /> {/* 占位符保持标题居中 */}
<h3 className="flex-1 truncate text-center font-medium text-lg text-white">
{currentTutorialVideo.title}
</h3>
<button
className="z-10 p-1 text-white transition-colors hover:text-red-400"
onClick={() => {
setShowTutorialModal(false);
setCurrentTutorialVideo(null);
}}
title="关闭 (ESC)"
>
<X className="h-8 w-8" />
</button>
</div>
{/* 视频播放器 */}
{currentTutorialVideo.videoUrl.includes("youtube.com") ||
currentTutorialVideo.videoUrl.includes("youtu.be") ? (
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black shadow-2xl">
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="h-full w-full"
loading="eager"
src={`${currentTutorialVideo.videoUrl.replace("watch?v=", "embed/").replace("youtu.be/", "youtube.com/embed/")}?autoplay=1&rel=0&modestbranding=1`}
/>
</div>
) : currentTutorialVideo.videoUrl.includes("bilibili.com") ? (
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black shadow-2xl">
<iframe
allowFullScreen
className="h-full w-full"
loading="eager"
src={`${currentTutorialVideo.videoUrl.replace("www.bilibili.com/video/", "player.bilibili.com/player.html?bvid=")}&autoplay=1&high_quality=1`}
/>
</div>
) : (
<VideoPlayer
autoPlay={true}
className="shadow-2xl"
poster={currentTutorialVideo.coverUrl || undefined}
src={currentTutorialVideo.videoUrl}
title={currentTutorialVideo.title}
/>
)}
{/* 视频描述 */}
{currentTutorialVideo.description && (
<p className="mt-3 text-center text-gray-300 text-sm">
{currentTutorialVideo.description}
</p>
)}
</div>
</div>,
document.body
)
: null;
return (
<>
{navContent}
{videoModal}
</>
);
}