diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 88c46e5de..9388edd2c 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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() { - {/* 搜索框 */} -
+ {/* 搜索框和同步按钮 */} +
handleUserSearch(e.target.value)} className="max-w-md" /> +
{/* 用户列表 */} @@ -914,6 +943,9 @@ export function AdminSettingsClient() {
邮箱: {user.email}
+
+ 部门: {user.department || "未设置"} +
角色: {ROLE_LABELS[user.role] ?? user.role} | 状态: {user.isActive ? "已激活" : "未激活"}
@@ -1128,13 +1160,19 @@ export function AdminSettingsClient() { className="group relative cursor-pointer" onClick={() => setSelectedImage(img)} > -
+
{img.prompt + {/* 精选图标 */} + {img.isFeatured && ( +
+ +
+ )}
{img.userName || img.userEmail?.split("@")[0]} @@ -1195,11 +1233,19 @@ export function AdminSettingsClient() { !open && setSelectedImage(null)}> - 图片详情 + + 图片详情 + {selectedImage?.isFeatured && ( + + + 精选 + + )} + {selectedImage && (
-
+
{selectedImage.prompt用户

{selectedImage.userName || selectedImage.userEmail}

+
+ +

{selectedImage.userDepartment || "未设置"}

+

{new Date(selectedImage.createdAt).toLocaleString("zh-CN")}

@@ -1223,11 +1273,51 @@ export function AdminSettingsClient() {

{selectedImage.modelId || "未知"}

+ {selectedImage.isFeatured && selectedImage.featuredAt && ( +
+ +

{new Date(selectedImage.featuredAt).toLocaleString("zh-CN")}

+
+ )}

{selectedImage.prompt || "无"}

+ {/* 精选操作按钮 */} +
+ +
)}
@@ -1306,10 +1396,10 @@ export function AdminSettingsClient() {
{/* 用户排行 */} - - + + 用户生图排行 (Top 20) - +
{analyticsData?.userStats?.map((user, index) => ( diff --git a/app/(chat)/featured/page.tsx b/app/(chat)/featured/page.tsx new file mode 100644 index 000000000..a2b46aa48 --- /dev/null +++ b/app/(chat)/featured/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [pagination, setPagination] = useState({ + page: 1, + pageSize: 24, + total: 0, + totalPages: 0, + }); + const [selectedImage, setSelectedImage] = useState(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 ( +
+
+
+ +
+

+ + AI智能精选 +

+

+ 共 {pagination.total} 张优秀AI作品 +

+
+
+
+ + {loading ? ( +
+
+
+ 加载中... +
+
+ ) : images.length === 0 ? ( +
+
+ +

暂无精选作品

+

管理员可以在后台标记优秀作品

+
+
+ ) : ( + <> +
+
+ {images.map((img) => ( +
handleImageClick(img)} + > + {/* 精选图标 */} +
+
+ + 精选 +
+
+ +
+ {img.prompt +
+ + {/* 底部信息区域 */} +
+ {/* 用户信息 */} +
+
+ +
+
+

{img.user.name}

+

+ + {img.user.department} +

+
+
+ + {/* 提示词 */} +

+ {img.prompt || "无提示词"} +

+ + {/* 底部元信息 */} +
+ + + {img.modelName} + + + + {new Date(img.createdAt).toLocaleDateString("zh-CN", { + month: "short", + day: "numeric", + })} + +
+
+
+ ))} +
+
+ + {/* 分页 */} +
+
+ 第 {pagination.page} 页 / 共 {pagination.totalPages} 页 +
+ + {pagination.totalPages > 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 ( +
+ {showEllipsis && ( + ... + )} + +
+ ); + })} +
+ + + + + +
+ setPageInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handlePageInputSubmit()} + placeholder="跳转" + className="w-20 h-8 text-sm" + /> + +
+
+ )} +
+ + )} + + {/* 图片预览对话框 */} + + + {selectedImage && ( +
+ {/* 图片 */} +
+ {selectedImage.prompt + {/* 精选标记 */} +
+
+ + 精选作品 +
+
+
+ + {/* 信息区域 */} +
+
+
+
+ +
+
+

{selectedImage.user.name}

+

+ + {selectedImage.user.department} +

+
+
+
+

生成时间

+

+ {new Date(selectedImage.createdAt).toLocaleString("zh-CN")} +

+
+
+ +
+

提示词

+

+ {selectedImage.prompt || "无提示词"} +

+
+ +
+
+ + + {selectedImage.modelName} + + 尺寸: {selectedImage.imageSize || "2K"} +
+ +
+
+
+ )} +
+
+
+ ); +} + diff --git a/app/api/admin/images/route.ts b/app/api/admin/images/route.ts index 46202ae08..2ec44e654 100644 --- a/app/api/admin/images/route.ts +++ b/app/api/admin/images/route.ts @@ -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 = { + 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 }); + } +} + diff --git a/app/api/admin/sync-dingtalk/route.ts b/app/api/admin/sync-dingtalk/route.ts new file mode 100644 index 000000000..41753582d --- /dev/null +++ b/app/api/admin/sync-dingtalk/route.ts @@ -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 }); + } +} + diff --git a/app/api/auth/dingtalk/callback/route.ts b/app/api/auth/dingtalk/callback/route.ts index 1fa7e2373..de7390eb4 100644 --- a/app/api/auth/dingtalk/callback/route.ts +++ b/app/api/auth/dingtalk/callback/route.ts @@ -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 = {}; + + // 更新姓名(如果数据库中为空但钉钉有数据) 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); // 不抛出错误,继续登录流程 } } diff --git a/app/api/featured/ranking/route.ts b/app/api/featured/ranking/route.ts new file mode 100644 index 000000000..3da6b40aa --- /dev/null +++ b/app/api/featured/ranking/route.ts @@ -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`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 }); + } +} + diff --git a/app/api/featured/route.ts b/app/api/featured/route.ts new file mode 100644 index 000000000..04e850b04 --- /dev/null +++ b/app/api/featured/route.ts @@ -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`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 }); + } +} + diff --git a/components/top-navbar.tsx b/components/top-navbar.tsx index 27724f6a9..ed142fc54 100644 --- a/components/top-navbar.tsx +++ b/components/top-navbar.tsx @@ -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([]); + 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 ; + case 2: + return ; + case 3: + return ; + default: + return {rank}; + } + }; + // 决定显示哪个 LOGO // 导航栏背景固定为黑色,理论上应该始终显示适合深色背景的 LOGO // 但如果有专门配置了 "Dark Mode Logo" (通常用于深色背景),我们优先使用它 @@ -101,6 +145,74 @@ export function TopNavbar({ )} + {/* AI智能精选 */} + + + {/* 精选榜单 */} + + + + + +
+ + 精选榜单 TOP5 +
+ {ranking.length === 0 ? ( +
+ 暂无精选数据 +
+ ) : ( +
+ {ranking.map((item) => ( +
+
+ {getRankIcon(item.rank)} +
+
+

{item.userName}

+

+ + {item.department} +

+
+
+ {item.featuredCount} + 精选 +
+
+ ))} +
+ )} +
+
+ {/* 我的图册 */}