From cc37d90d9265b597115b1e128c871766ece3987b Mon Sep 17 00:00:00 2001 From: nanxun Date: Fri, 8 May 2026 18:18:36 +0800 Subject: [PATCH] feat: async session cleanup and fix live room scroll --- frontend/src/types.ts | 50 +- frontend/src/views/LiveRoomsView.vue | 5 +- frontend/src/views/RecordTasksView.vue | 480 +++++++++++-- frontend/src/views/SettingsView.vue | 647 ++++++++++++------ .../Models/Cleanup/CleanupOperationModels.cs | 69 ++ .../Models/RecordTasks/RecordSessionModels.cs | 32 + .../Models/Settings/SettingsModels.cs | 26 +- .../Services/RecordSessionService.cs | 176 +++-- .../Services/SystemSettingsService.cs | 53 ++ .../Entities/CleanupOperation.cs | 165 +++++ .../Enums/CleanupOperationKind.cs | 9 + .../Enums/CleanupOperationStatus.cs | 9 + .../Enums/CleanupVideoFileCondition.cs | 8 + .../Persistence/LiveRecorderDbContext.cs | 14 + ...508090000_AddCleanupOperations.Designer.cs | 647 ++++++++++++++++++ .../20260508090000_AddCleanupOperations.cs | 55 ++ .../LiveRecorderDbContextModelSnapshot.cs | 66 ++ .../CleanupOperationBackgroundService.cs | 65 ++ .../Services/CleanupOperationCoordinator.cs | 228 ++++++ .../Services/CleanupOperationSupport.cs | 103 +++ .../Services/RecordSessionCleanupResolver.cs | 220 ++++++ .../RetentionCleanupBackgroundService.cs | 27 +- .../Services/RetentionCleanupService.cs | 266 +------ .../CleanupOperationsController.cs | 24 + .../Controllers/RecordSessionsController.cs | 24 +- .../Controllers/SettingsController.cs | 9 +- src/LiveRecorder.WebApi/Program.cs | 3 + 27 files changed, 2788 insertions(+), 692 deletions(-) create mode 100644 src/LiveRecorder.Application/Models/Cleanup/CleanupOperationModels.cs create mode 100644 src/LiveRecorder.Domain/Entities/CleanupOperation.cs create mode 100644 src/LiveRecorder.Domain/Enums/CleanupOperationKind.cs create mode 100644 src/LiveRecorder.Domain/Enums/CleanupOperationStatus.cs create mode 100644 src/LiveRecorder.Domain/Enums/CleanupVideoFileCondition.cs create mode 100644 src/LiveRecorder.Infrastructure/Persistence/Migrations/20260508090000_AddCleanupOperations.Designer.cs create mode 100644 src/LiveRecorder.Infrastructure/Persistence/Migrations/20260508090000_AddCleanupOperations.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/CleanupOperationBackgroundService.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/CleanupOperationCoordinator.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/CleanupOperationSupport.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/RecordSessionCleanupResolver.cs create mode 100644 src/LiveRecorder.WebApi/Controllers/CleanupOperationsController.cs diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 77c7cfc..721f9b5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -213,6 +213,38 @@ export interface DeleteCompletedRecordTasksResult { warnings: string[]; } +export type CleanupOperationKind = "selected" | "conditional" | "empty" | "retention"; + +export type CleanupOperationStatus = "queued" | "running" | "completed" | "failed"; + +export type CleanupVideoFileCondition = "any" | "allMissing" | "allPresent"; + +export interface CleanupOperation { + id: string; + kind: CleanupOperationKind; + status: CleanupOperationStatus; + deleteFiles: boolean; + createdAt: string; + startedAt?: string; + completedAt?: string; + totalSessionCount: number; + processedSessionCount: number; + deletedSessionCount: number; + deletedTaskCount: number; + deletedResultCount: number; + deletedLogCount: number; + deletedFileCount: number; + deletedDanmakuFileCount: number; + warnings: string[]; + errorMessage?: string; +} + +export interface DeleteConditionalSessionsRequest { + videoFileCondition: CleanupVideoFileCondition; + taskStatuses: number[]; + deleteFiles: boolean; +} + export interface RecordArtifactUploadItemResult { recordTaskId: string; success: boolean; @@ -230,6 +262,12 @@ export interface RecordArtifactUploadBatchResult { items: RecordArtifactUploadItemResult[]; } +export interface ManualSegmentCompletedTriggerResult { + recordTaskId: string; + success: boolean; + message: string; +} + export interface RecordPreviewTicket { url: string; expiresAt: string; @@ -386,6 +424,8 @@ export interface SystemSettings { enableRetentionCleanup: boolean; retentionDays: number; retentionDeleteFiles: boolean; + retentionVideoFileCondition: CleanupVideoFileCondition; + retentionTaskStatuses: number[]; enableAutoReconnect: boolean; reconnectDelayMaxSeconds: number; readWriteTimeoutMilliseconds: number; @@ -482,16 +522,6 @@ export interface WebhookTestResult { detail?: string; } -export interface RetentionCleanupResult { - deletedSessionCount: number; - deletedTaskCount: number; - deletedResultCount: number; - deletedLogCount: number; - deletedFileCount: number; - deletedDanmakuFileCount: number; - warnings: string[]; -} - export interface RecoveryOverview { storage: StorageGuardStatus; liveRooms: RecoverableLiveRoom[]; diff --git a/frontend/src/views/LiveRoomsView.vue b/frontend/src/views/LiveRoomsView.vue index 87b8953..87b6498 100644 --- a/frontend/src/views/LiveRoomsView.vue +++ b/frontend/src/views/LiveRoomsView.vue @@ -269,9 +269,12 @@ async function syncRoomsTableProxyScroll() { const scrollWidth = getRoomsTableContentWidth(wrap); const clientWidth = wrap.clientWidth; + const shellClientWidth = roomsTableShellRef.value?.clientWidth ?? clientWidth; + const proxyViewportWidth = proxy?.clientWidth ?? shellClientWidth; const canScrollHorizontally = scrollWidth > clientWidth + 1; + const proxyContentWidth = scrollWidth + Math.max(0, proxyViewportWidth - clientWidth); - roomsTableProxyInnerWidth.value = canScrollHorizontally ? scrollWidth : 0; + roomsTableProxyInnerWidth.value = canScrollHorizontally ? Math.ceil(proxyContentWidth) : 0; showRoomsTableProxyScroll.value = canScrollHorizontally; if (canScrollHorizontally && proxy && Math.abs(proxy.scrollLeft - wrap.scrollLeft) > 1) { diff --git a/frontend/src/views/RecordTasksView.vue b/frontend/src/views/RecordTasksView.vue index d81b898..a1bc40f 100644 --- a/frontend/src/views/RecordTasksView.vue +++ b/frontend/src/views/RecordTasksView.vue @@ -10,7 +10,11 @@ import apiClient, { import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus"; import { useViewport } from "@/composables/useViewport"; import type { + CleanupOperation, + CleanupVideoFileCondition, DeleteCompletedRecordTasksResult, + DeleteConditionalSessionsRequest, + ManualSegmentCompletedTriggerResult, RecordArtifactUploadBatchResult, RecordArtifactUploadItemResult, RecordSession, @@ -25,21 +29,29 @@ import { } from "@/types"; const router = useRouter(); +const cleanupOperationStorageKey = "live-recorder-record-tasks-cleanup-operation-id"; const loading = ref(false); const deleting = ref(false); const stoppingSessionId = ref(null); const uploadingSessionId = ref(null); const uploadingTaskId = ref(null); +const triggeringSegmentCompletedTaskId = ref(null); +const cleanupOperation = ref(null); const deleteDialogVisible = ref(false); -const deleteDialogMode = ref<"tasks" | "sessions" | "missing-sessions" | "mixed" | "empty-sessions">("tasks"); +const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions">("tasks"); const deleteDialogTaskIds = ref([]); const deleteDialogSessionIds = ref([]); const conditionalDialogVisible = ref(false); const conditionalFilter = reactive({ - checkFileExists: true as boolean | null, + videoFileCondition: "allMissing" as CleanupVideoFileCondition, taskStatuses: [] as number[] }); +const conditionalVideoFileOptions = [ + { label: "不限文件状态", value: "any" as CleanupVideoFileCondition }, + { label: "全部视频文件不存在", value: "allMissing" as CleanupVideoFileCondition }, + { label: "全部视频文件存在", value: "allPresent" as CleanupVideoFileCondition } +]; const conditionalTaskStatusOptions = Object.entries(taskStatusLabelMap) .map(([value, label]) => ({ value: Number(value), label })) .filter(o => o.value !== 1 && o.value !== 2 && o.value !== 3); @@ -53,6 +65,7 @@ const realtimeError = ref(""); const { isMobile } = useViewport(); let sessionsEventSource: EventSource | null = null; +let cleanupPollTimer: number | null = null; const selectedTasks = computed(() => Object.values(selectedTaskMap.value)); const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length); @@ -72,13 +85,85 @@ const mixedSelectionLabel = computed(() => { const deleteDialogTaskCount = computed(() => deleteDialogTaskIds.value.length); const deleteDialogSessionCount = computed(() => deleteDialogSessionIds.value.length); const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 48vw, 560px)")); +const cleanupStatusLabelMap: Record = { + queued: "排队中", + running: "执行中", + completed: "已完成", + failed: "已失败" +}; +const cleanupOperationStatusLabel = computed(() => + cleanupOperation.value ? cleanupStatusLabelMap[cleanupOperation.value.status] : "" +); +const cleanupOperationFinished = computed(() => + cleanupOperation.value?.status === "completed" || cleanupOperation.value?.status === "failed" +); +const cleanupOperationTagType = computed(() => { + if (!cleanupOperation.value) { + return "info"; + } + + if (cleanupOperation.value.status === "failed") { + return "danger"; + } + + if (cleanupOperation.value.status === "completed" && cleanupOperation.value.warnings.length === 0) { + return "success"; + } + + if (cleanupOperation.value.warnings.length > 0) { + return "warning"; + } + + return "info"; +}); +const cleanupOperationProgressText = computed(() => { + if (!cleanupOperation.value) { + return ""; + } + + if (cleanupOperation.value.totalSessionCount === 0) { + return cleanupOperation.value.status === "queued" ? "等待分析候选会话" : "0 / 0"; + } + + return `${cleanupOperation.value.processedSessionCount} / ${cleanupOperation.value.totalSessionCount}`; +}); +const cleanupOperationSummary = computed(() => { + if (!cleanupOperation.value) { + return ""; + } + + return [ + `会话 ${cleanupOperation.value.deletedSessionCount}`, + `分片 ${cleanupOperation.value.deletedTaskCount}`, + `结果 ${cleanupOperation.value.deletedResultCount}`, + `日志 ${cleanupOperation.value.deletedLogCount}`, + `视频 ${cleanupOperation.value.deletedFileCount}`, + `弹幕 ${cleanupOperation.value.deletedDanmakuFileCount}` + ].join(" · "); +}); +const cleanupOperationWarningsPreview = computed(() => cleanupOperation.value?.warnings.slice(0, 6) ?? []); +const conditionalFilterSummary = computed(() => { + const parts = [ + conditionalFilter.videoFileCondition === "allMissing" + ? "全部视频文件不存在" + : conditionalFilter.videoFileCondition === "allPresent" + ? "全部视频文件存在" + : "不限文件状态" + ]; + + if (conditionalFilter.taskStatuses.length > 0) { + parts.push(`全部分片状态属于 ${conditionalFilter.taskStatuses.length} 个已选状态`); + } + + return parts.join(","); +}); const deleteDialogEyebrow = computed(() => { if (deleteDialogMode.value === "sessions" || deleteDialogMode.value === "mixed") { return "会话删除"; } - if (deleteDialogMode.value === "missing-sessions") { - return "无文件清理"; + if (deleteDialogMode.value === "conditional-sessions") { + return "条件清理"; } if (deleteDialogMode.value === "empty-sessions") { @@ -96,8 +181,8 @@ const deleteDialogTitle = computed(() => { return "删除录制会话"; } - if (deleteDialogMode.value === "missing-sessions") { - return "清理无实体文件会话"; + if (deleteDialogMode.value === "conditional-sessions") { + return "清理命中条件的会话"; } if (deleteDialogMode.value === "empty-sessions") { @@ -108,15 +193,15 @@ const deleteDialogTitle = computed(() => { }); const deleteDialogLead = computed(() => { if (deleteDialogMode.value === "mixed") { - return `将删除 ${deleteDialogSessionCount.value} 个会话和 ${deleteDialogTaskCount.value} 个分片任务。会话删除会先停止录制再清理,你也可以选择同时删除本地文件。`; + return `将删除 ${deleteDialogSessionCount.value} 个会话和 ${deleteDialogTaskCount.value} 个分片任务。分片任务会立即删除,会话则会创建后台清理任务继续执行,你也可以选择同时删除本地文件。`; } if (deleteDialogMode.value === "sessions") { return `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`; } - if (deleteDialogMode.value === "missing-sessions") { - return `将自动筛出所有无实体文件的录制会话并批量清理。你也可以选择同时尝试删除残留的本地文件。`; + if (deleteDialogMode.value === "conditional-sessions") { + return `将按“${conditionalFilterSummary.value}”筛选命中的录制会话并批量清理。只有当会话下所有分片都满足这些条件时,才会进入后台删除任务。`; } if (deleteDialogMode.value === "empty-sessions") { @@ -130,8 +215,8 @@ const deleteDialogNote = computed(() => { return "活跃会话会先尝试优雅停止,超时后再强制结束 ffmpeg 进程。直播间仍保持启用时,后台巡检后续可能重新创建新会话。"; } - if (deleteDialogMode.value === "missing-sessions") { - return "只有当会话下所有分片都找不到实体视频文件时,才会命中这类清理。只要仍有任意一个视频文件存在,该会话就不会被误删。"; + if (deleteDialogMode.value === "conditional-sessions") { + return "命中规则固定为“会话下所有分片都满足条件”。空会话不会参与这类条件判断,而是继续通过“清理无分片会话”单独处理。"; } if (deleteDialogMode.value === "empty-sessions") { @@ -148,6 +233,10 @@ function isDeletableTask(task: RecordTask) { return !isActiveStatus(task.status); } +function canTriggerSegmentCompleted(task: RecordTask) { + return !isActiveStatus(task.status) && Boolean(task.outputFilePath); +} + function selectableTask(row: RecordTask) { return isDeletableTask(row); } @@ -288,6 +377,92 @@ function closeRealtimeUpdates() { sessionsEventSource = null; } +function persistCleanupOperationId(id: string | null) { + if (typeof window === "undefined") { + return; + } + + if (id) { + window.sessionStorage.setItem(cleanupOperationStorageKey, id); + return; + } + + window.sessionStorage.removeItem(cleanupOperationStorageKey); +} + +function stopCleanupPolling() { + if (cleanupPollTimer !== null && typeof window !== "undefined") { + window.clearTimeout(cleanupPollTimer); + } + + cleanupPollTimer = null; +} + +function scheduleCleanupPolling(operationId: string) { + stopCleanupPolling(); + + if (typeof window === "undefined") { + return; + } + + cleanupPollTimer = window.setTimeout(() => { + void refreshCleanupOperation(operationId, { silent: true }); + }, 2000); +} + +function clearTrackedCleanupOperation() { + cleanupOperation.value = null; + persistCleanupOperationId(null); + stopCleanupPolling(); +} + +async function refreshCleanupOperation(operationId: string, options?: { silent?: boolean }) { + try { + const { data } = await apiClient.get(`/cleanup-operations/${operationId}`); + cleanupOperation.value = data; + persistCleanupOperationId(data.id); + + if (data.status === "queued" || data.status === "running") { + scheduleCleanupPolling(data.id); + return; + } + + stopCleanupPolling(); + await loadSessions({ resetPanels: false, resetSelection: false }); + } catch (error) { + stopCleanupPolling(); + + if (!options?.silent) { + ElMessage.error(getApiErrorMessage(error, "清理任务状态加载失败,请稍后重试。")); + } + } +} + +async function startCleanupTracking(operation: CleanupOperation) { + cleanupOperation.value = operation; + persistCleanupOperationId(operation.id); + + if (operation.status === "queued" || operation.status === "running") { + scheduleCleanupPolling(operation.id); + return; + } + + stopCleanupPolling(); +} + +async function restoreCleanupTracking() { + if (typeof window === "undefined") { + return; + } + + const operationId = window.sessionStorage.getItem(cleanupOperationStorageKey); + if (!operationId) { + return; + } + + await refreshCleanupOperation(operationId, { silent: true }); +} + function handleSelectionChange(session: RecordSession, selection: RecordTask[]) { const nextMap = { ...selectedTaskMap.value }; session.tasks.forEach((task) => { @@ -345,6 +520,21 @@ async function uploadTask(task: RecordTask) { } } +async function triggerSegmentCompleted(task: RecordTask) { + triggeringSegmentCompletedTaskId.value = task.id; + + try { + const { data } = await apiClient.post( + `/record-tasks/${task.id}/trigger-segment-completed` + ); + ElMessage[data.success ? "success" : "warning"](data.message); + } catch (error) { + ElMessage.error(getApiErrorMessage(error, "手动触发分片完成事件失败,请稍后重试。")); + } finally { + triggeringSegmentCompletedTaskId.value = null; + } +} + function openDetail(task: RecordTask) { router.push({ name: "record-task-detail", params: { id: task.id } }); } @@ -366,7 +556,7 @@ function openMixedDeleteDialog() { } function openConditionalDeleteDialog() { - conditionalFilter.checkFileExists = true; + conditionalFilter.videoFileCondition = "allMissing"; conditionalFilter.taskStatuses = []; conditionalDialogVisible.value = true; } @@ -380,9 +570,7 @@ function openDeleteEmptySessionsDialog() { async function confirmConditionalDelete() { conditionalDialogVisible.value = false; - await loadSessions({ resetPanels: false, resetSelection: false }); - - deleteDialogMode.value = "missing-sessions"; + deleteDialogMode.value = "conditional-sessions"; deleteDialogSessionIds.value = []; deleteDialogTaskIds.value = []; deleteDialogVisible.value = true; @@ -412,13 +600,6 @@ function openDeleteSessionDialog(sessionIds: string[]) { deleteDialogVisible.value = true; } -function openDeleteMissingSessionsDialog() { - deleteDialogMode.value = "missing-sessions"; - deleteDialogSessionIds.value = []; - deleteDialogTaskIds.value = []; - deleteDialogVisible.value = true; -} - function closeDeleteDialog() { if (deleting.value) { return; @@ -453,11 +634,18 @@ function applyDeleteResult(data: DeleteCompletedRecordTasksResult) { } async function confirmDelete(deleteFiles: boolean) { - const deletingSessions = deleteDialogMode.value === "sessions" || deleteDialogMode.value === "mixed"; - const deletingMissingSessions = deleteDialogMode.value === "missing-sessions"; - const deletingEmptySessions = deleteDialogMode.value === "empty-sessions"; - const selectedIds = deletingSessions ? deleteDialogSessionIds.value : deleteDialogTaskIds.value; - if (!deletingMissingSessions && !deletingEmptySessions && selectedIds.length === 0) { + const currentMode = deleteDialogMode.value; + const deletingSessions = currentMode === "sessions"; + const deletingConditionalSessions = currentMode === "conditional-sessions"; + const deletingEmptySessions = currentMode === "empty-sessions"; + const deletingMixed = currentMode === "mixed"; + const hasSelection = deletingMixed + ? deleteDialogSessionIds.value.length > 0 || deleteDialogTaskIds.value.length > 0 + : deletingSessions + ? deleteDialogSessionIds.value.length > 0 + : deleteDialogTaskIds.value.length > 0; + + if (!deletingConditionalSessions && !deletingEmptySessions && !hasSelection) { closeDeleteDialog(); return; } @@ -465,46 +653,91 @@ async function confirmDelete(deleteFiles: boolean) { deleting.value = true; try { - const { data } = deletingEmptySessions - ? await apiClient.post("/record-sessions/delete-empty?deleteFiles=" + deleteFiles) - : deletingMissingSessions - ? await apiClient.post("/record-sessions/delete-missing-files", { - deleteFiles - }) - : deletingSessions - ? await apiClient.post("/record-sessions/delete", { - sessionIds: deleteDialogSessionIds.value, - deleteFiles - }) - : await apiClient.post("/record-tasks/delete", { - taskIds: deleteDialogTaskIds.value, - deleteFiles - }); + let deletedTaskResult: DeleteCompletedRecordTasksResult | null = null; + let createdCleanupOperation: CleanupOperation | null = null; - if ((deletingMissingSessions || deletingEmptySessions) && data.deletedSessionIds.length === 0) { - ElMessage.info(deletingEmptySessions ? "没有找到无分片的空会话。" : "没有找到符合条件的无实体文件会话。"); - } else { - ElMessage.success( - deletingSessions || deletingMissingSessions || deletingEmptySessions - ? `已删除 ${data.deletedSessionIds.length} 个录制会话。` - : `已删除 ${data.deletedTaskIds.length} 个分片任务。` - ); + if (currentMode === "tasks") { + const { data } = await apiClient.post("/record-tasks/delete", { + taskIds: deleteDialogTaskIds.value, + deleteFiles + }); + deletedTaskResult = data; + } else if (deletingSessions) { + const { data } = await apiClient.post("/record-sessions/delete", { + sessionIds: deleteDialogSessionIds.value, + deleteFiles + }); + createdCleanupOperation = data; + } else if (deletingConditionalSessions) { + const request: DeleteConditionalSessionsRequest = { + videoFileCondition: conditionalFilter.videoFileCondition, + taskStatuses: [...conditionalFilter.taskStatuses], + deleteFiles + }; + const { data } = await apiClient.post("/record-sessions/delete-conditional", request); + createdCleanupOperation = data; + } else if (deletingEmptySessions) { + const { data } = await apiClient.post("/record-sessions/delete-empty", { + deleteFiles + }); + createdCleanupOperation = data; + } else if (deletingMixed) { + if (deleteDialogTaskIds.value.length > 0) { + const { data } = await apiClient.post("/record-tasks/delete", { + taskIds: deleteDialogTaskIds.value, + deleteFiles + }); + deletedTaskResult = data; + } + + if (deleteDialogSessionIds.value.length > 0) { + const { data } = await apiClient.post("/record-sessions/delete", { + sessionIds: deleteDialogSessionIds.value, + deleteFiles + }); + createdCleanupOperation = data; + } } - applyDeleteResult(data); + if (deletedTaskResult) { + applyDeleteResult(deletedTaskResult); - if (data.warnings.length > 0) { - ElNotification({ - title: "删除完成,但有提示", - message: data.warnings.join("\n"), - type: "warning", - duration: 8000 - }); + if (deletedTaskResult.warnings.length > 0) { + ElNotification({ + title: "删除完成,但有提示", + message: deletedTaskResult.warnings.join("\n"), + type: "warning", + duration: 8000 + }); + } + } + + if (createdCleanupOperation) { + await startCleanupTracking(createdCleanupOperation); } deleteDialogVisible.value = false; resetDeleteDialogState(); - await loadSessions({ resetPanels: false, resetSelection: false }); + + if (deleteDialogMode.value === "tasks") { + ElMessage.success( + deletedTaskResult ? `已删除 ${deletedTaskResult.deletedTaskIds.length} 个分片任务。` : "已删除分片任务。" + ); + await loadSessions({ resetPanels: false, resetSelection: false }); + return; + } + + if (currentMode === "mixed") { + ElMessage.success("已启动会话后台清理;已选分片任务会按当前选项立即删除。"); + await loadSessions({ resetPanels: false, resetSelection: false }); + return; + } + + ElMessage.success( + currentMode === "tasks" + ? "已删除分片任务。" + : "后台清理任务已创建,页面会自动轮询进度。" + ); } finally { deleting.value = false; } @@ -568,11 +801,13 @@ function formatFileSize(bytes?: number) { onMounted(async () => { await loadSessions(); + await restoreCleanupTracking(); connectRealtimeUpdates(); }); onBeforeUnmount(() => { closeRealtimeUpdates(); + stopCleanupPolling(); }); @@ -614,6 +849,32 @@ onBeforeUnmount(() => { + +
+
+
后台清理任务
+
当前任务状态:{{ cleanupOperationStatusLabel }}
+
+
+ {{ cleanupOperationStatusLabel }} + 收起 +
+
+ +
+ 已处理 {{ cleanupOperationProgressText }} + {{ cleanupOperationSummary }} +
+ +
+ {{ cleanupOperation.errorMessage }} +
+ +
    +
  • {{ warning }}
  • +
+
+
录制会话
@@ -764,6 +1025,14 @@ onBeforeUnmount(() => {
详情 + + 触发完成事件 + { - +