2214 lines
69 KiB
TypeScript
2214 lines
69 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|