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:
1 parent
239c1bcd37
commit
7946625be9
37 files changed
+4599
-912
No files matched your search
+210
-164
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user