添加钉钉集成、精选作品和排行榜功能
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2025-12-18 15:12:34 +08:00
parent 3f3eed53be
commit 7a4fa8e1d8
12 changed files with 1091 additions and 17 deletions

View File

@ -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) => (

View 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>
);
}

View File

@ -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 });
}
}

View 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 });
}
}

View File

@ -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);
// 不抛出错误,继续登录流程
}
}

View 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
View 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 });
}
}

View File

@ -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"

View 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;

View 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获取用户信息';

View File

@ -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,

View File

@ -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(),
});