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

747 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, Select, Switch, Tooltip, Typography } from "antd";
import { LayoutGrid, List, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { ListToolbar, 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, isGenerationTaskSubmissionUncertain, operationOptions, statusLabel } from "@/lib/generation-task-display";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { cancelGenerationTask, createAgentSession, createGenerationTask, deleteGenerationTask, formatTaskLog, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, refreshGenerationTaskStatus, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { localDreaminaCancellationCopy, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
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 } = 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 { view: viewPreferenceKey, group: groupPreferenceKey } = preferenceKeys();
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 [kindFilter, setKindFilter] = useState<TaskKindFilter>("all");
const [modelFilter, setModelFilter] = useState("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [viewMode, setViewMode] = useState<TaskViewMode>(() => (readTaskPreference(viewPreferenceKey, "list") === "grid" ? "grid" : "list"));
const [groupEnabled, setGroupEnabled] = useState<boolean>(() => readTaskPreference(groupPreferenceKey, "0") === "1");
const [retryingGroup, setRetryingGroup] = useState("");
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 [tasks, setTasks] = useState<GenerationTask[]>([]);
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 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, "retry");
}
} finally {
setRetryingGroup("");
}
};
const renderTaskRow = (task: GenerationTask) => (
<TaskListRow
key={task.id}
task={task}
canvasById={canvasById}
projectNameById={domainProjectNameById}
effectiveConfig={effectiveConfig}
creditsEnabled={creditsEnabled}
actingId={actingId}
onOpen={() => void openTaskDetail(task)}
onRetry={() => void runAction(task.id, "retry")}
onCancel={() => void runAction(task.id, "cancel")}
onPreview={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: task.prompt || formatTaskKind(task) })}
/>
);
const renderTaskGridCard = (task: GenerationTask) => (
<TaskGridCard
key={task.id}
task={task}
actingId={actingId}
onOpen={() => void openTaskDetail(task)}
onRetry={() => void runAction(task.id, "retry")}
onCancel={() => void runAction(task.id, "cancel")}
/>
);
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") => {
const currentTask = tasksRef.current.find((task) => task.id === id);
if (action === "retry" && currentTask && isGenerationTaskSubmissionUncertain(currentTask)) {
message.warning("提交结果尚未确认,不能自动重试;请先核对官方状态,避免重复生成。");
return;
}
setActingId(id);
try {
const next = action === "retry" ? await retryGenerationTask(id) : await cancelGenerationTask(id);
setTasks((items) => items.map((item) => (item.id === id ? next : item)));
setDetailTask((current) => (current?.id === id ? { ...current, ...next } : current));
if (action === "retry") {
setStatusFilter("active");
setPage(1);
}
const localOutcome = localDreaminaDetachOutcome(next);
if (action === "retry") message.success("任务已重新入队");
else if (localOutcome?.kind === "background") message.info(localOutcome.message);
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 deleteLocalTask = (task: GenerationTask) => {
Modal.confirm({
title: "删除本机任务记录?",
content:
localDreaminaCancellationCopy(task)?.kind === "background"
? "任务已由官方接受;删除后仍会在后台同步官方状态。"
: task.status === "queued"
? "任务尚未提交官方;删除会取消本机排队且不会触发官方请求。"
: "这只会删除本机任务记录,不会删除已生成的素材。",
okText: "删除本机记录",
okButtonProps: { danger: true },
cancelText: "保留",
onOk: async () => {
setActingId(task.id);
try {
await deleteGenerationTask(task.id);
setTasks((items) => items.filter((item) => item.id !== task.id));
if (detailTask?.id === task.id) setDetailTask(null);
message.success("本机任务记录已删除");
} catch (error) {
message.error(error instanceof Error ? error.message : "删除失败");
} finally {
setActingId("");
}
},
});
};
const refreshLocalTaskStatus = async (task: GenerationTask) => {
setActingId(task.id);
try {
const next = await refreshGenerationTaskStatus(task.id);
setTasks((items) => items.map((item) => (item.id === task.id ? { ...item, ...next } : item)));
setDetailTask((current) => (current?.id === task.id ? { ...current, ...next } : current));
message.success(next.officialStatus ? `官方返回状态:${next.officialStatus}` : "状态已更新");
} 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);
}
};
return (
<>
<WorkspacePage grid className="library-page task-library-page">
<div className="studio-band">
<PageHeader
title="任务中心"
description="跟踪生成任务的排队、进度与失败原因。"
meta={<span className="app-projects-header-meta">{filteredTasks.length} 个任务{loading ? " · 同步中" : ""}</span>}
actions={(
<>
<Button icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} onClick={() => void loadTasks(true)}>刷新</Button>
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>
</>
)}
/>
<ListToolbar
className="library-toolbar task-library-toolbar"
active={Boolean(keyword || projectFilter !== "all" || kindFilter !== "all" || modelFilter !== "all" || statusFilter !== "all")}
onReset={() => { setKeyword(""); setProjectFilter("all"); setKindFilter("all"); setModelFilter("all"); setStatusFilter("all"); setPage(1); }}
trailing={(
<div className="flex flex-wrap items-center gap-2.5">
{viewMode === "list" ? (
<label className="inline-flex cursor-pointer items-center gap-2 text-xs text-foreground/55">
<Switch size="small" checked={groupEnabled} onChange={changeGroupEnabled} />
<span>按画布分组</span>
</label>
) : null}
<div className="task-view-switch" role="group" aria-label="任务视图">
<Tooltip title="列表视图">
<Button type={viewMode === "list" ? "primary" : "text"} size="small" aria-label="列表视图" aria-pressed={viewMode === "list"} icon={<List className="size-3.5" />} onClick={() => changeViewMode("list")} />
</Tooltip>
<Tooltip title="网格视图">
<Button type={viewMode === "grid" ? "primary" : "text"} size="small" aria-label="网格视图" aria-pressed={viewMode === "grid"} icon={<LayoutGrid className="size-3.5" />} onClick={() => changeViewMode("grid")} />
</Tooltip>
</div>
</div>
)}
>
<TaskStatusFilterBar stats={taskStats} value={statusFilter} onChange={(value) => { setStatusFilter(value); 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]} />
<Select className="w-full sm:w-32" value={kindFilter} onChange={(value) => { setKindFilter(value as TaskKindFilter); setPage(1); }} options={[{ label: "全部类型", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }]} />
<Select className="w-full sm:w-44" value={modelFilter} onChange={(value) => { setModelFilter(value); setPage(1); }} options={[{ label: "全部模型", value: "all" }, ...modelOptions.map((model) => ({ label: model, value: model }))]} />
</ListToolbar>
</div>
<div className="canvas-library-frame task-library-frame">
{loading && !tasks.length ? <div className="library-loading-grid" aria-label="正在加载任务">{Array.from({ length: 8 }, (_, index) => <div key={index} className="library-skeleton" />)}</div> : null}
{!loading || tasks.length ? (
visibleTasks.length ? (
viewMode === "grid" ? (
<div className="task-grid-view">
{visibleTasks.map(renderTaskGridCard)}
</div>
) : groupingActive ? (
<div className="task-group-list">
{visibleTaskGroups.map((group) => (
<section key={group.key} className="task-group">
<TaskGroupHeader group={group} retrying={retryingGroup === group.key} onRetryFailed={() => void retryGroupTasks(group.key, group.tasks)} />
<div className="task-record-list">
{group.tasks.map(renderTaskRow)}
</div>
</section>
))}
</div>
) : (
<div className="task-record-list">{visibleTasks.map(renderTaskRow)}</div>
)
) : (
<WorkspaceState
compact
title={taskEmptyState(statusFilter).title}
description={taskEmptyState(statusFilter).description}
action={<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>}
/>
)
) : null}
{!groupingActive ? <PaginationBar current={page} pageSize={pageSize} total={filteredTasks.length} pageSizeOptions={[20, 50, 100]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /> : null}
</div>
</WorkspacePage>
<Modal className="library-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 className="library-drawer" title="任务详情" open={Boolean(detailTask)} onClose={() => setDetailTask(null)} size="large" destroyOnHidden>
{detailTask ? (
<div className="space-y-5">
<div className="task-detail-facts grid 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>
{detailTask.provider === "dreamina-cli" ? <p className="text-xs leading-5 text-foreground/60">官方状态采用最终一致轮询;转入后台后仍会继续等待并同步官方状态。官方即梦 CLI 当前不支持可靠的官方取消。</p> : null}
<div className="flex flex-wrap justify-end gap-2">
{detailTask.provider === "dreamina-cli" && detailTask.receiptRecorded && detailTask.status === "running" ? (
<Button aria-label="更新官方状态" icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void refreshLocalTaskStatus(detailTask)}>
更新官方状态
</Button>
) : null}
{detailTask.provider === "dreamina-cli" && (detailTask.status === "queued" || detailTask.status === "running") ? (
<Button danger loading={actingId === detailTask.id} onClick={() => void runAction(detailTask.id, "cancel")}>
{localDreaminaCancellationCopy(detailTask)?.action || "取消任务"}
</Button>
) : null}
{detailTask.provider === "dreamina-cli" ? (
<Button danger aria-label="删除本机记录" icon={<Trash2 className="size-4" />} loading={actingId === detailTask.id} onClick={() => deleteLocalTask(detailTask)}>
删除本机记录
</Button>
) : null}
{canQueryProviderTask(detailTask) ? <Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button> : null}
</div>
{detailTask.error ? <pre className="task-detail-error max-h-28 overflow-auto whitespace-pre-wrap px-3 py-2 text-xs">{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()} ${formatTaskLog(log)}`).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="library-modal 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 === 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.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 groupTasksByCanvas(tasks: GenerationTask[], canvasById: Map<string, { title: string; projectId?: string }>, projectNameById: Map<string, string>): TaskGroup[] {
const groups: TaskGroup[] = [];
const byKey = new Map<string, TaskGroup>();
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 InfoItem({ label, value }: { label: string; value: string }) {
return (
<div className="task-detail-fact min-w-0 px-3 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}`;
}