765 lines
41 KiB
TypeScript
765 lines
41 KiB
TypeScript
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<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);
|
||
}
|
||
} 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)}
|
||
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)}
|
||
/>
|
||
);
|
||
|
||
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 (
|
||
<>
|
||
<WorkspacePage grid className="library-page task-library-page">
|
||
<div className="studio-band">
|
||
<PageHeader
|
||
title="创作历史"
|
||
description="查看文本、图片和视频生成任务,跟踪进度并处理失败任务。"
|
||
meta={<span className="app-projects-header-meta">{taskStats.total} 个任务</span>}
|
||
actions={
|
||
<Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>
|
||
新建任务
|
||
</Button>
|
||
}
|
||
/>
|
||
<CollectionToolbar
|
||
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="sm" checked={groupEnabled} onChange={changeGroupEnabled} />
|
||
<span>按画布分组</span>
|
||
</label>
|
||
) : null}
|
||
<div className="task-view-switch">
|
||
<SegmentedControl<TaskViewMode>
|
||
ariaLabel="任务视图"
|
||
size="sm"
|
||
value={viewMode}
|
||
options={[
|
||
{ value: "list", icon: <List className="size-3.5" />, title: "列表视图" },
|
||
{ value: "grid", icon: <LayoutGrid className="size-3.5" />, title: "网格视图" },
|
||
]}
|
||
onChange={changeViewMode}
|
||
/>
|
||
</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 }))]} />
|
||
</CollectionToolbar>
|
||
</div>
|
||
|
||
<div className="collection-content task-collection-content">
|
||
{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: "text_to_video" }}>
|
||
<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)} />
|
||
<InfoItem label="开始时间" value={formatDate(detailTask.startedAt)} />
|
||
<InfoItem label="完成时间" value={formatDate(detailTask.completedAt)} />
|
||
<InfoItem label="耗时" value={formatTaskDuration(detailTask)} />
|
||
{detailTask.providerCancelStatus ? <InfoItem label="上游取消" value={providerCancelStatusLabel(detailTask)} /> : null}
|
||
{detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null}
|
||
</div>
|
||
<div className="flex flex-wrap justify-end gap-2">
|
||
{canQueryProviderTask(detailTask) ? <Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button> : null}
|
||
{isTaskFailed(detailTask) ? <Button icon={<Bug className="size-4" />} onClick={() => navigate(`/settings?section=diagnostics&taskId=${encodeURIComponent(detailTask.id)}${detailTask.projectId ? `&projectId=${encodeURIComponent(detailTask.projectId)}` : ""}`)}>导出诊断包</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 ? "详情加载中..." : detailTask.prompt || "无"} tall />
|
||
<TaskParameters inputJson={detailLoading ? undefined : 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 ? (
|
||
<MediaPreview
|
||
src={mediaPreview.url}
|
||
kind={mediaPreview.kind}
|
||
alt={mediaPreview.title}
|
||
controls={mediaPreview.kind === "video"}
|
||
className="max-h-[76vh] w-full bg-black object-contain"
|
||
fallbackClassName="task-media-preview-unavailable"
|
||
/>
|
||
) : 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.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 (
|
||
<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) => {
|
||
const isVideo = isVideoResult(url, taskType);
|
||
return (
|
||
<MediaPreview
|
||
key={`${url}-${index}`}
|
||
src={url}
|
||
kind={isVideo ? "video" : "image"}
|
||
alt={`生成结果 ${index + 1}`}
|
||
controls={isVideo}
|
||
className={isVideo ? "task-result-media is-video" : "task-result-media"}
|
||
fallbackClassName={isVideo ? "task-result-media is-video" : "task-result-media"}
|
||
/>
|
||
);
|
||
})}
|
||
</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 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 (
|
||
<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 text-sm ${wrap ? "whitespace-pre-wrap break-words" : "truncate"}`} title={value}>
|
||
{value}
|
||
</Typography.Text>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DetailBlock({ title, value, tall = false }: { title: string; value: string; tall?: boolean }) {
|
||
return (
|
||
<div>
|
||
<Typography.Text strong>{title}</Typography.Text>
|
||
<pre className={`mt-2 overflow-auto rounded-md bg-slate-950 p-3 text-xs leading-5 text-slate-100 ${tall ? "h-40 whitespace-pre-wrap break-words" : "max-h-60"}`}>{value}</pre>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TaskParameters({ inputJson }: { inputJson?: string }) {
|
||
const fields = taskParameterFields(inputJson);
|
||
return (
|
||
<div>
|
||
<Typography.Text strong>参数</Typography.Text>
|
||
{fields.length ? (
|
||
<div className="task-detail-facts mt-2 grid text-sm sm:grid-cols-2">
|
||
{fields.map((field) => <InfoItem key={field.label} label={field.label} value={field.value} wrap />)}
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 rounded-md bg-foreground/[.04] px-3 py-3 text-sm text-foreground/50">暂无参数记录</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<string, unknown> | null {
|
||
if (!value) return null;
|
||
try {
|
||
const parsed: unknown = JSON.parse(value);
|
||
return asRecord(parsed);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|