fix(web): 任务中心 - 资源失效时显示预览占位 (#286)
This commit is contained in:
@@ -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 ."
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
55
web/src/pages/tasks/task-media-preview.tsx
Normal file
55
web/src/pages/tasks/task-media-preview.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
|
||||||
/* 项目概览:原本是「有边框的浅色盒子 + 一堆等重小字」,层级读不出来。
|
/* 项目概览:原本是「有边框的浅色盒子 + 一堆等重小字」,层级读不出来。
|
||||||
改成:主区留白撑开、标题字阶拉高、右栏用背景色而非边框分隔、流水线行给足行高。 */
|
改成:主区留白撑开、标题字阶拉高、右栏用背景色而非边框分隔、流水线行给足行高。 */
|
||||||
|
|||||||
29
web/test/task-media-fallback.test.ts
Normal file
29
web/test/task-media-fallback.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user