Files
ttstd-infinite-canvas/web/src/pages/tasks/index.tsx

709 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CreateTaskInput & { operation: string }>();
const [tasks, setTasks] = useState<GenerationTask[]>([]);
const [domainProjects, setDomainProjects] = useState<ProjectSummary[]>([]);
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<GenerationTask | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null);
const syncedCanvasTaskIdsRef = useRef(new Set<string>());
const tasksRef = useRef<GenerationTask[]>([]);
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<ColumnsType<GenerationTask>>(
() => [
{
title: "画布(项目)",
width: 190,
responsive: ["lg"],
render: (_, task) => {
const context = getTaskCanvasContext(task, canvasById, domainProjectNameById);
return (
<div className="min-w-0">
<div className="truncate text-xs font-medium text-foreground/82" title={context.canvasName}>{context.canvasName}</div>
<div className="mt-1 flex min-w-0 items-center gap-1 text-[var(--fs-tiny)] text-foreground/42" title={context.projectName || "未加入项目"}>
<FolderKanban className="size-3 shrink-0" />
<span className="truncate">{context.projectName || "未加入项目"}</span>
</div>
</div>
);
},
},
{
title: "类型|模型",
width: 180,
responsive: ["md"],
render: (_, task) => <div className="min-w-0"><div className="truncate text-xs font-medium text-foreground/78">{formatTaskKind(task)}</div><div className="mt-1 truncate text-[var(--fs-tiny)] text-foreground/42" title={formatModelName(effectiveConfig, task)}>{formatModelName(effectiveConfig, task)}</div></div>,
},
{
title: "任务名称",
dataIndex: "prompt",
width: 340,
render: (prompt, task) => {
const context = getTaskCanvasContext(task, canvasById, domainProjectNameById);
return <div className="flex min-w-0 items-start gap-3 md:items-center">
<TaskPreviewThumbnail task={task} onOpen={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: prompt || formatTaskKind(task) })} />
<div className="min-w-0 flex-1">
<div title={prompt} className="line-clamp-2 max-w-full break-words text-xs font-medium leading-5 text-foreground/88">{prompt || "未命名任务"}</div>
<div className={`mt-1 hidden truncate text-[var(--fs-tiny)] md:block ${task.status === "failed" ? "text-red-600 dark:text-red-400" : task.status === "cancelled" ? "text-amber-600 dark:text-amber-400" : "text-foreground/38"}`} title={task.error ? generationErrorMessage(task.error) : undefined}>
{task.status === "failed" || task.status === "cancelled" ? taskAttentionReason(task) : task.stage || statusLabel[task.status]}
</div>
<div className="mt-2 space-y-2 md:hidden">
<div className="truncate text-[var(--fs-tiny)] text-foreground/45">{formatTaskKind(task)} · {formatModelName(effectiveConfig, task)}</div>
<div className="truncate text-[var(--fs-tiny)] text-foreground/38">{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}</div>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-[var(--fs-label)]"><span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} /><span>{statusLabel[task.status]}</span>{creditsEnabled ? <TaskBilling billing={task.billing} /> : null}</div>
<Space size={0}>
<Button type="text" size="small" aria-label="查看详情" icon={<Eye className="size-3.5" />} onClick={() => openTaskDetail(task)} />
{task.status === "failed" || task.status === "cancelled" ? <Button type="text" size="small" aria-label="重新生成" icon={<RotateCcw className="size-3.5" />} loading={actingId === task.id} disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={() => runAction(task.id, "retry")} /> : null}
{task.status === "queued" || task.status === "running" ? <Button type="text" size="small" aria-label="取消任务" danger icon={<X className="size-3.5" />} loading={actingId === task.id} onClick={() => runAction(task.id, "cancel")} /> : null}
</Space>
</div>
</div>
</div>
</div>;
},
},
{
title: "状态",
width: 135,
responsive: ["md"],
render: (_, task) => (
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-medium">
<span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} />
<span>{statusLabel[task.status]}</span>
{task.status === "queued" || task.status === "running" ? <span className="ml-auto text-[var(--fs-tiny)] tabular-nums text-foreground/42">{task.progress || 0}%</span> : null}
</div>
{task.status === "queued" || task.status === "running" ? <Progress className="!mb-0 !mt-1.5 block" percent={task.progress || 0} showInfo={false} size={[90, 3]} strokeColor="var(--workspace-accent)" /> : null}
</div>
),
},
{
title: "重试",
dataIndex: "attempts",
width: 75,
align: "center",
responsive: ["lg"],
render: (attempts: number) => <span className="text-xs tabular-nums text-foreground/55">{Math.max(0, (attempts || 1) - 1)} 次</span>,
},
{
title: "创建时间",
dataIndex: "createdAt",
width: 145,
responsive: ["lg"],
render: (value) => <TaskDate value={value} />,
},
...(creditsEnabled ? [{
title: "消耗积分",
width: 130,
align: "right",
responsive: ["md"],
render: (_, task) => <TaskBilling billing={task.billing} />,
}] as ColumnsType<GenerationTask> : []),
{
title: "操作",
width: 168,
align: "right",
responsive: ["md"],
render: (_, task) => (
<Space size={2}>
<Button type="text" size="small" icon={<Eye className="size-3.5" />} onClick={() => openTaskDetail(task)}>详情</Button>
{task.status === "failed" || task.status === "cancelled" ? <Button type="text" size="small" aria-label="重新生成" title={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error) ? "内容审核未通过,请修改提示词后新建任务" : "重新生成"} icon={<RotateCcw className="size-3.5" />} loading={actingId === task.id} disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={() => runAction(task.id, "retry")}>重试</Button> : null}
{task.status === "queued" || task.status === "running" ? <Button type="text" size="small" aria-label="取消任务" danger icon={<X className="size-3.5" />} loading={actingId === task.id} onClick={() => runAction(task.id, "cancel")}>取消</Button> : null}
</Space>
),
},
],
[actingId, canvasById, creditsEnabled, domainProjectNameById, effectiveConfig, openTaskDetail],
);
return (
<>
<WorkspacePage grid>
<PageHeader
icon="tasks"
title="任务中心"
description="先处理失败任务,再跟踪运行进度和检查生成结果。"
meta={<span className="text-xs text-foreground/45">{filteredTasks.length} 个任务{loading ? " · 正在同步" : ""}</span>}
actions={(
<>
<Button icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} onClick={() => void loadTasks(true)}>刷新</Button>
<Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>
</>
)}
/>
<ListToolbar active={Boolean(keyword || projectFilter !== "all")} onReset={() => { setKeyword(""); setProjectFilter("all"); setPage(1); }}>
<Input id="task-search" name="taskSearch" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索任务、模型、画布或项目" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Select className="w-full sm:w-48" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部画布", value: "all" }, ...projectOptions]} />
<Segmented
size="small"
value={statusFilter}
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
options={[
{ label: `全部 ${tasks.length}`, value: "all" },
{ label: `需要处理 ${tasks.filter((task) => task.status === "failed" || task.status === "cancelled").length}`, value: "failed" },
{ label: `运行中 ${tasks.filter((task) => task.status === "queued" || task.status === "running").length}`, value: "active" },
{ label: `已完成 ${tasks.filter((task) => task.status === "succeeded").length}`, value: "succeeded" },
]}
/>
</ListToolbar>
<TableSurface className="task-table-surface">
<Table
rowKey="id"
size="middle"
className={taskTableClassName}
columns={columns}
dataSource={filteredTasks}
loading={loading}
rowClassName={() => "task-table-row align-middle"}
tableLayout="fixed"
pagination={{ current: page, pageSize, total: filteredTasks.length, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (total, range) => `${range[0]}-${range[1]} / 共 ${total} 个任务`, onChange: (nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); } }}
scroll={{ x: 1280 }}
locale={{ emptyText: <WorkspaceState compact title={taskEmptyState(statusFilter).title} description={taskEmptyState(statusFilter).description} /> }}
/>
</TableSurface>
</WorkspacePage>
<Modal title="新建异步生成任务" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={submitTask} confirmLoading={creating} okText="创建任务">
<Form form={form} layout="vertical" initialValues={{ operation: "agent_session" }}>
<Form.Item name="operation" label="任务类型" rules={[{ required: true, message: "请选择任务类型" }]}>
<Select options={operationOptions} />
</Form.Item>
<Form.Item name="prompt" label="创作指令" rules={[{ required: true, message: "请输入创作指令" }]}>
<Input.TextArea rows={5} placeholder="描述短剧、MV、TVC 或要执行的视频编辑操作" />
</Form.Item>
<Form.Item name="projectId" label="绑定画布">
<Select allowClear showSearch optionFilterProp="label" options={projectOptions} placeholder={projectOptions.length ? "可选,选择要绑定的画布" : "暂无本地画布"} />
</Form.Item>
<Form.Item name="model" label="目标模型">
<Input placeholder="可选,例如 seedance、kling、wan、nano-banana" />
</Form.Item>
</Form>
</Modal>
<Drawer title="任务详情" open={Boolean(detailTask)} onClose={() => setDetailTask(null)} size="large" destroyOnHidden>
{detailTask ? (
<div className="space-y-5">
<div className="grid border-y border-border text-sm sm:grid-cols-2">
<InfoItem label="状态" value={statusLabel[detailTask.status]} />
<InfoItem label="画布名称" value={getTaskCanvasContext(detailTask, canvasById, domainProjectNameById).canvasName} />
<InfoItem label="任务类型" value={formatTaskKind(detailTask)} />
<InfoItem label="模型" value={formatModelName(effectiveConfig, detailTask)} />
<InfoItem label="尝试次数" value={`第 ${detailTask.attempts || 1} 次`} />
<InfoItem label="创建时间" value={formatDate(detailTask.createdAt)} />
{detailTask.providerCancelStatus ? <InfoItem label="上游取消" value={providerCancelStatusLabel(detailTask)} /> : null}
{detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null}
</div>
{canQueryProviderTask(detailTask) ? <div className="flex justify-end"><Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button></div> : null}
{detailTask.error ? <pre className="max-h-28 overflow-auto whitespace-pre-wrap border-l-2 border-red-500 bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950/30 dark:text-red-300">{generationErrorMessage(detailTask.error)}</pre> : null}
<TaskResultMedia value={detailTask.resultJson} taskType={detailTask.type} />
<DetailBlock title="输入" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.inputJson)} />
<DetailBlock title="结果" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.resultJson)} />
<div>
<Typography.Text strong>日志</Typography.Text>
<div className="mt-2 max-h-60 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-100">
{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") : "暂无日志"}
</div>
</div>
</div>
) : null}
</Drawer>
<Modal
title={<span className="block truncate pr-8">{mediaPreview?.title || "生成结果预览"}</span>}
open={Boolean(mediaPreview)}
onCancel={() => setMediaPreview(null)}
footer={null}
centered
width="min(1040px, calc(100vw - 32px))"
destroyOnHidden
className="task-media-preview-modal"
>
{mediaPreview?.kind === "video"
? <video src={mediaPreview.url} className="max-h-[76vh] w-full bg-black object-contain" controls playsInline preload="metadata" />
: mediaPreview ? <img src={mediaPreview.url} alt={mediaPreview.title} className="max-h-[76vh] w-full bg-black object-contain" /> : null}
</Modal>
</>
);
}
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 (
<div>
<Typography.Text strong>生成结果</Typography.Text>
<div className="mt-2 grid max-h-[360px] grid-cols-2 gap-2 overflow-auto rounded-lg bg-stone-950 p-2 md:grid-cols-3">
{urls.map((url, index) => isVideoResult(url, taskType)
? <video key={`${url}-${index}`} src={url} className="aspect-video w-full rounded-md bg-black object-contain" controls preload="metadata" />
: <img key={`${url}-${index}`} src={url} alt={`生成结果 ${index + 1}`} className="aspect-square w-full rounded-md bg-black object-contain" />)}
</div>
</div>
);
}
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 <span className="grid h-12 w-[68px] shrink-0 place-items-center rounded-md border border-border/70 bg-muted/35 text-foreground/28"><Icon className="size-4" /></span>;
}
return (
<button type="button" onClick={onOpen} className="group relative h-12 w-[68px] shrink-0 overflow-hidden rounded-md border border-border/80 bg-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={isVideo ? "放大预览生成视频" : "放大预览生成图片"}>
{isVideo
? <video src={task.previewUrl} width={68} height={48} muted playsInline preload="metadata" className="h-full w-full object-cover" />
: <img src={task.previewUrl} alt="" width={68} height={48} loading="lazy" className="h-full w-full object-cover" />}
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition-[background-color,opacity] duration-150 group-hover:bg-black/30 group-hover:opacity-100 group-focus-visible:bg-black/30 group-focus-visible:opacity-100">
{isVideo ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}
</span>
</button>
);
}
function getTaskCanvasContext(task: GenerationTask, canvasById: Map<string, { title: string; projectId?: string }>, projectNameById: Map<string, string>) {
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 <span className="text-xs text-foreground/38">-</span>;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return <span className="text-xs text-foreground/38">-</span>;
return <div className="text-xs tabular-nums text-foreground/62"><div>{date.toLocaleDateString()}</div><div className="mt-1 text-[var(--fs-tiny)] text-foreground/38">{date.toLocaleTimeString()}</div></div>;
}
function TaskBilling({ billing }: { billing?: GenerationTask["billing"] }) {
if (!billing) return <span className="text-xs text-foreground/30">-</span>;
const amount = formatCredits(billing.amountMicrocredits);
const note = billing.status === "settled" ? "已结算" : billing.status === "refunded" ? "已退回" : billing.status === "uncertain" ? "待核对" : "预计";
return <div className="text-right"><div className="text-xs font-medium tabular-nums text-foreground/78">{amount}</div><div className={`mt-1 text-[var(--fs-tiny)] ${billing.status === "uncertain" ? "text-amber-600 dark:text-amber-300" : "text-foreground/38"}`}>{note}</div></div>;
}
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 (
<div className="min-w-0 border-b border-border px-0 py-2.5">
<Typography.Text type="secondary" className="block text-xs">
{label}
</Typography.Text>
<Typography.Text className="block truncate text-sm" title={value}>
{value}
</Typography.Text>
</div>
);
}
function DetailBlock({ title, value }: { title: string; value: string }) {
return (
<div>
<Typography.Text strong>{title}</Typography.Text>
<pre className="mt-2 max-h-60 overflow-auto rounded-md bg-slate-950 p-3 text-xs leading-5 text-slate-100">{value}</pre>
</div>
);
}
function formatTaskJson(value?: string) {
if (!value) return "无";
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
function backendProviderConfig(config: ReturnType<typeof resolveModelRequestConfig>) {
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}`;
}