- 修复浏览器favicon未更新问题(使用settings.favicon) - 将'图片标注'组件名改为'图片编辑'(所有相关文本和注释) - 在顶部导航栏添加'图片编辑'按钮,支持上传图片进行编辑 - 图片编辑完成后自动添加到聊天输入框(通过sessionStorage传递) - 优化图片编辑组件下载按钮UI(更明显的样式,添加文字标签) - 图片处理结果(变清晰/抠图/图像拓展)自动保存到相册和生图记录 - 优化图片编辑确认逻辑,优先使用处理后的图片
339 lines
12 KiB
TypeScript
339 lines
12 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 } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { useTheme } from "next-themes";
|
||
import { useState, useEffect, useRef } from "react";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { ImageAnnotator } from "@/components/image-annotator";
|
||
|
||
type RankingItem = {
|
||
rank: number;
|
||
userId: string;
|
||
userName: string;
|
||
department: string;
|
||
featuredCount: number;
|
||
};
|
||
|
||
type TopNavbarProps = {
|
||
siteName?: string;
|
||
siteLogo?: string;
|
||
siteLogoDark?: string;
|
||
isAdmin?: boolean;
|
||
customLinks?: Array<{
|
||
id: string;
|
||
name: string;
|
||
url: string;
|
||
order: number;
|
||
}>;
|
||
};
|
||
|
||
export function TopNavbar({
|
||
siteName = "AI Chatbot",
|
||
siteLogo,
|
||
siteLogoDark,
|
||
isAdmin = false,
|
||
customLinks = [],
|
||
}: TopNavbarProps) {
|
||
const router = useRouter();
|
||
const { resolvedTheme } = useTheme();
|
||
const [mounted, setMounted] = useState(false);
|
||
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
||
const [rankingOpen, setRankingOpen] = useState(false);
|
||
const [showImageEditor, setShowImageEditor] = useState(false);
|
||
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
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
|
||
// 导航栏背景固定为黑色,理论上应该始终显示适合深色背景的 LOGO
|
||
// 但如果有专门配置了 "Dark Mode Logo" (通常用于深色背景),我们优先使用它
|
||
// 如果没配置 Dark Mode Logo,则使用默认 Logo
|
||
// 注意:这里的背景是黑色的,所以通常需要一个浅色的 LOGO(即 Dark Mode 下使用的 LOGO)
|
||
// 如果用户配置逻辑是:siteLogo(Light Mode/白底用), siteLogoDark(Dark Mode/黑底用)
|
||
// 那么在黑色导航栏上,应该优先使用 siteLogoDark
|
||
const displayLogo = (siteLogoDark && siteLogoDark.trim() !== "") ? siteLogoDark : siteLogo;
|
||
|
||
// 处理图片上传
|
||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
// 检查文件类型
|
||
if (!file.type.startsWith("image/")) {
|
||
return;
|
||
}
|
||
|
||
// 转换为 data URL
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
const result = reader.result as string;
|
||
setEditorImageUrl(result);
|
||
setShowImageEditor(true);
|
||
};
|
||
reader.onerror = () => {
|
||
console.error("图片读取失败");
|
||
};
|
||
reader.readAsDataURL(file);
|
||
|
||
// 清空文件输入,以便可以再次选择同一文件
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = "";
|
||
}
|
||
};
|
||
|
||
// 打开图片编辑器
|
||
const handleOpenImageEditor = () => {
|
||
fileInputRef.current?.click();
|
||
};
|
||
|
||
// 处理图片编辑完成
|
||
const handleImageEditorConfirm = (annotatedImageUrl: string) => {
|
||
// 将编辑后的图片存储到 sessionStorage,以便在聊天页面中使用
|
||
try {
|
||
sessionStorage.setItem("pending-edited-image", annotatedImageUrl);
|
||
// 导航到新聊天页面
|
||
router.push("/");
|
||
} catch (error) {
|
||
console.error("保存图片数据失败:", error);
|
||
}
|
||
setShowImageEditor(false);
|
||
setEditorImageUrl("");
|
||
};
|
||
|
||
// 取消图片编辑
|
||
const handleImageEditorCancel = () => {
|
||
setShowImageEditor(false);
|
||
setEditorImageUrl("");
|
||
};
|
||
|
||
return (
|
||
<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 + 网站名称 */}
|
||
<Link href="/" className="flex items-center gap-2 transition-opacity hover:opacity-80">
|
||
{displayLogo && (
|
||
<img
|
||
src={displayLogo}
|
||
alt={siteName}
|
||
className="h-6 w-6 object-contain"
|
||
/>
|
||
)}
|
||
<span className="text-sm font-semibold">{siteName}</span>
|
||
</Link>
|
||
|
||
{/* 右侧区域:自定义链接 + 系统设置 */}
|
||
<div className="flex items-center gap-2">
|
||
{/* 工具箱 (如果有自定义链接) */}
|
||
{customLinks.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">工具箱</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-[320px] p-2">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{customLinks
|
||
.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智能精选 */}
|
||
<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={() => router.push("/featured")}
|
||
type="button"
|
||
title="AI智能精选"
|
||
>
|
||
<Sparkles className="h-4 w-4 text-yellow-400" />
|
||
<span className="text-sm font-medium">AI精选</span>
|
||
</Button>
|
||
|
||
{/* 精选榜单 */}
|
||
<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>
|
||
|
||
{/* 隐藏的文件输入 */}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleImageUpload}
|
||
className="hidden"
|
||
/>
|
||
|
||
{/* 我的图册 */}
|
||
<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={() => router.push("/gallery")}
|
||
type="button"
|
||
title="我的图册"
|
||
>
|
||
<Images className="h-4 w-4" />
|
||
<span className="text-sm font-medium">我的图册</span>
|
||
</Button>
|
||
|
||
{/* 分隔线 (如果有链接或图册按钮,且是管理员) */}
|
||
{isAdmin && (
|
||
<div className="hidden h-4 w-px bg-white/20 mx-1 sm:block" />
|
||
)}
|
||
|
||
{/* 系统设置(仅管理员可见)*/}
|
||
{isAdmin && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8 w-8 p-0 text-white hover:bg-white/15 hover:text-white rounded-full"
|
||
onClick={() => router.push("/settings")}
|
||
type="button"
|
||
title="系统设置"
|
||
>
|
||
<Settings className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 图片编辑器 */}
|
||
{showImageEditor && editorImageUrl && (
|
||
<ImageAnnotator
|
||
imageUrl={editorImageUrl}
|
||
onConfirm={handleImageEditorConfirm}
|
||
onCancel={handleImageEditorCancel}
|
||
/>
|
||
)}
|
||
</nav>
|
||
);
|
||
}
|