/** * 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(); 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 = ({ 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( 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( (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(); 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}

{feedHeading}

{groupedTaskItems.length ? ( <> {groupedTaskItems.map((batchItem) => ( {batchItem.dateHeading ? (

{batchItem.dateHeading}

) : null}
))} {hasMore ? ( ) : null} ) : (
{isLoading ?

加载中...

:

{feedEmptyText}

}
)}
{/* Unified Preview */} {/* Character Create Dialog */} 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; 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 } { 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(); 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 { const params = task.params.params; if (!params || typeof params !== 'object' || Array.isArray(params)) { return {}; } return Object.entries(params as Record).reduce< Record >((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 | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } return value as Record; } function readLooseValue( task: Task, keys: string[] ): string | number | undefined { const paramRecord = task.params as unknown as Record; const resultRecord = task.result as unknown as Record; 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[]; 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 (

{title}

{batchMeta} {statusMeta ? {statusMeta} : null}
{failureReason ? (
失败原因:{failureReason}
) : null}
{references.length ? (
{references.slice(0, 4).map((image, index) => ( {image.name ))} {references.length > 4 ? ( +{references.length - 4} ) : null}
) : null}
{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 (
{isAudio && isCompleted ? ( ) : null} {isCompleted ? ( {isVideo ? ( ) : null} {isPublishable ? ( ) : null} ) : null}
); })}
{canEdit ? ( ) : null} {hasFailedTask ? ( ) : primaryTask.type === TaskType.IMAGE ? ( ) : null} {hasCompletedMedia ? ( ) : null} {hasCompletedMedia ? ( ) : null} {firstCompletedVideoTask ? ( ) : null} {hasPublishableMedia ? (
更多
) : ( )}
); }