Files
Genarrative/src/components/image-editor/ImageCanvasTaskSidebarView.tsx
T
kdletters 10b170e7a2 完善画布素材与抠图任务体验
素材库资产支持直接下载。

画布复制图片时同步写入系统剪贴板。

手动抠图请求直接进入画布任务列表并支持聚焦。
2026-06-29 19:53:05 +08:00

602 lines
18 KiB
TypeScript

import {
CheckCircle2,
Clock3,
ListChecks,
Loader2,
XCircle,
} from 'lucide-react';
import {
type WheelEvent as ReactWheelEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { listExternalGenerationTasks } from '../../services/external-generation';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
CanvasTaskListItem,
CanvasTaskStatus,
} from './ImageCanvasEditorTypes';
const COMPLETED_TASK_LIST_LIMIT = 20;
const ACTIVE_TASK_LIST_LIMIT = 100;
const ACTIVE_TASK_POLL_INTERVAL_MS = 4000;
type TaskSidebarTab = 'active' | 'completed';
type TaskSidebarItem = {
id: string;
title: string;
detail: string;
status: CanvasTaskStatus;
startedAt?: number;
finishedAt?: number;
elapsedMs?: number;
errorMessage?: string;
onClick?: () => void;
sortTime: number;
};
type ImageCanvasTaskSidebarViewProps = {
projectId?: string | null;
localTasks?: CanvasTaskListItem[];
open: boolean;
onToggleOpen: () => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onFocusLocalTask?: (task: CanvasTaskListItem) => void;
};
function parseTaskTimeMs(value?: string | null) {
if (!value) {
return undefined;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function formatElapsedMs(elapsedMs?: number) {
if (typeof elapsedMs !== 'number' || !Number.isFinite(elapsedMs)) {
return null;
}
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
if (totalSeconds >= 60) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}${seconds}秒` : `${minutes}分`;
}
return `${Math.max(1, totalSeconds)}秒`;
}
function formatTaskTimeMs(timestampMs?: number) {
if (typeof timestampMs !== 'number' || !Number.isFinite(timestampMs)) {
return null;
}
const date = new Date(timestampMs);
const pad = (value: number) => String(value).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours(),
)}:${pad(date.getMinutes())}`;
}
function normalizeTaskPrompt(value?: string | null) {
const prompt = value?.trim();
return prompt || null;
}
function externalTaskDetail(
task: ExternalGenerationTaskRecord,
status: CanvasTaskStatus,
) {
if (status === 'failed') {
return task.error?.trim() || task.phaseDetail || task.updatedAt;
}
if (status === 'done') {
return (
normalizeTaskPrompt(task.requestPrompt) ||
task.requestLabel.trim() ||
task.jobKind
);
}
const phaseLabel = task.phaseLabel.trim();
const phaseDetail = task.phaseDetail.trim();
const progressLabel =
Number.isFinite(task.progress) && task.progress > 0
? `总进度 ${Math.min(100, Math.max(0, Math.round(task.progress)))}%`
: null;
return [phaseLabel, phaseDetail, progressLabel]
.filter((value): value is string => Boolean(value))
.join(' · ');
}
function stopTaskSidebarWheel(event: ReactWheelEvent<HTMLElement>) {
event.stopPropagation();
}
function canvasTaskStatusFromExternalTask(
status: ExternalGenerationTaskRecord['status'],
): CanvasTaskStatus {
if (status === 'running') {
return 'running';
}
if (status === 'queued') {
return 'pending';
}
if (status === 'failed') {
return 'failed';
}
return 'done';
}
function statusLabel(status: CanvasTaskStatus) {
if (status === 'pending') {
return '排队中';
}
if (status === 'running') {
return '进行中';
}
if (status === 'failed') {
return '失败';
}
return '完成';
}
function statusIcon(status: CanvasTaskStatus) {
if (status === 'pending') {
return <Clock3 className="h-3.5 w-3.5" aria-hidden="true" />;
}
if (status === 'running') {
return <Loader2 className="h-3.5 w-3.5" aria-hidden="true" />;
}
if (status === 'failed') {
return <XCircle className="h-3.5 w-3.5" aria-hidden="true" />;
}
return <CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />;
}
function isActiveTaskStatus(status: CanvasTaskStatus) {
return status === 'running' || status === 'pending';
}
function activeTaskSortPriority(status: CanvasTaskStatus) {
if (status === 'running') {
return 0;
}
if (status === 'pending') {
return 1;
}
return 2;
}
function mergeExternalTasks(
currentTasks: ExternalGenerationTaskRecord[],
nextTasks: ExternalGenerationTaskRecord[],
) {
const taskById = new Map(currentTasks.map((task) => [task.jobId, task]));
for (const task of nextTasks) {
taskById.set(task.jobId, task);
}
return Array.from(taskById.values());
}
function isActiveExternalTask(task: ExternalGenerationTaskRecord) {
return task.status === 'queued' || task.status === 'running';
}
function isTerminalExternalTask(task: ExternalGenerationTaskRecord) {
return task.status === 'completed' || task.status === 'failed';
}
function isEditorCanvasTaskForProject(
task: ExternalGenerationTaskRecord,
projectId?: string | null,
) {
const normalizedProjectId = projectId?.trim();
return (
task.sourceModule === 'editor-canvas' &&
Boolean(normalizedProjectId) &&
task.sourceEntityId.trim() === normalizedProjectId
);
}
function mergeExternalTaskRefresh(
currentTasks: ExternalGenerationTaskRecord[],
refreshedTasks: ExternalGenerationTaskRecord[],
) {
const refreshedIds = new Set(refreshedTasks.map((task) => task.jobId));
return mergeExternalTasks(
currentTasks.filter(
(task) => !isActiveExternalTask(task) && !refreshedIds.has(task.jobId),
),
refreshedTasks,
);
}
export function ImageCanvasTaskSidebarView({
projectId,
localTasks = [],
open,
onToggleOpen,
onFocusExternalTask,
onFocusLocalTask,
}: ImageCanvasTaskSidebarViewProps) {
const [activeTab, setActiveTab] = useState<TaskSidebarTab>('active');
const [now, setNow] = useState(() => Date.now());
const [externalTasks, setExternalTasks] = useState<
ExternalGenerationTaskRecord[]
>([]);
const previousActiveItemCountRef = useRef(0);
const filterVisibleExternalTasks = useCallback(
(tasks: ExternalGenerationTaskRecord[]) =>
tasks.filter((task) => isEditorCanvasTaskForProject(task, projectId)),
[projectId],
);
useEffect(() => {
let disposed = false;
const controller = new AbortController();
Promise.all([
listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: controller.signal,
}),
listExternalGenerationTasks({
limit: COMPLETED_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
}),
])
.then(([activeResponse, completedResponse]) => {
if (disposed) {
return;
}
setExternalTasks(
mergeExternalTasks(
filterVisibleExternalTasks(activeResponse.tasks),
filterVisibleExternalTasks(completedResponse.tasks),
),
);
})
.catch(() => {
if (!disposed) {
setExternalTasks([]);
}
});
return () => {
disposed = true;
controller.abort();
};
}, [filterVisibleExternalTasks]);
const activeExternalTaskIds = useMemo(
() =>
externalTasks
.filter((task) => task.status === 'queued' || task.status === 'running')
.map((task) => task.jobId),
[externalTasks],
);
const activeExternalTaskKey = activeExternalTaskIds.join('|');
useEffect(() => {
if (!activeExternalTaskIds.length) {
return undefined;
}
let disposed = false;
let timerId: number | null = null;
const refreshActiveTasks = () => {
const controller = new AbortController();
Promise.all([
listExternalGenerationTasks({
limit: ACTIVE_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: false,
statuses: ['running', 'queued'],
signal: controller.signal,
}),
listExternalGenerationTasks({
limit: COMPLETED_TASK_LIST_LIMIT,
includeAcknowledgedTerminal: true,
statuses: ['completed', 'failed'],
signal: controller.signal,
}),
])
.then(([activeResponse, completedResponse]) => {
if (disposed) {
return;
}
const refreshedTasks = mergeExternalTasks(
filterVisibleExternalTasks(activeResponse.tasks),
filterVisibleExternalTasks(completedResponse.tasks),
);
setExternalTasks((currentTasks) =>
mergeExternalTaskRefresh(currentTasks, refreshedTasks),
);
})
.catch(() => {
// 下轮继续刷新;单次列表读取失败不应该冻结正在生成的任务状态。
})
.finally(() => {
if (disposed) {
return;
}
timerId = window.setTimeout(
refreshActiveTasks,
ACTIVE_TASK_POLL_INTERVAL_MS,
);
});
};
timerId = window.setTimeout(
refreshActiveTasks,
ACTIVE_TASK_POLL_INTERVAL_MS,
);
return () => {
disposed = true;
if (timerId !== null) {
window.clearTimeout(timerId);
}
};
}, [
activeExternalTaskIds,
activeExternalTaskKey,
filterVisibleExternalTasks,
]);
useEffect(() => {
const hasRunningTask =
externalTasks.some((task) => task.status === 'running') ||
localTasks.some((task) => task.status === 'running');
if (!hasRunningTask) {
return undefined;
}
const timerId = window.setInterval(() => {
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timerId);
}, [externalTasks, localTasks]);
const items = useMemo<TaskSidebarItem[]>(() => {
const externalVisibleTasks = mergeExternalTasks(
externalTasks.filter(
(task) => task.status === 'queued' || task.status === 'running',
),
externalTasks
.filter(isTerminalExternalTask)
.sort(
(left, right) =>
(parseTaskTimeMs(right.completedAt) ??
parseTaskTimeMs(right.updatedAt) ??
0) -
(parseTaskTimeMs(left.completedAt) ??
parseTaskTimeMs(left.updatedAt) ??
0),
)
.slice(0, COMPLETED_TASK_LIST_LIMIT),
);
const externalItems = externalVisibleTasks.map<TaskSidebarItem>((task) => {
const status = canvasTaskStatusFromExternalTask(task.status);
const startedAt =
parseTaskTimeMs(task.startedAt) ?? parseTaskTimeMs(task.createdAt);
const finishedAt =
parseTaskTimeMs(task.completedAt) ?? parseTaskTimeMs(task.updatedAt);
return {
id: `external-${task.jobId}`,
title: task.requestLabel.trim() || task.jobKind,
detail: externalTaskDetail(task, status),
status,
startedAt,
finishedAt,
elapsedMs:
status === 'running' && startedAt
? now - startedAt
: startedAt && finishedAt
? finishedAt - startedAt
: undefined,
errorMessage: task.error ?? undefined,
onClick: () => onFocusExternalTask(task),
sortTime:
finishedAt ??
startedAt ??
parseTaskTimeMs(task.createdAt) ??
parseTaskTimeMs(task.updatedAt) ??
0,
};
});
const localItems = localTasks.map<TaskSidebarItem>((task) => ({
id: `local-${task.id}`,
title: task.title,
detail: task.detail,
status: task.status,
startedAt: task.startedAt,
finishedAt: task.finishedAt,
elapsedMs:
task.status === 'running'
? now - task.startedAt
: task.elapsedMs ??
(task.finishedAt ? task.finishedAt - task.startedAt : undefined),
errorMessage: task.errorMessage,
onClick:
task.focusLayerId && onFocusLocalTask
? () => onFocusLocalTask(task)
: undefined,
sortTime: task.finishedAt ?? task.startedAt,
}));
return [...localItems, ...externalItems];
}, [externalTasks, localTasks, now, onFocusExternalTask, onFocusLocalTask]);
const activeItems = useMemo(
() =>
items
.filter((item) => isActiveTaskStatus(item.status))
.sort(
(left, right) =>
activeTaskSortPriority(left.status) -
activeTaskSortPriority(right.status) ||
right.sortTime - left.sortTime ||
left.id.localeCompare(right.id),
),
[items],
);
const completedItems = useMemo(
() =>
items
.filter((item) => !isActiveTaskStatus(item.status))
.sort(
(left, right) =>
right.sortTime - left.sortTime || left.id.localeCompare(right.id),
)
.slice(0, COMPLETED_TASK_LIST_LIMIT),
[items],
);
useEffect(() => {
const previousActiveItemCount = previousActiveItemCountRef.current;
if (activeItems.length > previousActiveItemCount) {
setActiveTab('active');
}
previousActiveItemCountRef.current = activeItems.length;
}, [activeItems.length]);
const visibleItems = activeTab === 'active' ? activeItems : completedItems;
if (!open && items.length === 0) {
return (
<div className="image-canvas-editor__task-sidebar image-canvas-editor__task-sidebar--closed">
<EditorIconButton
className="image-canvas-editor__task-sidebar-toggle"
label="任务列表"
title="任务列表"
icon={ListChecks}
expanded={false}
onClick={onToggleOpen}
/>
</div>
);
}
return (
<aside
className={`image-canvas-editor__task-sidebar ${open ? 'image-canvas-editor__task-sidebar--open' : 'image-canvas-editor__task-sidebar--closed'}`}
aria-label="画布任务列表"
onWheel={stopTaskSidebarWheel}
onWheelCapture={stopTaskSidebarWheel}
>
<EditorIconButton
className="image-canvas-editor__task-sidebar-toggle"
label="任务列表"
title="任务列表"
icon={ListChecks}
expanded={open}
onClick={onToggleOpen}
/>
{open ? (
<div className="image-canvas-editor__task-sidebar-panel">
<div className="image-canvas-editor__task-sidebar-header">
<strong>任务列表</strong>
<span>{items.length}</span>
</div>
<div
className="image-canvas-editor__task-sidebar-tabs"
role="tablist"
>
<button
type="button"
role="tab"
aria-selected={activeTab === 'active'}
className={
activeTab === 'active'
? 'image-canvas-editor__task-sidebar-tab image-canvas-editor__task-sidebar-tab--active'
: 'image-canvas-editor__task-sidebar-tab'
}
onClick={() => setActiveTab('active')}
>
排队/生成中
<span>{activeItems.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'completed'}
className={
activeTab === 'completed'
? 'image-canvas-editor__task-sidebar-tab image-canvas-editor__task-sidebar-tab--active'
: 'image-canvas-editor__task-sidebar-tab'
}
onClick={() => setActiveTab('completed')}
>
已完成
<span>{completedItems.length}</span>
</button>
</div>
{visibleItems.length > 0 ? (
<div className="image-canvas-editor__task-sidebar-list">
{visibleItems.map((item) => {
const elapsedLabel = formatElapsedMs(item.elapsedMs);
const finishedLabel =
item.status === 'done' || item.status === 'failed'
? formatTaskTimeMs(item.finishedAt)
: null;
const detailText = [
item.detail,
elapsedLabel
? item.status === 'running'
? `已用时 ${elapsedLabel}`
: `耗时 ${elapsedLabel}`
: null,
finishedLabel
? item.status === 'failed'
? `失败于 ${finishedLabel}`
: `完成于 ${finishedLabel}`
: null,
item.errorMessage && item.detail !== item.errorMessage
? item.errorMessage
: null,
]
.filter((value): value is string => Boolean(value))
.join(' · ');
return (
<button
key={item.id}
type="button"
className={`image-canvas-editor__task-sidebar-item image-canvas-editor__task-sidebar-item--${item.status}`}
onClick={item.onClick}
disabled={!item.onClick}
>
<span className="image-canvas-editor__task-sidebar-item-icon">
{statusIcon(item.status)}
</span>
<span className="image-canvas-editor__task-sidebar-item-main">
<span className="image-canvas-editor__task-sidebar-item-title">
{item.title}
</span>
<span
className="image-canvas-editor__task-sidebar-item-detail"
title={detailText}
>
{detailText}
</span>
</span>
<span className="image-canvas-editor__task-sidebar-item-status">
{statusLabel(item.status)}
</span>
</button>
);
})}
</div>
) : (
<div className="image-canvas-editor__task-sidebar-empty">
暂无任务
</div>
)}
</div>
) : null}
</aside>
);
}