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