fix(web): 资源预览 - 请求明细资源失效时显示预览占位

This commit is contained in:
MasterChiefCN
2026-08-21 16:14:32 +08:00
committed by ddCat
parent 16359271f6
commit ce86bb781b
7 changed files with 34 additions and 23 deletions

View File

@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
const DEFAULT_UNAVAILABLE_LABEL = "预览不可用,素材可能已删除";
export function TaskMediaPreview({
export function MediaPreview({
src,
kind,
alt = "",
@@ -40,7 +40,7 @@ export function TaskMediaPreview({
if (unavailable) {
return (
<span className={cn("task-media-unavailable", fallbackClassName)} role="img" aria-label={fallbackLabel} title={fallbackLabel}>
<span className={cn("media-unavailable", fallbackClassName)} role="img" aria-label={fallbackLabel} title={fallbackLabel}>
<ImageOff aria-hidden="true" />
<span>{fallbackLabel}</span>
</span>

View File

@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { PaginationBar } from "@/components/layout/workspace-page";
import { MediaPreview } from "@/components/media-preview";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { exportAdminApiLogs, listAdminApiLogs, type ApiCallLog } from "@/services/api/auth";
import { ApiLogDetailDrawer } from "../components/api-log-detail-drawer";
@@ -83,7 +84,7 @@ export default function LogsPage() {
/>
<ApiLogDetailDrawer logId={detailLogId} onClose={() => setDetailLogId(null)} onLogUpdated={(next) => setLogs((items) => items.map((item) => item.id === next.id ? next : item))} />
<Modal title={mediaPreview?.title || "媒体预览"} open={Boolean(mediaPreview)} width={880} onCancel={() => setMediaPreview(null)} footer={mediaPreview ? <Button icon={<Download className="size-4" />} onClick={() => downloadMedia(mediaPreview.url, mediaPreview.kind)}>下载原文件</Button> : null} destroyOnHidden>
{mediaPreview?.kind === "video" ? <video src={mediaPreview.url} controls playsInline preload="metadata" className="max-h-[72vh] w-full bg-black object-contain" /> : mediaPreview ? <img src={mediaPreview.url} alt={mediaPreview.title} className="max-h-[72vh] w-full bg-black object-contain" /> : null}
{mediaPreview ? <MediaPreview src={mediaPreview.url} kind={mediaPreview.kind} alt={mediaPreview.title} controls={mediaPreview.kind === "video"} className="max-h-[72vh] w-full bg-black object-contain" fallbackClassName="min-h-[360px] rounded-lg bg-black/90 text-white/55" /> : null}
</Modal>
</AdminPageFrame>
);
@@ -100,12 +101,14 @@ function formatCost(log: ApiCallLog) { return `${log.currency || "USD"} ${(log.e
function MediaResult({ log, onPreview }: { log: ApiCallLog; onPreview: (url: string, kind: "image" | "video") => void }) {
const url = log.mediaPreviewUrl;
const kind = log.mediaPreviewKind;
const [unavailableUrl, setUnavailableUrl] = useState("");
if (!url || (kind !== "image" && kind !== "video")) return <span className="text-foreground/30">--</span>;
const previewUnavailable = unavailableUrl === url;
return <div className="flex w-[90px] items-center gap-1.5">
<button type="button" title={`预览${kind === "video" ? "视频" : "图片"}`} className="group relative h-11 w-16 shrink-0 overflow-hidden rounded border border-border/75 bg-black/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => onPreview(url, kind)}>
{kind === "video" ? <video src={url} muted playsInline preload="metadata" className="size-full object-cover" /> : <img src={url} alt="生成结果" loading="lazy" decoding="async" className="size-full object-cover" />}
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition group-hover:bg-black/35 group-hover:opacity-100 group-focus-visible:bg-black/35 group-focus-visible:opacity-100">{kind === "video" ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}</span>
{log.mediaCount > 1 ? <span className="absolute bottom-0.5 right-0.5 rounded-sm bg-black/65 px-1 text-[var(--fs-micro)] leading-4 text-white">{log.mediaCount}</span> : null}
<button type="button" title={previewUnavailable ? "预览不可用,素材可能已删除" : `预览${kind === "video" ? "视频" : "图片"}`} aria-label={previewUnavailable ? "预览不可用,素材可能已删除" : `预览${kind === "video" ? "视频" : "图片"}`} disabled={previewUnavailable} className="group relative h-11 w-16 shrink-0 overflow-hidden rounded border border-border/75 bg-black/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => onPreview(url, kind)}>
<MediaPreview src={url} kind={kind} alt="生成结果" loading="lazy" className="size-full object-cover" fallbackClassName="text-white/55" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(url)} />
{!previewUnavailable ? <span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition group-hover:bg-black/35 group-hover:opacity-100 group-focus-visible:bg-black/35 group-focus-visible:opacity-100">{kind === "video" ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}</span> : null}
{!previewUnavailable && log.mediaCount > 1 ? <span className="absolute bottom-0.5 right-0.5 rounded-sm bg-black/65 px-1 text-[var(--fs-micro)] leading-4 text-white">{log.mediaCount}</span> : null}
</button>
<Button type="text" size="small" className="!size-7 !min-w-7 !p-0" icon={<Download className="size-3.5" />} onClick={() => downloadMedia(url, kind)} title="下载原文件" aria-label="下载原文件" />
</div>;

View File

@@ -3,6 +3,7 @@ import { LayoutGrid, List, Plus, RefreshCw, Search, Trash2 } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { MediaPreview } from "@/components/media-preview";
import { ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
@@ -19,7 +20,6 @@ import { listProjects, type ProjectSummary } from "@/services/api/projects";
import { TaskGridCard } from "./task-grid-card";
import { TaskGroupHeader, type TaskGroup } from "./task-group-header";
import { TaskListRow } from "./task-list-row";
import { TaskMediaPreview } from "./task-media-preview";
import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared";
import { TaskStatusFilterBar, type TaskStatusFilter } from "./task-status-filter";
@@ -587,7 +587,7 @@ export default function TasksPage() {
className="library-modal task-media-preview-modal"
>
{mediaPreview ? (
<TaskMediaPreview
<MediaPreview
src={mediaPreview.url}
kind={mediaPreview.kind}
alt={mediaPreview.title}
@@ -628,7 +628,7 @@ function TaskResultMedia({ value, taskType }: { value?: string; taskType: string
{urls.map((url, index) => {
const isVideo = isVideoResult(url, taskType);
return (
<TaskMediaPreview
<MediaPreview
key={`${url}-${index}`}
src={url}
kind={isVideo ? "video" : "image"}

View File

@@ -1,10 +1,10 @@
import { Button, Tooltip } from "antd";
import { Eye, FileText, Image as ImageIcon, RotateCcw, Video, X } from "lucide-react";
import { MediaPreview } from "@/components/media-preview";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import { TaskMediaPreview } from "./task-media-preview";
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void; onCancel: () => void }) {
@@ -17,7 +17,7 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { ta
<article className={`task-grid-card${isFailed ? " is-attention" : ""}`}>
<div className="task-grid-thumb">
{task.previewUrl ? (
<TaskMediaPreview src={task.previewUrl} kind={isVideo ? "video" : "image"} loading="lazy" className="h-full w-full object-cover" />
<MediaPreview src={task.previewUrl} kind={isVideo ? "video" : "image"} loading="lazy" className="h-full w-full object-cover" />
) : (
<Icon />
)}

View File

@@ -2,11 +2,11 @@ import { Button, Tooltip } from "antd";
import { Eye, FileText, FolderKanban, Image as ImageIcon, Play, RotateCcw, Video, X } from "lucide-react";
import { useState } from "react";
import { MediaPreview } from "@/components/media-preview";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store";
import { TaskMediaPreview } from "./task-media-preview";
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
export function TaskListRow({
@@ -126,7 +126,7 @@ function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen:
aria-label={previewUnavailable ? "预览不可用,素材可能已删除" : isVideo ? "放大预览生成视频" : "放大预览生成图片"}
title={previewUnavailable ? "预览不可用,素材可能已删除" : undefined}
>
<TaskMediaPreview src={task.previewUrl} kind={isVideo ? "video" : "image"} width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(task.previewUrl || "")} />
<MediaPreview src={task.previewUrl} kind={isVideo ? "video" : "image"} width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(task.previewUrl || "")} />
{!previewUnavailable ? (
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition-[background-color,opacity] duration-150 group-hover:bg-black/30 group-hover:opacity-100 group-focus-visible:bg-black/30 group-focus-visible:opacity-100">
{isVideo ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}

View File

@@ -4334,9 +4334,9 @@
.task-record-thumb { position: relative; display: grid; width: 84px; height: 60px; flex: 0 0 auto; place-items: center; overflow: hidden; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 4%, transparent); padding: 0; color: color-mix(in srgb, var(--foreground) 28%, transparent); }
/* 媒体脱离 Grid 固有尺寸计算,避免竖图按原始宽高比把固定缩略图撑出任务行。 */
.task-record-thumb img, .task-record-thumb video { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; }
/* 资源已被删除或失效时,由组件接管原生破图,任务记录本身仍保持可读。 */
.task-media-unavailable { display: flex; width: 100%; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 5px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-tiny); line-height: 1.25; text-align: center; }
.task-media-unavailable > svg { width: 18px; height: 18px; flex: 0 0 auto; }
/* 资源已被删除或失效时,由组件接管原生破图,相关页面仍保持可读。 */
.media-unavailable { display: flex; width: 100%; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 5px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-tiny); line-height: 1.25; text-align: center; }
.media-unavailable > svg { width: 18px; height: 18px; flex: 0 0 auto; }
.task-record-thumb:disabled { cursor: default; }
.task-record-meta { display: flex; min-width: 0; align-items: center; gap: 8px; margin-top: 7px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); white-space: nowrap; }
.task-record-meta-canvas { display: inline-flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; text-overflow: ellipsis; }
@@ -4391,7 +4391,7 @@
.task-grid-card.is-attention .task-grid-body { background: color-mix(in srgb, var(--palette-status-error) 5%, transparent); }
.task-grid-thumb { position: relative; display: grid; aspect-ratio: 16 / 9; place-items: center; overflow: hidden; background: color-mix(in srgb, var(--foreground) 4%, transparent); color: color-mix(in srgb, var(--foreground) 30%, transparent); }
.task-grid-thumb > svg { width: 26px; height: 26px; }
.task-grid-thumb .task-media-unavailable > svg { width: 26px; height: 26px; }
.task-grid-thumb .media-unavailable > svg { width: 26px; height: 26px; }
.task-grid-thumb img, .task-grid-thumb video { width: 100%; height: 100%; object-fit: cover; }
.task-grid-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 6px; background: color-mix(in srgb, var(--background) 58%, transparent); opacity: 0; backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); transition: opacity 200ms ease-out; }
.task-grid-card:hover .task-grid-overlay, .task-grid-card:focus-within .task-grid-overlay { opacity: 1; }

View File

@@ -6,9 +6,9 @@ function source(path: string) {
return readFileSync(resolve(import.meta.dir, path), "utf8");
}
describe("task media fallback", () => {
describe("media fallback", () => {
test("replaces failed image and video elements with an unavailable state", () => {
const preview = source("../src/pages/tasks/task-media-preview.tsx");
const preview = source("../src/components/media-preview.tsx");
expect(preview).toContain("failedSrc === src");
expect(preview).toContain("onError={handleUnavailable}");
@@ -21,9 +21,17 @@ describe("task media fallback", () => {
const grid = source("../src/pages/tasks/task-grid-card.tsx");
const page = source("../src/pages/tasks/index.tsx");
expect(list).toContain("<TaskMediaPreview");
expect(list).toContain("<MediaPreview");
expect(list).toContain("disabled={previewUnavailable}");
expect(grid).toContain("<TaskMediaPreview");
expect(page.match(/<TaskMediaPreview/g)).toHaveLength(2);
expect(grid).toContain("<MediaPreview");
expect(page.match(/<MediaPreview/g)).toHaveLength(2);
});
test("uses the fallback in admin log thumbnails and enlarged previews", () => {
const page = source("../src/pages/admin/logs/logs-page.tsx");
expect(page.match(/<MediaPreview/g)).toHaveLength(2);
expect(page).toContain("disabled={previewUnavailable}");
expect(page).toContain("onUnavailable={() => setUnavailableUrl(url)}");
});
});