806 lines
27 KiB
TypeScript
806 lines
27 KiB
TypeScript
"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;
|
||
videoCanvasEnabled?: 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,
|
||
videoCanvasEnabled = 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 不能用滤镜自动反白;暗色主题只在配置了独立 dark 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 === "HiCAD") {
|
||
return "CAD";
|
||
}
|
||
if (trimmed === "建筑编辑") {
|
||
return "建筑";
|
||
}
|
||
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 overflow-x-hidden 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 min-w-0 flex-1 flex-col items-center gap-3 overflow-x-hidden overflow-y-auto overscroll-contain px-2 pb-4 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||
{/* 工具箱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="HiCAD"
|
||
>
|
||
<Box className="h-5 w-5" />
|
||
<span className={navLabelClass}>{getShortLabel("HiCAD")}</span>
|
||
</a>
|
||
|
||
<a
|
||
className={navItemBaseClass}
|
||
href="/pascal-editor"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
window.location.href = "/pascal-editor";
|
||
}}
|
||
title="建筑编辑"
|
||
>
|
||
<Building2 className="h-5 w-5" />
|
||
<span className={navLabelClass}>{getShortLabel("建筑编辑")}</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>
|
||
)}
|
||
|
||
{videoCanvasEnabled && (
|
||
<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}
|
||
</>
|
||
);
|
||
}
|