fix(web): 任务中心 - 资源失效时显示预览占位 (#286)

This commit is contained in:
MasterChiefCN
2026-08-21 13:26:27 +08:00
committed by GitHub
parent 8e30d58b1b
commit 81ef033f58
7 changed files with 138 additions and 21 deletions

View File

@@ -7,7 +7,7 @@
"dev": "vite --host 0.0.0.0 --port 3000", "dev": "vite --host 0.0.0.0 --port 3000",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
"start": "vite preview --host 0.0.0.0 --port 3000", "start": "vite preview --host 0.0.0.0 --port 3000",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check ."

View File

@@ -19,6 +19,7 @@ import { listProjects, type ProjectSummary } from "@/services/api/projects";
import { TaskGridCard } from "./task-grid-card"; import { TaskGridCard } from "./task-grid-card";
import { TaskGroupHeader, type TaskGroup } from "./task-group-header"; import { TaskGroupHeader, type TaskGroup } from "./task-group-header";
import { TaskListRow } from "./task-list-row"; import { TaskListRow } from "./task-list-row";
import { TaskMediaPreview } from "./task-media-preview";
import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared"; import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared";
import { TaskStatusFilterBar, type TaskStatusFilter } from "./task-status-filter"; import { TaskStatusFilterBar, type TaskStatusFilter } from "./task-status-filter";
@@ -585,9 +586,16 @@ export default function TasksPage() {
destroyOnHidden destroyOnHidden
className="library-modal task-media-preview-modal" className="library-modal task-media-preview-modal"
> >
{mediaPreview?.kind === "video" {mediaPreview ? (
? <video src={mediaPreview.url} className="max-h-[76vh] w-full bg-black object-contain" controls playsInline preload="metadata" /> <TaskMediaPreview
: mediaPreview ? <img src={mediaPreview.url} alt={mediaPreview.title} className="max-h-[76vh] w-full bg-black object-contain" /> : null} src={mediaPreview.url}
kind={mediaPreview.kind}
alt={mediaPreview.title}
controls={mediaPreview.kind === "video"}
className="max-h-[76vh] w-full bg-black object-contain"
fallbackClassName="task-media-preview-unavailable"
/>
) : null}
</Modal> </Modal>
</> </>
); );
@@ -617,9 +625,20 @@ function TaskResultMedia({ value, taskType }: { value?: string; taskType: string
<div> <div>
<Typography.Text strong>生成结果</Typography.Text> <Typography.Text strong>生成结果</Typography.Text>
<div className="mt-2 grid max-h-[360px] grid-cols-2 gap-2 overflow-auto rounded-lg bg-stone-950 p-2 md:grid-cols-3"> <div className="mt-2 grid max-h-[360px] grid-cols-2 gap-2 overflow-auto rounded-lg bg-stone-950 p-2 md:grid-cols-3">
{urls.map((url, index) => isVideoResult(url, taskType) {urls.map((url, index) => {
? <video key={`${url}-${index}`} src={url} className="aspect-video w-full rounded-md bg-black object-contain" controls preload="metadata" /> const isVideo = isVideoResult(url, taskType);
: <img key={`${url}-${index}`} src={url} alt={`生成结果 ${index + 1}`} className="aspect-square w-full rounded-md bg-black object-contain" />)} return (
<TaskMediaPreview
key={`${url}-${index}`}
src={url}
kind={isVideo ? "video" : "image"}
alt={`生成结果 ${index + 1}`}
controls={isVideo}
className={isVideo ? "task-result-media is-video" : "task-result-media"}
fallbackClassName={isVideo ? "task-result-media is-video" : "task-result-media"}
/>
);
})}
</div> </div>
</div> </div>
); );

View File

