优化后台生成记录分页与模型管理
生成记录改为数据库分页,每页 40 条,默认最近 7 天;可见预览限制为 4 路并发并使用 192px 缩略图,翻页取消旧请求,视频在详情中按需播放。保留精选、分类和原图操作,新增画布任务失败记录,统一时间显示。 模型管理拆分模型、上游和运行健康视图,提供搜索、筛选、分页和完整故障详情;列表使用摘要,编辑按需获取完整配置。后台按当前设置页加载,模型 GET 不再自动修改健康状态,98K 同步先显示变更预览。 验证:600 项现有辅助测试和 3 项新增回归测试通过,TypeScript 和本次 API/组件 lint 通过;浏览器验证请求峰值 4、翻页、搜索、失败重试、详情、模型筛选与 390px 窄屏。只读生产查询:当天页约 29ms,历史视频页 122ms,第 7800 页 739ms。两个并发索引已在生产建成,SQL 随代码保留。 已知检查状态:全仓 lint 存量 1004 项诊断使 check:quality 停在 lint 阶段;日志、品牌、CI、环境、凭据、元数据、测试与制品检查单独通过。
This commit is contained in:
parent
6053271352
commit
af8189908d
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { db } from "@/lib/db";
|
||||
import { aiModelHealthEvent } from "@/lib/db/schema";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
listAiModelHealthEvents,
|
||||
restoreAiModelHealth,
|
||||
} from "@/lib/ai/runtime/model-health";
|
||||
|
||||
@ -15,14 +17,30 @@ export async function GET(request: Request) {
|
||||
await requireAdmin();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const modelId = searchParams.get("modelId") || undefined;
|
||||
const limit = Number(searchParams.get("limit") || 80);
|
||||
const events = await listAiModelHealthEvents({
|
||||
modelId,
|
||||
limit: Number.isFinite(limit) ? limit : 80,
|
||||
});
|
||||
|
||||
return Response.json({ events });
|
||||
const modelId = searchParams.get("modelId");
|
||||
const providerId = searchParams.get("providerId");
|
||||
const rawLimit = Number(searchParams.get("limit") || 20);
|
||||
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 20;
|
||||
const rawPage = Number(searchParams.get("page") || 1);
|
||||
const page = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1;
|
||||
const filters = [];
|
||||
if (modelId) filters.push(eq(aiModelHealthEvent.modelId, modelId));
|
||||
if (providerId) filters.push(eq(aiModelHealthEvent.providerId, providerId));
|
||||
if (searchParams.get("eventType")) filters.push(eq(aiModelHealthEvent.eventType, searchParams.get("eventType")!));
|
||||
const start = searchParams.get("startDate");
|
||||
const end = searchParams.get("endDate");
|
||||
if (start && /^\d{4}-\d{2}-\d{2}$/.test(start)) filters.push(sql`${aiModelHealthEvent.createdAt}>=${start}::date`);
|
||||
if (end && /^\d{4}-\d{2}-\d{2}$/.test(end)) filters.push(sql`${aiModelHealthEvent.createdAt}<${end}::date+interval '1 day'`);
|
||||
const events = await db.select({
|
||||
id: aiModelHealthEvent.id, modelId: aiModelHealthEvent.modelId, modelDisplayName: aiModelHealthEvent.modelDisplayName,
|
||||
modelRuntimeId: aiModelHealthEvent.modelRuntimeId, providerName: aiModelHealthEvent.providerName,
|
||||
providerId: aiModelHealthEvent.providerId, eventType: aiModelHealthEvent.eventType,
|
||||
errorMessage: aiModelHealthEvent.errorMessage, elapsedMs: aiModelHealthEvent.elapsedMs,
|
||||
consecutiveFailures: aiModelHealthEvent.consecutiveFailures, autoDisabled: aiModelHealthEvent.autoDisabled,
|
||||
counted: aiModelHealthEvent.counted, createdAt: aiModelHealthEvent.createdAt,
|
||||
}).from(aiModelHealthEvent).where(and(...filters)).orderBy(desc(aiModelHealthEvent.createdAt), desc(aiModelHealthEvent.id))
|
||||
.limit(limit + 1).offset((page - 1) * limit);
|
||||
return Response.json({ events: events.slice(0, limit), hasMore: events.length > limit, page });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { aiModel, aiProvider } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import {
|
||||
getAllAiModels,
|
||||
getAiModelById,
|
||||
getAiModelsByProviderId,
|
||||
createAiModel,
|
||||
updateAiModel,
|
||||
deleteAiModel,
|
||||
@ -154,8 +155,14 @@ export async function GET(request: Request) {
|
||||
const id = searchParams.get("id");
|
||||
const providerId = searchParams.get("providerId");
|
||||
|
||||
if (searchParams.get("view") === "options") {
|
||||
const models = await db.select({ modelId: aiModel.modelId, displayName: aiModel.displayName,
|
||||
providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
|
||||
return Response.json({ models: sortModelsForDisplay(models) });
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const model = await getAiModelById({ id });
|
||||
const model = await db.query.aiModel.findFirst({ where: eq(aiModel.id, id) });
|
||||
if (!model) {
|
||||
return new ChatSDKError("bad_request:api", "Model not found").toResponse();
|
||||
}
|
||||
@ -163,12 +170,19 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
if (providerId) {
|
||||
const models = await getAiModelsByProviderId({ providerId });
|
||||
const models = await db.select().from(aiModel).where(eq(aiModel.providerId, providerId));
|
||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||
}
|
||||
|
||||
const models = await getAllAiModels();
|
||||
return Response.json({ models: sortModelsForDisplay(models.map(normalizeModel)) });
|
||||
const models = await db.select().from(aiModel);
|
||||
const normalized = models.map(normalizeModel);
|
||||
const output = searchParams.get("view") === "summary" ? normalized.map(model => {
|
||||
const { capabilities, defaultParams, imageGenConfig, description, ...summary } = model;
|
||||
const fields = ["kind", "mediaType", "modelType", "outputType", "supportedModes"];
|
||||
return { ...summary, healthLastError: summary.healthLastError?.slice(0, 1200),
|
||||
capabilities: Object.fromEntries(fields.filter(key => capabilities?.[key] !== undefined).map(key => [key, capabilities[key]])) };
|
||||
}) : normalized;
|
||||
return Response.json({ models: sortModelsForDisplay(output) });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) {
|
||||
return error.toResponse();
|
||||
|
||||
@ -1,237 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { chat, generatedImage, message, user } from "@/lib/db/schema";
|
||||
import { eq, desc, sql, and, gte, lte, like, or, isNotNull } from "drizzle-orm";
|
||||
import { generatedImage, user } from "@/lib/db/schema";
|
||||
import { eq } 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;
|
||||
}
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { mediaRecordsQuery, parseMediaQuery } from "@/lib/admin/media-records";
|
||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const session = await requireAdmin();
|
||||
const operator = await db.query.user.findFirst({ where: eq(user.id, session.user.id) });
|
||||
if (operator?.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const query = parseMediaQuery(request.nextUrl.searchParams);
|
||||
if (query.startDate && query.endDate && query.startDate > query.endDate) {
|
||||
return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
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 searchParams = request.nextUrl.searchParams;
|
||||
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "200");
|
||||
const offset = (page - 1) * pageSize;
|
||||
const search = searchParams.get("search") || "";
|
||||
const startDate = searchParams.get("startDate");
|
||||
const endDate = searchParams.get("endDate");
|
||||
|
||||
// 构建查询条件
|
||||
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
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(or(
|
||||
like(user.name, `%${search}%`),
|
||||
like(user.email, `%${search}%`)
|
||||
));
|
||||
|
||||
if (matchingUsers.length > 0) {
|
||||
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({
|
||||
images: [],
|
||||
pagination: { page, pageSize, total: 0, totalPages: 0 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (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);
|
||||
imageConditions.push(lte(generatedImage.createdAt, end));
|
||||
videoConditions.push(lte(message.createdAt, end));
|
||||
}
|
||||
|
||||
const imageWhereClause = imageConditions.length > 0 ? and(...imageConditions) : undefined;
|
||||
const videoWhereClause = videoConditions.length > 0 ? and(...videoConditions) : undefined;
|
||||
|
||||
// 查询图片总数
|
||||
const imageCountResult = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(generatedImage)
|
||||
.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 imageRows = await db
|
||||
.select({
|
||||
id: generatedImage.id,
|
||||
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,
|
||||
featuredCategory: generatedImage.featuredCategory,
|
||||
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(imageWhereClause)
|
||||
.orderBy(desc(generatedImage.createdAt))
|
||||
.limit(fetchLimit);
|
||||
|
||||
// 查询视频列表(来自消息表)
|
||||
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, totalImages, totalVideos }
|
||||
});
|
||||
const [result] = await db.execute(mediaRecordsQuery(query));
|
||||
const images = await resolveArchivedImageUrls(result.records);
|
||||
const total = Number(result.total);
|
||||
return NextResponse.json({ images, pagination: { page: query.page, pageSize: query.pageSize, total,
|
||||
totalPages: Math.ceil(total / query.pageSize), totalImages: Number(result.totalImages), totalVideos: Number(result.totalVideos) } });
|
||||
} catch (error) {
|
||||
console.error("[Admin Images API] Error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch images" }, { status: 500 });
|
||||
if (error instanceof ChatSDKError) return error.toResponse();
|
||||
console.error("[Admin Images API] GET failed", error);
|
||||
return NextResponse.json({ error: "记录加载失败,请重试" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
app/api/admin/media-tasks/route.ts
Normal file
31
app/api/admin/media-tasks/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import { parseMediaQuery } from "@/lib/admin/media-records";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const q = parseMediaQuery(request.nextUrl.searchParams);
|
||||
const status = request.nextUrl.searchParams.get("status") || "all";
|
||||
const filters = [sql`true`];
|
||||
if (q.search)
|
||||
filters.push(sql`(u.name ilike ${`%${q.search}%`} or u.email ilike ${`%${q.search}%`})`);
|
||||
if (["pending", "running", "failed", "succeeded"].includes(status))
|
||||
filters.push(sql`t.status=${status}`);
|
||||
if (q.startDate) filters.push(sql`t."createdAt">=${q.startDate}::date - interval '8 hours'`);
|
||||
if (q.endDate) filters.push(sql`t."createdAt"<${q.endDate}::date+interval '16 hours'`);
|
||||
const [result] = await db.execute(sql`with filtered as not materialized (
|
||||
select t.id,u.name as "userName",t.status,t."createdAt"+interval '8 hours' as "createdAt",t.error,coalesce(t.request->>'modelId',t.request->>'model') as model,
|
||||
extract(epoch from (t."completedAt"-t."startedAt"))*1000 as "elapsedMs"
|
||||
from "CanvasImageTask" t left join "User" u on u.id=t."userId" where ${sql.join(filters, sql` and `)}
|
||||
) select (select count(*)::int from filtered) as total,
|
||||
coalesce((select jsonb_agg(p) from (select * from filtered order by "createdAt" desc,id desc limit ${q.pageSize} offset ${(q.page - 1) * q.pageSize}) p),'[]'::jsonb) as tasks`);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ChatSDKError) return error.toResponse();
|
||||
return NextResponse.json({ error: "任务记录加载失败,请重试" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -106,6 +106,22 @@ export async function POST(request: Request) {
|
||||
? catalogPresets.filter((preset) => existingModelIds.has(preset.modelId))
|
||||
: catalogPresets;
|
||||
|
||||
if (body?.dryRun === true) {
|
||||
const catalogIds = new Set(catalogPresets.map(preset => preset.modelId));
|
||||
const fields = ["displayName", "description", "supportsVision", "supportsImageGen", "isHidden", "sortOrder", "capabilities", "defaultParams", "imageGenConfig"] as const;
|
||||
const added = presets.filter(preset => !existingModelIds.has(preset.modelId)).map(preset => ({ modelId: preset.modelId, displayName: preset.displayName }));
|
||||
const updated = presets.flatMap(preset => {
|
||||
const existing = existingModels.find(model => model.modelId === preset.modelId);
|
||||
if (!existing) return [];
|
||||
const changedFields: string[] = fields.filter(field => preset[field] !== undefined && JSON.stringify(existing[field]) !== JSON.stringify(preset[field]));
|
||||
if (existing.avatarUrl) changedFields.push("avatarUrl");
|
||||
if (requestedModelIds !== null && !existing.isEnabled) changedFields.push("isEnabled");
|
||||
return changedFields.length ? [{ modelId: preset.modelId, displayName: existing.displayName, changedFields }] : [];
|
||||
});
|
||||
const disabled = requestedModelIds === null ? existingModels.filter(model => !catalogIds.has(model.modelId) && (model.isEnabled || !model.isHidden)).map(model => ({ modelId: model.modelId, displayName: model.displayName })) : [];
|
||||
return NextResponse.json({ preview: { added, updated, disabled }, total: catalogPresets.length });
|
||||
}
|
||||
|
||||
const addedModels: AiModel[] = [];
|
||||
const updatedModels: AiModel[] = [];
|
||||
const disabledModels: AiModel[] = [];
|
||||
|
||||
30
components/admin/admin-surface.tsx
Normal file
30
components/admin/admin-surface.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
export function AdminSurface({ children }: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
let context: { revert: () => void } | undefined;
|
||||
let disposed = false;
|
||||
import("gsap").then(({ gsap }) => {
|
||||
if (disposed) return;
|
||||
context = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
ref.current,
|
||||
{ y: 6, opacity: 0.85 },
|
||||
{ y: 0, opacity: 1, duration: 0.25, ease: "power3.out", clearProps: "transform,opacity" },
|
||||
);
|
||||
}, ref);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
context?.revert();
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div ref={ref} className="space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
601
components/admin/media-records-panel.tsx
Normal file
601
components/admin/media-records-panel.tsx
Normal file
@ -0,0 +1,601 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { MediaThumbnail } from "./media-thumbnail";
|
||||
import { AdminSurface } from "./admin-surface";
|
||||
import { formatRecordTime } from "@/lib/admin/record-time";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
|
||||
export type MediaRecord = {
|
||||
id: string;
|
||||
recordType: "image" | "video";
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
userDepartment: string | null;
|
||||
createdAt: string;
|
||||
modelId: string | null;
|
||||
chatId: string | null;
|
||||
thumbnailUrl: string;
|
||||
originalUrl: string;
|
||||
videoUrl?: string | null;
|
||||
prompt: string | null;
|
||||
imageSize: string | null;
|
||||
isFeatured: boolean;
|
||||
featuredCategory: string | null;
|
||||
featuredAt?: string | null;
|
||||
};
|
||||
type MediaResponse = {
|
||||
images: MediaRecord[];
|
||||
pagination: {
|
||||
total: number;
|
||||
totalPages: number;
|
||||
totalImages: number;
|
||||
totalVideos: number;
|
||||
page: number;
|
||||
};
|
||||
};
|
||||
export async function adminFetcher(url: string) {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || data.message || "加载失败,请重试");
|
||||
return data;
|
||||
}
|
||||
const selectClass =
|
||||
"h-10 rounded-md border bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
||||
function recentDate() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 6);
|
||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(d);
|
||||
}
|
||||
|
||||
export function MediaRecordsPanel({ categories }: { categories: string[] }) {
|
||||
const [view, setView] = useState("assets");
|
||||
const [search, setSearch] = useState("");
|
||||
const [filters, setFilters] = useState({
|
||||
search: "",
|
||||
type: "all",
|
||||
startDate: recentDate(),
|
||||
endDate: "",
|
||||
model: "",
|
||||
featured: "false",
|
||||
status: "all",
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState<MediaRecord | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [originalFailed, setOriginalFailed] = useState(false);
|
||||
const update = (changes: Partial<typeof filters>) => {
|
||||
setFilters((f) => ({ ...f, ...changes }));
|
||||
setPage(1);
|
||||
};
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setFilters((f) => (f.search === search ? f : { ...f, search }));
|
||||
setPage(1);
|
||||
}, 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
const { data: options } = useSWR<{
|
||||
models: Array<{ modelId: string; displayName: string; providerName: string }>;
|
||||
}>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false });
|
||||
const modelNames = new Map<string, string>();
|
||||
for (const model of options?.models || [])
|
||||
if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName);
|
||||
const openDetails = (item: MediaRecord) => {
|
||||
setSelected(item);
|
||||
setOriginalFailed(false);
|
||||
};
|
||||
const query = new URLSearchParams({ ...filters, page: String(page), pageSize: "40" });
|
||||
const { data, error, isLoading, isValidating, mutate } = useSWR<MediaResponse>(
|
||||
view === "assets" ? `/api/admin/images?${query}` : null,
|
||||
adminFetcher,
|
||||
{ keepPreviousData: true, revalidateOnFocus: false },
|
||||
);
|
||||
const tasks = useSWR<{
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
userName: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
error: string | null;
|
||||
model: string | null;
|
||||
elapsedMs: number | null;
|
||||
}>;
|
||||
total: number;
|
||||
}>(view === "tasks" ? `/api/admin/media-tasks?${query}` : null, adminFetcher, {
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const busy = view === "assets" ? isValidating : tasks.isValidating;
|
||||
const failure = view === "assets" ? error : tasks.error;
|
||||
const loading = view === "assets" ? isLoading : tasks.isLoading;
|
||||
const total = view === "assets" ? data?.pagination.total : tasks.data?.total;
|
||||
const pages = Math.max(1, Math.ceil((total || 0) / 40));
|
||||
async function save(changes: Partial<MediaRecord>) {
|
||||
if (!selected || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/images", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ imageId: selected.id, ...changes }),
|
||||
});
|
||||
if (!response.ok) throw new Error("保存失败,请重试");
|
||||
setSelected({ ...selected, ...changes });
|
||||
toast.success("已保存");
|
||||
await mutate();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<AdminSurface>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">生成记录</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
查看图片与视频产物,追踪画布任务。时间统一按北京时间展示。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
||||
>
|
||||
<RefreshCw className={`mr-2 size-4 ${busy ? "motion-safe:animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2" role="tablist" aria-label="记录视图">
|
||||
{[
|
||||
["assets", "产物记录"],
|
||||
["tasks", "画布任务"],
|
||||
].map(([key, label]) => (
|
||||
<Button
|
||||
key={key}
|
||||
role="tab"
|
||||
aria-selected={view === key}
|
||||
variant={view === key ? "default" : "ghost"}
|
||||
onClick={() => {
|
||||
setView(key);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border bg-background p-4">
|
||||
<label className="min-w-48 flex-1 text-xs font-medium">
|
||||
用户
|
||||
<div className="relative mt-2">
|
||||
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-10 pl-9"
|
||||
placeholder="搜索姓名或邮箱"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{view === "assets" ? (
|
||||
<label className="text-xs font-medium">
|
||||
类型
|
||||
<select
|
||||
aria-label="产物类型"
|
||||
className={`${selectClass} mt-2 block`}
|
||||
value={filters.type}
|
||||
onChange={(e) => update({ type: e.target.value })}
|
||||
>
|
||||
<option value="all">图片与视频</option>
|
||||
<option value="image">只看图片</option>
|
||||
<option value="video">只看视频</option>
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<label className="text-xs font-medium">
|
||||
任务状态
|
||||
<select
|
||||
aria-label="任务状态"
|
||||
className={`${selectClass} mt-2 block`}
|
||||
value={filters.status}
|
||||
onChange={(e) => update({ status: e.target.value })}
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="running">运行中</option>
|
||||
<option value="pending">排队中</option>
|
||||
<option value="succeeded">成功</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="text-xs font-medium">
|
||||
开始日期
|
||||
<Input
|
||||
aria-label="开始日期"
|
||||
className="mt-2 h-10 w-40"
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => update({ startDate: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs font-medium">
|
||||
结束日期
|
||||
<Input
|
||||
aria-label="结束日期"
|
||||
className="mt-2 h-10 w-40"
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => update({ endDate: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
update({
|
||||
search: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
model: "",
|
||||
featured: "false",
|
||||
type: "all",
|
||||
status: "all",
|
||||
});
|
||||
}}
|
||||
>
|
||||
全部时间 / 重置
|
||||
</Button>
|
||||
{view === "assets" && (
|
||||
<div className="flex w-full flex-wrap items-center gap-3">
|
||||
<select
|
||||
aria-label="记录模型"
|
||||
className={`${selectClass} max-w-full sm:max-w-96`}
|
||||
value={filters.model}
|
||||
onChange={(e) => update({ model: e.target.value })}
|
||||
>
|
||||
<option value="">全部模型</option>
|
||||
{Array.from(modelNames, ([id, name]) => (
|
||||
<option key={id} value={id}>
|
||||
{name} · {id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.featured === "true"}
|
||||
onChange={(e) => update({ featured: String(e.target.checked) })}
|
||||
/>
|
||||
只看精选
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex items-center justify-between text-sm text-muted-foreground"
|
||||
>
|
||||
<span>
|
||||
{total === undefined ? "正在读取记录…" : `共 ${total.toLocaleString()} 条记录`}
|
||||
{view === "assets" && data
|
||||
? ` · 图片 ${data.pagination.totalImages.toLocaleString()} · 视频 ${data.pagination.totalVideos.toLocaleString()}`
|
||||
: ""}
|
||||
</span>
|
||||
{busy && !loading && <span>正在更新…</span>}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<span>
|
||||
第 {page} / {pages} 页 · 每页 40 条
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || page === 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{failure ? (
|
||||
<div role="alert" className="rounded-xl border border-destructive/30 p-6 text-sm">
|
||||
<p>{failure.message}</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
variant="outline"
|
||||
onClick={() => (view === "assets" ? mutate() : tasks.mutate())}
|
||||
>
|
||||
重试加载
|
||||
</Button>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div role="status" className="space-y-3 rounded-xl border p-6">
|
||||
{[0, 1, 2, 3].map((x) => (
|
||||
<div key={x} className="h-16 rounded-lg bg-muted motion-safe:animate-pulse" />
|
||||
))}
|
||||
<span className="sr-only">正在加载记录</span>
|
||||
</div>
|
||||
) : total === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-12 text-center text-sm text-muted-foreground">
|
||||
没有符合条件的记录,请调整筛选条件。
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border bg-background" aria-busy={busy}>
|
||||
{view === "assets" ? (
|
||||
<table className="w-full min-w-[720px] text-left text-sm">
|
||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
{["产物", "用户", "生成时间", "模型 / 尺寸", "精选", ""].map((v, i) => (
|
||||
<th key={i} className="px-4 py-3 font-medium">
|
||||
{v}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{data?.images.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-muted/30">
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<MediaThumbnail
|
||||
url={item.thumbnailUrl}
|
||||
video={item.recordType === "video"}
|
||||
onOpen={() => openDetails(item)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{item.userName || item.userEmail}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.userDepartment || "未设置部门"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 tabular-nums">
|
||||
{formatRecordTime(item.createdAt)}
|
||||
</td>
|
||||
<td className="max-w-64 px-4 py-3">
|
||||
<div className="truncate" title={item.modelId || ""}>
|
||||
{modelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.recordType === "video" ? "视频" : "图片"} ·{" "}
|
||||
{item.imageSize || "未知尺寸"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.isFeatured ? (
|
||||
<span className="inline-flex items-center gap-1 text-amber-700 dark:text-amber-300">
|
||||
<Star className="size-3" />
|
||||
{item.featuredCategory || "精选"}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => openDetails(item)}>
|
||||
详情
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full min-w-[720px] text-left text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{["用户 / 时间", "状态", "模型", "耗时", "失败原因 / 任务编号"].map((x) => (
|
||||
<th key={x} className="px-4 py-3 font-medium">
|
||||
{x}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{tasks.data?.tasks.map((task) => (
|
||||
<tr key={task.id}>
|
||||
<td className="px-4 py-4">
|
||||
{task.userName}
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatRecordTime(task.createdAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{{ failed: "失败", running: "运行中", succeeded: "成功", pending: "排队中" }[
|
||||
task.status
|
||||
] || task.status}
|
||||
</td>
|
||||
<td className="max-w-48 break-words px-4 py-4">{task.model || "自动选择"}</td>
|
||||
<td className="px-4 py-4 tabular-nums">
|
||||
{task.elapsedMs === null ? "—" : `${Math.round(task.elapsedMs / 1000)} 秒`}
|
||||
</td>
|
||||
<td className="max-w-xl break-words px-4 py-4">
|
||||
<p className={task.error ? "text-destructive" : "text-muted-foreground"}>
|
||||
{task.error || "—"}
|
||||
</p>
|
||||
<code className="mt-2 block text-xs text-muted-foreground">{task.id}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
每页 40 条 · 第 {page} / {pages} 页
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" disabled={busy || page === 1} onClick={() => setPage(1)}>
|
||||
首页
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="上一页"
|
||||
variant="outline"
|
||||
disabled={busy || page === 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="下一页"
|
||||
variant="outline"
|
||||
disabled={busy || page >= pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" disabled={busy || page >= pages} onClick={() => setPage(pages)}>
|
||||
末页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
open={!!selected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selected?.recordType === "video" ? "视频详情" : "图片详情"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selected && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex min-h-40 justify-center rounded-xl bg-muted">
|
||||
{selected.recordType === "video" ? (
|
||||
<video
|
||||
src={selected.videoUrl || selected.originalUrl}
|
||||
controls
|
||||
preload="none"
|
||||
className="max-h-[45vh] w-full"
|
||||
/>
|
||||
) : originalFailed ? (
|
||||
<div role="alert" className="p-8 text-sm">
|
||||
原图无法加载,源文件可能已过期或存储不可达。
|
||||
<Button
|
||||
className="ml-3"
|
||||
variant="outline"
|
||||
onClick={() => setOriginalFailed(false)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={selected.originalUrl}
|
||||
alt="生成原图"
|
||||
className="max-h-[45vh] object-contain"
|
||||
onError={() => setOriginalFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<dl className="grid gap-4 text-sm sm:grid-cols-2">
|
||||
{[
|
||||
["用户", selected.userName || selected.userEmail],
|
||||
["部门", selected.userDepartment],
|
||||
["生成时间", formatRecordTime(selected.createdAt)],
|
||||
["尺寸", selected.imageSize],
|
||||
["模型", selected.modelId],
|
||||
["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"],
|
||||
].map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="text-xs text-muted-foreground">{k}</dt>
|
||||
<dd className="mt-1 break-words">{v || "未记录"}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">提示词</h3>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-muted-foreground">
|
||||
{selected.prompt || "未记录提示词"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<a href={selected.originalUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="mr-2 size-4" />
|
||||
打开原文件
|
||||
</a>
|
||||
</Button>
|
||||
{selected.recordType === "image" && (
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href={buildImageProxyUrl(selected.originalUrl, { delivery: "original" })}
|
||||
download
|
||||
>
|
||||
<Download className="mr-2 size-4" />
|
||||
下载原图
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{selected.recordType === "image" && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border p-4">
|
||||
<select
|
||||
aria-label="精选分类"
|
||||
className={selectClass}
|
||||
value={selected.featuredCategory || ""}
|
||||
onChange={(e) =>
|
||||
setSelected({ ...selected, featuredCategory: e.target.value || null })
|
||||
}
|
||||
>
|
||||
<option value="">未分类</option>
|
||||
{[...new Set([...categories, selected.featuredCategory].filter(Boolean))].map(
|
||||
(c) => (
|
||||
<option key={c} value={c!}>
|
||||
{c}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => save({ featuredCategory: selected.featuredCategory })}
|
||||
>
|
||||
保存分类
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => save({ isFeatured: !selected.isFeatured })}
|
||||
>
|
||||
<Star className="mr-2 size-4" />
|
||||
{selected.isFeatured ? "取消精选" : "设为精选"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AdminSurface>
|
||||
);
|
||||
}
|
||||
147
components/admin/media-thumbnail.tsx
Normal file
147
components/admin/media-thumbnail.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ImageOff, Video, RotateCw } from "lucide-react";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { isRetiredStorageUrl } from "@/lib/storage/retired-storage";
|
||||
|
||||
import { queuePreview } from "@/lib/admin/preview-queue";
|
||||
|
||||
export function MediaThumbnail({
|
||||
url,
|
||||
video = false,
|
||||
onOpen,
|
||||
}: {
|
||||
url: string;
|
||||
video?: boolean;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<"waiting" | "loading" | "ready" | "error">("waiting");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [source, setSource] = useState("");
|
||||
const [failure, setFailure] = useState("");
|
||||
const retired = isRetiredStorageUrl(url);
|
||||
useEffect(() => {
|
||||
setState("waiting");
|
||||
setSource("");
|
||||
setFailure("");
|
||||
if (video || retired || !url || !container.current) return;
|
||||
let dispose: (() => void) | undefined;
|
||||
let abort = () => {};
|
||||
let objectUrl = "";
|
||||
let alive = true;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting || dispose) return;
|
||||
observer.disconnect();
|
||||
dispose = queuePreview((release) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 18000);
|
||||
setState("loading");
|
||||
// Fetch is abortable, so leaving a page also stops in-flight previews.
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
const target =
|
||||
parsed.hostname === "scqnycdn.jiansun.vip" && !parsed.search
|
||||
? `${url}?imageView2/2/w/192/q/72/format/webp`
|
||||
: buildImageProxyUrl(url, {
|
||||
width: 192,
|
||||
quality: 72,
|
||||
format: "webp",
|
||||
delivery: "proxy",
|
||||
});
|
||||
fetch(target, { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`预览请求失败(HTTP ${response.status})`);
|
||||
const blob = await response.blob();
|
||||
if (!blob.type.startsWith("image/")) throw new Error("Not an image");
|
||||
if (alive) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setSource(objectUrl);
|
||||
setState("ready");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (alive) {
|
||||
setFailure(
|
||||
error.name === "AbortError" ? "预览加载超时" : error.message || "预览加载失败",
|
||||
);
|
||||
setState("error");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeout);
|
||||
release();
|
||||
});
|
||||
abort = () => {
|
||||
controller.abort();
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
});
|
||||
},
|
||||
{ rootMargin: "80px" },
|
||||
);
|
||||
observer.observe(container.current);
|
||||
return () => {
|
||||
alive = false;
|
||||
observer.disconnect();
|
||||
abort();
|
||||
dispose?.();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [url, video, retired, attempt]);
|
||||
return (
|
||||
<div
|
||||
ref={container}
|
||||
className="relative flex h-16 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted"
|
||||
data-preview-state={state}
|
||||
>
|
||||
{video ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="查看视频详情"
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<Video className="size-5 text-muted-foreground" aria-label="视频,点击查看后播放" />
|
||||
</button>
|
||||
) : retired ? (
|
||||
<span className="text-center text-xs text-muted-foreground">
|
||||
旧存储
|
||||
<br />
|
||||
已停用
|
||||
</span>
|
||||
) : source && state === "ready" ? (
|
||||
<button type="button" aria-label="查看图片详情" className="h-full w-full" onClick={onOpen}>
|
||||
<img
|
||||
src={source}
|
||||
alt="生成结果预览"
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => {
|
||||
setFailure("图片内容无法解码");
|
||||
setState("error");
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
) : state === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
title={`${failure},点击重试`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAttempt((x) => x + 1);
|
||||
}}
|
||||
>
|
||||
<ImageOff className="size-4" />
|
||||
加载失败
|
||||
</button>
|
||||
) : (
|
||||
<RotateCw
|
||||
className={`size-4 text-muted-foreground ${state === "loading" ? "motion-safe:animate-spin" : ""}`}
|
||||
aria-label={state === "loading" ? "正在加载预览" : "等待进入可见区域"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
components/admin/model-health-panel.tsx
Normal file
191
components/admin/model-health-panel.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { adminFetcher } from "./media-records-panel";
|
||||
import { formatRecordTime } from "@/lib/admin/record-time";
|
||||
type Event = {
|
||||
id: string;
|
||||
modelDisplayName: string;
|
||||
modelRuntimeId: string;
|
||||
providerName: string;
|
||||
eventType: string;
|
||||
createdAt: string;
|
||||
errorMessage: string;
|
||||
elapsedMs: number | null;
|
||||
consecutiveFailures: number;
|
||||
autoDisabled: boolean;
|
||||
};
|
||||
export function ModelHealthPanel({
|
||||
providers,
|
||||
models,
|
||||
initialModelId = "",
|
||||
}: {
|
||||
initialModelId?: string;
|
||||
providers: Array<{ id: string; name: string }>;
|
||||
models: Array<{ id: string; displayName: string; providerId: string }>;
|
||||
}) {
|
||||
const [filters, setFilters] = useState({
|
||||
providerId: "",
|
||||
modelId: initialModelId,
|
||||
eventType: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const query = new URLSearchParams({ ...filters, page: String(page), limit: "20" });
|
||||
const { data, error, isLoading, isValidating, mutate } = useSWR<{
|
||||
events: Event[];
|
||||
hasMore: boolean;
|
||||
}>(`/api/admin/ai-model-health?${query}`, adminFetcher, {
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const update = (change: Partial<typeof filters>) => {
|
||||
setFilters((f) => ({ ...f, ...change }));
|
||||
setPage(1);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">运行健康</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
按上游和模型追踪故障,查看完整原因与发生时间。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{[
|
||||
["providerId", "全部上游", providers.map((p) => [p.id, p.name])],
|
||||
[
|
||||
"modelId",
|
||||
"全部模型",
|
||||
models
|
||||
.filter((m) => !filters.providerId || m.providerId === filters.providerId)
|
||||
.map((m) => [m.id, m.displayName]),
|
||||
],
|
||||
[
|
||||
"eventType",
|
||||
"全部错误",
|
||||
[
|
||||
["quota", "额度 / 余额"],
|
||||
["timeout", "超时"],
|
||||
["unavailable", "不可用"],
|
||||
["invalid_model", "模型不可用"],
|
||||
["download_error", "结果下载失败"],
|
||||
["error", "其他错误"],
|
||||
["success", "成功"],
|
||||
],
|
||||
],
|
||||
].map(([key, label, options]) => (
|
||||
<select
|
||||
key={key as string}
|
||||
aria-label={label as string}
|
||||
className="h-10 max-w-64 rounded-md border bg-background px-3 text-sm"
|
||||
value={filters[key as keyof typeof filters]}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
[key as string]: e.target.value,
|
||||
...(key === "providerId" ? { modelId: "" } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">{label as string}</option>
|
||||
{(options as string[][]).map(([value, text]) => (
|
||||
<option key={value} value={value}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
<Input
|
||||
aria-label="故障开始日期"
|
||||
className="h-10 w-40"
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={(e) => update({ startDate: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
aria-label="故障结束日期"
|
||||
className="h-10 w-40"
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={(e) => update({ endDate: e.target.value })}
|
||||
/>
|
||||
<Button variant="outline" disabled={isValidating} onClick={() => mutate()}>
|
||||
刷新日志
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="p-6 text-sm text-destructive">
|
||||
日志加载失败,请点击刷新重试。
|
||||
</p>
|
||||
) : isLoading ? (
|
||||
<p role="status" className="p-6 text-sm">
|
||||
正在加载故障日志…
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y rounded-xl border">
|
||||
{data?.events.map((event) => (
|
||||
<div key={event.id} className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<strong className="font-semibold">
|
||||
{event.modelDisplayName || event.modelRuntimeId || "已删除模型"}
|
||||
</strong>
|
||||
<span className="text-muted-foreground">{event.providerName || "未知上游"}</span>
|
||||
<span className="rounded bg-muted px-2 py-1 text-xs">
|
||||
{{
|
||||
quota: "额度 / 余额",
|
||||
timeout: "超时",
|
||||
unavailable: "不可用",
|
||||
error: "服务错误",
|
||||
invalid_model: "模型不可用",
|
||||
download_error: "结果下载失败",
|
||||
success: "成功",
|
||||
}[event.eventType] || event.eventType}
|
||||
</span>
|
||||
<time className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||||
{formatRecordTime(event.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm">
|
||||
{event.errorMessage || "未记录详细原因"}
|
||||
</p>
|
||||
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
||||
<span>连续失败 {event.consecutiveFailures} 次</span>
|
||||
{event.elapsedMs !== null && (
|
||||
<span>耗时 {Math.round(event.elapsedMs / 1000)} 秒</span>
|
||||
)}
|
||||
{event.autoDisabled && <span>已自动停用</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!data?.events.length && (
|
||||
<p className="p-12 text-center text-sm text-muted-foreground">该范围内没有故障记录。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
第 {page} 页{isValidating && " · 正在更新…"}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page === 1 || isValidating}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!data?.hasMore || isValidating}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
326
components/admin/model-management-list.tsx
Normal file
326
components/admin/model-management-list.tsx
Normal file
@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { AiModel } from "@/lib/db/schema";
|
||||
import {
|
||||
is3DModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
isImageGenerationModelDescriptor,
|
||||
} from "@/lib/ai/model-classification";
|
||||
|
||||
type Model = AiModel & { isAgentModel?: boolean; isMultimodal?: boolean };
|
||||
type Provider = { id: string; name: string; type: string; isEnabled: boolean };
|
||||
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm";
|
||||
export function ModelManagementList({
|
||||
models,
|
||||
providers,
|
||||
error,
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRestore,
|
||||
onRefresh,
|
||||
onHealth,
|
||||
}: {
|
||||
models: Model[];
|
||||
providers: Provider[];
|
||||
error?: Error;
|
||||
loading: boolean;
|
||||
onEdit: (model: Model) => Promise<void>;
|
||||
onDelete: (id: string) => void;
|
||||
onRestore: (model: Model) => void;
|
||||
onRefresh: () => void;
|
||||
onHealth?: (id: string) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [status, setStatus] = useState("enabled");
|
||||
const [kind, setKind] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [opening, setOpening] = useState<string | null>(null);
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
models
|
||||
.map((model) => {
|
||||
const upstream = providers.find((p) => p.id === model.providerId);
|
||||
const descriptor = { ...model, providerType: upstream?.type };
|
||||
const type = is3DModelDescriptor(descriptor)
|
||||
? "3d"
|
||||
: isVideoModelDescriptor(descriptor)
|
||||
? "video"
|
||||
: isImageGenerationModelDescriptor(descriptor)
|
||||
? "image"
|
||||
: "chat";
|
||||
return { model, upstream, type };
|
||||
})
|
||||
.filter(({ model, upstream, type }) => {
|
||||
const text =
|
||||
`${model.displayName} ${model.modelId} ${upstream?.name || ""}`.toLowerCase();
|
||||
return (
|
||||
text.includes(search.trim().toLowerCase()) &&
|
||||
(!provider || model.providerId === provider) &&
|
||||
(!kind || kind === type) &&
|
||||
(status === "all" ||
|
||||
(status === "enabled" && model.isEnabled) ||
|
||||
(status === "disabled" && !model.isEnabled) ||
|
||||
(status === "unhealthy" && model.healthStatus !== "healthy") ||
|
||||
(status === "visible" && !model.isHidden))
|
||||
);
|
||||
}),
|
||||
[models, providers, search, provider, status, kind],
|
||||
);
|
||||
const pages = Math.max(1, Math.ceil(rows.length / 25));
|
||||
const currentPage = Math.min(page, pages);
|
||||
const change = (fn: () => void) => {
|
||||
fn();
|
||||
setPage(1);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="relative min-w-56 flex-1">
|
||||
<Search className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-10 pl-9"
|
||||
aria-label="搜索模型"
|
||||
placeholder="搜索模型名称、ID 或上游"
|
||||
value={search}
|
||||
onChange={(e) => change(() => setSearch(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
aria-label="模型上游"
|
||||
className={selectClass}
|
||||
value={provider}
|
||||
onChange={(e) => change(() => setProvider(e.target.value))}
|
||||
>
|
||||
<option value="">全部上游</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="模型类型"
|
||||
className={selectClass}
|
||||
value={kind}
|
||||
onChange={(e) => change(() => setKind(e.target.value))}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="image">生图</option>
|
||||
<option value="video">视频</option>
|
||||
<option value="chat">对话</option>
|
||||
<option value="3d">3D / CAD</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="模型状态"
|
||||
className={selectClass}
|
||||
value={status}
|
||||
onChange={(e) => change(() => setStatus(e.target.value))}
|
||||
>
|
||||
<option value="enabled">已启用</option>
|
||||
<option value="all">全部模型</option>
|
||||
<option value="disabled">已停用</option>
|
||||
<option value="unhealthy">运行异常</option>
|
||||
<option value="visible">前台可见</option>
|
||||
</select>
|
||||
<Button variant="outline" onClick={onRefresh} aria-label="刷新模型">
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loading
|
||||
? "正在加载模型…"
|
||||
: `筛选结果 ${rows.length} 个 · 已启用 ${models.filter((m) => m.isEnabled).length} 个 · 全部 ${models.length} 个`}
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
第 {currentPage} / {pages} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === pages}
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<div role="alert" className="rounded-xl border p-6 text-sm">
|
||||
模型加载失败。
|
||||
<Button variant="outline" onClick={onRefresh}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div role="status" className="h-48 rounded-xl bg-muted motion-safe:animate-pulse" />
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full min-w-[850px] text-left text-sm">
|
||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
{["模型", "上游 / 类型", "启用", "前台展示", "运行健康", "操作"].map((x) => (
|
||||
<th key={x} className="px-4 py-3 font-medium">
|
||||
{x}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{rows
|
||||
.slice((currentPage - 1) * 25, currentPage * 25)
|
||||
.map(({ model, upstream, type }) => (
|
||||
<tr key={model.id} className="hover:bg-muted/30">
|
||||
<td className="max-w-64 px-4 py-4">
|
||||
<div className="font-medium">{model.displayName}</div>
|
||||
<div
|
||||
className="mt-1 truncate text-xs text-muted-foreground"
|
||||
title={model.modelId}
|
||||
>
|
||||
{model.modelId}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{model.isDefault && <span>默认模型</span>}
|
||||
{model.isDefaultTextEditModel && <span>文字编辑默认</span>}
|
||||
{model.isAgentModel && <span>Agent</span>}
|
||||
{model.isMultimodal && <span>多模态</span>}
|
||||
{model.isSubModel && <span>子模型</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{upstream?.name || "未知上游"}
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{{ image: "生图", video: "视频", chat: "对话", "3d": "3D / CAD" }[type]}
|
||||
{upstream && !upstream.isEnabled ? " · 上游已停用" : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-4">
|
||||
<span
|
||||
className={`rounded-md px-2 py-1 text-xs ${model.isEnabled ? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{model.isEnabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{model.isHidden || model.isSubModel ? "隐藏" : "可见"}
|
||||
</td>
|
||||
<td className="max-w-72 px-4 py-4">
|
||||
<span
|
||||
className={
|
||||
model.healthStatus !== "healthy"
|
||||
? "text-amber-700 dark:text-amber-300"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{model.healthStatus === "auto_disabled"
|
||||
? "自动停用"
|
||||
: model.healthStatus === "degraded"
|
||||
? "观察中"
|
||||
: "正常"}
|
||||
</span>
|
||||
{model.healthConsecutiveFailures > 0 && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
连续失败 {model.healthConsecutiveFailures} 次
|
||||
</span>
|
||||
)}
|
||||
{model.healthLastError && (
|
||||
<details className="mt-2 text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
查看最近原因
|
||||
</summary>
|
||||
<p className="mt-2 whitespace-pre-wrap break-words">
|
||||
{model.healthLastError}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 block text-xs underline underline-offset-4"
|
||||
onClick={() => onHealth?.(model.id)}
|
||||
>
|
||||
故障记录
|
||||
</button>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{model.excludeFromHealthBan && "不自动封禁 "}
|
||||
{model.excludeFromFailover && "不参与迁移"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={opening !== null}
|
||||
onClick={async () => {
|
||||
setOpening(model.id);
|
||||
try {
|
||||
await onEdit(model);
|
||||
} finally {
|
||||
setOpening(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{opening === model.id ? "加载中…" : "编辑"}
|
||||
</Button>
|
||||
{model.healthStatus === "auto_disabled" && (
|
||||
<Button variant="outline" size="sm" onClick={() => onRestore(model)}>
|
||||
恢复
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => onDelete(model.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && (
|
||||
<p className="p-12 text-center text-sm text-muted-foreground">没有符合条件的模型。</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
每页 25 个 · 第 {currentPage} / {pages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
aria-label="上一页模型"
|
||||
variant="outline"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setPage(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="下一页模型"
|
||||
variant="outline"
|
||||
disabled={currentPage === pages}
|
||||
onClick={() => setPage(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
lib/admin/media-records.ts
Normal file
83
lib/admin/media-records.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
|
||||
export function parseMediaQuery(params: URLSearchParams) {
|
||||
const integer = (key: string, fallback: number, max: number) => {
|
||||
const value = Number(params.get(key));
|
||||
return Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback;
|
||||
};
|
||||
const date = (key: string) => {
|
||||
const value = params.get(key) || "";
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
||||
};
|
||||
return {
|
||||
page: integer("page", 1, 100000),
|
||||
pageSize: integer("pageSize", 40, 100),
|
||||
search: (params.get("search") || "").trim().slice(0, 128),
|
||||
type: ["image", "video"].includes(params.get("type") || "") ? params.get("type")! : "all",
|
||||
model: params.get("model") || "",
|
||||
featured: params.get("featured") === "true",
|
||||
startDate: date("startDate"),
|
||||
endDate: date("endDate"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mediaRecordsQuery(params: ReturnType<typeof parseMediaQuery>) {
|
||||
const conditions: SQL[] = [sql`true`];
|
||||
if (params.search)
|
||||
conditions.push(
|
||||
sql`exists (select 1 from "User" u where u.id=m."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
|
||||
);
|
||||
if (params.startDate) conditions.push(sql`m."createdAt" >= ${params.startDate}::date`);
|
||||
if (params.endDate)
|
||||
conditions.push(sql`m."createdAt" < ${params.endDate}::date + interval '1 day'`);
|
||||
if (params.model) conditions.push(sql`m."modelId" = ${params.model}`);
|
||||
const where = sql.join(conditions, sql` and `);
|
||||
const videoFilters: SQL[] = [sql`true`];
|
||||
if (params.startDate)
|
||||
videoFilters.push(sql`v."createdAt">=${params.startDate}::date - interval '8 hours'`);
|
||||
if (params.endDate)
|
||||
videoFilters.push(sql`v."createdAt"<${params.endDate}::date+interval '16 hours'`);
|
||||
if (params.model) videoFilters.push(sql`v."video_metadata"->>'model'=${params.model}`);
|
||||
if (params.search)
|
||||
videoFilters.push(
|
||||
sql`exists (select 1 from "User" u where u.id=c."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`,
|
||||
);
|
||||
return sql`
|
||||
with video_candidates as materialized (
|
||||
select v.id, v."chatId", v."createdAt" + interval '8 hours' as "createdAt", v."video_metadata", c."userId",
|
||||
coalesce(nullif(v."permanent_video_url", ''), nullif(v."temporary_video_url", ''),
|
||||
(regexp_match(t.body, '<video[^>]+src="([^"]+)"', 'i'))[1],
|
||||
(regexp_match(t.body, '(https?://[^[:space:]"<>]+\\.mp4[^[:space:]"<>)]*)', 'i'))[1]) as url
|
||||
from "Message_v2" v join "Chat" c on c.id=v."chatId"
|
||||
cross join lateral (select case when jsonb_typeof(v.parts::jsonb)='array'
|
||||
then (select string_agg(p->>'text', E'\\n') from jsonb_array_elements(v.parts::jsonb) p)
|
||||
else v.parts::jsonb #>> '{}' end as body) t
|
||||
where ${params.type !== "image" && !params.featured} and v.role='assistant'
|
||||
and ${sql.join(videoFilters, sql` and `)}
|
||||
and (v."permanent_video_url" is not null or v."temporary_video_url" is not null or v.parts::text ilike '%<video%' or v.parts::text ilike '%.mp4%')
|
||||
), media as not materialized (
|
||||
select g.id, 'image'::text as kind, g."createdAt", g."userId", g."modelId", null::text as url
|
||||
from "GeneratedImage" g where ${params.type !== "video"} and (${!params.featured} or g."isFeatured")
|
||||
union all
|
||||
select id, 'video', "createdAt", "userId", "video_metadata"->>'model', url
|
||||
from video_candidates where url is not null
|
||||
), filtered as not materialized (select * from media m where ${where}),
|
||||
totals as (select count(*)::int as total, count(*) filter(where kind='image')::int as images, count(*) filter(where kind='video')::int as videos from filtered),
|
||||
page_rows as (
|
||||
select * from filtered order by "createdAt" desc, kind, id desc
|
||||
limit ${params.pageSize} offset ${(params.page - 1) * params.pageSize}
|
||||
)
|
||||
select t.total, t.images as "totalImages", t.videos as "totalVideos",
|
||||
coalesce((select jsonb_agg(jsonb_build_object(
|
||||
'id', case when p.kind='video' then 'video-'||p.id::text else p.id::text end,
|
||||
'recordType',p.kind,'userId',p."userId",'userName',u.name,'userEmail',u.email,'userDepartment',u.department,
|
||||
'createdAt',p."createdAt",'modelId',p."modelId",'chatId',coalesce(g."chatId",v."chatId"),
|
||||
'thumbnailUrl',coalesce(p.url,g."thumbnailUrl"),'originalUrl',coalesce(p.url,g."originalUrl"),'videoUrl',p.url,
|
||||
'prompt',coalesce(g.prompt,v."video_metadata"->>'prompt'),'imageSize',coalesce(g."imageSize",v."video_metadata"->>'resolution'),
|
||||
'isFeatured',coalesce(g."isFeatured",false),'featuredCategory',g."featuredCategory",'featuredAt',g."featuredAt" + interval '8 hours'
|
||||
) order by p."createdAt" desc,p.kind,p.id desc)
|
||||
from page_rows p left join "User" u on u.id=p."userId"
|
||||
left join "GeneratedImage" g on p.kind='image' and g.id=p.id
|
||||
left join "Message_v2" v on p.kind='video' and v.id=p.id),'[]'::jsonb) as records
|
||||
from totals t`;
|
||||
}
|
||||
31
lib/admin/preview-queue.ts
Normal file
31
lib/admin/preview-queue.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// One queue for the records screen: visible previews get four network slots.
|
||||
const waiting: Array<() => void> = [];
|
||||
let active = 0;
|
||||
function pump() {
|
||||
while (active < 4 && waiting.length) waiting.shift()?.();
|
||||
}
|
||||
export function queuePreview(start: (release: () => void) => void) {
|
||||
let released = false;
|
||||
let started = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (started) active--;
|
||||
else {
|
||||
const index = waiting.indexOf(run);
|
||||
if (index >= 0) waiting.splice(index, 1);
|
||||
}
|
||||
pump();
|
||||
};
|
||||
const run = () => {
|
||||
started = true;
|
||||
active++;
|
||||
start(release);
|
||||
};
|
||||
waiting.push(run);
|
||||
pump();
|
||||
// Active fetches release their slot in finally, after abort has settled.
|
||||
return () => {
|
||||
if (!started) release();
|
||||
};
|
||||
}
|
||||
7
lib/admin/record-time.ts
Normal file
7
lib/admin/record-time.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export function formatRecordTime(value: string) {
|
||||
// The admin API normalizes each storage convention to Beijing wall-clock time.
|
||||
return value
|
||||
.replace("T", " ")
|
||||
.replace(/(?:\.\d+)?Z$/, "")
|
||||
.slice(0, 16);
|
||||
}
|
||||
@ -132,6 +132,7 @@
|
||||
"form-data": "^4.0.5",
|
||||
"framer-motion": "^11.18.2",
|
||||
"geist": "^1.3.1",
|
||||
"gsap": "3.14.2",
|
||||
"heic-convert": "^2.1.0",
|
||||
"immer": "^11.1.3",
|
||||
"is-hotkey": "^0.2.0",
|
||||
|
||||
@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: [
|
||||
/routes\/admin-media-loading\.test\.ts/,
|
||||
/routes\/video-canvas-production\.test\.ts/,
|
||||
/routes\/video-canvas-media-import\.test\.ts/,
|
||||
/routes\/video-canvas-toolbar-jobs\.test\.ts/,
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -234,6 +234,9 @@ importers:
|
||||
geist:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
|
||||
gsap:
|
||||
specifier: 3.14.2
|
||||
version: 3.14.2
|
||||
heic-convert:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
@ -460,6 +463,9 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
gsap@3.14.2:
|
||||
resolution: {integrity: sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==}
|
||||
|
||||
'@ai-sdk/gateway@1.0.15':
|
||||
resolution: {integrity: sha512-xySXoQ29+KbGuGfmDnABx+O6vc7Gj7qugmj1kGpn0rW0rQNn6UKUuvscKMzWyv1Uv05GyC1vqHq8ZhEOLfXscQ==}
|
||||
engines: {node: '>=18'}
|
||||
@ -6967,6 +6973,8 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
gsap@3.14.2: {}
|
||||
|
||||
'@ai-sdk/gateway@1.0.15(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
|
||||
10
scripts/sql/admin-media-record-indexes.sql
Normal file
10
scripts/sql/admin-media-record-indexes.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- Run outside a transaction. Concurrent creation keeps normal inserts available.
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "GeneratedImage_created_id_idx"
|
||||
ON public."GeneratedImage" ("createdAt" DESC, id DESC);
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS "Message_v2_video_created_id_idx"
|
||||
ON public."Message_v2" ("createdAt" DESC, id DESC)
|
||||
WHERE role = 'assistant' AND (
|
||||
permanent_video_url IS NOT NULL OR temporary_video_url IS NOT NULL
|
||||
OR parts::text ILIKE '%<video%' OR parts::text ILIKE '%.mp4%'
|
||||
);
|
||||
45
tests/routes/admin-media-loading.test.ts
Normal file
45
tests/routes/admin-media-loading.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { queuePreview } from "../../lib/admin/preview-queue";
|
||||
import { formatRecordTime } from "../../lib/admin/record-time";
|
||||
import { parseMediaQuery } from "../../lib/admin/media-records";
|
||||
|
||||
test("pagination rejects invalid values and bounds a requested page size", () => {
|
||||
expect(parseMediaQuery(new URLSearchParams("page=-1&pageSize=200"))).toMatchObject({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
expect(
|
||||
parseMediaQuery(new URLSearchParams("page=2&pageSize=40&search=%E9%99%88%E9%BE%99")),
|
||||
).toMatchObject({ page: 2, pageSize: 40, search: "陈龙" });
|
||||
expect(parseMediaQuery(new URLSearchParams("page=NaN&pageSize=0"))).toMatchObject({
|
||||
page: 1,
|
||||
pageSize: 40,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalized Beijing record times do not receive a second timezone offset", () => {
|
||||
expect(formatRecordTime("2026-11-03T23:55:00.123Z")).toBe("2026-11-03 23:55");
|
||||
expect(formatRecordTime("2026-11-03T17:12:00.123456")).toBe("2026-11-03 17:12");
|
||||
});
|
||||
|
||||
test("leaving a page cancels waiting previews and retains active slots until fetch settles", () => {
|
||||
const started: number[] = [];
|
||||
const releases: Array<() => void> = [];
|
||||
const cancel = Array.from({ length: 7 }, (_, index) =>
|
||||
queuePreview((release) => {
|
||||
started.push(index);
|
||||
releases[index] = release;
|
||||
}),
|
||||
);
|
||||
expect(started).toEqual([0, 1, 2, 3]);
|
||||
cancel[0]();
|
||||
cancel[4]();
|
||||
expect(started).toEqual([0, 1, 2, 3]);
|
||||
releases[0]();
|
||||
expect(started).toEqual([0, 1, 2, 3, 5]);
|
||||
releases[0]();
|
||||
expect(started).toHaveLength(5);
|
||||
releases[1]();
|
||||
expect(started).toEqual([0, 1, 2, 3, 5, 6]);
|
||||
for (const release of releases) release?.();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user