feat(admin): include video generation records in image history
This commit is contained in:
parent
1defac0b8c
commit
8386755e6d
@ -296,12 +296,15 @@ type AuthSettings = {
|
||||
|
||||
type GeneratedImageRecord = {
|
||||
id: string;
|
||||
sourceMessageId?: string;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userEmail: string;
|
||||
userDepartment: string | null;
|
||||
thumbnailUrl: string;
|
||||
originalUrl: string;
|
||||
videoUrl?: string | null;
|
||||
recordType?: "image" | "video";
|
||||
imageSize: string | null;
|
||||
prompt: string | null;
|
||||
modelId: string | null;
|
||||
@ -473,7 +476,14 @@ export function AdminSettingsClient() {
|
||||
});
|
||||
const { data: imagesData, mutate: mutateImages } = useSWR<{
|
||||
images: GeneratedImageRecord[];
|
||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
totalImages?: number;
|
||||
totalVideos?: number;
|
||||
};
|
||||
}>(`/api/admin/images?${imageQueryParams}`, fetcher);
|
||||
|
||||
// 🆕 生图数据分析
|
||||
@ -2126,7 +2136,7 @@ export function AdminSettingsClient() {
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="images">
|
||||
<ImageIcon className="mr-2 h-4 w-4" />
|
||||
生图记录
|
||||
生图/视频记录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics">
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
@ -2413,8 +2423,8 @@ export function AdminSettingsClient() {
|
||||
<TabsContent value="images" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>所有生图记录</CardTitle>
|
||||
<CardDescription>查看所有用户的 AI 生图历史记录</CardDescription>
|
||||
<CardTitle>所有生图/视频记录</CardTitle>
|
||||
<CardDescription>查看所有用户的 AI 图片与视频生成历史记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 筛选条件 */}
|
||||
@ -2466,6 +2476,10 @@ export function AdminSettingsClient() {
|
||||
{/* 统计信息 */}
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
共 {imagesData?.pagination.total || 0} 条记录
|
||||
{imagesData?.pagination.totalImages !== undefined ||
|
||||
imagesData?.pagination.totalVideos !== undefined
|
||||
? `(图片 ${imagesData?.pagination.totalImages || 0} / 视频 ${imagesData?.pagination.totalVideos || 0})`
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
{/* 图片网格 */}
|
||||
@ -2477,14 +2491,29 @@ export function AdminSettingsClient() {
|
||||
onClick={() => setSelectedImage(img)}
|
||||
>
|
||||
<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.recordType === "video" ? (
|
||||
<video
|
||||
src={img.videoUrl || img.originalUrl}
|
||||
className="w-full h-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={img.thumbnailUrl}
|
||||
alt={img.prompt || "AI生成的图片"}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
{img.recordType === "video" ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/25">
|
||||
<Video className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
) : null}
|
||||
{/* 精选图标 */}
|
||||
{img.isFeatured && (
|
||||
{img.recordType !== "video" && 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>
|
||||
@ -2550,8 +2579,8 @@ export function AdminSettingsClient() {
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
图片详情
|
||||
{selectedImage?.isFeatured && (
|
||||
{selectedImage?.recordType === "video" ? "视频详情" : "图片详情"}
|
||||
{selectedImage?.recordType !== "video" && 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" />
|
||||
精选
|
||||
@ -2562,11 +2591,20 @@ export function AdminSettingsClient() {
|
||||
{selectedImage && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center relative">
|
||||
<img
|
||||
src={selectedImage.originalUrl}
|
||||
alt={selectedImage.prompt || "AI生成的图片"}
|
||||
className="max-h-[400px] object-contain rounded-lg"
|
||||
/>
|
||||
{selectedImage.recordType === "video" ? (
|
||||
<video
|
||||
src={selectedImage.videoUrl || selectedImage.originalUrl}
|
||||
className="max-h-[400px] w-full rounded-lg bg-black object-contain"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={selectedImage.originalUrl}
|
||||
alt={selectedImage.prompt || "AI生成的图片"}
|
||||
className="max-h-[400px] object-contain rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
@ -2582,18 +2620,22 @@ export function AdminSettingsClient() {
|
||||
<p>{new Date(selectedImage.createdAt).toLocaleString("zh-CN")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">图片尺寸</Label>
|
||||
<Label className="text-muted-foreground">
|
||||
{selectedImage.recordType === "video" ? "视频分辨率" : "图片尺寸"}
|
||||
</Label>
|
||||
<p>{selectedImage.imageSize || "未知"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">模型</Label>
|
||||
<p>{selectedImage.modelId || "未知"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">精选分类</Label>
|
||||
<p>{selectedImage.featuredCategory || "未设置"}</p>
|
||||
</div>
|
||||
{selectedImage.isFeatured && selectedImage.featuredAt && (
|
||||
{selectedImage.recordType !== "video" ? (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">精选分类</Label>
|
||||
<p>{selectedImage.featuredCategory || "未设置"}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedImage.recordType !== "video" && selectedImage.isFeatured && selectedImage.featuredAt && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">精选时间</Label>
|
||||
<p>{new Date(selectedImage.featuredAt).toLocaleString("zh-CN")}</p>
|
||||
@ -2604,34 +2646,64 @@ export function AdminSettingsClient() {
|
||||
<p className="mt-1">{selectedImage.prompt || "无"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 精选操作按钮 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">分类</Label>
|
||||
<Select
|
||||
value={selectedImage.featuredCategory || "__none__"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedImage({
|
||||
...selectedImage,
|
||||
featuredCategory: value === "__none__" ? null : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">未分类</SelectItem>
|
||||
{featuredCategoryOptions.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedImage.recordType !== "video" ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground">分类</Label>
|
||||
<Select
|
||||
value={selectedImage.featuredCategory || "__none__"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedImage({
|
||||
...selectedImage,
|
||||
featuredCategory: value === "__none__" ? null : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">未分类</SelectItem>
|
||||
{featuredCategoryOptions.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/images", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imageId: selectedImage.id,
|
||||
featuredCategory: selectedImage.featuredCategory,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("更新精选分类失败");
|
||||
}
|
||||
toast.success("精选分类已更新");
|
||||
mutateImages();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "更新精选分类失败");
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存分类
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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", {
|
||||
@ -2639,54 +2711,29 @@ export function AdminSettingsClient() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imageId: selectedImage.id,
|
||||
featuredCategory: selectedImage.featuredCategory,
|
||||
isFeatured: !selectedImage.isFeatured,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("更新精选分类失败");
|
||||
if (res.ok) {
|
||||
// 更新本地状态
|
||||
setSelectedImage({
|
||||
...selectedImage,
|
||||
isFeatured: !selectedImage.isFeatured,
|
||||
featuredAt: !selectedImage.isFeatured ? new Date().toISOString() : null,
|
||||
});
|
||||
// 刷新列表
|
||||
mutateImages();
|
||||
}
|
||||
toast.success("精选分类已更新");
|
||||
mutateImages();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "更新精选分类失败");
|
||||
console.error("Failed to update featured status:", error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存分类
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
{selectedImage.isFeatured ? "取消精选" : "设为精选"}
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@ -1,9 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage, user } from "@/lib/db/schema";
|
||||
import { eq, desc, sql, and, gte, lte, like, or } from "drizzle-orm";
|
||||
import { chat, generatedImage, message, user } from "@/lib/db/schema";
|
||||
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
function extractVideoUrlFromParts(parts: unknown): string | null {
|
||||
const text = Array.isArray(parts)
|
||||
? parts
|
||||
.map((part) => (part && typeof part === "object" && "text" in part ? String((part as any).text || "") : ""))
|
||||
.join("\n")
|
||||
: typeof parts === "string"
|
||||
? parts
|
||||
: "";
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
const htmlMatch = text.match(/<video[^>]+src="([^"]+)"/i);
|
||||
if (htmlMatch?.[1]) return htmlMatch[1];
|
||||
|
||||
const markdownMatch = text.match(/\[[^\]]*]\((https?:\/\/[^)\s]+\.mp4(?:\?[^)\s]*)?)\)/i);
|
||||
if (markdownMatch?.[1]) return markdownMatch[1];
|
||||
|
||||
const plainMatch = text.match(/(https?:\/\/[^\s"'<>]+\.mp4(?:\?[^\s"'<>]+)?)/i);
|
||||
return plainMatch?.[1] || null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
@ -31,8 +52,16 @@ export async function GET(request: NextRequest) {
|
||||
const endDate = searchParams.get("endDate");
|
||||
|
||||
// 构建查询条件
|
||||
const conditions = [];
|
||||
|
||||
const imageConditions = [];
|
||||
const videoConditions = [
|
||||
eq(message.role, "assistant"),
|
||||
or(
|
||||
isNotNull(message.temporaryVideoUrl),
|
||||
isNotNull(message.permanentVideoUrl),
|
||||
sql`${message.parts}::text ILIKE '%<video%'`,
|
||||
sql`${message.parts}::text ILIKE '%.mp4%'`
|
||||
),
|
||||
];
|
||||
if (search) {
|
||||
// 搜索用户名或邮箱
|
||||
const matchingUsers = await db
|
||||
@ -44,8 +73,13 @@ export async function GET(request: NextRequest) {
|
||||
));
|
||||
|
||||
if (matchingUsers.length > 0) {
|
||||
const userIds = matchingUsers.map(u => u.id);
|
||||
conditions.push(sql`${generatedImage.userId} IN (${sql.join(userIds.map(id => sql`${id}`), sql`, `)})`);
|
||||
const matchedUserIds = matchingUsers.map((u) => u.id);
|
||||
imageConditions.push(
|
||||
sql`${generatedImage.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
||||
);
|
||||
videoConditions.push(
|
||||
sql`${chat.userId} IN (${sql.join(matchedUserIds.map((id) => sql`${id}`), sql`, `)})`
|
||||
);
|
||||
} else {
|
||||
// 没有匹配的用户,返回空结果
|
||||
return NextResponse.json({
|
||||
@ -56,27 +90,43 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(gte(generatedImage.createdAt, new Date(startDate)));
|
||||
const start = new Date(startDate);
|
||||
imageConditions.push(gte(generatedImage.createdAt, start));
|
||||
videoConditions.push(gte(message.createdAt, start));
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
conditions.push(lte(generatedImage.createdAt, end));
|
||||
imageConditions.push(lte(generatedImage.createdAt, end));
|
||||
videoConditions.push(lte(message.createdAt, end));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
|
||||
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
|
||||
|
||||
// 查询总数
|
||||
const countResult = await db
|
||||
// 查询图片总数
|
||||
const imageCountResult = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(generatedImage)
|
||||
.where(whereClause);
|
||||
|
||||
const total = countResult[0]?.count || 0;
|
||||
.where(imageWhereClause);
|
||||
|
||||
// 查询视频总数
|
||||
const videoCountResult = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(message)
|
||||
.innerJoin(chat, eq(message.chatId, chat.id))
|
||||
.where(videoWhereClause);
|
||||
|
||||
const totalImages = imageCountResult[0]?.count || 0;
|
||||
const totalVideos = videoCountResult[0]?.count || 0;
|
||||
const total = totalImages + totalVideos;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const fetchLimit = Math.max(page * pageSize, pageSize);
|
||||
|
||||
// 查询图片列表(包含用户信息)
|
||||
const images = await db
|
||||
const imageRows = await db
|
||||
.select({
|
||||
id: generatedImage.id,
|
||||
userId: generatedImage.userId,
|
||||
@ -93,19 +143,91 @@ export async function GET(request: NextRequest) {
|
||||
featuredAt: generatedImage.featuredAt,
|
||||
createdAt: generatedImage.createdAt,
|
||||
chatId: generatedImage.chatId,
|
||||
recordType: sql<"image">`'image'`,
|
||||
videoUrl: sql<string | null>`null`,
|
||||
})
|
||||
.from(generatedImage)
|
||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||
.where(whereClause)
|
||||
.where(imageWhereClause)
|
||||
.orderBy(desc(generatedImage.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
.limit(fetchLimit);
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
// 查询视频列表(来自消息表)
|
||||
const videoRows = await db
|
||||
.select({
|
||||
id: message.id,
|
||||
userId: chat.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userDepartment: user.department,
|
||||
temporaryVideoUrl: message.temporaryVideoUrl,
|
||||
permanentVideoUrl: message.permanentVideoUrl,
|
||||
parts: message.parts,
|
||||
videoMetadata: message.videoMetadata,
|
||||
createdAt: message.createdAt,
|
||||
chatId: message.chatId,
|
||||
})
|
||||
.from(message)
|
||||
.innerJoin(chat, eq(message.chatId, chat.id))
|
||||
.leftJoin(user, eq(chat.userId, user.id))
|
||||
.where(videoWhereClause)
|
||||
.orderBy(desc(message.createdAt))
|
||||
.limit(fetchLimit);
|
||||
|
||||
const normalizedVideos = videoRows
|
||||
.map((item) => {
|
||||
const metadata = (item.videoMetadata as Record<string, any> | null) || {};
|
||||
const videoUrl =
|
||||
item.permanentVideoUrl ||
|
||||
item.temporaryVideoUrl ||
|
||||
extractVideoUrlFromParts(item.parts);
|
||||
|
||||
if (!videoUrl) return null;
|
||||
|
||||
const resolution =
|
||||
typeof metadata.resolution === "string" && metadata.resolution.trim()
|
||||
? metadata.resolution.trim()
|
||||
: null;
|
||||
const prompt =
|
||||
typeof metadata.prompt === "string" && metadata.prompt.trim()
|
||||
? metadata.prompt.trim()
|
||||
: null;
|
||||
const modelId =
|
||||
typeof metadata.model === "string" && metadata.model.trim()
|
||||
? metadata.model.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: `video-${item.id}`,
|
||||
sourceMessageId: item.id,
|
||||
userId: item.userId,
|
||||
userName: item.userName,
|
||||
userEmail: item.userEmail,
|
||||
userDepartment: item.userDepartment,
|
||||
thumbnailUrl: videoUrl,
|
||||
originalUrl: videoUrl,
|
||||
videoUrl,
|
||||
imageSize: resolution,
|
||||
prompt,
|
||||
modelId,
|
||||
isFeatured: false,
|
||||
featuredCategory: null,
|
||||
featuredAt: null,
|
||||
createdAt: item.createdAt,
|
||||
chatId: item.chatId,
|
||||
recordType: "video" as const,
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item));
|
||||
|
||||
const merged = [...imageRows, ...normalizedVideos].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
const images = merged.slice(offset, offset + pageSize);
|
||||
|
||||
return NextResponse.json({
|
||||
images,
|
||||
pagination: { page, pageSize, total, totalPages }
|
||||
pagination: { page, pageSize, total, totalPages, totalImages, totalVideos }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Admin Images API] Error:", error);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user