754d364227
任务列表识别当前画布项目的未确认完成任务并发出完成通知。 编辑器收到任务完成通知后重新加载项目快照并刷新素材库。 补充刷新后任务已完成和轮询完成的回归测试。
754 lines
23 KiB
TypeScript
754 lines
23 KiB
TypeScript
import {
|
|
CheckCircle2,
|
|
Clock3,
|
|
ListChecks,
|
|
Loader2,
|
|
XCircle,
|
|
} from 'lucide-react';
|
|
import {
|
|
type WheelEvent as ReactWheelEvent,
|
|
type UIEvent as ReactUIEvent,
|
|
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 { CanvasTaskStatus } from './ImageCanvasEditorTypes';
|
|
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
|
|
|
|
const COMPLETED_TASK_LIST_LIMIT = 20;
|
|
const COMPLETED_TASK_LIST_MAX_LIMIT = 100;
|
|
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;
|
|
progressDetail?: string;
|
|
status: CanvasTaskStatus;
|
|
startedAt?: number;
|
|
finishedAt?: number;
|
|
elapsedMs?: number;
|
|
errorMessage?: string;
|
|
onClick?: () => void;
|
|
sortTime: number;
|
|
};
|
|
|
|
type ImageCanvasTaskSidebarViewProps = {
|
|
refreshKey?: number;
|
|
open: boolean;
|
|
onToggleOpen: () => void;
|
|
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
|
|
onExternalTasksCompleted?: (tasks: ExternalGenerationTaskRecord[]) => 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 requestLabel = task.requestLabel.trim();
|
|
const prompt = normalizeTaskPrompt(task.requestPrompt);
|
|
return (
|
|
(prompt && prompt !== requestLabel ? prompt : null) ||
|
|
(phaseLabel && phaseLabel !== requestLabel ? phaseLabel : null) ||
|
|
''
|
|
);
|
|
}
|
|
|
|
function externalTaskProgressDetail(
|
|
task: ExternalGenerationTaskRecord,
|
|
status: CanvasTaskStatus,
|
|
) {
|
|
if (!isActiveTaskStatus(status)) {
|
|
return null;
|
|
}
|
|
const values = [task.phaseDetail.trim()].filter((value): value is string =>
|
|
Boolean(value),
|
|
);
|
|
return values.length > 0 ? values.join(' · ') : null;
|
|
}
|
|
|
|
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 isTimedTaskStatus(status: CanvasTaskStatus) {
|
|
return status === 'running';
|
|
}
|
|
|
|
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 isCompletedExternalTask(task: ExternalGenerationTaskRecord) {
|
|
return task.status === 'completed';
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
function trimStoredExternalTasks(
|
|
tasks: ExternalGenerationTaskRecord[],
|
|
completedLimit: number,
|
|
) {
|
|
return mergeExternalTasks(
|
|
tasks.filter(isActiveExternalTask),
|
|
tasks
|
|
.filter(isTerminalExternalTask)
|
|
.sort(
|
|
(left, right) =>
|
|
(parseTaskTimeMs(right.completedAt) ??
|
|
parseTaskTimeMs(right.updatedAt) ??
|
|
0) -
|
|
(parseTaskTimeMs(left.completedAt) ??
|
|
parseTaskTimeMs(left.updatedAt) ??
|
|
0),
|
|
)
|
|
.slice(0, completedLimit),
|
|
);
|
|
}
|
|
|
|
export function ImageCanvasTaskSidebarView({
|
|
refreshKey = 0,
|
|
open,
|
|
onToggleOpen,
|
|
onFocusExternalTask,
|
|
onExternalTasksCompleted,
|
|
}: ImageCanvasTaskSidebarViewProps) {
|
|
const projectId = useImageCanvasContextStore((state) => state.projectId);
|
|
const normalizedProjectId = projectId?.trim() ?? '';
|
|
const [activeTab, setActiveTab] = useState<TaskSidebarTab>('active');
|
|
const [now, setNow] = useState(() => Date.now());
|
|
const [externalTasks, setExternalTasks] = useState<
|
|
ExternalGenerationTaskRecord[]
|
|
>([]);
|
|
const [completedListState, setCompletedListState] = useState(() => ({
|
|
limit: COMPLETED_TASK_LIST_LIMIT,
|
|
projectId: normalizedProjectId,
|
|
}));
|
|
const completedListLimit =
|
|
completedListState.projectId === normalizedProjectId
|
|
? completedListState.limit
|
|
: COMPLETED_TASK_LIST_LIMIT;
|
|
const previousActiveItemCountRef = useRef(0);
|
|
const activeExternalTaskIdsRef = useRef<Set<string>>(new Set());
|
|
const completedRefreshNotifiedTaskIdsRef = useRef<Set<string>>(new Set());
|
|
const filterVisibleExternalTasks = useCallback(
|
|
(tasks: ExternalGenerationTaskRecord[]) =>
|
|
tasks.filter((task) =>
|
|
isEditorCanvasTaskForProject(task, normalizedProjectId),
|
|
),
|
|
[normalizedProjectId],
|
|
);
|
|
const notifyCompletedExternalTasks = useCallback(
|
|
(
|
|
tasks: ExternalGenerationTaskRecord[],
|
|
options: {
|
|
activeTaskIds?: ReadonlySet<string>;
|
|
unacknowledgedOnly?: boolean;
|
|
} = {},
|
|
) => {
|
|
const newlyCompletedTasks = tasks.filter((task) => {
|
|
if (
|
|
!isCompletedExternalTask(task) ||
|
|
completedRefreshNotifiedTaskIdsRef.current.has(task.jobId)
|
|
) {
|
|
return false;
|
|
}
|
|
if (options.activeTaskIds && !options.activeTaskIds.has(task.jobId)) {
|
|
return false;
|
|
}
|
|
if (options.unacknowledgedOnly && task.notificationAcknowledgedAt) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (!newlyCompletedTasks.length) {
|
|
return;
|
|
}
|
|
for (const task of newlyCompletedTasks) {
|
|
completedRefreshNotifiedTaskIdsRef.current.add(task.jobId);
|
|
}
|
|
onExternalTasksCompleted?.(newlyCompletedTasks);
|
|
},
|
|
[onExternalTasksCompleted],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setCompletedListState((state) =>
|
|
state.projectId === normalizedProjectId
|
|
? state
|
|
: { limit: COMPLETED_TASK_LIST_LIMIT, projectId: normalizedProjectId },
|
|
);
|
|
completedRefreshNotifiedTaskIdsRef.current = new Set();
|
|
}, [normalizedProjectId]);
|
|
|
|
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: completedListLimit,
|
|
includeAcknowledgedTerminal: true,
|
|
statuses: ['completed', 'failed'],
|
|
signal: controller.signal,
|
|
}),
|
|
])
|
|
.then(([activeResponse, completedResponse]) => {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
const visibleActiveTasks = filterVisibleExternalTasks(
|
|
activeResponse.tasks,
|
|
);
|
|
const visibleCompletedTasks = filterVisibleExternalTasks(
|
|
completedResponse.tasks,
|
|
);
|
|
notifyCompletedExternalTasks(visibleCompletedTasks, {
|
|
unacknowledgedOnly: true,
|
|
});
|
|
setExternalTasks(
|
|
trimStoredExternalTasks(
|
|
mergeExternalTasks(visibleActiveTasks, visibleCompletedTasks),
|
|
completedListLimit,
|
|
),
|
|
);
|
|
})
|
|
.catch(() => {
|
|
if (!disposed) {
|
|
setExternalTasks([]);
|
|
}
|
|
});
|
|
return () => {
|
|
disposed = true;
|
|
controller.abort();
|
|
};
|
|
}, [
|
|
completedListLimit,
|
|
filterVisibleExternalTasks,
|
|
notifyCompletedExternalTasks,
|
|
refreshKey,
|
|
]);
|
|
|
|
const activeExternalTaskIds = useMemo(
|
|
() =>
|
|
externalTasks
|
|
.filter((task) => task.status === 'queued' || task.status === 'running')
|
|
.map((task) => task.jobId),
|
|
[externalTasks],
|
|
);
|
|
const activeExternalTaskKey = activeExternalTaskIds.join('|');
|
|
|
|
useEffect(() => {
|
|
activeExternalTaskIdsRef.current = new Set(activeExternalTaskIds);
|
|
}, [activeExternalTaskKey, activeExternalTaskIds]);
|
|
|
|
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: completedListLimit,
|
|
includeAcknowledgedTerminal: true,
|
|
statuses: ['completed', 'failed'],
|
|
signal: controller.signal,
|
|
}),
|
|
])
|
|
.then(([activeResponse, completedResponse]) => {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
const refreshedTasks = mergeExternalTasks(
|
|
filterVisibleExternalTasks(activeResponse.tasks),
|
|
filterVisibleExternalTasks(completedResponse.tasks),
|
|
);
|
|
notifyCompletedExternalTasks(refreshedTasks, {
|
|
activeTaskIds: activeExternalTaskIdsRef.current,
|
|
});
|
|
setExternalTasks((currentTasks) =>
|
|
trimStoredExternalTasks(
|
|
mergeExternalTaskRefresh(currentTasks, refreshedTasks),
|
|
completedListLimit,
|
|
),
|
|
);
|
|
})
|
|
.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,
|
|
completedListLimit,
|
|
filterVisibleExternalTasks,
|
|
notifyCompletedExternalTasks,
|
|
]);
|
|
|
|
const handleTaskListScroll = useCallback(
|
|
(event: ReactUIEvent<HTMLDivElement>) => {
|
|
if (activeTab !== 'completed') {
|
|
return;
|
|
}
|
|
const element = event.currentTarget;
|
|
const remaining =
|
|
element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
if (remaining > 24) {
|
|
return;
|
|
}
|
|
setCompletedListState((state) => {
|
|
const limit =
|
|
state.projectId === normalizedProjectId
|
|
? state.limit
|
|
: COMPLETED_TASK_LIST_LIMIT;
|
|
return {
|
|
limit: Math.min(
|
|
COMPLETED_TASK_LIST_MAX_LIMIT,
|
|
limit + COMPLETED_TASK_LIST_LIMIT,
|
|
),
|
|
projectId: normalizedProjectId,
|
|
};
|
|
});
|
|
},
|
|
[activeTab, normalizedProjectId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const hasRunningTask = externalTasks.some(
|
|
(task) => task.status === 'running',
|
|
);
|
|
if (!hasRunningTask) {
|
|
return undefined;
|
|
}
|
|
const timerId = window.setInterval(() => {
|
|
setNow(Date.now());
|
|
}, 1000);
|
|
return () => window.clearInterval(timerId);
|
|
}, [externalTasks]);
|
|
|
|
const items = useMemo<TaskSidebarItem[]>(() => {
|
|
const externalVisibleTasks = externalTasks;
|
|
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),
|
|
progressDetail: externalTaskProgressDetail(task, status) ?? undefined,
|
|
status,
|
|
startedAt,
|
|
finishedAt,
|
|
elapsedMs:
|
|
isTimedTaskStatus(status) && startedAt
|
|
? now - startedAt
|
|
: !isActiveTaskStatus(status) && startedAt && finishedAt
|
|
? finishedAt - startedAt
|
|
: undefined,
|
|
errorMessage: task.error ?? undefined,
|
|
onClick: () => onFocusExternalTask(task),
|
|
sortTime:
|
|
finishedAt ??
|
|
startedAt ??
|
|
parseTaskTimeMs(task.createdAt) ??
|
|
parseTaskTimeMs(task.updatedAt) ??
|
|
0,
|
|
};
|
|
});
|
|
return externalItems;
|
|
}, [externalTasks, now, onFocusExternalTask]);
|
|
|
|
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_MAX_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"
|
|
onScroll={handleTaskListScroll}
|
|
>
|
|
{visibleItems.map((item) => {
|
|
const elapsedLabel = formatElapsedMs(item.elapsedMs);
|
|
const finishedLabel =
|
|
item.status === 'done' || item.status === 'failed'
|
|
? formatTaskTimeMs(item.finishedAt)
|
|
: null;
|
|
const detailText = [
|
|
item.detail,
|
|
item.errorMessage && item.detail !== item.errorMessage
|
|
? item.errorMessage
|
|
: null,
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.join(' · ');
|
|
const timeText = [
|
|
elapsedLabel
|
|
? isTimedTaskStatus(item.status)
|
|
? `已用时 ${elapsedLabel}`
|
|
: `耗时 ${elapsedLabel}`
|
|
: null,
|
|
finishedLabel
|
|
? item.status === 'failed'
|
|
? `失败于 ${finishedLabel}`
|
|
: `完成于 ${finishedLabel}`
|
|
: null,
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.join(' · ');
|
|
const progressText = [
|
|
item.progressDetail,
|
|
isTimedTaskStatus(item.status) ? timeText : null,
|
|
]
|
|
.filter((value): value is string => Boolean(value))
|
|
.join(' · ');
|
|
const titleText = [detailText, item.progressDetail, timeText]
|
|
.filter((value): value is string => Boolean(value))
|
|
.join(' · ');
|
|
const metaTimeText = isActiveTaskStatus(item.status)
|
|
? null
|
|
: timeText;
|
|
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-row">
|
|
<span className="image-canvas-editor__task-sidebar-item-title">
|
|
{item.title}
|
|
</span>
|
|
<span className="image-canvas-editor__task-sidebar-item-meta">
|
|
<span className="image-canvas-editor__task-sidebar-item-status">
|
|
{statusLabel(item.status)}
|
|
</span>
|
|
{metaTimeText ? (
|
|
<span className="image-canvas-editor__task-sidebar-item-time">
|
|
{metaTimeText}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</span>
|
|
<span
|
|
className="image-canvas-editor__task-sidebar-item-detail"
|
|
title={titleText}
|
|
>
|
|
{detailText}
|
|
</span>
|
|
{progressText ? (
|
|
<span
|
|
className="image-canvas-editor__task-sidebar-item-progress"
|
|
title={progressText}
|
|
>
|
|
{progressText}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="image-canvas-editor__task-sidebar-empty">
|
|
暂无任务
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</aside>
|
|
);
|
|
}
|