import { App, Button, Drawer, Form, Input, Modal, Progress, Segmented, Select, Space, Table, Typography } from "antd"; import type { ColumnsType } from "antd/es/table"; import { Eye, FileText, FolderKanban, Image as ImageIcon, Play, Plus, RefreshCw, RotateCcw, Search, Video, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router"; import { ListToolbar, PageHeader, TableSurface, 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 { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import { cancelGenerationTask, createAgentSession, createGenerationTask, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog, type TaskStatus } from "@/services/api/task-center"; import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { modelDisplayName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; import { formatCredits } from "@/constant/credits"; import { listProjects, type ProjectSummary } from "@/services/api/projects"; const taskTableClassName = "app-data-table"; type TaskStatusFilter = "all" | "failed" | "active" | "succeeded"; function taskStatusFilter(value: string | null): TaskStatusFilter { return value === "failed" || value === "active" || value === "succeeded" ? value : "all"; } export default function TasksPage() { const { message } = App.useApp(); 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 [tasks, setTasks] = useState([]); 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 [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); 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 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 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; 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, keyword, projectFilter, statusFilter, tasks]); useEffect(() => { if (!shortDramaEnabled) { setDomainProjects([]); return; } let cancelled = false; void listProjects().then((result) => { if (!cancelled) setDomainProjects(result.projects); }).catch(() => undefined); 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); if (await syncGenerationTaskToCanvasStore(detail)) message.success("已同步到画布"); } 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, action: "retry" | "cancel") => { setActingId(id); try { const next = action === "retry" ? await retryGenerationTask(id) : await cancelGenerationTask(id); setTasks((items) => items.map((item) => (item.id === id ? next : item))); if (action === "retry") { setStatusFilter("active"); setPage(1); } if (action === "retry") message.success("任务已重新入队"); else if (next.providerCancelStatus === "requested") message.info("已请求上游取消,正在确认费用状态"); else if (next.providerCancelStatus === "confirmed") message.success("上游已确认取消,积分已退回"); else if (next.providerCancelStatus === "uncertain") message.warning("任务已取消,上游费用待核对"); else 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 { if (values.operation === "agent_session") { const textModel = values.model?.trim() || effectiveConfig.textModel || effectiveConfig.model; if (!isAiConfigReady(effectiveConfig, textModel)) { message.error("请先在设置里配置可用的文本模型、Base URL 和 API Key"); return; } const requestConfig = resolveModelRequestConfig(effectiveConfig, textModel); const detail = await createAgentSession({ projectId: values.projectId, prompt: values.prompt, config: backendProviderConfig(requestConfig) }); setTasks((items) => [...detail.tasks, ...items]); } else { 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, input: { source: "tasks-page", mode: values.operation === "compare_versions" ? "workflow" : "video", prompt: buildVideoOperationPrompt(values.operation, values.prompt), 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); } }; const columns = useMemo>( () => [ { title: "画布(项目)", width: 190, responsive: ["lg"], render: (_, task) => { const context = getTaskCanvasContext(task, canvasById, domainProjectNameById); return (
{context.canvasName}
{context.projectName || "未加入项目"}
); }, }, { title: "类型|模型", width: 180, responsive: ["md"], render: (_, task) =>
{formatTaskKind(task)}
{formatModelName(effectiveConfig, task)}
, }, { title: "任务名称", dataIndex: "prompt", width: 340, render: (prompt, task) => { const context = getTaskCanvasContext(task, canvasById, domainProjectNameById); return
task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: prompt || formatTaskKind(task) })} />
{prompt || "未命名任务"}
{formatTaskKind(task)} · {formatModelName(effectiveConfig, task)}
{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}
{statusLabel[task.status]}{creditsEnabled ? : null}
; }, }, { title: "状态", width: 135, responsive: ["md"], render: (_, task) => (
{statusLabel[task.status]} {task.status === "queued" || task.status === "running" ? {task.progress || 0}% : null}
{task.status === "queued" || task.status === "running" ? : null}
), }, { title: "重试", dataIndex: "attempts", width: 75, align: "center", responsive: ["lg"], render: (attempts: number) => {Math.max(0, (attempts || 1) - 1)} 次, }, { title: "创建时间", dataIndex: "createdAt", width: 145, responsive: ["lg"], render: (value) => , }, ...(creditsEnabled ? [{ title: "消耗积分", width: 130, align: "right", responsive: ["md"], render: (_, task) => , }] as ColumnsType : []), { title: "操作", width: 168, align: "right", responsive: ["md"], render: (_, task) => ( {task.status === "failed" || task.status === "cancelled" ? : null} {task.status === "queued" || task.status === "running" ? : null} ), }, ], [actingId, canvasById, creditsEnabled, domainProjectNameById, effectiveConfig, openTaskDetail], ); return ( <> {filteredTasks.length} 个任务{loading ? " · 正在同步" : ""}} actions={( <> )} /> { setKeyword(""); setProjectFilter("all"); setPage(1); }}> } value={keyword} placeholder="搜索任务、模型、画布或项目" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} /> setDetailTask(null)} size="large" destroyOnHidden> {detailTask ? (
{detailTask.providerCancelStatus ? : null} {detailTask.providerCancelRequestedAt ? : null}
{canQueryProviderTask(detailTask) ?
: null} {detailTask.error ?
{generationErrorMessage(detailTask.error)}
: null}
日志
{logsLoading ? "日志加载中..." : taskLogs.length ? taskLogs.map((log) => `[${new Date(log.createdAt).toLocaleString()}] ${log.level.toUpperCase()} ${log.message}${log.payload ? `\n${generationErrorMessage(log.payload)}` : ""}`).join("\n\n") : "暂无日志"}
) : null}
{mediaPreview?.title || "生成结果预览"}} open={Boolean(mediaPreview)} onCancel={() => setMediaPreview(null)} footer={null} centered width="min(1040px, calc(100vw - 32px))" destroyOnHidden className="task-media-preview-modal" > {mediaPreview?.kind === "video" ? ); } 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 !== next.length) 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.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) => isVideoResult(url, taskType) ?
); } 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 TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen: () => void }) { const isVideo = task.previewKind === "video"; const fallbackVideo = task.type.includes("video"); if (!task.previewUrl) { const Icon = fallbackVideo ? Video : task.type.includes("image") ? ImageIcon : FileText; return ; } return ( ); } function getTaskCanvasContext(task: GenerationTask, canvasById: Map, projectNameById: Map) { if (!task.projectId) return { canvasName: "未绑定画布", projectName: "" }; const canvas = canvasById.get(task.projectId); if (canvas) return { canvasName: canvas.title || "未命名画布", projectName: canvas.projectId ? projectNameById.get(canvas.projectId) || "" : "" }; const projectName = projectNameById.get(task.projectId); return projectName ? { canvasName: "项目级任务", projectName } : { canvasName: "画布已移除", projectName: "" }; } function taskAttentionReason(task: GenerationTask) { if (task.status === "cancelled") return providerCancelStatusLabel(task); if (task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)) return "内容审核未通过,请修改输入后新建任务"; if (task.error) return generationErrorMessage(task.error); return task.stage || "生成失败,打开详情查看原因"; } function providerCancelStatusLabel(task: GenerationTask) { if (task.providerCancelStatus === "requested") return "已请求上游取消,正在等待确认"; if (task.providerCancelStatus === "confirmed") return "上游已确认取消,积分已退回"; if (task.providerCancelStatus === "uncertain") { if (task.billing?.status === "settled") return "上游未能取消,费用已结算"; if (task.billing?.status === "refunded") return "上游取消结果未确认,积分已退回"; return task.providerCancelError || "上游无法确认取消,费用待核对"; } return task.billing?.status === "refunded" ? "任务在调用上游前取消,积分已退回" : "任务已取消,可按原输入重新提交"; } 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 statusDotClassName(status: TaskStatus) { if (status === "succeeded") return "bg-emerald-500"; if (status === "running") return "bg-amber-500"; if (status === "queued") return "bg-blue-500"; if (status === "failed") return "bg-red-500"; return "bg-foreground/30"; } function TaskDate({ value }: { value?: string }) { if (!value) return -; const date = new Date(value); if (Number.isNaN(date.getTime())) return -; return
{date.toLocaleDateString()}
{date.toLocaleTimeString()}
; } function TaskBilling({ billing }: { billing?: GenerationTask["billing"] }) { if (!billing) return -; const amount = formatCredits(billing.amountMicrocredits); const note = billing.status === "settled" ? "已结算" : billing.status === "refunded" ? "已退回" : billing.status === "uncertain" ? "待核对" : "预计"; return
{amount}
{note}
; } function formatModelName(config: AiConfig, task: GenerationTask) { const raw = (task.model || task.provider || "").trim(); const model = raw.includes("::") ? raw.split("::").pop()?.trim() || raw : raw; if (!model) return "工作流"; if (model === "version-router") return "版本对比工作流"; if (model === "workflow-router") return "工作流路由"; if (model === "internal-agent") return "内置工作流"; if (model === "openai-compatible") return "OpenAI 兼容接口"; return modelDisplayName(config, raw); } function formatDate(value?: string) { if (!value) return "-"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(); } function InfoItem({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } function DetailBlock({ title, value }: { title: string; value: string }) { return (
{title}
{value}
); } function formatTaskJson(value?: string) { if (!value) return "无"; try { return JSON.stringify(JSON.parse(value), null, 2); } catch { return value; } } function backendProviderConfig(config: ReturnType) { return { channelId: config.channelId, apiFormat: config.apiFormat, interfaceType: config.interfaceType, baseUrl: config.baseUrl, apiKey: config.apiKey, secretKey: config.secretKey, model: config.model, size: config.size, quality: config.quality, transparentBackground: config.transparentBackground, count: config.count, videoSeconds: config.videoSeconds, vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio, videoWatermark: config.videoWatermark, audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: config.audioSpeed, audioInstructions: config.audioInstructions, capabilityConfig: modelCapabilityConfigFor(config, config.model), systemPrompt: config.systemPrompt, }; } function buildVideoOperationPrompt(operation: string, prompt: string) { const operationLabel = operationOptions.find((item) => item.value === operation)?.label || "其他视频操作"; if (operation === "compare_versions") return `请对以下视频结果版本做对比分析,输出推荐版本、差异点和修改建议:\n${prompt}`; return `视频编辑任务:${operationLabel}\n创作要求:${prompt}`; }