生成记录同时包含模型内部 ID 和上游模型 ID,名称显示和筛选统一关联模型目录。图片代理处理源响应体超时与传输中断,返回明确的 504/502;浏览器取消请求同步停止上游下载。小尺寸预览允许 45 秒读取窗口,前端保留 4 路并发和可见区域加载,失败提示展示接口原因。原图下载保持字节不变。 验证:模型 ID 筛选的生产只读查询返回正确记录,浏览器夹具验证内部 ID 名称和峰值并发 4;15 项相关测试覆盖取消、响应体超时、原图字节与 ICC 保留。TypeScript 与改动文件 lint 通过。
604 lines
23 KiB
TypeScript
604 lines
23 KiB
TypeScript
"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<{ id: string; 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 recordModelNames = new Map(modelNames);
|
||
for (const model of options?.models || []) recordModelNames.set(model.id, 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 || ""}>
|
||
{recordModelNames.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],
|
||
["模型", recordModelNames.get(selected.modelId || "") || 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>
|
||
);
|
||
}
|