import { CollectionToolbar } from "@/components/layout/collection-toolbar"; import { App, Button, Drawer, Form, Input, Modal, Select, Typography } from "antd"; import { Switch } from "@/components/ui/base/switch"; import { SegmentedControl } from "@/components/ui/base/segmented-control"; import { Bug, LayoutGrid, List, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { MediaPreview } from "@/components/media-preview"; import { PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page"; import { WorkspaceState } from "@/components/layout/workspace-state"; import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error"; import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display"; import { buildVideoOperationPrompt } from "@/lib/prompts"; import { backendProviderConfig, logicalModelIDForConfig } from "@/services/api/generation-task"; import { createGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center"; import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; import { listProjects, type ProjectSummary } from "@/services/api/projects"; import { TaskGridCard } from "./task-grid-card"; import { TaskGroupHeader, type TaskGroup } from "./task-group-header"; import { TaskListRow } from "./task-list-row"; import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared"; import { TaskStatusFilterBar, type TaskStatusFilter } from "./task-status-filter"; type TaskKindFilter = "all" | "text" | "image" | "video"; type TaskViewMode = "list" | "grid"; function preferenceKeys() { const userId = useUserStore.getState().user?.id ?? "anon"; return { view: `task-center-view.${userId}`, group: `task-center-group.${userId}` }; } function readTaskPreference(key: string, fallback: string): string { try { return window.localStorage.getItem(key) ?? fallback; } catch (error) { console.warn("读取任务中心偏好失败", error); return fallback; } } function writeTaskPreference(key: string, value: string): void { try { window.localStorage.setItem(key, value); } catch (error) { console.warn("保存任务中心偏好失败", error); } } function taskStatusFilter(value: string | null): TaskStatusFilter { return value === "failed" || value === "active" || value === "succeeded" ? value : "all"; } export default function TasksPage() { const { message, modal } = App.useApp(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const effectiveConfig = useEffectiveConfig(); const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady); const projects = useCanvasStore((state) => state.projects); const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled); const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const [form] = Form.useForm(); const { view: viewPreferenceKey, group: groupPreferenceKey } = preferenceKeys(); const [domainProjects, setDomainProjects] = useState([]); const [loading, setLoading] = useState(false); const [actingId, setActingId] = useState(""); const [createOpen, setCreateOpen] = useState(false); const [creating, setCreating] = useState(false); const statusFilter = taskStatusFilter(searchParams.get("status")); const setStatusFilter = (value: TaskStatusFilter) => { const next = new URLSearchParams(searchParams); next.set("status", value); setSearchParams(next, { replace: true }); }; const [keyword, setKeyword] = useState(""); const [projectFilter, setProjectFilter] = useState("all"); const [kindFilter, setKindFilter] = useState("all"); const [modelFilter, setModelFilter] = useState("all"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [viewMode, setViewMode] = useState(() => (readTaskPreference(viewPreferenceKey, "list") === "grid" ? "grid" : "list")); const [groupEnabled, setGroupEnabled] = useState(() => readTaskPreference(groupPreferenceKey, "0") === "1"); const [retryingGroup, setRetryingGroup] = useState(""); const [detailTask, setDetailTask] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [taskLogs, setTaskLogs] = useState([]); const [logsLoading, setLogsLoading] = useState(false); const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null); const [tasks, setTasks] = useState([]); const syncedCanvasTaskIdsRef = useRef(new Set()); const tasksRef = useRef([]); const canvasById = useMemo(() => new Map(projects.map((project) => [project.id, project])), [projects]); const domainProjectNameById = useMemo(() => new Map(domainProjects.map((item) => [item.project.id, item.project.name])), [domainProjects]); const projectOptions = useMemo(() => projects.map((project) => { const projectName = project.projectId ? domainProjectNameById.get(project.projectId) : ""; return { label: projectName ? `${project.title || "未命名画布"} · ${projectName}` : project.title || "未命名画布", value: project.id }; }), [domainProjectNameById, projects]); const modelOptions = useMemo(() => Array.from(new Set(tasks.map((task) => formatModelName(effectiveConfig, task)).filter(Boolean))).sort((left, right) => left.localeCompare(right, "zh-CN")), [effectiveConfig, tasks]); const filteredTasks = useMemo(() => tasks.filter((task) => { if (statusFilter === "all") return true; if (statusFilter === "active") return task.status === "queued" || task.status === "running"; if (statusFilter === "failed") return task.status === "failed" || task.status === "cancelled"; if (statusFilter === "succeeded") return task.status === "succeeded"; return false; }).filter((task) => { if (projectFilter !== "all" && task.projectId !== projectFilter) return false; if (kindFilter !== "all" && taskMediaKind(task) !== kindFilter) return false; if (modelFilter !== "all" && formatModelName(effectiveConfig, task) !== modelFilter) return false; const query = keyword.trim().toLowerCase(); const context = getTaskCanvasContext(task, canvasById, domainProjectNameById); return !query || `${task.prompt} ${task.model || ""} ${formatTaskKind(task)} ${context.canvasName} ${context.projectName}`.toLowerCase().includes(query); }), [canvasById, domainProjectNameById, effectiveConfig, keyword, kindFilter, modelFilter, projectFilter, statusFilter, tasks]); const visibleTasks = useMemo(() => filteredTasks.slice((page - 1) * pageSize, page * pageSize), [filteredTasks, page, pageSize]); const taskStats = useMemo(() => { let today = 0; let active = 0; let succeeded = 0; let failed = 0; const now = new Date(); for (const task of tasks) { if (task.createdAt) { const created = new Date(task.createdAt); if (!Number.isNaN(created.getTime()) && created.getFullYear() === now.getFullYear() && created.getMonth() === now.getMonth() && created.getDate() === now.getDate()) today += 1; } if (task.status === "queued" || task.status === "running") active += 1; else if (task.status === "succeeded") succeeded += 1; else if (task.status === "failed" || task.status === "cancelled") failed += 1; } return { total: tasks.length, today, active, succeeded, failed }; }, [tasks]); const groupingActive = viewMode === "list" && groupEnabled; const visibleTaskGroups = useMemo( () => (groupingActive ? groupTasksByCanvas(filteredTasks, canvasById, domainProjectNameById) : []), [canvasById, domainProjectNameById, filteredTasks, groupingActive], ); const changeViewMode = (mode: TaskViewMode) => { setViewMode(mode); writeTaskPreference(viewPreferenceKey, mode); }; const changeGroupEnabled = (enabled: boolean) => { setGroupEnabled(enabled); writeTaskPreference(groupPreferenceKey, enabled ? "1" : "0"); }; const retryGroupTasks = async (key: string, items: GenerationTask[]) => { const retryable = items.filter((task) => isTaskFailed(task) && task.errorCode !== CONTENT_MODERATION_ERROR_CODE && !isContentModerationError(task.error)); if (!retryable.length) return; setRetryingGroup(key); try { for (const task of retryable) { await runAction(task.id); } } finally { setRetryingGroup(""); } }; const renderTaskRow = (task: GenerationTask) => ( void openTaskDetail(task)} onRetry={() => void runAction(task.id)} onPreview={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: task.prompt || formatTaskKind(task) })} /> ); const renderTaskGridCard = (task: GenerationTask) => ( void openTaskDetail(task)} onRetry={() => void runAction(task.id)} /> ); useEffect(() => { if (!shortDramaEnabled) { setDomainProjects([]); return; } let cancelled = false; void listProjects() .then((result) => { if (!cancelled) setDomainProjects(result.projects); }) .catch((error) => { if (cancelled) return; // 领域项目只是任务页的辅助筛选数据;读取失败不能阻断任务列表,但必须清空旧快照并留下可检索的诊断。 setDomainProjects([]); console.warn("加载任务关联项目失败,已禁用本次项目筛选", error); }); return () => { cancelled = true; }; }, [shortDramaEnabled]); useEffect(() => { const maxPage = Math.max(1, Math.ceil(filteredTasks.length / pageSize)); if (page > maxPage) setPage(maxPage); }, [filteredTasks.length, page, pageSize]); const syncCompletedCanvasTasks = useCallback(async (items: GenerationTask[]) => { const pendingTaskIds = new Set( useCanvasStore .getState() .projects.flatMap((project) => project.nodes) .filter((node) => node.metadata?.taskId && (node.metadata.status !== "success" || !node.metadata.content)) .map((node) => node.metadata!.taskId!), ); const candidates = items.filter((task) => task.status === "succeeded" && pendingTaskIds.has(task.id) && task.projectId && task.type.startsWith("canvas_") && !syncedCanvasTaskIdsRef.current.has(task.id)); await Promise.all( candidates.map(async (task) => { syncedCanvasTaskIdsRef.current.add(task.id); try { const detail = task.resultJson ? task : await queryGenerationTask(task.id); await syncGenerationTaskToCanvasStore(detail); } catch { syncedCanvasTaskIdsRef.current.delete(task.id); } }), ); }, []); const loadTasks = useCallback(async (showLoading = false) => { if (showLoading) setLoading(true); try { const next = await listGenerationTasks(); setTasks((current) => reconcileTaskSummaries(current, next)); void syncCompletedCanvasTasks(next); return next; } catch (error) { if (showLoading) message.error(error instanceof Error ? error.message : "任务加载失败"); return undefined; } finally { if (showLoading) setLoading(false); } }, [message, syncCompletedCanvasTasks]); const openTaskDetail = useCallback( async (task: GenerationTask) => { setDetailTask(task); setTaskLogs([]); setDetailLoading(true); setLogsLoading(true); try { const [detail, logs] = await Promise.all([queryGenerationTask(task.id), listTaskLogs(task.id)]); setDetailTask(detail); setTaskLogs(logs); } catch (error) { message.error(error instanceof Error ? error.message : "任务详情加载失败"); } finally { setDetailLoading(false); setLogsLoading(false); } }, [message], ); useEffect(() => { tasksRef.current = tasks; }, [tasks]); useEffect(() => { let stopped = false; let timer = 0; const poll = async (initial = false) => { const next = await loadTasks(initial); if (stopped) return; const items = next || tasksRef.current; const hasActiveTasks = items.some((task) => task.status === "queued" || task.status === "running"); timer = window.setTimeout(() => void poll(false), document.hidden ? 60_000 : hasActiveTasks ? 10_000 : 60_000); }; const handleVisibility = () => { if (document.hidden) return; window.clearTimeout(timer); void poll(false); }; void poll(true); document.addEventListener("visibilitychange", handleVisibility); return () => { stopped = true; window.clearTimeout(timer); document.removeEventListener("visibilitychange", handleVisibility); }; }, [loadTasks]); const runAction = async (id: string) => { const currentTask = tasksRef.current.find((task) => task.id === id); setActingId(id); try { const next = await retryGenerationTask(id); setTasks((items) => items.map((item) => (item.id === id ? next : item))); setDetailTask((current) => (current?.id === id ? { ...current, ...next } : current)); setStatusFilter("active"); setPage(1); message.success("任务已重新入队"); } catch (error) { message.error(error instanceof Error ? error.message : "操作失败"); } finally { setActingId(""); } }; const queryProviderTask = async (task: GenerationTask) => { setActingId(task.id); try { const result = await queryFailedVideoProviderTask(task.id); if (!result.recovered) { setTaskLogs(await listTaskLogs(task.id)); message.info(`上游任务仍在处理中${result.providerStatus ? `(${result.providerStatus})` : ""}`); return; } setDetailTask(result.task); setTasks((items) => items.map((item) => (item.id === task.id ? { ...item, ...result.task } : item))); setTaskLogs(await listTaskLogs(task.id)); await syncGenerationTaskToCanvasStore(result.task); window.dispatchEvent(new CustomEvent("wallet:updated")); void loadTasks(false); if (result.billingSettled) message.success("已获取上游视频,任务已恢复并完成结算"); else message.warning("已获取上游视频,任务已恢复,计费状态待管理员核对"); } catch (error) { message.error(error instanceof Error ? error.message : "查询上游任务失败"); } finally { setActingId(""); } }; const submitTask = async () => { const values = await form.validateFields(); setCreating(true); try { { const videoModel = values.model?.trim() || effectiveConfig.videoModel || effectiveConfig.model; if (values.operation !== "compare_versions" && !isAiConfigReady(effectiveConfig, videoModel)) { message.error("请先在设置里配置可用的视频模型、Base URL 和 API Key"); return; } const requestConfig = resolveModelRequestConfig(effectiveConfig, videoModel); const task = await createGenerationTask({ projectId: values.projectId, type: `video_${values.operation}`, operation: values.operation, prompt: values.prompt, provider: values.operation === "compare_versions" ? "internal-agent" : "openai-compatible", model: values.operation === "compare_versions" ? "version-router" : requestConfig.model, ...(values.operation !== "compare_versions" && logicalModelIDForConfig(requestConfig) ? { logicalModelId: logicalModelIDForConfig(requestConfig) } : {}), input: { source: "tasks-page", mode: values.operation === "compare_versions" ? "workflow" : "video", prompt: buildVideoOperationPrompt(values.operation, values.prompt, operationOptions.find((item) => item.value === values.operation)?.label || "其他视频操作"), config: values.operation === "compare_versions" ? undefined : backendProviderConfig(requestConfig), metadata: { videoEditOperation: values.operation }, }, }); setTasks((items) => [task, ...items]); } setStatusFilter("active"); setPage(1); setCreateOpen(false); form.resetFields(); message.success("任务已创建"); } catch (error) { message.error(error instanceof Error ? error.message : "任务创建失败"); } finally { setCreating(false); } }; return ( <>
{taskStats.total} 个任务} actions={ } /> { setKeyword(""); setProjectFilter("all"); setKindFilter("all"); setModelFilter("all"); setStatusFilter("all"); setPage(1); }} trailing={(
{viewMode === "list" ? ( ) : null}
ariaLabel="任务视图" size="sm" value={viewMode} options={[ { value: "list", icon: , title: "列表视图" }, { value: "grid", icon: , title: "网格视图" }, ]} onChange={changeViewMode} />
)} > { setStatusFilter(value); setPage(1); }} /> } value={keyword} placeholder="搜索任务、模型或画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} /> { setKindFilter(value as TaskKindFilter); setPage(1); }} options={[{ label: "全部类型", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }]} /> setDetailTask(null)} size="large" destroyOnHidden> {detailTask ? (
{detailTask.providerCancelStatus ? : null} {detailTask.providerCancelRequestedAt ? : null}
{canQueryProviderTask(detailTask) ? : null} {isTaskFailed(detailTask) ? : null}
{detailTask.error ?
{generationErrorMessage(detailTask.error)}
: null}
日志
{logsLoading ? "日志加载中..." : taskLogs.length ? taskLogs.map((log) => `[${new Date(log.createdAt).toLocaleString()}] ${log.level.toUpperCase()} ${formatTaskLog(log)}`).join("\n\n") : "暂无日志"}
) : null}
{mediaPreview?.title || "生成结果预览"}} open={Boolean(mediaPreview)} onCancel={() => setMediaPreview(null)} footer={null} centered width="min(1040px, calc(100vw - 32px))" destroyOnHidden className="library-modal task-media-preview-modal" > {mediaPreview ? ( ) : null} ); } function canQueryProviderTask(task: GenerationTask) { return task.status === "failed" && (task.type.startsWith("canvas_video") || task.type.startsWith("video_")) && Boolean(task.providerRequestId); } function reconcileTaskSummaries(current: GenerationTask[], next: GenerationTask[]) { if (current.length === 0) return next; const currentById = new Map(current.map((task) => [task.id, task])); let changed = false; const reconciled = next.map((task) => { const previous = currentById.get(task.id); if (previous?.updatedAt === task.updatedAt && previous.previewUrl === task.previewUrl && previous.previewPosterUrl === task.previewPosterUrl && previous.billing?.status === task.billing?.status && previous.billing?.amountMicrocredits === task.billing?.amountMicrocredits) return previous; changed = true; return task; }); return changed ? reconciled : current; } function TaskResultMedia({ value, taskType }: { value?: string; taskType: string }) { const urls = resultMediaUrls(value); if (!urls.length) return null; return (
生成结果
{urls.map((url, index) => { const isVideo = isVideoResult(url, taskType); return ( ); })}
); } function resultMediaUrls(value?: string) { if (!value) return []; let parsed: unknown; try { parsed = JSON.parse(value); } catch { parsed = value; } const urls: string[] = []; const visit = (item: unknown, key = "") => { if (typeof item === "string") { const isInlineMedia = /^(data:image\/|data:video\/)/.test(item); const isMediaPath = /\.(png|jpe?g|webp|gif|avif|mp4|webm|mov)(?:$|\?)/i.test(item); const isNamedMediaUrl = /^(https?:|blob:)/.test(item) && /(url|image|video|result|output|media)/i.test(key); if ((isInlineMedia || isMediaPath || isNamedMediaUrl) && !urls.includes(item)) urls.push(item); return; } if (Array.isArray(item)) return item.forEach((value) => visit(value, key)); if (item && typeof item === "object") Object.entries(item).forEach(([field, value]) => visit(value, field)); }; visit(parsed); return urls.slice(0, 12); } function isVideoResult(value: string, taskType: string) { return value.startsWith("data:video/") || /\.(mp4|webm|mov)(?:$|\?)/i.test(value) || taskType.includes("video"); } function groupTasksByCanvas(tasks: GenerationTask[], canvasById: Map, projectNameById: Map): TaskGroup[] { const groups: TaskGroup[] = []; const byKey = new Map(); for (const task of tasks) { const context = getTaskCanvasContext(task, canvasById, projectNameById); const key = `${context.projectName}\u0000${context.canvasName}`; let group = byKey.get(key); if (!group) { group = { key, title: context.canvasName, projectName: context.projectName, tasks: [] }; byKey.set(key, group); groups.push(group); } group.tasks.push(task); } return groups; } function taskEmptyState(status: TaskStatusFilter) { if (status === "all") return { title: "还没有任务", description: "新提交的生成会在这里显示状态和实时进度。" }; if (status === "active") return { title: "没有运行中的任务", description: "新提交的生成会在这里显示排队状态和实时进度。" }; if (status === "succeeded") return { title: "还没有已完成任务", description: "生成成功后,结果预览和执行记录会保留在这里。" }; return { title: "没有失败或取消的任务", description: "失败或取消的生成会出现在这里,并提供原因和可用操作。" }; } function formatDate(value?: string) { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); } function formatTaskDuration(task: GenerationTask) { if (!task.createdAt) return "-"; const start = new Date(task.startedAt || task.createdAt).getTime(); const end = task.completedAt ? new Date(task.completedAt).getTime() : task.status === "queued" || task.status === "running" ? Date.now() : Number.NaN; if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "-"; const totalSeconds = Math.max(0, Math.floor((end - start) / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return minutes ? `${minutes}分 ${seconds}秒` : `${seconds}秒`; } function InfoItem({ label, value, wrap = false }: { label: string; value: string; wrap?: boolean }) { return (
{label} {value}
); } function DetailBlock({ title, value, tall = false }: { title: string; value: string; tall?: boolean }) { return (
{title}
{value}
); } function TaskParameters({ inputJson }: { inputJson?: string }) { const fields = taskParameterFields(inputJson); return (
参数 {fields.length ? (
{fields.map((field) => )}
) : (
暂无参数记录
)}
); } function taskParameterFields(inputJson?: string) { const input = parseTaskInput(inputJson); if (!input) return []; const config = asRecord(input.config); const fields: Array<{ label: string; value: string }> = []; const add = (label: string, value: unknown) => { const text = formatParameterValue(value); if (text) fields.push({ label, value: text }); }; add("模式", input.mode); add("尺寸 / 比例", config.size); add("分辨率", config.vquality || config.quality); add("时长", config.videoSeconds === undefined ? undefined : `${config.videoSeconds} 秒`); add("生成数量", config.count); add("生成声音", booleanParameter(config.videoGenerateAudio)); add("水印", booleanParameter(config.videoWatermark)); add("音色", config.audioVoice); add("音频格式", config.audioFormat); add("音频速度", config.audioSpeed); add("参考图片", formatReferenceList(input.referenceImages, "图片")); add("参考视频", formatReferenceList(input.referenceVideos, "视频")); add("参考音频", formatReferenceList(input.referenceAudios, "音频")); add("遮罩图片", formatReferenceList(input.mask ? [input.mask] : [], "遮罩")); return fields; } function parseTaskInput(value?: string): Record | null { if (!value) return null; try { const parsed: unknown = JSON.parse(value); return asRecord(parsed); } catch { return null; } } function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } function formatParameterValue(value: unknown) { if (value === undefined || value === null || value === "") return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return ""; } function booleanParameter(value: unknown) { if (value === true || value === "true") return "是"; if (value === false || value === "false") return "否"; return undefined; } function formatReferenceList(value: unknown, kind: string) { if (!Array.isArray(value) || !value.length) return "无"; return value.map((item, index) => { const reference = asRecord(item); const name = typeof reference.name === "string" && reference.name.trim() && !/^https?:|^data:|^blob:/i.test(reference.name) ? reference.name.trim() : `${kind}${index + 1}`; const dimensions = typeof reference.width === "number" && typeof reference.height === "number" ? `${reference.width}×${reference.height}` : ""; const duration = typeof reference.durationMs === "number" && reference.durationMs > 0 ? `${Math.round(reference.durationMs / 100) / 10}s` : ""; const details = [dimensions, duration].filter(Boolean).join(","); return details ? `${name}(${details})` : name; }).join("、"); } function formatTaskJson(value?: string) { if (!value) return "无"; try { return JSON.stringify(JSON.parse(value), null, 2); } catch { return value; } }