添加钉钉集成、精选作品和排行榜功能
This commit is contained in:
parent
3f3eed53be
commit
7a4fa8e1d8
@ -45,6 +45,8 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
Clock,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@ -65,6 +67,7 @@ type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
department: string | null;
|
||||
role: "guest" | "regular" | "admin";
|
||||
isActive: boolean;
|
||||
monthlyImageLimit: number | null;
|
||||
@ -153,11 +156,14 @@ type GeneratedImageRecord = {
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userEmail: string;
|
||||
userDepartment: string | null;
|
||||
thumbnailUrl: string;
|
||||
originalUrl: string;
|
||||
imageSize: string | null;
|
||||
prompt: string | null;
|
||||
modelId: string | null;
|
||||
isFeatured: boolean;
|
||||
featuredAt: string | null;
|
||||
createdAt: string;
|
||||
chatId: string | null;
|
||||
};
|
||||
@ -270,7 +276,7 @@ export function AdminSettingsClient() {
|
||||
...(imageStartDate && { startDate: imageStartDate }),
|
||||
...(imageEndDate && { endDate: imageEndDate }),
|
||||
});
|
||||
const { data: imagesData } = useSWR<{
|
||||
const { data: imagesData, mutate: mutateImages } = useSWR<{
|
||||
images: GeneratedImageRecord[];
|
||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||
}>(`/api/admin/images?${imageQueryParams}`, fetcher);
|
||||
@ -894,8 +900,8 @@ export function AdminSettingsClient() {
|
||||
<TabsContent value="users" className="mt-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{/* 搜索框 */}
|
||||
<div className="mb-4">
|
||||
{/* 搜索框和同步按钮 */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索用户(邮箱或用户名)..."
|
||||
@ -903,6 +909,29 @@ export function AdminSettingsClient() {
|
||||
onChange={(e) => handleUserSearch(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
toast.info("正在同步钉钉用户部门信息...");
|
||||
const res = await fetch("/api/admin/sync-dingtalk", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success(`同步完成:${data.stats.synced} 个用户已更新,${data.stats.skipped} 个跳过,${data.stats.errors} 个失败`);
|
||||
mutateUsers(); // 刷新用户列表
|
||||
} else {
|
||||
toast.error(data.error || "同步失败");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("同步请求失败");
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
同步钉钉用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
@ -914,6 +943,9 @@ export function AdminSettingsClient() {
|
||||
<div className="text-sm text-muted-foreground">
|
||||
邮箱: {user.email}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
部门: {user.department || "未设置"}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
角色: {ROLE_LABELS[user.role] ?? user.role} | 状态: {user.isActive ? "已激活" : "未激活"}
|
||||
</div>
|
||||
@ -1128,13 +1160,19 @@ export function AdminSettingsClient() {
|
||||
className="group relative cursor-pointer"
|
||||
onClick={() => setSelectedImage(img)}
|
||||
>
|
||||
<div className="w-[37px] h-[37px] mx-auto rounded border overflow-hidden bg-muted">
|
||||
<div className="w-[37px] h-[37px] mx-auto rounded border overflow-hidden bg-muted relative">
|
||||
<img
|
||||
src={img.thumbnailUrl}
|
||||
alt={img.prompt || "AI生成的图片"}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* 精选图标 */}
|
||||
{img.isFeatured && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full flex items-center justify-center shadow">
|
||||
<Sparkles className="h-2.5 w-2.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-center mt-1 truncate text-muted-foreground">
|
||||
{img.userName || img.userEmail?.split("@")[0]}
|
||||
@ -1195,11 +1233,19 @@ export function AdminSettingsClient() {
|
||||
<Dialog open={!!selectedImage} onOpenChange={(open) => !open && setSelectedImage(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>图片详情</DialogTitle>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
图片详情
|
||||
{selectedImage?.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gradient-to-r from-yellow-400 to-orange-500 text-white text-xs rounded-full">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
精选
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedImage && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-center relative">
|
||||
<img
|
||||
src={selectedImage.originalUrl}
|
||||
alt={selectedImage.prompt || "AI生成的图片"}
|
||||
@ -1211,6 +1257,10 @@ export function AdminSettingsClient() {
|
||||
<Label className="text-muted-foreground">用户</Label>
|
||||
<p>{selectedImage.userName || selectedImage.userEmail}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">部门</Label>
|
||||
<p>{selectedImage.userDepartment || "未设置"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">生成时间</Label>
|
||||
<p>{new Date(selectedImage.createdAt).toLocaleString("zh-CN")}</p>
|
||||
@ -1223,11 +1273,51 @@ export function AdminSettingsClient() {
|
||||
<Label className="text-muted-foreground">模型</Label>
|
||||
<p>{selectedImage.modelId || "未知"}</p>
|
||||
</div>
|
||||
{selectedImage.isFeatured && selectedImage.featuredAt && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">精选时间</Label>
|
||||
<p>{new Date(selectedImage.featuredAt).toLocaleString("zh-CN")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<Label className="text-muted-foreground">提示词</Label>
|
||||
<p className="mt-1">{selectedImage.prompt || "无"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 精选操作按钮 */}
|
||||
<div className="flex justify-end pt-2 border-t">
|
||||
<Button
|
||||
variant={selectedImage.isFeatured ? "outline" : "default"}
|
||||
className={selectedImage.isFeatured ? "" : "bg-gradient-to-r from-yellow-400 to-orange-500 hover:from-yellow-500 hover:to-orange-600"}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/images", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imageId: selectedImage.id,
|
||||
isFeatured: !selectedImage.isFeatured,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
// 更新本地状态
|
||||
setSelectedImage({
|
||||
...selectedImage,
|
||||
isFeatured: !selectedImage.isFeatured,
|
||||
featuredAt: !selectedImage.isFeatured ? new Date().toISOString() : null,
|
||||
});
|
||||
// 刷新列表
|
||||
mutateImages();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update featured status:", error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
{selectedImage.isFeatured ? "取消精选" : "设为精选"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
@ -1306,10 +1396,10 @@ export function AdminSettingsClient() {
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* 用户排行 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">用户生图排行 (Top 20)</CardTitle>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||||
{analyticsData?.userStats?.map((user, index) => (
|
||||
|
||||
388
app/(chat)/featured/page.tsx
Normal file
388
app/(chat)/featured/page.tsx
Normal file
@ -0,0 +1,388 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Sparkles, User, Building2, Clock, Bot } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Image from "next/image";
|
||||
|
||||
type FeaturedImage = {
|
||||
id: string;
|
||||
thumbnailUrl: string;
|
||||
originalUrl: string;
|
||||
imageSize: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
createdAt: string;
|
||||
featuredAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
department: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Pagination = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
export default function FeaturedPage() {
|
||||
const router = useRouter();
|
||||
const [images, setImages] = useState<FeaturedImage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 1,
|
||||
pageSize: 24,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
const [selectedImage, setSelectedImage] = useState<FeaturedImage | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [pageInput, setPageInput] = useState("");
|
||||
|
||||
const fetchImages = async (page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/featured?page=${page}&pageSize=24`);
|
||||
const data = await res.json();
|
||||
if (data.images) {
|
||||
setImages(data.images);
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch featured images:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchImages(1);
|
||||
}, []);
|
||||
|
||||
const handleImageClick = (image: FeaturedImage) => {
|
||||
setSelectedImage(image);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, prompt: string) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = `featured-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= pagination.totalPages) {
|
||||
fetchImages(page);
|
||||
setPageInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageInputSubmit = () => {
|
||||
const pageNum = Number.parseInt(pageInput);
|
||||
if (!Number.isNaN(pageNum) && pageNum >= 1 && pageNum <= pagination.totalPages) {
|
||||
goToPage(pageNum);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 h-full flex flex-col">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 text-yellow-500" />
|
||||
AI智能精选
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
共 {pagination.total} 张优秀AI作品
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto mb-4" />
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
<Sparkles className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>暂无精选作品</p>
|
||||
<p className="text-sm mt-2">管理员可以在后台标记优秀作品</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-wrap justify-center gap-6 px-4">
|
||||
{images.map((img) => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="group relative cursor-pointer rounded-lg border bg-muted overflow-hidden transition-all hover:shadow-xl hover:scale-105"
|
||||
style={{ width: "220px", height: "320px", flexShrink: 0 }}
|
||||
onClick={() => handleImageClick(img)}
|
||||
>
|
||||
{/* 精选图标 */}
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<div className="bg-gradient-to-r from-yellow-400 to-orange-500 text-white px-2 py-1 rounded-full text-xs font-bold flex items-center gap-1 shadow-lg">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
精选
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full h-[200px]">
|
||||
<Image
|
||||
src={img.thumbnailUrl || img.originalUrl}
|
||||
alt={img.prompt || "精选作品"}
|
||||
width={220}
|
||||
height={200}
|
||||
className="object-cover w-full h-full"
|
||||
sizes="220px"
|
||||
quality={75}
|
||||
loading="lazy"
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjIwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjIwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2VlZSIvPjwvc3ZnPg=="
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部信息区域 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-white dark:bg-gray-900 p-3 h-[120px]">
|
||||
{/* 用户信息 */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center">
|
||||
<User className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{img.user.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{img.user.department}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示词 */}
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-2 mb-2" title={img.prompt}>
|
||||
{img.prompt || "无提示词"}
|
||||
</p>
|
||||
|
||||
{/* 底部元信息 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Bot className="h-3 w-3" />
|
||||
{img.modelName}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(img.createdAt).toLocaleDateString("zh-CN", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="mt-6">
|
||||
<div className="text-center text-sm text-muted-foreground mb-3">
|
||||
第 {pagination.page} 页 / 共 {pagination.totalPages} 页
|
||||
</div>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => goToPage(1)}
|
||||
disabled={pagination.page === 1}
|
||||
title="第一页"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => goToPage(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1)
|
||||
.filter((p) => {
|
||||
return (
|
||||
p === 1 ||
|
||||
p === pagination.totalPages ||
|
||||
Math.abs(p - pagination.page) <= 1
|
||||
);
|
||||
})
|
||||
.map((p, idx, arr) => {
|
||||
const showEllipsis = idx > 0 && p - arr[idx - 1] > 1;
|
||||
return (
|
||||
<div key={p} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
<Button
|
||||
variant={p === pagination.page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => goToPage(p)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => goToPage(pagination.page + 1)}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => goToPage(pagination.totalPages)}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
title="最后一页"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={pagination.totalPages}
|
||||
value={pageInput}
|
||||
onChange={(e) => setPageInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handlePageInputSubmit()}
|
||||
placeholder="跳转"
|
||||
className="w-20 h-8 text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={handlePageInputSubmit}>
|
||||
GO
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 图片预览对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-4xl p-0 overflow-hidden">
|
||||
{selectedImage && (
|
||||
<div className="flex flex-col">
|
||||
{/* 图片 */}
|
||||
<div className="relative bg-black flex items-center justify-center min-h-[400px] max-h-[70vh]">
|
||||
<Image
|
||||
src={selectedImage.originalUrl}
|
||||
alt={selectedImage.prompt || "精选作品"}
|
||||
width={800}
|
||||
height={600}
|
||||
className="object-contain max-h-[70vh]"
|
||||
quality={90}
|
||||
/>
|
||||
{/* 精选标记 */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<div className="bg-gradient-to-r from-yellow-400 to-orange-500 text-white px-3 py-1.5 rounded-full text-sm font-bold flex items-center gap-1.5 shadow-lg">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
精选作品
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信息区域 */}
|
||||
<div className="p-6 bg-white dark:bg-gray-900">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{selectedImage.user.name}</p>
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
{selectedImage.user.department}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">生成时间</p>
|
||||
<p className="font-medium">
|
||||
{new Date(selectedImage.createdAt).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-1">提示词</p>
|
||||
<p className="text-sm bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
|
||||
{selectedImage.prompt || "无提示词"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Bot className="h-4 w-4" />
|
||||
{selectedImage.modelName}
|
||||
</span>
|
||||
<span>尺寸: {selectedImage.imageSize || "2K"}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleDownload(selectedImage.originalUrl, selectedImage.prompt)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载原图
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -82,11 +82,14 @@ export async function GET(request: NextRequest) {
|
||||
userId: generatedImage.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userDepartment: user.department,
|
||||
thumbnailUrl: generatedImage.thumbnailUrl,
|
||||
originalUrl: generatedImage.originalUrl,
|
||||
imageSize: generatedImage.imageSize,
|
||||
prompt: generatedImage.prompt,
|
||||
modelId: generatedImage.modelId,
|
||||
isFeatured: generatedImage.isFeatured,
|
||||
featuredAt: generatedImage.featuredAt,
|
||||
createdAt: generatedImage.createdAt,
|
||||
chatId: generatedImage.chatId,
|
||||
})
|
||||
@ -109,3 +112,58 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: 设置/取消图片精选
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
const currentUser = await db.query.user.findFirst({
|
||||
where: eq(user.id, session.user.id),
|
||||
});
|
||||
|
||||
if (currentUser?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { imageId, isFeatured } = body;
|
||||
|
||||
if (!imageId) {
|
||||
return NextResponse.json({ error: "Image ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 更新精选状态
|
||||
const updateData: Record<string, unknown> = {
|
||||
isFeatured: Boolean(isFeatured),
|
||||
};
|
||||
|
||||
if (isFeatured) {
|
||||
updateData.featuredAt = new Date();
|
||||
updateData.featuredBy = session.user.id;
|
||||
} else {
|
||||
updateData.featuredAt = null;
|
||||
updateData.featuredBy = null;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(generatedImage)
|
||||
.set(updateData)
|
||||
.where(eq(generatedImage.id, imageId));
|
||||
|
||||
console.log(`[Admin Images API] Image ${imageId} featured status set to ${isFeatured} by ${session.user.id}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: isFeatured ? "已添加到精选" : "已取消精选"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Admin Images API] PATCH Error:", error);
|
||||
return NextResponse.json({ error: "Failed to update featured status" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
233
app/api/admin/sync-dingtalk/route.ts
Normal file
233
app/api/admin/sync-dingtalk/route.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { user } from "@/lib/db/schema";
|
||||
import { eq, isNotNull, isNull, or } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
// 获取钉钉配置
|
||||
async function getDingtalkConfig() {
|
||||
const settings = await db.query.authSettings.findFirst();
|
||||
if (!settings?.isDingTalkEnabled || !settings.dingTalkAppKey || !settings.dingTalkAppSecret) {
|
||||
throw new Error("钉钉配置未启用或配置不完整");
|
||||
}
|
||||
return {
|
||||
appKey: settings.dingTalkAppKey,
|
||||
appSecret: settings.dingTalkAppSecret,
|
||||
};
|
||||
}
|
||||
|
||||
// 获取钉钉 access_token
|
||||
async function getDingtalkAccessToken(appKey: string, appSecret: string) {
|
||||
const tokenResponse = await fetch(
|
||||
`https://oapi.dingtalk.com/gettoken?appkey=${appKey}&appsecret=${appSecret}`
|
||||
);
|
||||
const tokenData = await tokenResponse.json();
|
||||
|
||||
if (tokenData.errcode !== 0) {
|
||||
throw new Error(`获取钉钉token失败: ${tokenData.errmsg}`);
|
||||
}
|
||||
|
||||
return tokenData.access_token;
|
||||
}
|
||||
|
||||
// 获取用户详细信息(包括部门)
|
||||
async function getUserDetailFromDingtalk(accessToken: string, userId: string) {
|
||||
const response = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${accessToken}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userid: userId }),
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errcode !== 0) {
|
||||
console.error(`获取用户 ${userId} 信息失败:`, data.errmsg);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.result;
|
||||
}
|
||||
|
||||
// 获取部门名称
|
||||
async function getDepartmentName(accessToken: string, deptId: number) {
|
||||
const response = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/v2/department/get?access_token=${accessToken}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dept_id: deptId }),
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errcode !== 0) {
|
||||
console.error(`获取部门 ${deptId} 信息失败:`, data.errmsg);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.result?.name;
|
||||
}
|
||||
|
||||
// POST: 批量同步钉钉用户信息
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
const currentUser = await db.query.user.findFirst({
|
||||
where: eq(user.id, session.user.id),
|
||||
});
|
||||
|
||||
if (currentUser?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// 获取钉钉配置
|
||||
const config = await getDingtalkConfig();
|
||||
const accessToken = await getDingtalkAccessToken(config.appKey, config.appSecret);
|
||||
|
||||
// 获取需要同步的用户(有钉钉userId但没有部门信息的用户)
|
||||
const usersToSync = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
dingtalkUserId: user.dingtalkUserId,
|
||||
department: user.department,
|
||||
})
|
||||
.from(user)
|
||||
.where(
|
||||
// 有钉钉userId但没有部门信息
|
||||
isNotNull(user.dingtalkUserId)
|
||||
);
|
||||
|
||||
console.log(`[Sync DingTalk] Found ${usersToSync.length} users to check`);
|
||||
|
||||
let syncedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let errorCount = 0;
|
||||
const results: Array<{ email: string; status: string; department?: string }> = [];
|
||||
|
||||
for (const u of usersToSync) {
|
||||
if (!u.dingtalkUserId) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户详细信息
|
||||
const userDetail = await getUserDetailFromDingtalk(accessToken, u.dingtalkUserId);
|
||||
|
||||
if (!userDetail) {
|
||||
errorCount++;
|
||||
results.push({ email: u.email, status: "failed", department: undefined });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取部门名称列表
|
||||
const deptIdList = userDetail.dept_id_list || [];
|
||||
const departmentNames: string[] = [];
|
||||
|
||||
for (const deptId of deptIdList) {
|
||||
const deptName = await getDepartmentName(accessToken, deptId);
|
||||
if (deptName) {
|
||||
departmentNames.push(deptName);
|
||||
}
|
||||
}
|
||||
|
||||
const departmentStr = departmentNames.length > 0 ? departmentNames.join(" / ") : null;
|
||||
|
||||
// 只有部门信息变化时才更新
|
||||
if (departmentStr && departmentStr !== u.department) {
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
department: departmentStr,
|
||||
name: userDetail.name || undefined, // 同时更新姓名
|
||||
})
|
||||
.where(eq(user.id, u.id));
|
||||
|
||||
syncedCount++;
|
||||
results.push({ email: u.email, status: "synced", department: departmentStr });
|
||||
console.log(`[Sync DingTalk] Updated user ${u.email}: ${departmentStr}`);
|
||||
} else {
|
||||
skippedCount++;
|
||||
results.push({ email: u.email, status: "skipped", department: u.department || undefined });
|
||||
}
|
||||
|
||||
// 添加延迟避免触发钉钉API限流
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[Sync DingTalk] Error syncing user ${u.email}:`, error);
|
||||
errorCount++;
|
||||
results.push({ email: u.email, status: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `同步完成`,
|
||||
stats: {
|
||||
total: usersToSync.length,
|
||||
synced: syncedCount,
|
||||
skipped: skippedCount,
|
||||
errors: errorCount,
|
||||
},
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Sync DingTalk API] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || "同步失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET: 获取同步状态统计
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
const currentUser = await db.query.user.findFirst({
|
||||
where: eq(user.id, session.user.id),
|
||||
});
|
||||
|
||||
if (currentUser?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// 统计用户数据
|
||||
const allUsers = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
dingtalkUserId: user.dingtalkUserId,
|
||||
department: user.department,
|
||||
})
|
||||
.from(user);
|
||||
|
||||
const stats = {
|
||||
total: allUsers.length,
|
||||
withDingtalkId: allUsers.filter(u => u.dingtalkUserId).length,
|
||||
withDepartment: allUsers.filter(u => u.department).length,
|
||||
needSync: allUsers.filter(u => u.dingtalkUserId && !u.department).length,
|
||||
};
|
||||
|
||||
return NextResponse.json({ stats });
|
||||
} catch (error) {
|
||||
console.error("[Sync DingTalk API] Error:", error);
|
||||
return NextResponse.json({ error: "获取统计失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,6 +169,16 @@ export async function GET(request: NextRequest) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 组合部门信息为字符串
|
||||
const departmentStr = userInfo.departmentNames?.length > 0
|
||||
? userInfo.departmentNames.join(" / ")
|
||||
: null;
|
||||
console.log("[DingTalk Callback] Department string:", departmentStr);
|
||||
|
||||
// 获取钉钉用户ID
|
||||
const dingtalkUserId = userInfo.userId || null;
|
||||
console.log("[DingTalk Callback] DingTalk userId:", dingtalkUserId);
|
||||
|
||||
if (existingUsers.length === 0) {
|
||||
// 创建新用户
|
||||
console.log("[DingTalk Callback] Creating new user:", email, "with name:", userInfo.name || userInfo.nick);
|
||||
@ -177,10 +187,12 @@ export async function GET(request: NextRequest) {
|
||||
email,
|
||||
password: "", // 钉钉用户无密码
|
||||
name: userInfo.name || userInfo.nick, // 保存真实姓名或昵称
|
||||
department: departmentStr, // 保存部门信息
|
||||
dingtalkUserId: dingtalkUserId, // 保存钉钉用户ID
|
||||
role: "regular",
|
||||
isActive: true,
|
||||
});
|
||||
console.log("[DingTalk Callback] User created successfully");
|
||||
console.log("[DingTalk Callback] User created successfully with department:", departmentStr, ", dingtalkUserId:", dingtalkUserId);
|
||||
// 重新查询用户
|
||||
existingUsers = await db
|
||||
.select()
|
||||
@ -191,18 +203,36 @@ export async function GET(request: NextRequest) {
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// 更新现有用户的姓名(如果数据库中为空但钉钉有数据)
|
||||
// 更新现有用户的姓名、部门信息和钉钉用户ID
|
||||
const existingUser = existingUsers[0];
|
||||
console.log("[DingTalk Callback] Existing user found, current name:", existingUser.name);
|
||||
console.log("[DingTalk Callback] Existing user found, current name:", existingUser.name, ", department:", (existingUser as any).department);
|
||||
|
||||
const updateData: Record<string, string | null> = {};
|
||||
|
||||
// 更新姓名(如果数据库中为空但钉钉有数据)
|
||||
if (!existingUser.name && (userInfo.name || userInfo.nick)) {
|
||||
console.log("[DingTalk Callback] Updating user name for:", email, "to:", userInfo.name || userInfo.nick);
|
||||
updateData.name = userInfo.name || userInfo.nick;
|
||||
}
|
||||
|
||||
// 每次登录都更新部门信息(部门可能变化)
|
||||
if (departmentStr) {
|
||||
updateData.department = departmentStr;
|
||||
}
|
||||
|
||||
// 更新钉钉用户ID(如果数据库中为空)
|
||||
if (!(existingUser as any).dingtalkUserId && dingtalkUserId) {
|
||||
updateData.dingtalkUserId = dingtalkUserId;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
console.log("[DingTalk Callback] Updating user info:", updateData);
|
||||
try {
|
||||
await db.update(user)
|
||||
.set({ name: userInfo.name || userInfo.nick })
|
||||
.set(updateData)
|
||||
.where(eq(user.email, email));
|
||||
console.log("[DingTalk Callback] User name updated successfully");
|
||||
console.log("[DingTalk Callback] User info updated successfully");
|
||||
} catch (error) {
|
||||
console.error("[DingTalk Callback] Error updating user name:", error);
|
||||
console.error("[DingTalk Callback] Error updating user info:", error);
|
||||
// 不抛出错误,继续登录流程
|
||||
}
|
||||
}
|
||||
|
||||
46
app/api/featured/ranking/route.ts
Normal file
46
app/api/featured/ranking/route.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage, user } from "@/lib/db/schema";
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
// GET: 获取精选榜单(前5名用户)
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 查询精选数量排行榜
|
||||
const ranking = await db
|
||||
.select({
|
||||
userId: generatedImage.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userDepartment: user.department,
|
||||
featuredCount: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(generatedImage)
|
||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||
.where(eq(generatedImage.isFeatured, true))
|
||||
.groupBy(generatedImage.userId, user.name, user.email, user.department)
|
||||
.orderBy(desc(sql`count(*)`))
|
||||
.limit(5);
|
||||
|
||||
return NextResponse.json({
|
||||
ranking: ranking.map((item, index) => ({
|
||||
rank: index + 1,
|
||||
userId: item.userId,
|
||||
userName: item.userName || item.userEmail?.split("@")[0] || "未知用户",
|
||||
department: item.userDepartment || "未设置部门",
|
||||
featuredCount: item.featuredCount,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Featured Ranking API] Error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch ranking" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
84
app/api/featured/route.ts
Normal file
84
app/api/featured/route.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
// GET: 获取精选图片列表
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 查询精选图片,关联用户和模型信息
|
||||
const featuredImages = await db
|
||||
.select({
|
||||
id: generatedImage.id,
|
||||
thumbnailUrl: generatedImage.thumbnailUrl,
|
||||
originalUrl: generatedImage.originalUrl,
|
||||
imageSize: generatedImage.imageSize,
|
||||
prompt: generatedImage.prompt,
|
||||
modelId: generatedImage.modelId,
|
||||
createdAt: generatedImage.createdAt,
|
||||
featuredAt: generatedImage.featuredAt,
|
||||
userId: generatedImage.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userDepartment: user.department,
|
||||
modelName: aiModel.displayName,
|
||||
})
|
||||
.from(generatedImage)
|
||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
|
||||
.where(eq(generatedImage.isFeatured, true))
|
||||
.orderBy(desc(generatedImage.featuredAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
// 获取总数
|
||||
const totalResult = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(generatedImage)
|
||||
.where(eq(generatedImage.isFeatured, true));
|
||||
|
||||
const total = totalResult[0]?.count || 0;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return NextResponse.json({
|
||||
images: featuredImages.map(img => ({
|
||||
id: img.id,
|
||||
thumbnailUrl: img.thumbnailUrl,
|
||||
originalUrl: img.originalUrl,
|
||||
imageSize: img.imageSize,
|
||||
prompt: img.prompt,
|
||||
modelId: img.modelId,
|
||||
modelName: img.modelName || img.modelId || "未知模型",
|
||||
createdAt: img.createdAt,
|
||||
featuredAt: img.featuredAt,
|
||||
user: {
|
||||
id: img.userId,
|
||||
name: img.userName || img.userEmail?.split("@")[0] || "未知用户",
|
||||
department: img.userDepartment || "未设置部门",
|
||||
},
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Featured API] Error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch featured images" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Settings, ExternalLink, Briefcase, Images } from "lucide-react";
|
||||
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState, useEffect } from "react";
|
||||
@ -13,6 +13,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
type RankingItem = {
|
||||
rank: number;
|
||||
userId: string;
|
||||
userName: string;
|
||||
department: string;
|
||||
featuredCount: number;
|
||||
};
|
||||
|
||||
type TopNavbarProps = {
|
||||
siteName?: string;
|
||||
siteLogo?: string;
|
||||
@ -36,11 +44,47 @@ export function TopNavbar({
|
||||
const router = useRouter();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [ranking, setRanking] = useState<RankingItem[]>([]);
|
||||
const [rankingOpen, setRankingOpen] = useState(false);
|
||||
|
||||
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" (通常用于深色背景),我们优先使用它
|
||||
@ -101,6 +145,74 @@ export function TopNavbar({
|
||||
</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"
|
||||
|
||||
14
lib/db/migrations/0023_add_featured_and_department.sql
Normal file
14
lib/db/migrations/0023_add_featured_and_department.sql
Normal file
@ -0,0 +1,14 @@
|
||||
-- 迁移文件:0023_add_featured_and_department.sql
|
||||
-- 功能:添加精选功能和部门信息字段
|
||||
|
||||
-- 1. User 表添加部门字段
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "department" varchar(256) DEFAULT NULL;
|
||||
|
||||
-- 2. GeneratedImage 表添加精选字段
|
||||
ALTER TABLE "GeneratedImage" ADD COLUMN IF NOT EXISTS "isFeatured" boolean DEFAULT false NOT NULL;
|
||||
ALTER TABLE "GeneratedImage" ADD COLUMN IF NOT EXISTS "featuredAt" timestamp DEFAULT NULL;
|
||||
ALTER TABLE "GeneratedImage" ADD COLUMN IF NOT EXISTS "featuredBy" uuid DEFAULT NULL;
|
||||
|
||||
-- 3. 创建精选索引加速查询
|
||||
CREATE INDEX IF NOT EXISTS "idx_generated_image_featured" ON "GeneratedImage"("isFeatured") WHERE "isFeatured" = true;
|
||||
|
||||
12
lib/db/migrations/0024_add_dingtalk_userid.sql
Normal file
12
lib/db/migrations/0024_add_dingtalk_userid.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- 迁移文件:0024_add_dingtalk_userid.sql
|
||||
-- 功能:添加钉钉用户ID字段,支持批量同步用户信息
|
||||
|
||||
-- 添加钉钉用户ID字段
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "dingtalkUserId" varchar(128) DEFAULT NULL;
|
||||
|
||||
-- 添加索引加速查询
|
||||
CREATE INDEX IF NOT EXISTS "idx_user_dingtalk_userid" ON "User"("dingtalkUserId") WHERE "dingtalkUserId" IS NOT NULL;
|
||||
|
||||
-- 添加注释
|
||||
COMMENT ON COLUMN "User"."dingtalkUserId" IS '钉钉用户ID,用于调用钉钉API获取用户信息';
|
||||
|
||||
@ -663,6 +663,8 @@ export async function getAllUsers({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
department: user.department,
|
||||
dingtalkUserId: user.dingtalkUserId,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
monthlyImageLimit: user.monthlyImageLimit,
|
||||
|
||||
@ -19,6 +19,8 @@ export const user = pgTable("User", {
|
||||
email: varchar("email", { length: 64 }).notNull(),
|
||||
password: varchar("password", { length: 64 }),
|
||||
name: varchar("name", { length: 100 }), // 用户姓名(用于显示)
|
||||
department: varchar("department", { length: 256 }), // 部门信息
|
||||
dingtalkUserId: varchar("dingtalkUserId", { length: 128 }), // 钉钉用户ID
|
||||
role: varchar("role", { enum: ["guest", "regular", "admin"] })
|
||||
.notNull()
|
||||
.default("regular"),
|
||||
@ -341,6 +343,9 @@ export const generatedImage = pgTable("GeneratedImage", {
|
||||
imageSize: varchar("imageSize", { length: 10 }).default("2K"),
|
||||
prompt: text("prompt"),
|
||||
modelId: varchar("modelId", { length: 128 }),
|
||||
isFeatured: boolean("isFeatured").notNull().default(false), // 是否精选
|
||||
featuredAt: timestamp("featuredAt"), // 精选时间
|
||||
featuredBy: uuid("featuredBy").references(() => user.id), // 精选操作人
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user