@@ -4,6 +4,7 @@ import { Eye, FileText, Image as ImageIcon, RotateCcw, Video, X } from "lucide-r
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error"; import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { statusLabel } from "@/lib/generation-task-display"; import { statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center"; import type { GenerationTask } from "@/services/api/task-center";
import { TaskMediaPreview } from "./task-media-preview";
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared"; 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 }) { export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void; onCancel: () => void }) {
@@ -16,11 +17,7 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { ta
<article className={`task-grid-card${isFailed ? " is-attention" : ""}`}> <article className={`task-grid-card${isFailed ? " is-attention" : ""}`}>
<div className="task-grid-thumb"> <div className="task-grid-thumb">
{task.previewUrl ? ( {task.previewUrl ? (
isVideo ? ( <TaskMediaPreview src={task.previewUrl} kind={isVideo ? "video" : "image"} loading="lazy" className="h-full w-full object-cover" />
<video src={task.previewUrl} muted playsInline preload="metadata" className="h-full w-full object-cover" />
) : (
<img src={task.previewUrl} alt="" loading="lazy" className="h-full w-full object-cover" />
)
) : ( ) : (
<Icon /> <Icon />
)} )}

View File

@@ -1,10 +1,12 @@
import { Button, Tooltip } from "antd"; import { Button, Tooltip } from "antd";
import { Eye, FileText, FolderKanban, Image as ImageIcon, Play, RotateCcw, Video, X } from "lucide-react"; import { Eye, FileText, FolderKanban, Image as ImageIcon, Play, RotateCcw, Video, X } from "lucide-react";
import { useState } from "react";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, statusLabel } from "@/lib/generation-task-display"; import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center"; import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store"; 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"; import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
export function TaskListRow({ export function TaskListRow({
@@ -105,6 +107,8 @@ export function TaskListRow({
function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen: () => void }) { function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen: () => void }) {
const isVideo = task.previewKind === "video"; const isVideo = task.previewKind === "video";
const fallbackVideo = task.type.includes("video"); const fallbackVideo = task.type.includes("video");
const [unavailableUrl, setUnavailableUrl] = useState("");
const previewUnavailable = Boolean(task.previewUrl && unavailableUrl === task.previewUrl);
if (!task.previewUrl) { if (!task.previewUrl) {
const Icon = fallbackVideo ? Video : task.type.includes("image") ? ImageIcon : FileText; const Icon = fallbackVideo ? Video : task.type.includes("image") ? ImageIcon : FileText;
return ( return (
@@ -114,15 +118,20 @@ function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen:
); );
} }
return ( return (
<button type="button" onClick={onOpen} className="task-record-thumb group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={isVideo ? "放大预览生成视频" : "放大预览生成图片"}> <button
{isVideo ? ( type="button"
<video src={task.previewUrl} width={68} height={48} muted playsInline preload="metadata" className="h-full w-full object-cover" /> onClick={onOpen}
) : ( disabled={previewUnavailable}
<img src={task.previewUrl} alt="" width={68} height={48} loading="lazy" className="h-full w-full object-cover" /> className="task-record-thumb group focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)} aria-label={previewUnavailable ? "预览不可用,素材可能已删除" : isVideo ? "放大预览生成视频" : "放大预览生成图片"}
<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"> title={previewUnavailable ? "预览不可用,素材可能已删除" : undefined}
{isVideo ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />} >
</span> <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 || "")} />
{!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" />}
</span>
) : null}
</button> </button>
); );
} }

View File

@@ -0,0 +1,55 @@
import { ImageOff } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
const DEFAULT_UNAVAILABLE_LABEL = "预览不可用,素材可能已删除";
export function TaskMediaPreview({
src,
kind,
alt = "",
className,
fallbackClassName,
fallbackLabel = DEFAULT_UNAVAILABLE_LABEL,
controls = false,
loading,
width,
height,
onUnavailable,
}: {
src: string;
kind: "image" | "video";
alt?: string;
className?: string;
fallbackClassName?: string;
fallbackLabel?: string;
controls?: boolean;
loading?: "eager" | "lazy";
width?: number;
height?: number;
onUnavailable?: () => void;
}) {
const [failedSrc, setFailedSrc] = useState("");
const unavailable = failedSrc === src;
const handleUnavailable = () => {
setFailedSrc(src);
onUnavailable?.();
};
if (unavailable) {
return (
<span className={cn("task-media-unavailable", fallbackClassName)} role="img" aria-label={fallbackLabel} title={fallbackLabel}>
<ImageOff aria-hidden="true" />
<span>{fallbackLabel}</span>
</span>
);
}
if (kind === "video") {
return <video src={src} width={width} height={height} muted={!controls} playsInline controls={controls} preload="metadata" className={className} onError={handleUnavailable} />;
}
return <img src={src} alt={alt} width={width} height={height} loading={loading} className={className} onError={handleUnavailable} />;
}

View File

@@ -4334,6 +4334,10 @@
.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); } .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 固有尺寸计算,避免竖图按原始宽高比把固定缩略图撑出任务行。 */ /* 媒体脱离 Grid 固有尺寸计算,避免竖图按原始宽高比把固定缩略图撑出任务行。 */
.task-record-thumb img, .task-record-thumb video { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; } .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; }
.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 { 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; } .task-record-meta-canvas { display: inline-flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; text-overflow: ellipsis; }
.task-record-meta-canvas svg { flex: 0 0 auto; opacity: .7; } .task-record-meta-canvas svg { flex: 0 0 auto; opacity: .7; }
@@ -4387,6 +4391,7 @@
.task-grid-card.is-attention .task-grid-body { background: color-mix(in srgb, var(--palette-status-error) 5%, transparent); } .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 { 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 > svg { width: 26px; height: 26px; }
.task-grid-thumb .task-media-unavailable > svg { width: 26px; height: 26px; }
.task-grid-thumb img, .task-grid-thumb video { width: 100%; height: 100%; object-fit: cover; } .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-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; } .task-grid-card:hover .task-grid-overlay, .task-grid-card:focus-within .task-grid-overlay { opacity: 1; }
@@ -4411,6 +4416,9 @@
.task-detail-facts { gap: 2px; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); padding: 4px; } .task-detail-facts { gap: 2px; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); padding: 4px; }
.task-detail-fact { border-radius: var(--r-sm); } .task-detail-fact { border-radius: var(--r-sm); }
.task-detail-error { border-radius: var(--r-md); background: color-mix(in srgb, var(--palette-status-error) 8%, transparent); color: color-mix(in srgb, var(--palette-status-error) 78%, var(--foreground)); } .task-detail-error { border-radius: var(--r-md); background: color-mix(in srgb, var(--palette-status-error) 8%, transparent); color: color-mix(in srgb, var(--palette-status-error) 78%, var(--foreground)); }
.task-result-media { aspect-ratio: 1; width: 100%; border-radius: var(--r-md); object-fit: contain; }
.task-result-media.is-video { aspect-ratio: 16 / 9; }
.task-media-preview-unavailable { aspect-ratio: 16 / 9; max-height: 76vh; background: color-mix(in srgb, var(--foreground) 4%, transparent); }
/* 项目概览:原本是「有边框的浅色盒子 + 一堆等重小字」,层级读不出来。 /* 项目概览:原本是「有边框的浅色盒子 + 一堆等重小字」,层级读不出来。
改成:主区留白撑开、标题字阶拉高、右栏用背景色而非边框分隔、流水线行给足行高。 */ 改成:主区留白撑开、标题字阶拉高、右栏用背景色而非边框分隔、流水线行给足行高。 */

View File

@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function source(path: string) {
return readFileSync(resolve(import.meta.dir, path), "utf8");
}
describe("task media fallback", () => {
test("replaces failed image and video elements with an unavailable state", () => {
const preview = source("../src/pages/tasks/task-media-preview.tsx");
expect(preview).toContain("failedSrc === src");
expect(preview).toContain("onError={handleUnavailable}");
expect(preview).toContain("预览不可用,素材可能已删除");
expect(preview).toContain("<ImageOff");
});
test("uses the fallback in list, grid, detail and enlarged previews", () => {
const list = source("../src/pages/tasks/task-list-row.tsx");
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("disabled={previewUnavailable}");
expect(grid).toContain("<TaskMediaPreview");
expect(page.match(/<TaskMediaPreview/g)).toHaveLength(2);
});
});