Files
TrueGrowth/packages/drawnix/src/components/task-queue/DialogTaskList.tsx
jiam 5119ac0ef8
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled
Sync latest TrueGrowth updates
2026-07-08 02:03:18 +08:00

2214 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* DialogTaskList Component
*
* Displays tasks that were created from the current dialog session.
* Used within AI generation dialogs to show only tasks created in that dialog.
* Supports pagination with scroll-to-load-more and type filtering via RPC.
*/
import React, { useMemo, useState, useCallback } from 'react';
import type { CSSProperties } from 'react';
import { useFilteredTaskQueue } from '../../hooks/useFilteredTaskQueue';
import {
Task,
TaskType,
TaskStatus,
isRetryableStatus,
} from '../../types/task.types';
import { useDrawnix, DialogType } from '../../hooks/use-drawnix';
import { MessagePlugin } from 'tdesign-react';
import {
Download,
Edit3,
Eye,
Image as ImageIcon,
ImagePlus,
MoreHorizontal,
Music2,
RefreshCcw,
Scissors,
Send,
Trash2,
Video as VideoIcon,
} from 'lucide-react';
import { normalizeImageDataUrl } from '@aitu/utils';
import { taskQueueService } from '../../services/task-queue';
import { taskStorageReader } from '../../services/task-storage-reader';
import { hasAIImageDraftContent } from '../../utils/ai-image-draft-state';
import {
buildImageTaskPrefillInitialData,
getImageTaskReferenceImages,
type ImageGenerationReferenceImage,
} from '../../utils/image-task-prefill';
import {
buildTaskDownloadItems,
smartDownload,
} from '../../utils/download-utils';
import { CharacterCreateDialog } from '../character/CharacterCreateDialog';
import { useConfirmDialog } from '../dialog/ConfirmDialog';
import {
UnifiedMediaViewer,
type MediaItem as UnifiedMediaItem,
} from '../shared/media-preview';
import { VideoPosterPreview } from '../shared/VideoPosterPreview';
import { HoverTip } from '../shared';
import { getModelConfig } from '../../constants/model-config';
import {
executeCanvasInsertion,
type InsertionItem,
} from '../../services/canvas-operations';
import './dialog-task-list.scss';
import './dialog-task-loading-final.scss';
const TRUEGROWTH_PUBLISH_HANDOFF_EVENT = 'truegrowth:publish-asset';
const TRUEGROWTH_PUBLISH_HANDOFF_STORAGE_KEY = 'truegrowth-publish-handoff-v1';
const TRUEGROWTH_TIMELINE_HANDOFF_EVENT = 'truegrowth:timeline-asset';
const TRUEGROWTH_TIMELINE_HANDOFF_STORAGE_KEY =
'truegrowth-timeline-handoff-v1';
const FALLBACK_TASK_FAILURE_REASON = '生成失败,请检查模型服务或参数配置。';
const TECHNICAL_FAILURE_PATTERNS = [
/\b(seedance|volcengine|comfyui|api|endpoint|fetch|http|https|json|request|response|submit|xhr|network|timeout|exception|stack|trace)\b/i,
/\b(4\d{2}|5\d{2})\b/,
/\bfailed\b/i,
/\berror\b/i,
/\bnot\s+found\b/i,
/\bunauthorized\b/i,
/\bforbidden\b/i,
/\bquota\b/i,
/\brate\s*limit\b/i,
/[{}[\]"']/,
];
export interface DialogTaskListProps {
/** Task IDs to display. If not provided, shows all tasks (subject to taskType filter) */
taskIds?: string[];
/** Type of tasks to show (optional filter) - used for RPC filtering */
taskType?: TaskType;
/** Multiple task types to show in one creation feed. Uses RPC filtering only for single-type lists. */
taskTypes?: TaskType[];
/** Callback when edit button is clicked - if provided, will update parent form instead of opening dialog */
onEditTask?: (task: any) => void;
}
type CreationBatch = {
id: string;
tasks: Task[];
representative: Task;
createdAt: number;
};
type CreationBatchItem = CreationBatch & {
dateHeading: string | null;
};
type CreationBatchMedia = {
id: string;
task: Task;
taskId: string;
url?: string;
thumbnailUrl?: string;
duration?: number | null;
title: string;
index: number;
};
const sortBatchTasks = (tasks: Task[]): Task[] => {
return [...tasks].sort((a, b) => {
const aIndex =
typeof a.params.batchIndex === 'number'
? a.params.batchIndex
: Number.MAX_SAFE_INTEGER;
const bIndex =
typeof b.params.batchIndex === 'number'
? b.params.batchIndex
: Number.MAX_SAFE_INTEGER;
if (aIndex !== bIndex) {
return aIndex - bIndex;
}
return a.createdAt - b.createdAt;
});
};
const buildCreationBatches = (tasks: Task[]): CreationBatch[] => {
const batchMap = new Map<string, Task[]>();
tasks.forEach((task) => {
const batchId =
typeof task.params.batchId === 'string' && task.params.batchId.trim()
? task.params.batchId
: task.id;
const existing = batchMap.get(batchId);
if (existing) {
existing.push(task);
} else {
batchMap.set(batchId, [task]);
}
});
return Array.from(batchMap.entries())
.map(([id, batchTasks]) => {
const sortedTasks = sortBatchTasks(batchTasks);
const representative = sortedTasks[0] || batchTasks[0];
return {
id,
tasks: sortedTasks,
representative,
createdAt: Math.max(...sortedTasks.map((task) => task.createdAt)),
};
})
.sort((a, b) => b.createdAt - a.createdAt);
};
/**
* DialogTaskList component - displays filtered tasks for a specific dialog
* Now uses useFilteredTaskQueue for pagination and type filtering via RPC.
*/
export const DialogTaskList: React.FC<DialogTaskListProps> = ({
taskIds,
taskType,
taskTypes,
onEditTask,
}) => {
const effectiveTaskTypes = useMemo(
() =>
Array.from(
new Set(taskTypes?.length ? taskTypes : taskType ? [taskType] : [])
),
[taskType, taskTypes]
);
const rpcTaskType =
effectiveTaskTypes.length === 1 ? effectiveTaskTypes[0] : undefined;
// 使用按类型过滤的分页 hook
const {
tasks,
isLoading,
isLoadingMore,
hasMore,
totalCount,
loadedCount,
loadMore,
retryTask,
deleteTask,
} = useFilteredTaskQueue({ taskType: rpcTaskType });
const { board, openDialog } = useDrawnix();
const { confirm, confirmDialog } = useConfirmDialog();
const [previewVisible, setPreviewVisible] = useState(false);
const [previewInitialIndex, setPreviewInitialIndex] = useState<
number | number[]
>(0);
const [previewInitialMode, setPreviewInitialMode] = useState<
'single' | 'compare'
>('single');
// Character extraction dialog state
const [characterDialogTask, setCharacterDialogTask] = useState<Task | null>(
null
);
// Filter tasks by IDs and search text (type filtering is now done via RPC)
const filteredTasks = useMemo(() => {
let filtered = tasks;
if (effectiveTaskTypes.length > 0) {
filtered = filtered.filter((task) =>
effectiveTaskTypes.includes(task.type)
);
}
// 如果指定了 taskIds进行过滤
if (taskIds && taskIds.length > 0) {
filtered = filtered.filter((task) => taskIds.includes(task.id));
}
// Sort by creation time - newest first
return [...filtered].sort((a, b) => b.createdAt - a.createdAt);
}, [tasks, taskIds, effectiveTaskTypes]);
const resolveTask = useCallback(
async (taskId: string) =>
(await taskStorageReader.getTask(taskId)) ||
taskQueueService.getTask(taskId) ||
tasks.find((item) => item.id === taskId),
[tasks]
);
// Task action handlers
const handleRetry = (taskIds: string | string[]) => {
const uniqueTaskIds = Array.from(
new Set(Array.isArray(taskIds) ? taskIds : [taskIds])
).filter(Boolean);
if (uniqueTaskIds.length === 0) {
return;
}
uniqueTaskIds.forEach((taskId) => retryTask(taskId));
MessagePlugin.success(
uniqueTaskIds.length > 1
? `已重新提交 ${uniqueTaskIds.length} 个失败任务`
: '已重新提交失败任务'
);
};
const handleDelete = async (taskId: string) => {
const accepted = await confirm({
title: '删除这条记录?',
description: '删除后会从作品流移除,已插入画布或已下载的素材不受影响。',
confirmText: '删除',
cancelText: '取消',
confirmTheme: 'danger',
});
if (!accepted) {
return;
}
deleteTask(taskId);
MessagePlugin.success('任务记录已删除');
};
const handleDeleteBatch = async (taskIds: string[]) => {
const uniqueTaskIds = Array.from(new Set(taskIds));
if (uniqueTaskIds.length === 0) {
return;
}
const accepted = await confirm({
title: uniqueTaskIds.length > 1 ? '删除这一批记录?' : '删除这条记录?',
description:
uniqueTaskIds.length > 1
? `将从作品流移除 ${uniqueTaskIds.length} 条记录,已插入画布或已下载的素材不受影响。`
: '删除后会从作品流移除,已插入画布或已下载的素材不受影响。',
confirmText: '删除',
cancelText: '取消',
confirmTheme: 'danger',
});
if (!accepted) {
return;
}
uniqueTaskIds.forEach((id) => deleteTask(id));
MessagePlugin.success(
uniqueTaskIds.length > 1
? `已删除 ${uniqueTaskIds.length} 条任务记录`
: '任务记录已删除'
);
};
const handleDownload = async (taskId: string) => {
const task = await resolveTask(taskId);
if (!task) return;
const downloadItems = buildTaskDownloadItems(task);
if (downloadItems.length === 0) return;
try {
const result = await smartDownload(downloadItems);
if (result.openedCount > 0 && result.downloadedCount === 0) {
MessagePlugin.success(
result.openedCount > 1
? `已打开 ${result.openedCount} 个链接,请在新标签页下载`
: '资源不支持直接下载,已打开链接'
);
} else {
MessagePlugin.success(
task.type === TaskType.AUDIO
? downloadItems.length > 1
? '多条音频已开始下载'
: '音频下载成功'
: downloadItems.length > 1
? '多图已开始下载'
: '下载成功'
);
}
} catch (error) {
console.error('Download failed:', error);
MessagePlugin.error('下载失败,请稍后重试');
}
};
const handleDownloadBatch = async (taskIds: string[]) => {
const resolvedTasks = (
await Promise.all(taskIds.map((id) => resolveTask(id)))
).filter((task): task is Task => Boolean(task));
const downloadItems = resolvedTasks.flatMap((task) =>
buildTaskDownloadItems(task)
);
if (downloadItems.length === 0) {
MessagePlugin.warning('这一批暂无可下载作品');
return;
}
try {
const result = await smartDownload(
downloadItems,
`truegrowth_batch_${Date.now()}.zip`
);
if (result.openedCount > 0 && result.downloadedCount === 0) {
MessagePlugin.success(
result.openedCount > 1
? `已打开 ${result.openedCount} 个链接,请在新标签页下载`
: '资源不支持直接下载,已打开链接'
);
} else {
MessagePlugin.success(
downloadItems.length > 1 ? '这一批作品已开始下载' : '下载成功'
);
}
} catch (error) {
console.error('Batch download failed:', error);
MessagePlugin.error('批量下载失败,请稍后重试');
}
};
const handleInsert = async (taskId: string) => {
const task = await resolveTask(taskId);
if (!task || !hasTaskResultMedia(task)) {
console.warn('Cannot insert: task result not available');
MessagePlugin.warning('无法插入:任务结果为空');
return;
}
const { result } = task;
try {
if (task.type === TaskType.IMAGE) {
const urls = result.urls?.length ? result.urls : [result.url];
await executeCanvasInsertion({
items: urls.map((url) => ({
type: 'image',
content: normalizeImageDataUrl(url),
groupId: `dialog-task-image-${task.id}`,
})),
});
MessagePlugin.success(
urls.length > 1 ? '多图已插入到白板' : '图片已插入到白板'
);
} else if (task.type === TaskType.VIDEO) {
await executeCanvasInsertion({
items: [{ type: 'video', content: result.url }],
});
MessagePlugin.success('视频已插入到白板');
} else if (task.type === TaskType.AUDIO) {
const urls = resolveAudioResultUrls(task);
await executeCanvasInsertion({
items: urls.map((url, index) => {
const clip = task.result?.clips?.[index];
return {
type: 'audio',
content: url,
groupId: `dialog-task-audio-${task.id}`,
metadata: {
title:
clip?.title ||
task.result?.title ||
task.params.title ||
task.params.prompt,
duration:
typeof clip?.duration === 'number'
? clip.duration || undefined
: task.result?.duration,
previewImageUrl:
clip?.imageLargeUrl ||
clip?.imageUrl ||
task.result?.previewImageUrl,
tags:
typeof task.params.tags === 'string'
? task.params.tags
: undefined,
mv:
typeof task.params.mv === 'string'
? task.params.mv
: undefined,
prompt: task.params.prompt,
providerTaskId: task.result?.providerTaskId || task.remoteId,
clipId:
clip?.clipId ||
clip?.id ||
task.result?.clipIds?.[index] ||
task.result?.primaryClipId,
clipIds: task.result?.clipIds,
},
};
}),
});
MessagePlugin.success(
urls.length > 1 ? '多条音频卡片已插入到白板' : '音频卡片已插入到白板'
);
}
} catch (error) {
console.error('Failed to insert to board:', error);
MessagePlugin.error(
`插入失败: ${error instanceof Error ? error.message : '未知错误'}`
);
}
};
const handleInsertBatch = async (taskIds: string[]) => {
const resolvedTasks = (
await Promise.all(taskIds.map((id) => resolveTask(id)))
).filter((task): task is Task => Boolean(task));
const insertableTasks = resolvedTasks.filter(
(task) => task.status === TaskStatus.COMPLETED && hasTaskResultMedia(task)
);
if (insertableTasks.length === 0) {
MessagePlugin.warning('这一批暂无可插入作品');
return;
}
try {
const items: InsertionItem[] = insertableTasks.flatMap<InsertionItem>(
(task) => {
if (task.type === TaskType.IMAGE) {
const urls = task.result!.urls?.length
? task.result!.urls
: [task.result!.url];
return urls.map((url) => ({
type: 'image' as const,
content: normalizeImageDataUrl(url),
groupId: `dialog-batch-image-${task.id}`,
}));
}
if (task.type === TaskType.VIDEO && task.result?.url) {
return [
{
type: 'video' as const,
content: task.result.url,
groupId: `dialog-batch-video-${task.id}`,
},
];
}
if (task.type === TaskType.AUDIO) {
const urls = resolveAudioResultUrls(task);
return urls.map((url, index) => {
const clip = task.result?.clips?.[index];
return {
type: 'audio' as const,
content: url,
groupId: `dialog-batch-audio-${task.id}`,
metadata: {
title:
clip?.title ||
task.result?.title ||
task.params.title ||
task.params.prompt,
duration:
typeof clip?.duration === 'number'
? clip.duration || undefined
: task.result?.duration,
previewImageUrl:
clip?.imageLargeUrl ||
clip?.imageUrl ||
task.result?.previewImageUrl,
tags:
typeof task.params.tags === 'string'
? task.params.tags
: undefined,
mv:
typeof task.params.mv === 'string'
? task.params.mv
: undefined,
prompt: task.params.prompt,
providerTaskId: task.result?.providerTaskId || task.remoteId,
clipId:
clip?.clipId ||
clip?.id ||
task.result?.clipIds?.[index] ||
task.result?.primaryClipId,
clipIds: task.result?.clipIds,
},
};
});
}
return [];
}
);
await executeCanvasInsertion({ items });
MessagePlugin.success(`已插入 ${items.length} 个作品到画布`);
} catch (error) {
console.error('Failed to insert batch to board:', error);
MessagePlugin.error(
`插入失败: ${error instanceof Error ? error.message : '未知错误'}`
);
}
};
const handlePublish = async (taskId: string) => {
const task = await resolveTask(taskId);
const mediaUrl = task ? getFeedMediaUrl(task) : undefined;
if (
!task ||
task.status !== TaskStatus.COMPLETED ||
!mediaUrl ||
(task.type !== TaskType.IMAGE && task.type !== TaskType.VIDEO)
) {
MessagePlugin.warning('当前作品还不能发布');
return;
}
const asset = buildPublishAsset(task, mediaUrl);
const detail = {
asset,
assets: [asset],
route: '/publish',
createdAt: Date.now(),
};
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(
TRUEGROWTH_PUBLISH_HANDOFF_STORAGE_KEY,
JSON.stringify(detail)
);
} catch (error) {
console.warn('[TrueGrowth] Failed to store publish handoff:', error);
}
window.dispatchEvent(
new CustomEvent(TRUEGROWTH_PUBLISH_HANDOFF_EVENT, { detail })
);
if (window.location.pathname !== '/publish') {
window.history.pushState(null, '', '/publish');
window.dispatchEvent(new PopStateEvent('popstate'));
}
}
MessagePlugin.success('已带入发布中心草稿');
};
const handlePublishBatch = async (taskIds: string[]) => {
const resolvedTasks = (
await Promise.all(taskIds.map((id) => resolveTask(id)))
).filter((task): task is Task => Boolean(task));
const assets = resolvedTasks
.filter(
(task) =>
task.status === TaskStatus.COMPLETED &&
(task.type === TaskType.IMAGE || task.type === TaskType.VIDEO)
)
.flatMap((task) => buildPublishAssets(task));
if (assets.length === 0) {
MessagePlugin.warning('这一批暂无可发布作品');
return;
}
const detail = {
asset: assets[0],
assets,
route: '/publish',
createdAt: Date.now(),
};
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(
TRUEGROWTH_PUBLISH_HANDOFF_STORAGE_KEY,
JSON.stringify(detail)
);
} catch (error) {
console.warn('[TrueGrowth] Failed to store publish handoff:', error);
}
window.dispatchEvent(
new CustomEvent(TRUEGROWTH_PUBLISH_HANDOFF_EVENT, { detail })
);
if (window.location.pathname !== '/publish') {
window.history.pushState(null, '', '/publish');
window.dispatchEvent(new PopStateEvent('popstate'));
}
}
MessagePlugin.success(`已带入 ${assets.length} 个作品到发布中心`);
};
const handleClip = async (taskId: string) => {
const task = await resolveTask(taskId);
if (
!task ||
task.type !== TaskType.VIDEO ||
task.status !== TaskStatus.COMPLETED ||
!task.result
) {
MessagePlugin.warning('当前视频还不能剪辑');
return;
}
const url = task.result.urls?.[0] || task.result.url;
if (!url) {
MessagePlugin.warning('当前视频没有可剪辑的视频源');
return;
}
const title = getFeedTitle(task);
const detail = {
asset: {
id: `task-${task.id}`,
assetId: `task-${task.id}`,
type: 'video',
name: title,
source: 'ai-video',
provider: task.params.model || 'TrueGrowth',
url,
thumbnailUrl:
task.result.thumbnailUrls?.[0] ||
task.result.previewImageUrl ||
undefined,
durationMs:
typeof task.result.duration === 'number'
? task.result.duration * 1000
: undefined,
width: task.result.width,
height: task.result.height,
createdAt: task.completedAt || task.createdAt || Date.now(),
publishState: 'unpublished',
sourceRefs: [
{
type: 'task',
id: task.id,
label: title,
},
],
},
route: '/timeline',
intent: 'clip',
createdAt: Date.now(),
};
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(
TRUEGROWTH_TIMELINE_HANDOFF_STORAGE_KEY,
JSON.stringify(detail)
);
} catch (error) {
console.warn('[TrueGrowth] Failed to store timeline handoff:', error);
}
window.dispatchEvent(
new CustomEvent(TRUEGROWTH_TIMELINE_HANDOFF_EVENT, { detail })
);
if (window.location.pathname !== '/timeline') {
window.history.pushState(null, '', '/timeline');
window.dispatchEvent(new PopStateEvent('popstate'));
}
}
MessagePlugin.success('已带入剪辑工作台,请选择剪辑能力');
};
const confirmOverwriteDraftIfNeeded = useCallback(async () => {
if (!hasAIImageDraftContent()) {
return true;
}
return confirm({
title: '覆盖当前输入?',
description:
'当前 AI 图片生成窗口已有提示词或参考图,继续会覆盖当前输入。',
confirmText: '覆盖当前输入',
cancelText: '取消',
confirmTheme: 'warning',
});
}, [confirm]);
const handleRegenerate = async (taskId: string) => {
const task =
(await taskStorageReader.getTask(taskId)) ||
taskQueueService.getTask(taskId) ||
tasks.find((item) => item.id === taskId);
if (!task || task.type !== TaskType.IMAGE) {
MessagePlugin.warning('未找到可回填的图片任务');
return;
}
if (!(await confirmOverwriteDraftIfNeeded())) {
return;
}
if (onEditTask) {
onEditTask(task);
} else {
openDialog(
DialogType.aiImageGeneration,
buildImageTaskPrefillInitialData(task)
);
}
MessagePlugin.success('已回填历史提示词和参考图,请手动发送');
};
const handleEdit = async (taskId: string) => {
const task =
(await taskStorageReader.getTask(taskId)) ||
taskQueueService.getTask(taskId) ||
tasks.find((item) => item.id === taskId);
if (!task) {
console.warn('Cannot edit: task not found');
return;
}
if (task.type === TaskType.IMAGE && task.status !== TaskStatus.COMPLETED) {
MessagePlugin.warning('只有已完成且有结果图的图片任务可以编辑');
return;
}
if (
task.type === TaskType.IMAGE &&
!task.result?.url &&
!task.result?.urls?.length
) {
MessagePlugin.warning('当前任务没有可编辑的结果图');
return;
}
// 如果有 onEditTask 回调(从弹窗内部调用),直接更新父组件表单
if (onEditTask) {
onEditTask(task);
return;
}
// 否则打开新的对话框(从任务队列面板调用)
if (task.type === TaskType.IMAGE) {
// 准备图片生成初始数据
openDialog(
DialogType.aiImageGeneration,
buildImageTaskPrefillInitialData(task)
);
} else if (task.type === TaskType.VIDEO) {
// 准备视频生成初始数据
const initialData = {
initialPrompt: task.params.prompt,
initialDuration:
typeof task.params.seconds === 'string'
? parseInt(task.params.seconds, 10)
: task.params.seconds, // 确保转换为数字
initialModel: task.params.model, // 传递模型
initialSize: task.params.size, // 传递尺寸
initialImages: task.params.uploadedImages, // 传递上传的图片(多图片格式)
initialResultUrl: task.result?.url, // 传递结果URL用于预览
initialResultUrls: task.result?.urls, // 多图/多视频结果
};
// console.log('DialogTaskList - handleEdit VIDEO task:', {
// taskId,
// taskParams: task.params,
// initialData
// });
openDialog(DialogType.aiVideoGeneration, initialData);
}
};
// Get completed tasks with results for navigation (deduplicated by ID)
const completedTasksWithResults = useMemo(() => {
const seen = new Set<string>();
return filteredTasks.filter((t) => {
if (t.status !== TaskStatus.COMPLETED) return false;
if (!hasTaskResultMedia(t)) return false;
if (
t.type !== TaskType.IMAGE &&
t.type !== TaskType.VIDEO &&
!(t.type === TaskType.AUDIO && t.result?.resultKind !== 'lyrics')
) {
return false;
}
if (seen.has(t.id)) return false;
seen.add(t.id);
return true;
});
}, [filteredTasks]);
// 展开多图任务为多个 MediaItem同时建立 taskId -> 首个 previewIndex 的映射
const { previewMediaItems, taskIdToPreviewConfig } = useMemo(() => {
const items: UnifiedMediaItem[] = [];
const configMap = new Map<
string,
{ mode: 'single' | 'compare'; index: number | number[] }
>();
for (const task of completedTasksWithResults) {
const startIndex = items.length;
const title =
task.result?.title ||
task.params.title ||
task.params.prompt?.substring(0, 50) ||
'媒体预览';
if (task.type === TaskType.AUDIO) {
const audioItems =
task.result?.clips
?.filter((clip) => Boolean(clip.audioUrl))
.map((clip, i) => ({
id: clip.clipId || clip.id || `${task.id}-${i}`,
url: clip.audioUrl,
type: 'audio' as const,
title:
clip.title ||
(task.result?.clips && task.result.clips.length > 1
? `${title} (${i + 1}/${task.result.clips.length})`
: title),
posterUrl:
clip.imageLargeUrl ||
clip.imageUrl ||
task.result?.previewImageUrl,
duration:
typeof clip.duration === 'number'
? clip.duration
: task.result?.duration,
prompt: task.params.prompt,
tags:
typeof task.params.tags === 'string'
? task.params.tags
: undefined,
artist: task.params.model || task.params.mv || 'TrueGrowth',
album: 'TrueGrowth Generated',
})) ?? [];
const fallbackUrls = task.result!.urls?.length
? task.result!.urls
: task.result!.url
? [task.result!.url]
: [];
const mediaItems =
audioItems.length > 0
? audioItems
: fallbackUrls.map((url, i) => ({
id: fallbackUrls.length > 1 ? `${task.id}-${i}` : task.id,
url,
type: 'audio' as const,
title:
fallbackUrls.length > 1
? `${title} (${i + 1}/${fallbackUrls.length})`
: title,
posterUrl: task.result?.previewImageUrl,
duration: task.result?.duration,
prompt: task.params.prompt,
tags:
typeof task.params.tags === 'string'
? task.params.tags
: undefined,
artist: task.params.model || task.params.mv || 'TrueGrowth',
album: 'TrueGrowth Generated',
}));
items.push(...mediaItems);
mediaItems.forEach((item, offset) => {
configMap.set(item.id, {
mode: 'single',
index: startIndex + offset,
});
});
} else {
const urls = task.result!.urls?.length
? task.result!.urls
: [task.result!.url];
const mediaType =
task.type === TaskType.VIDEO
? ('video' as const)
: ('image' as const);
for (let i = 0; i < urls.length; i++) {
const previewKey = `${task.id}:${i}`;
items.push({
id: previewKey,
url:
mediaType === 'image' ? normalizeImageDataUrl(urls[i]) : urls[i],
type: mediaType,
title:
urls.length > 1 ? `${title} (${i + 1}/${urls.length})` : title,
});
configMap.set(previewKey, {
mode: 'single',
index: startIndex + i,
});
}
}
const taskItemCount = items.length - startIndex;
configMap.set(task.id, {
mode:
task.type === TaskType.AUDIO && taskItemCount > 1
? 'compare'
: 'single',
index:
task.type === TaskType.AUDIO && taskItemCount > 1
? Array.from(
{ length: Math.min(taskItemCount, 4) },
(_, offset) => startIndex + offset
)
: startIndex,
});
}
return { previewMediaItems: items, taskIdToPreviewConfig: configMap };
}, [completedTasksWithResults]);
// Preview handlers - 使用 Map 精确查找索引
const handlePreviewOpen = useCallback(
(taskId: string) => {
const config = taskIdToPreviewConfig.get(taskId);
if (config !== undefined) {
setPreviewInitialMode(config.mode);
setPreviewInitialIndex(config.index);
setPreviewVisible(true);
}
},
[taskIdToPreviewConfig]
);
const handlePreviewClose = useCallback(() => {
setPreviewVisible(false);
}, []);
const groupedTaskItems = useMemo(() => {
const batches = buildCreationBatches(filteredTasks);
return batches.map((batch, index): CreationBatchItem => {
const currentDate = new Date(batch.createdAt);
const previousBatch = batches[index - 1];
const previousDate = previousBatch
? new Date(previousBatch.createdAt)
: null;
const currentKey = currentDate.toDateString();
const previousKey = previousDate?.toDateString();
return {
...batch,
dateHeading:
currentKey !== previousKey
? currentDate.toLocaleDateString('zh-CN', {
month: 'long',
day: 'numeric',
})
: null,
};
});
}, [filteredTasks]);
// 显示的总数(优先使用 RPC 返回的总数)
const displayTotalCount = totalCount > 0 ? totalCount : tasks.length;
const activeFilter =
effectiveTaskTypes.length > 1
? 'mixed'
: effectiveTaskTypes[0] === TaskType.VIDEO
? 'video'
: effectiveTaskTypes[0] === TaskType.IMAGE
? 'image'
: effectiveTaskTypes[0] === TaskType.AUDIO
? 'audio'
: 'all';
const feedHeading = formatFeedDateHeading(filteredTasks[0]?.createdAt);
const feedLabel =
activeFilter === 'video'
? '视频生成'
: activeFilter === 'image'
? '图片生成'
: activeFilter === 'audio'
? '音乐生成'
: activeFilter === 'mixed'
? '创作记录'
: '全部生成';
const feedSummary = getFeedSummary(filteredTasks, rpcTaskType);
const feedEmptyText =
activeFilter === 'video'
? '生成后的视频作品会按日期显示在这里。'
: activeFilter === 'audio'
? '生成后的音乐作品会按日期显示在这里。'
: '生成后的作品会按日期显示在这里。';
return (
<>
{confirmDialog}
<div className="dialog-task-list tg-creation-feed">
<div className="tg-creation-feed__header">
<h2>{feedHeading}</h2>
</div>
<div className="dialog-task-list__content dialog-task-list__content--grouped tg-creation-feed__content">
{groupedTaskItems.length ? (
<>
{groupedTaskItems.map((batchItem) => (
<React.Fragment key={batchItem.id}>
{batchItem.dateHeading ? (
<h3 className="dialog-task-list__date-heading tg-creation-feed__date">
{batchItem.dateHeading}
</h3>
) : null}
<CreationBatchCard
batch={batchItem}
onRetry={handleRetry}
onDownloadBatch={handleDownloadBatch}
onInsertBatch={handleInsertBatch}
onEdit={handleEdit}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
onDeleteBatch={handleDeleteBatch}
onPublishBatch={handlePublishBatch}
onClip={handleClip}
onPreviewOpen={handlePreviewOpen}
/>
</React.Fragment>
))}
{hasMore ? (
<button
className="dialog-task-list__load-more"
type="button"
disabled={isLoadingMore}
onClick={loadMore}
>
{isLoadingMore
? '加载中...'
: `加载更多 ${loadedCount}/${displayTotalCount}`}
</button>
) : null}
</>
) : (
<div className="dialog-task-list__empty">
{isLoading ? <p>...</p> : <p>{feedEmptyText}</p>}
</div>
)}
</div>
</div>
{/* Unified Preview */}
<UnifiedMediaViewer
visible={previewVisible}
items={previewMediaItems}
initialMode={previewInitialMode}
initialIndex={previewInitialIndex}
onClose={handlePreviewClose}
showThumbnails={true}
/>
{/* Character Create Dialog */}
<CharacterCreateDialog
visible={!!characterDialogTask}
task={characterDialogTask}
onClose={() => setCharacterDialogTask(null)}
onCreateComplete={(characterId) => {
// console.log('Character created:', characterId);
setCharacterDialogTask(null);
}}
/>
</>
);
};
function getStatusLabel(status: TaskStatus): string {
if (status === TaskStatus.COMPLETED) return '已完成';
if (status === TaskStatus.FAILED) return '生成失败';
if (status === TaskStatus.CANCELLED) return '已取消';
return '生成中';
}
function truncateFailureReason(text: string, maxLength = 120): string {
const value = text.replace(/\s+/g, ' ').trim();
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 1)}`;
}
function getFriendlyTaskFailureReason(task: Task, rawReason?: string): string {
const fallback =
task.type === TaskType.VIDEO
? '视频生成失败,请检查视频模型配置、额度或网络后重试。'
: task.type === TaskType.IMAGE
? '图片生成失败,请检查模型配置、额度或网络后重试。'
: task.type === TaskType.AUDIO
? '音乐生成失败,请检查音乐模型配置、额度或网络后重试。'
: FALLBACK_TASK_FAILURE_REASON;
if (!rawReason) {
return fallback;
}
const normalized = rawReason.replace(/^失败原因[:]\s*/i, '').trim();
const isTechnical = TECHNICAL_FAILURE_PATTERNS.some((pattern) =>
pattern.test(normalized)
);
if (isTechnical || normalized.length > 90) {
return fallback;
}
return truncateFailureReason(normalized, 90);
}
function normalizeFailureReason(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const looksLikeJson =
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
if (looksLikeJson) {
try {
const parsed = JSON.parse(trimmed);
const parsedText = normalizeFailureReason(parsed);
if (parsedText) {
return parsedText;
}
} catch {
// Fall back to the raw API text below.
}
}
return truncateFailureReason(trimmed.replace(/^error:\s*/i, ''));
}
if (value instanceof Error) {
return normalizeFailureReason(value.message);
}
if (Array.isArray(value)) {
for (const item of value) {
const text = normalizeFailureReason(item);
if (text) {
return text;
}
}
return undefined;
}
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
const preferredKeys = [
'message',
'msg',
'originalError',
'errorMessage',
'failReason',
'failureReason',
'reason',
'task_status_msg',
'detail',
'details',
'error',
'apiResponse',
'data',
];
for (const key of preferredKeys) {
const text = normalizeFailureReason(record[key]);
if (text) {
return text;
}
}
}
return undefined;
}
function getTaskFailureReason(task: Task): string {
const taskWithLooseFields = task as Task & {
error?: unknown;
errorMessage?: unknown;
failReason?: unknown;
};
const candidates: unknown[] = [
task.error?.message,
task.error?.details?.originalError,
task.error?.details?.apiResponse,
taskWithLooseFields.errorMessage,
taskWithLooseFields.failReason,
task.params.errorMessage,
task.params.failReason,
task.params.failureReason,
(task.result as { errorMessage?: unknown } | undefined)?.errorMessage,
(task.result as { failReason?: unknown } | undefined)?.failReason,
taskWithLooseFields.error,
];
for (const candidate of candidates) {
const reason = normalizeFailureReason(candidate);
if (reason) {
return getFriendlyTaskFailureReason(task, reason);
}
}
return getFriendlyTaskFailureReason(task);
}
function getBatchFailureReason(batch: CreationBatch): string | undefined {
const reasons = Array.from(
new Set(
batch.tasks
.filter((task) => task.status === TaskStatus.FAILED)
.map((task) => getTaskFailureReason(task))
.filter(Boolean)
)
);
if (reasons.length === 0) {
return undefined;
}
if (reasons.length === 1) {
return reasons[0];
}
return `${reasons[0]}(另有 ${reasons.length - 1} 条失败原因)`;
}
function formatFeedTime(value: number): string {
return new Date(value).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
}
function formatFeedDateHeading(value?: number): string {
if (!value) return '今天';
const date = new Date(value);
const today = new Date();
if (
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate()
) {
return '今天';
}
return date.toLocaleDateString('zh-CN', {
month: 'long',
day: 'numeric',
});
}
function getFeedTitle(task: Task): string {
return (
task.result?.title ||
task.params.title ||
task.params.prompt ||
(task.type === TaskType.VIDEO
? '视频作品'
: task.type === TaskType.AUDIO
? '音频作品'
: '图片作品')
);
}
function getFeedMediaUrl(task: Task): string | undefined {
const url =
task.type === TaskType.AUDIO
? resolveAudioResultUrls(task)[0]
: task.result?.urls?.[0] || task.result?.url;
return task.type === TaskType.IMAGE && url ? normalizeImageDataUrl(url) : url;
}
function resolveAudioResultUrls(task: Task): string[] {
if (task.type !== TaskType.AUDIO) {
return [];
}
const clipUrls =
task.result?.clips
?.map((clip) => clip.audioUrl)
.filter((url): url is string => Boolean(url)) ?? [];
if (clipUrls.length) {
return clipUrls;
}
const fallbackUrls = task.result?.urls?.length
? task.result.urls
: task.result?.url
? [task.result.url]
: [];
return fallbackUrls.filter((url): url is string => Boolean(url));
}
function hasTaskResultMedia(
task: Task
): task is Task & { result: NonNullable<Task['result']> } {
if (!task.result) {
return false;
}
if (task.type === TaskType.AUDIO) {
return (
task.result.resultKind !== 'lyrics' &&
resolveAudioResultUrls(task).length > 0
);
}
return Boolean(task.result.url || task.result.urls?.length);
}
function getTaskMediaItems(task: Task): CreationBatchMedia[] {
const title = getFeedTitle(task);
const audioClipItems =
task.type === TaskType.AUDIO
? task.result?.clips
?.filter((clip) => Boolean(clip.audioUrl))
.map((clip, index) => ({
id: clip.clipId || clip.id || `${task.id}:${index}`,
task,
taskId: task.id,
url: clip.audioUrl,
thumbnailUrl:
clip.imageLargeUrl ||
clip.imageUrl ||
task.result?.previewImageUrl,
duration: clip.duration,
title:
clip.title ||
(task.result?.clips && task.result.clips.length > 1
? `${title} ${index + 1}`
: title),
index:
typeof task.params.batchIndex === 'number'
? task.params.batchIndex
: index + 1,
})) ?? []
: [];
const urls =
task.type === TaskType.AUDIO
? resolveAudioResultUrls(task)
: task.result?.urls?.length
? task.result.urls
: task.result?.url
? [task.result.url]
: [];
if (audioClipItems.length) {
return audioClipItems;
}
if (urls.length === 0) {
return [
{
id: `${task.id}:pending`,
task,
taskId: task.id,
title,
index:
typeof task.params.batchIndex === 'number'
? task.params.batchIndex
: 1,
},
];
}
return urls.map((url, index) => ({
id: `${task.id}:${index}`,
task,
taskId: task.id,
url: task.type === TaskType.IMAGE ? normalizeImageDataUrl(url) : url,
thumbnailUrl:
task.type === TaskType.VIDEO
? task.result?.thumbnailUrls?.[index] || task.result?.previewImageUrl
: task.result?.thumbnailUrls?.[index] ||
task.result?.previewImageUrl ||
normalizeImageDataUrl(url),
duration:
task.type === TaskType.AUDIO && typeof task.result?.duration === 'number'
? task.result.duration
: undefined,
title: urls.length > 1 ? `${title} ${index + 1}` : title,
index:
typeof task.params.batchIndex === 'number'
? task.params.batchIndex
: index + 1,
}));
}
function getBatchMediaItems(batch: CreationBatch): CreationBatchMedia[] {
return batch.tasks.flatMap((task) => getTaskMediaItems(task));
}
function getBatchTitle(batch: CreationBatch): string {
return getFeedTitle(batch.representative);
}
function getBatchReferenceImages(
batch: CreationBatch
): ImageGenerationReferenceImage[] {
const seen = new Set<string>();
const references: ImageGenerationReferenceImage[] = [];
batch.tasks.forEach((task) => {
getImageTaskReferenceImages(task).forEach((image) => {
if (!image.url || seen.has(image.url)) {
return;
}
seen.add(image.url);
references.push(image);
});
});
return references;
}
function readTaskExtraParams(task: Task): Record<string, string> {
const params = task.params.params;
if (!params || typeof params !== 'object' || Array.isArray(params)) {
return {};
}
return Object.entries(params as Record<string, unknown>).reduce<
Record<string, string>
>((acc, [key, value]) => {
if (value !== undefined && value !== null && String(value).trim()) {
acc[key] = String(value);
}
return acc;
}, {});
}
function isVisibleMetaValue(
value?: string | number | null
): value is string | number {
if (value === undefined || value === null) return false;
const text = String(value).trim();
return Boolean(text) && text.toLowerCase() !== 'auto';
}
function formatBatchModel(task: Task): string | undefined {
const modelId =
task.params.modelRef?.modelId || task.params.model || task.params.mv;
if (!isVisibleMetaValue(modelId)) {
return undefined;
}
const config = getModelConfig(String(modelId));
return (
config?.shortLabel || config?.label || config?.shortCode || String(modelId)
);
}
function formatBatchSize(batch: CreationBatch): string | undefined {
const task = batch.representative;
const params = task.params;
const extraParams = readTaskExtraParams(task);
const size =
params.size ||
params.resolution ||
extraParams.size ||
extraParams.resolution;
if (isVisibleMetaValue(size)) {
return String(size);
}
const resultWithDimensions = batch.tasks.find(
(item) => item.result?.width && item.result?.height
);
if (
resultWithDimensions?.result?.width &&
resultWithDimensions.result.height
) {
return `${resultWithDimensions.result.width}x${resultWithDimensions.result.height}`;
}
if (params.width && params.height) {
return `${params.width}x${params.height}`;
}
return undefined;
}
function readLooseRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function readLooseValue(
task: Task,
keys: string[]
): string | number | undefined {
const paramRecord = task.params as unknown as Record<string, unknown>;
const resultRecord = task.result as unknown as Record<string, unknown>;
const records = [
paramRecord,
readLooseRecord(paramRecord.params),
readLooseRecord(paramRecord.metadata),
readLooseRecord(paramRecord.options),
readLooseRecord(paramRecord.extra),
resultRecord,
readLooseRecord(resultRecord?.metadata),
readLooseRecord(resultRecord?.params),
].filter(Boolean) as Record<string, unknown>[];
for (const record of records) {
for (const key of keys) {
const value = record[key];
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
}
return undefined;
}
function parsePositiveDimension(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value === 'string') {
const parsed = Number.parseFloat(value.replace(/[^\d.]/g, ''));
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
function parseRatioText(value: unknown): number | undefined {
if (!isVisibleMetaValue(value as string | number | undefined)) {
return undefined;
}
const text = String(value)
.trim()
.toLowerCase()
.replace(/[]/g, ':')
.replace(/[×*]/g, 'x');
const pairMatch = text.match(/(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)/);
if (pairMatch) {
const width = Number.parseFloat(pairMatch[1]);
const height = Number.parseFloat(pairMatch[2]);
if (width > 0 && height > 0) {
return width / height;
}
}
if (/(portrait|vertical|竖屏|竖版|手机|小红书|抖音)/i.test(text)) {
return 9 / 16;
}
if (/(landscape|horizontal|横屏|横版|宽屏|bilibili|youtube)/i.test(text)) {
return 16 / 9;
}
if (/(square|方形|正方形)/i.test(text)) {
return 1;
}
const numeric = Number.parseFloat(text);
if (Number.isFinite(numeric) && numeric > 0 && numeric < 10) {
return numeric;
}
return undefined;
}
function getTaskPlaceholderRatio(task: Task): number {
const width = parsePositiveDimension(
readLooseValue(task, ['width', 'w', 'imageWidth', 'videoWidth'])
);
const height = parsePositiveDimension(
readLooseValue(task, ['height', 'h', 'imageHeight', 'videoHeight'])
);
if (width && height) {
return width / height;
}
const ratio = parseRatioText(
readLooseValue(task, [
'aspectRatio',
'aspect_ratio',
'ratio',
'imageRatio',
'videoRatio',
'format',
'videoFormat',
'screenRatio',
'screen_ratio',
'outputRatio',
'output_ratio',
'dimensions',
'dimension',
'size',
'imageSize',
'image_size',
'videoSize',
'video_size',
'outputSize',
'output_size',
'resolution',
])
);
if (ratio) {
return ratio;
}
return task.type === TaskType.VIDEO ? 16 / 9 : 1;
}
function getTaskPlaceholderStyle(task: Task): CSSProperties {
const ratio = Math.min(3, Math.max(1 / 3, getTaskPlaceholderRatio(task)));
const width =
ratio >= 1
? Math.min(360, Math.round(240 * ratio))
: Math.round(320 * ratio);
const height = ratio >= 1 ? Math.max(168, Math.round(width / ratio)) : 320;
const processingRingSize = Math.min(
54,
Math.max(34, Math.round(height * 0.24))
);
const processingCopyHeight = Math.min(
20,
Math.max(16, Math.round(height * 0.095))
);
const processingGap = Math.min(10, Math.max(4, Math.round(height * 0.035)));
const processingPadding = Math.min(
18,
Math.max(8, Math.round(height * 0.06))
);
return {
'--tg-creation-placeholder-ratio': String(ratio),
'--tg-creation-placeholder-width': `${width}px`,
'--tg-creation-placeholder-height': `${height}px`,
'--tg-processing-ring-size': `${processingRingSize}px`,
'--tg-processing-copy-height': `${processingCopyHeight}px`,
'--tg-processing-gap': `${processingGap}px`,
'--tg-processing-padding': `${processingPadding}px`,
'--tg-loading-ring-size': `${processingRingSize}px`,
'--tg-loading-copy-height': `${processingCopyHeight}px`,
'--tg-loading-gap': `${processingGap}px`,
'--tg-loading-padding': `${processingPadding}px`,
} as CSSProperties;
}
function getBatchMeta(batch: CreationBatch): string {
const task = batch.representative;
const params = task.params;
const count =
typeof params.batchTotal === 'number' &&
params.batchTotal > batch.tasks.length
? params.batchTotal
: batch.tasks.length;
const unit =
task.type === TaskType.VIDEO
? '条'
: task.type === TaskType.AUDIO
? '首'
: '张';
const pieces = [
`${count} ${unit}`,
formatBatchModel(task),
formatBatchSize(batch),
].filter(isVisibleMetaValue);
return pieces.join(' | ') || '历史参数待补全';
}
function getBatchStatusMeta(batch: CreationBatch): string {
const completedCount = batch.tasks.filter(
(task) => task.status === TaskStatus.COMPLETED
).length;
const failedCount = batch.tasks.filter(
(task) => task.status === TaskStatus.FAILED
).length;
const processingCount = batch.tasks.filter(
(task) =>
task.status === TaskStatus.PROCESSING ||
task.status === TaskStatus.PENDING
).length;
const pieces = [
`${formatFeedTime(batch.createdAt)}`,
completedCount ? `完成 ${completedCount}` : undefined,
processingCount ? `生成中 ${processingCount}` : undefined,
failedCount ? `失败 ${failedCount}` : undefined,
].filter(Boolean);
return pieces.join(' | ');
}
function getFeedSummary(tasks: Task[], taskType?: TaskType): string {
const latestBatch = buildCreationBatches(tasks)[0];
if (!latestBatch) {
return taskType === TaskType.VIDEO
? '选择模型、比例与时长后,生成结果会按时间展示。'
: taskType === TaskType.AUDIO
? '选择音乐风格、歌词与模型后,生成结果会按批次展示。'
: '选择模型、尺寸与数量后,生成结果会按批次展示。';
}
return getBatchMeta(latestBatch);
}
function buildPublishAsset(task: Task, url: string, index = 0) {
const normalizedUrl =
task.type === TaskType.IMAGE ? normalizeImageDataUrl(url) : url;
const isMulti = Boolean(task.result?.urls && task.result.urls.length > 1);
const title = getFeedTitle(task);
return {
id: `task-${task.id}${isMulti ? `-${index + 1}` : ''}`,
assetId: `task-${task.id}${isMulti ? `-${index + 1}` : ''}`,
type: task.type === TaskType.VIDEO ? 'video' : 'image',
name: isMulti ? `${title} ${index + 1}` : title,
source: task.type === TaskType.VIDEO ? 'ai-video' : 'ai-image',
provider: task.params.model || 'TrueGrowth',
url: normalizedUrl,
thumbnailUrl:
task.result?.thumbnailUrls?.[index] ||
task.result?.previewImageUrl ||
normalizedUrl,
durationMs:
typeof task.result?.duration === 'number'
? task.result.duration * 1000
: undefined,
width: task.result?.width,
height: task.result?.height,
createdAt: task.completedAt || task.createdAt || Date.now(),
publishState: 'unpublished',
sourceRefs: [
{
type: 'task',
id: task.id,
label: title,
},
],
};
}
function buildPublishAssets(task: Task) {
if (task.type !== TaskType.IMAGE && task.type !== TaskType.VIDEO) {
return [];
}
const urls = task.result?.urls?.length
? task.result.urls
: task.result?.url
? [task.result.url]
: [];
return urls.map((url, index) => buildPublishAsset(task, url, index));
}
function CreationBatchCard({
batch,
onRetry,
onDownloadBatch,
onInsertBatch,
onEdit,
onRegenerate,
onDelete,
onDeleteBatch,
onPublishBatch,
onClip,
onPreviewOpen,
}: {
batch: CreationBatch;
onRetry?: (taskIds: string[]) => void;
onDownloadBatch?: (taskIds: string[]) => void;
onInsertBatch?: (taskIds: string[]) => void;
onEdit?: (taskId: string) => void;
onRegenerate?: (taskId: string) => void;
onDelete?: (taskId: string) => void;
onDeleteBatch?: (taskIds: string[]) => void;
onPublishBatch?: (taskIds: string[]) => void;
onClip?: (taskId: string) => void;
onPreviewOpen?: (previewKey: string) => void;
}) {
const mediaItems = getBatchMediaItems(batch);
const references = getBatchReferenceImages(batch);
const title = getBatchTitle(batch);
const taskIds = batch.tasks.map((task) => task.id);
const primaryTask =
batch.tasks.find(
(task) => task.status === TaskStatus.COMPLETED && hasTaskResultMedia(task)
) ||
batch.tasks.find((task) => task.type === batch.representative.type) ||
batch.representative;
const hasCompletedMedia = batch.tasks.some(
(task) => task.status === TaskStatus.COMPLETED && hasTaskResultMedia(task)
);
const hasPublishableMedia = batch.tasks.some(
(task) =>
task.status === TaskStatus.COMPLETED &&
(task.type === TaskType.IMAGE || task.type === TaskType.VIDEO) &&
hasTaskResultMedia(task)
);
const firstCompletedVideoTask = batch.tasks.find(
(task) =>
task.type === TaskType.VIDEO &&
task.status === TaskStatus.COMPLETED &&
hasTaskResultMedia(task)
);
const hasFailedTask = batch.tasks.some(
(task) => task.status === TaskStatus.FAILED
);
const retryableTaskIds = batch.tasks
.filter((task) => isRetryableStatus(task.status))
.map((task) => task.id);
const canEdit =
primaryTask.type === TaskType.IMAGE &&
primaryTask.status === TaskStatus.COMPLETED &&
Boolean(primaryTask.result?.url || primaryTask.result?.urls?.length);
const statusMeta = getBatchStatusMeta(batch);
const batchMeta = getBatchMeta(batch);
const failureReason = getBatchFailureReason(batch);
return (
<article className="tg-creation-batch">
<div className="tg-creation-batch__head">
<div className="tg-creation-batch__copy">
<h3>{title}</h3>
<div className="tg-creation-batch__meta">
<span>{batchMeta}</span>
{statusMeta ? <span>{statusMeta}</span> : null}
</div>
{failureReason ? (
<div className="tg-creation-batch__failure" title={failureReason}>
{failureReason}
</div>
) : null}
</div>
{references.length ? (
<div className="tg-creation-batch__refs" aria-label="历史参考图">
{references.slice(0, 4).map((image, index) => (
<img
key={`${image.url}-${index}`}
src={image.url}
alt={image.name || `参考图 ${index + 1}`}
/>
))}
{references.length > 4 ? (
<span>+{references.length - 4}</span>
) : null}
</div>
) : null}
</div>
<div className="tg-creation-batch__rail" aria-label="本批生成结果">
{mediaItems.map((media) => {
const isCompleted =
media.task.status === TaskStatus.COMPLETED && Boolean(media.url);
const isVideo = media.task.type === TaskType.VIDEO;
const isAudio = media.task.type === TaskType.AUDIO;
const isPublishable =
media.task.type === TaskType.IMAGE ||
media.task.type === TaskType.VIDEO;
const isProcessing =
media.task.status === TaskStatus.PROCESSING ||
media.task.status === TaskStatus.PENDING;
const mediaFailureReason =
media.task.status === TaskStatus.FAILED
? getTaskFailureReason(media.task)
: undefined;
const mediaProgress = Math.max(
0,
Math.min(100, media.task.progress || 0)
);
const processingTypeLabel =
media.task.type === TaskType.VIDEO
? '生成视频'
: media.task.type === TaskType.AUDIO
? '生成音乐'
: '生成图片';
const placeholderStyle = !isCompleted
? getTaskPlaceholderStyle(media.task)
: undefined;
const mediaStyle =
isCompleted && isVideo
? getTaskPlaceholderStyle(media.task)
: placeholderStyle;
const mediaTaskIds = [media.taskId];
return (
<div className="tg-creation-batch__item" key={media.id}>
<div
className={`tg-creation-batch__media${
isVideo ? ' tg-creation-batch__media--video' : ''
}${
mediaFailureReason ? ' tg-creation-batch__media--failed' : ''
}${
isProcessing ? ' tg-creation-batch__media--processing' : ''
}`}
style={mediaStyle}
>
<button
className="tg-creation-batch__media-preview"
type="button"
disabled={!isCompleted}
aria-label={
mediaFailureReason
? `${getStatusLabel(
media.task.status
)}${mediaFailureReason}`
: `预览 ${media.title}`
}
onClick={() => {
if (isCompleted) {
onPreviewOpen?.(media.id);
}
}}
>
{isCompleted && isVideo ? (
<VideoPosterPreview
src={media.url!}
poster={media.thumbnailUrl}
alt={media.title}
fit="contain"
thumbnailSize="small"
playOnHover={true}
videoProps={{
muted: true,
loop: true,
playsInline: true,
preload: 'metadata',
controls: false,
}}
/>
) : isCompleted && isAudio ? (
<span className="tg-creation-batch__audio-card">
<span className="tg-creation-batch__audio-art">
{media.thumbnailUrl ? (
<img src={media.thumbnailUrl} alt="" />
) : (
<Music2 size={24} />
)}
</span>
<span className="tg-creation-batch__audio-copy">
<strong>{media.title}</strong>
<small>
{typeof media.duration === 'number' &&
media.duration > 0
? `${Math.round(media.duration)}`
: 'AI 音乐作品'}
</small>
</span>
<audio
controls
preload="metadata"
src={media.url}
onClick={(event) => event.stopPropagation()}
/>
</span>
) : isCompleted ? (
<img src={media.url} alt={media.title} />
) : (
<span
className={`tg-creation-batch__placeholder${
isProcessing
? ' tg-creation-batch__placeholder--processing'
: ''
}`}
>
{isProcessing ? (
<span className="tg-creation-batch__processing-state tg-creation-batch__loading-stack">
<span className="tg-creation-batch__loading-title">
{processingTypeLabel}
</span>
<span
className="tg-creation-batch__progress tg-creation-batch__loading-progress"
aria-label={`生成进度 ${mediaProgress}%`}
>
<span className="tg-creation-batch__progress-ring" />
<span className="tg-creation-batch__progress-value">
{mediaProgress}%
</span>
</span>
<span className="tg-creation-batch__loading-status">
{getStatusLabel(media.task.status)}
</span>
</span>
) : (
<span className="tg-creation-batch__placeholder-icon">
{isVideo ? (
<VideoIcon size={20} />
) : isAudio ? (
<Music2 size={20} />
) : (
<ImageIcon size={20} />
)}
</span>
)}
{!isProcessing ? (
<span className="tg-creation-batch__placeholder-label">
{getStatusLabel(media.task.status)}
</span>
) : null}
</span>
)}
</button>
{isAudio && isCompleted ? (
<span className="tg-creation-batch__play" aria-hidden="true">
<Music2 size={22} />
</span>
) : null}
{isCompleted ? (
<span className="tg-creation-batch__hover-actions">
<HoverTip content="预览">
<button
className="tg-creation-batch__icon"
type="button"
aria-label="预览"
onClick={(event) => {
event.stopPropagation();
onPreviewOpen?.(media.id);
}}
>
<Eye size={15} />
</button>
</HoverTip>
{isVideo ? (
<HoverTip content="去剪辑">
<button
className="tg-creation-batch__icon"
type="button"
aria-label="去剪辑"
onClick={(event) => {
event.stopPropagation();
onClip?.(media.taskId);
}}
>
<Scissors size={15} />
</button>
</HoverTip>
) : null}
<HoverTip content="插入画布">
<button
className="tg-creation-batch__icon"
type="button"
aria-label="插入画布"
onClick={(event) => {
event.stopPropagation();
onInsertBatch?.(mediaTaskIds);
}}
>
<ImagePlus size={15} />
</button>
</HoverTip>
{isPublishable ? (
<HoverTip content="去发布">
<button
className="tg-creation-batch__icon"
type="button"
aria-label="去发布"
onClick={(event) => {
event.stopPropagation();
onPublishBatch?.(mediaTaskIds);
}}
>
<Send size={15} />
</button>
</HoverTip>
) : null}
<HoverTip content="下载">
<button
className="tg-creation-batch__icon"
type="button"
aria-label="下载"
onClick={(event) => {
event.stopPropagation();
onDownloadBatch?.(mediaTaskIds);
}}
>
<Download size={15} />
</button>
</HoverTip>
</span>
) : null}
</div>
</div>
);
})}
</div>
<div className="tg-creation-batch__actions">
{canEdit ? (
<button type="button" onClick={() => onEdit?.(primaryTask.id)}>
<Edit3 size={15} />
<span></span>
</button>
) : null}
{hasFailedTask ? (
<button
type="button"
onClick={() => {
onRetry?.(
retryableTaskIds.length ? retryableTaskIds : [primaryTask.id]
);
}}
>
<RefreshCcw size={15} />
<span></span>
</button>
) : primaryTask.type === TaskType.IMAGE ? (
<button type="button" onClick={() => onRegenerate?.(primaryTask.id)}>
<RefreshCcw size={15} />
<span></span>
</button>
) : null}
{hasCompletedMedia ? (
<button type="button" onClick={() => onDownloadBatch?.(taskIds)}>
<Download size={15} />
<span></span>
</button>
) : null}
{hasCompletedMedia ? (
<button type="button" onClick={() => onInsertBatch?.(taskIds)}>
<ImagePlus size={15} />
<span></span>
</button>
) : null}
{firstCompletedVideoTask ? (
<button
type="button"
onClick={() => onClip?.(firstCompletedVideoTask.id)}
>
<Scissors size={15} />
<span></span>
</button>
) : null}
{hasPublishableMedia ? (
<details className="tg-creation-batch__more">
<summary>
<MoreHorizontal size={15} />
<span></span>
</summary>
<div className="tg-creation-batch__more-menu">
<button type="button" onClick={() => onPublishBatch?.(taskIds)}>
<Send size={15} />
<span></span>
</button>
<button
type="button"
className="tg-creation-batch__danger"
onClick={() =>
batch.tasks.length > 1
? onDeleteBatch?.(taskIds)
: onDelete?.(primaryTask.id)
}
>
<Trash2 size={15} />
<span></span>
</button>
</div>
</details>
) : (
<button
type="button"
className="tg-creation-batch__danger"
onClick={() =>
batch.tasks.length > 1
? onDeleteBatch?.(taskIds)
: onDelete?.(primaryTask.id)
}
>
<Trash2 size={15} />
<span></span>
</button>
)}
</div>
</article>
);
}