feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选
This commit is contained in:
@@ -251,7 +251,6 @@ export default function TasksPage() {
|
||||
const [detail, logs] = await Promise.all([queryGenerationTask(task.id), listTaskLogs(task.id)]);
|
||||
setDetailTask(detail);
|
||||
setTaskLogs(logs);
|
||||
if (await syncGenerationTaskToCanvasStore(detail)) message.success("已同步到画布");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "任务详情加载失败");
|
||||
} finally {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/g
|
||||
import { statusLabel } from "@/lib/generation-task-display";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
|
||||
import { TaskVideoThumbnail } from "./task-video-thumbnail";
|
||||
|
||||
export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void }) {
|
||||
const isActive = task.status === "queued" || task.status === "running";
|
||||
@@ -22,7 +23,7 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
|
||||
{thumbnailUrl ? (
|
||||
<MediaPreview src={thumbnailUrl} kind="image" loading="lazy" className="h-full w-full object-cover" />
|
||||
) : isVideo && task.previewUrl ? (
|
||||
<span className="task-video-poster-placeholder"><Video /><small>视频预览</small></span>
|
||||
<TaskVideoThumbnail src={task.previewUrl} />
|
||||
) : (
|
||||
<Icon />
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
|
||||
import { TaskVideoThumbnail } from "./task-video-thumbnail";
|
||||
|
||||
export function TaskListRow({
|
||||
task,
|
||||
@@ -122,7 +123,7 @@ function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen:
|
||||
aria-label={previewUnavailable ? "预览不可用,素材可能已删除" : isVideo ? "放大预览生成视频" : "放大预览生成图片"}
|
||||
title={previewUnavailable ? "预览不可用,素材可能已删除" : undefined}
|
||||
>
|
||||
{thumbnailUrl ? <MediaPreview src={thumbnailUrl} kind="image" width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(thumbnailUrl)} /> : <span className="task-video-poster-placeholder"><Video className="size-4" /><small>视频</small></span>}
|
||||
{thumbnailUrl ? <MediaPreview src={thumbnailUrl} kind="image" width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(thumbnailUrl)} /> : isVideo ? <TaskVideoThumbnail src={task.previewUrl} /> : <ImageIcon className="size-4" />}
|
||||
{!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" />}
|
||||
|
||||
41
web/src/pages/tasks/task-video-thumbnail.tsx
Normal file
41
web/src/pages/tasks/task-video-thumbnail.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Video } from "lucide-react";
|
||||
import { MediaPreview } from "@/components/media-preview";
|
||||
import { captureVideoPoster } from "@/lib/video-poster";
|
||||
|
||||
/** 只解码进入视口的视频,复用串行解码队列;缩略图随组件释放,不写入用户资产。 */
|
||||
export function TaskVideoThumbnail({ src }: { src: string }) {
|
||||
const container = useRef<HTMLSpanElement>(null);
|
||||
const [poster, setPoster] = useState<{ source: string; url: string }>();
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let objectUrl = "";
|
||||
let started = false;
|
||||
const capture = () => {
|
||||
if (started || controller.signal.aborted) return;
|
||||
started = true;
|
||||
void captureVideoPoster(src, { signal: controller.signal, maxWidth: 400 }).then((result) => {
|
||||
if (controller.signal.aborted || !result.poster) return;
|
||||
objectUrl = URL.createObjectURL(result.poster);
|
||||
setPoster({ source: src, url: objectUrl });
|
||||
}).catch((error) => {
|
||||
if (!controller.signal.aborted) console.warn("任务视频首帧提取失败", error instanceof Error ? error.name : "UnknownError");
|
||||
});
|
||||
};
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
observer.disconnect();
|
||||
capture();
|
||||
}
|
||||
});
|
||||
if (container.current) observer.observe(container.current);
|
||||
return () => {
|
||||
controller.abort();
|
||||
observer.disconnect();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [src]);
|
||||
return <span ref={container} className="block h-full w-full">
|
||||
{poster?.source === src ? <MediaPreview src={poster.url} kind="image" className="h-full w-full object-cover" /> : <span className="task-video-poster-placeholder"><Video /><small>视频预览</small></span>}
|
||||
</span>;
|
||||
}
|
||||
Reference in New Issue
Block a user