生成记录改为数据库分页,每页 40 条,默认最近 7 天;可见预览限制为 4 路并发并使用 192px 缩略图,翻页取消旧请求,视频在详情中按需播放。保留精选、分类和原图操作,新增画布任务失败记录,统一时间显示。 模型管理拆分模型、上游和运行健康视图,提供搜索、筛选、分页和完整故障详情;列表使用摘要,编辑按需获取完整配置。后台按当前设置页加载,模型 GET 不再自动修改健康状态,98K 同步先显示变更预览。 验证:600 项现有辅助测试和 3 项新增回归测试通过,TypeScript 和本次 API/组件 lint 通过;浏览器验证请求峰值 4、翻页、搜索、失败重试、详情、模型筛选与 390px 窄屏。只读生产查询:当天页约 29ms,历史视频页 122ms,第 7800 页 739ms。两个并发索引已在生产建成,SQL 随代码保留。 已知检查状态:全仓 lint 存量 1004 项诊断使 check:quality 停在 lint 阶段;日志、品牌、CI、环境、凭据、元数据、测试与制品检查单独通过。
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|