ai-chatbot/components/admin/media-thumbnail.tsx
WuYongJie af8189908d 优化后台生成记录分页与模型管理
生成记录改为数据库分页,每页 40 条,默认最近 7 天;可见预览限制为 4 路并发并使用 192px 缩略图,翻页取消旧请求,视频在详情中按需播放。保留精选、分类和原图操作,新增画布任务失败记录,统一时间显示。

模型管理拆分模型、上游和运行健康视图,提供搜索、筛选、分页和完整故障详情;列表使用摘要,编辑按需获取完整配置。后台按当前设置页加载,模型 GET 不再自动修改健康状态,98K 同步先显示变更预览。

验证:600 项现有辅助测试和 3 项新增回归测试通过,TypeScript 和本次 API/组件 lint 通过;浏览器验证请求峰值 4、翻页、搜索、失败重试、详情、模型筛选与 390px 窄屏。只读生产查询:当天页约 29ms,历史视频页 122ms,第 7800 页 739ms。两个并发索引已在生产建成,SQL 随代码保留。

已知检查状态:全仓 lint 存量 1004 项诊断使 check:quality 停在 lint 阶段;日志、品牌、CI、环境、凭据、元数据、测试与制品检查单独通过。
2026-11-03 17:41:34 +08:00

148 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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