feat(canvas): 前端重构与渠道兼容 - 重构工作区、任务中心等多页面,修复智能体对话与节点生成

Co-authored-by: sikongyue <3155157671@qq.com>
Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/217
This commit is contained in:
爱笑的毛毛虫andsikongyue authored and GitHub committed 2026-08-14 12:01:21 +08:00
1 parent 239c1bcd37
commit 7946625be9
37 files changed
+4599 -912

No files matched your search

+210 -164
View File
@@ -1,5 +1,5 @@
import { App, Button, Drawer, Form, Input, Modal, Segmented, Select, Tooltip, Typography } from "antd";
import { Coins, Eye, FileText, FolderKanban, Image as ImageIcon, Play, Plus, RefreshCw, RotateCcw, Search, Video, X } from "lucide-react";
import { App, Button, Drawer, Form, Input, Modal, Segmented, Select, Switch, Tooltip, Typography } from "antd";
import { LayoutGrid, List, Plus, RefreshCw, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
@@ -9,16 +9,42 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
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 { cancelGenerationTask, createAgentSession, createGenerationTask, 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 { modelDisplayName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } 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";
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 { TaskStatPills, type TaskStatusFilter } from "./task-stat-pills";
type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
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";
@@ -33,7 +59,7 @@ export default function TasksPage() {
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 { view: viewPreferenceKey, group: groupPreferenceKey } = preferenceKeys();
const [domainProjects, setDomainProjects] = useState<ProjectSummary[]>([]);
const [loading, setLoading] = useState(false);
const [actingId, setActingId] = useState("");
@@ -51,14 +77,17 @@ export default function TasksPage() {
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) => {
@@ -81,6 +110,78 @@ export default function TasksPage() {
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 { 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) {
@@ -284,40 +385,93 @@ export default function TasksPage() {
return (
<>
<WorkspacePage grid className="library-page task-library-page">
<PageHeader
icon="tasks"
title="任务中心"
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 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); }}>
<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 }))]} />
<Segmented
size="small"
value={statusFilter}
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
options={[
{ label: "全部", value: "all" },
{ label: "待处理", value: "failed" },
{ label: "运行中", value: "active" },
{ label: "已完成", value: "succeeded" },
]}
<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>
<TaskStatPills stats={taskStats} statusFilter={statusFilter} onFilterChange={setStatusFilter} />
<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>
)}
>
<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 }))]} />
<Segmented
size="small"
value={statusFilter}
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
options={[
{ label: "全部", value: "all" },
{ label: "失败/取消", value: "failed" },
{ label: "运行中", value: "active" },
{ label: "已完成", value: "succeeded" },
]}
/>
</ListToolbar>
</div>
{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 ? <div className="task-record-list">{visibleTasks.map((task) => <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) })} />)}</div> : <WorkspaceState compact title={taskEmptyState(statusFilter).title} description={taskEmptyState(statusFilter).description} />
) : null}
<PaginationBar current={page} pageSize={pageSize} total={filteredTasks.length} pageSizeOptions={[20, 50, 100]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
<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" }}>
@@ -385,7 +539,7 @@ function canQueryProviderTask(task: GenerationTask) {
}
function reconcileTaskSummaries(current: GenerationTask[], next: GenerationTask[]) {
if (current.length !== next.length) return next;
if (current.length === 0) return next;
const currentById = new Map(current.map((task) => [task.id, task]));
let changed = false;
const reconciled = next.map((task) => {
@@ -412,44 +566,6 @@ function TaskResultMedia({ value, taskType }: { value?: string; taskType: string
);
}
function TaskListRow({ task, canvasById, projectNameById, effectiveConfig, creditsEnabled, actingId, onOpen, onRetry, onCancel, onPreview }: {
task: GenerationTask;
canvasById: Map<string, { title: string; projectId?: string }>;
projectNameById: Map<string, string>;
effectiveConfig: AiConfig;
creditsEnabled: boolean;
actingId: string;
onOpen: () => void;
onRetry: () => void;
onCancel: () => void;
onPreview: () => void;
}) {
const context = getTaskCanvasContext(task, canvasById, projectNameById);
const isActive = task.status === "queued" || task.status === "running";
const isFailed = task.status === "failed" || task.status === "cancelled";
return (
<article className={`task-record-row group${isFailed ? " is-attention" : ""}`}>
<TaskPreviewThumbnail task={task} onOpen={onPreview} />
<div className="task-record-main">
<div className="task-record-heading">
<span className={`task-record-status ${isFailed ? "is-failed" : isActive ? "is-active" : "is-success"}`}><i className={statusDotClassName(task.status)} />{statusLabel[task.status]}</span>
<button type="button" className="task-record-title" title={task.prompt} onClick={onOpen}>{task.prompt || "未命名任务"}</button>
</div>
<div className="task-record-meta"><span>{formatTaskKind(task)}</span><span aria-hidden="true">·</span><span>{formatModelName(effectiveConfig, task)}</span><span className="task-record-meta-canvas"><FolderKanban className="size-3" />{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}</span></div>
{isActive ? <div className="task-record-progress"><span>{task.stage || "正在生成"}</span><span>{task.progress || 0}%</span><i><b style={{ width: `${task.progress || 0}%` }} /></i></div> : null}
{isFailed ? <p className="task-record-error" title={task.error ? generationErrorMessage(task.error) : undefined}>{taskAttentionReason(task)}</p> : null}
</div>
<div className="task-record-date"><TaskDate value={task.createdAt} /></div>
{creditsEnabled ? <TaskBilling billing={task.billing} /> : <span className="task-record-billing-empty" aria-hidden="true" />}
<div className="task-record-actions">
<Tooltip title="查看详情"><Button type="text" size="small" icon={<Eye className="size-3.5" />} aria-label="查看详情" onClick={onOpen} /></Tooltip>
{isFailed ? <Tooltip title="重试任务"><Button type="text" size="small" icon={<RotateCcw className="size-3.5" />} aria-label="重试任务" loading={actingId === task.id} disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={onRetry} /></Tooltip> : null}
{isActive ? <Tooltip title="取消任务"><Button type="text" size="small" danger icon={<X className="size-3.5" />} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} /></Tooltip> : null}
</div>
</article>
);
}
function resultMediaUrls(value?: string) {
if (!value) return [];
let parsed: unknown;
@@ -478,98 +594,28 @@ 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>;
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 (
<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" ? "任务在调用上游前取消,积分已退回" : "任务已取消,可按原输入重新提交";
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 statusDotClassName(status: TaskStatus) {
if (status === "succeeded") return "task-record-dot bg-emerald-500";
if (status === "running") return "task-record-dot is-pulsing bg-amber-500";
if (status === "queued") return "task-record-dot bg-blue-500";
if (status === "failed") return "task-record-dot bg-red-500";
return "task-record-dot bg-foreground/30";
}
function taskMediaKind(task: GenerationTask): Exclude<TaskKindFilter, "all"> {
const value = `${task.type} ${task.operation || ""}`.toLowerCase();
if (value.includes("video") || value.includes("视频")) return "video";
if (value.includes("image") || value.includes("图片") || value.includes("画面")) return "image";
return "text";
}
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>;
const compact = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
return <time className="task-record-date-value" dateTime={date.toISOString()} title={date.toLocaleString()}>{compact}</time>;
}
function TaskBilling({ billing }: { billing?: GenerationTask["billing"] }) {
if (!billing) return <span className="task-record-billing-empty 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={`task-record-billing ${billing.status === "uncertain" ? "is-uncertain" : ""}`} title={`积分${note}`}><Coins className="size-4" /><span><strong>{amount}</strong><small>{note}</small></span></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);
return { title: "没有失败或取消的任务", description: "失败或取消的生成会出现在这里,并提供原因和可用操作。" };
}
function formatDate(value?: string) {
+67
View File
@@ -0,0 +1,67 @@
import { Button, Tooltip } from "antd";
import { Eye, FileText, Image as ImageIcon, RotateCcw, Video, X } from "lucide-react";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
export function TaskGridCard({ task, actingId, onOpen, onRetry, onCancel }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void; onCancel: () => void }) {
const isActive = task.status === "queued" || task.status === "running";
const isFailed = isTaskFailed(task);
const isVideo = task.previewKind === "video";
const fallbackVideo = task.type.includes("video");
const Icon = fallbackVideo ? Video : task.type.includes("image") ? ImageIcon : FileText;
return (
<article className={`task-grid-card${isFailed ? " is-attention" : ""}`}>
<div className="task-grid-thumb">
{task.previewUrl ? (
isVideo ? (
<video src={task.previewUrl} muted playsInline preload="metadata" className="h-full w-full object-cover" />
) : (
<img src={task.previewUrl} alt="" loading="lazy" className="h-full w-full object-cover" />
)
) : (
<Icon />
)}
<div className="task-grid-overlay">
<Tooltip title="查看详情">
<Button type="text" size="small" icon={<Eye className="size-3.5" />} aria-label="查看详情" onClick={onOpen} />
</Tooltip>
{isFailed ? (
<Tooltip title="重试任务">
<Button
type="text"
size="small"
icon={<RotateCcw className="size-3.5" />}
aria-label="重试任务"
loading={actingId === task.id}
disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)}
onClick={onRetry}
/>
</Tooltip>
) : null}
{isActive ? (
<Tooltip title="取消任务">
<Button type="text" size="small" danger icon={<X className="size-3.5" />} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} />
</Tooltip>
) : null}
</div>
</div>
<div className="task-grid-body">
<button type="button" className="task-grid-title" title={task.prompt} onClick={onOpen}>
{task.prompt || "未命名任务"}
</button>
<div className="task-grid-meta">
<span className={`task-grid-status ${isFailed ? "is-failed" : isActive ? "is-active" : task.status === "succeeded" ? "is-success" : ""}`}>
<i className={statusDotClassName(task.status)} />
{statusLabel[task.status]}
</span>
<span className="task-grid-date">
<TaskDate value={task.createdAt} />
</span>
</div>
</div>
</article>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { FolderKanban, RotateCcw } from "lucide-react";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import type { GenerationTask } from "@/services/api/task-center";
import { isTaskFailed } from "./task-shared";
export type TaskGroup = { key: string; title: string; projectName: string; tasks: GenerationTask[] };
export function TaskGroupHeader({ group, retrying = false, onRetryFailed }: { group: TaskGroup; retrying?: boolean; onRetryFailed: () => void }) {
const succeeded = group.tasks.filter((task) => task.status === "succeeded").length;
const active = group.tasks.filter((task) => task.status === "queued" || task.status === "running").length;
const failed = group.tasks.filter((task) => isTaskFailed(task) && task.errorCode !== CONTENT_MODERATION_ERROR_CODE && !isContentModerationError(task.error)).length;
const title = group.projectName ? `${group.title} · ${group.projectName}` : group.title;
return (
<div className="task-group-head">
<span className="task-group-ic">
<FolderKanban />
</span>
<div className="min-w-0">
<div className="task-group-name">
<span>{title}</span>
<span className="task-group-count">
共 {group.tasks.length} 项 · 完成 {succeeded}
</span>
</div>
<span className="task-group-sub">
{active ? <span>{active} 运行中</span> : null}
{active && failed ? <span aria-hidden="true"> · </span> : null}
{failed ? <b className="is-bad">{failed} 失败</b> : null}
{(active || failed) && succeeded ? <span aria-hidden="true"> · </span> : null}
{succeeded ? <span>{succeeded} 已完成</span> : null}
{!active && !failed && !succeeded ? "暂无进行中的任务" : null}
</span>
</div>
{failed ? (
<button type="button" className="task-group-reset" disabled={retrying} onClick={onRetryFailed}>
<RotateCcw className={retrying ? "animate-spin" : undefined} />
{retrying ? "重试中..." : "重试失败任务"}
</button>
) : null}
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { Button, Tooltip } from "antd";
import { Eye, FileText, FolderKanban, Image as ImageIcon, Play, RotateCcw, Video, X } from "lucide-react";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store";
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
export function TaskListRow({
task,
canvasById,
projectNameById,
effectiveConfig,
creditsEnabled,
actingId,
onOpen,
onRetry,
onCancel,
onPreview,
}: {
task: GenerationTask;
canvasById: Map<string, { title: string; projectId?: string }>;
projectNameById: Map<string, string>;
effectiveConfig: AiConfig;
creditsEnabled: boolean;
actingId: string;
onOpen: () => void;
onRetry: () => void;
onCancel: () => void;
onPreview: () => void;
}) {
const context = getTaskCanvasContext(task, canvasById, projectNameById);
const isActive = task.status === "queued" || task.status === "running";
const isFailed = isTaskFailed(task);
return (
<article className={`task-record-row group${isFailed ? " is-attention" : ""}`}>
<TaskPreviewThumbnail task={task} onOpen={onPreview} />
<div className="task-record-main">
<div className="task-record-heading">
<span className={`task-record-status ${isFailed ? "is-failed" : isActive ? "is-active" : "is-success"}`}>
<i className={statusDotClassName(task.status)} />
{statusLabel[task.status]}
</span>
<button type="button" className="task-record-title" title={task.prompt} onClick={onOpen}>
{task.prompt || "未命名任务"}
</button>
</div>
<div className="task-record-meta">
<span>{formatTaskKind(task)}</span>
<span aria-hidden="true">·</span>
<span>{formatModelName(effectiveConfig, task)}</span>
<span className="task-record-meta-canvas">
<FolderKanban className="size-3" />
{context.canvasName}
{context.projectName ? ` · ${context.projectName}` : ""}
</span>
</div>
{isActive ? (
<div className="task-record-progress">
<span>{task.stage || "正在生成"}</span>
<span>{task.progress || 0}%</span>
<i>
<b style={{ width: `${task.progress || 0}%` }} />
</i>
</div>
) : null}
{isFailed ? (
<p className="task-record-error" title={task.error ? generationErrorMessage(task.error) : undefined}>
{taskAttentionReason(task)}
</p>
) : null}
</div>
<div className="task-record-date">
<TaskDate value={task.createdAt} />
</div>
{creditsEnabled ? <TaskBilling billing={task.billing} /> : <span className="task-record-billing-empty" aria-hidden="true" />}
<div className="task-record-actions">
<Tooltip title="查看详情">
<Button type="text" size="small" icon={<Eye className="size-3.5" />} aria-label="查看详情" onClick={onOpen} />
</Tooltip>
{isFailed ? (
<Tooltip title="重试任务">
<Button
type="text"
size="small"
icon={<RotateCcw className="size-3.5" />}
aria-label="重试任务"
loading={actingId === task.id}
disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)}
onClick={onRetry}
/>
</Tooltip>
) : null}
{isActive ? (
<Tooltip title="取消任务">
<Button type="text" size="small" danger icon={<X className="size-3.5" />} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} />
</Tooltip>
) : null}
</div>
</article>
);
}
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="task-record-thumb">
<Icon className="size-4" />
</span>
);
}
return (
<button type="button" onClick={onOpen} className="task-record-thumb group 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>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Coins } from "lucide-react";
import { formatCredits } from "@/constant/credits";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import type { GenerationTask, TaskStatus } from "@/services/api/task-center";
import { modelDisplayName, type AiConfig } from "@/stores/use-config-store";
export 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: "" };
}
export function isTaskFailed(task: GenerationTask) {
return task.status === "failed" || task.status === "cancelled";
}
export 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 || "生成失败,打开详情查看原因";
}
export 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" ? "任务在调用上游前取消,积分已退回" : "任务已取消,可按原输入重新提交";
}
export function statusDotClassName(status: TaskStatus) {
if (status === "succeeded") return "task-record-dot is-success";
if (status === "running") return "task-record-dot is-active is-pulsing";
if (status === "queued") return "task-record-dot is-queued";
if (status === "failed") return "task-record-dot is-failed";
return "task-record-dot is-idle";
}
export function taskMediaKind(task: GenerationTask): "text" | "image" | "video" {
const value = `${task.type} ${task.operation || ""}`.toLowerCase();
if (value.includes("video") || value.includes("视频")) return "video";
if (value.includes("image") || value.includes("图片") || value.includes("画面")) return "image";
return "text";
}
export 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>;
const compact = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
return (
<time className="task-record-date-value" dateTime={date.toISOString()} title={date.toLocaleString()}>
{compact}
</time>
);
}
export function TaskBilling({ billing }: { billing?: GenerationTask["billing"] }) {
if (!billing) return <span className="task-record-billing-empty 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={`task-record-billing ${billing.status === "uncertain" ? "is-uncertain" : ""}`} title={`积分${note}`}>
<Coins className="size-4" />
<span>
<strong>{amount}</strong>
<small>{note}</small>
</span>
</div>
);
}
export 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);
}
+36
View File
@@ -0,0 +1,36 @@
export type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
export type TaskStats = { today: number; active: number; succeeded: number; failed: number };
export function TaskStatPills({ stats, statusFilter, onFilterChange }: { stats: TaskStats; statusFilter: TaskStatusFilter; onFilterChange: (value: TaskStatusFilter) => void }) {
return (
<div className="task-stat-pills">
<span className="task-stat-pill">
<span className="task-stat-dot" />
今日生成<b>{stats.today}</b>
</span>
<button type="button" aria-pressed={statusFilter === "active"} className={`task-stat-pill is-clickable${statusFilter === "active" ? " is-active-filter" : ""}`} onClick={() => onFilterChange(statusFilter === "active" ? "all" : "active")}>
<span className="task-stat-dot is-run" />
运行中<b>{stats.active}</b>
</button>
<button
type="button"
aria-pressed={statusFilter === "succeeded"}
className={`task-stat-pill is-clickable${statusFilter === "succeeded" ? " is-active-filter" : ""}`}
onClick={() => onFilterChange(statusFilter === "succeeded" ? "all" : "succeeded")}
>
<span className="task-stat-dot is-ok" />
已完成<b>{stats.succeeded}</b>
</button>
<button
type="button"
aria-pressed={statusFilter === "failed"}
className={`task-stat-pill is-clickable is-fail${statusFilter === "failed" ? " is-active-filter" : ""}`}
onClick={() => onFilterChange(statusFilter === "failed" ? "all" : "failed")}
>
<span className="task-stat-dot is-bad" />
失败<b>{stats.failed}</b>
</button>
</div>
);
}