feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选

This commit is contained in:
ddCat
2026-09-08 17:26:34 +08:00
parent 95b004ec0f
commit 068a8daea6
56 changed files with 3316 additions and 274 deletions

View File

@@ -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 {

View File

@@ -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 />
)}

View File

@@ -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" />}

View 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>;
}