diff --git a/frontend/src/views/RecordTasksView.vue b/frontend/src/views/RecordTasksView.vue index 9dce652..ddd0788 100644 --- a/frontend/src/views/RecordTasksView.vue +++ b/frontend/src/views/RecordTasksView.vue @@ -42,7 +42,7 @@ const uploadingTaskId = ref(null); const triggeringSegmentCompletedTaskId = ref(null); const cleanupOperation = ref(null); const deleteDialogVisible = ref(false); -const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions">("tasks"); +const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions" | "missing-file-tasks">("tasks"); const deleteDialogTaskIds = ref([]); const deleteDialogSessionIds = ref([]); @@ -174,6 +174,10 @@ const deleteDialogEyebrow = computed(() => { return "空闲会话清理"; } + if (deleteDialogMode.value === "missing-file-tasks") { + return "无文件分片清理"; + } + return "删除确认"; }); const deleteDialogTitle = computed(() => { @@ -193,6 +197,10 @@ const deleteDialogTitle = computed(() => { return "清理无分片会话"; } + if (deleteDialogMode.value === "missing-file-tasks") { + return "清理无文件分片"; + } + return "删除分片任务"; }); const deleteDialogLead = computed(() => { @@ -212,6 +220,10 @@ const deleteDialogLead = computed(() => { return `将自动找出所有没有任何分片任务的录制会话并批量删除。你也可以选择同时删除可能残留的本地文件。`; } + if (deleteDialogMode.value === "missing-file-tasks") { + return `将自动找出所有视频文件已丢失的分片任务并批量删除(不限会话,只删命中的分片本身)。删除后若某个会话下不再有任何分片,会话也会一并清理;你也可以选择同时清理残留的弹幕 XML 文件。`; + } + return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`; }); const deleteDialogNote = computed(() => { @@ -227,6 +239,10 @@ const deleteDialogNote = computed(() => { return "仅清理没有任何关联分片的空会话,不影响有录制产物的会话。"; } + if (deleteDialogMode.value === "missing-file-tasks") { + return "以分片为单位判定:仅当分片的视频文件在磁盘上不存在时才会删除。正在录制或处理中的分片会自动跳过,有视频文件的分片不受影响。"; + } + return "记录加文件会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。"; }); function isActiveStatus(status: number) { @@ -572,6 +588,13 @@ function openDeleteEmptySessionsDialog() { deleteDialogVisible.value = true; } +function openDeleteMissingFileTasksDialog() { + deleteDialogMode.value = "missing-file-tasks"; + deleteDialogSessionIds.value = []; + deleteDialogTaskIds.value = []; + deleteDialogVisible.value = true; +} + async function confirmConditionalDelete() { conditionalDialogVisible.value = false; deleteDialogMode.value = "conditional-sessions"; @@ -642,6 +665,7 @@ async function confirmDelete(deleteFiles: boolean) { const deletingSessions = currentMode === "sessions"; const deletingConditionalSessions = currentMode === "conditional-sessions"; const deletingEmptySessions = currentMode === "empty-sessions"; + const deletingMissingFileTasks = currentMode === "missing-file-tasks"; const deletingMixed = currentMode === "mixed"; const hasSelection = deletingMixed ? deleteDialogSessionIds.value.length > 0 || deleteDialogTaskIds.value.length > 0 @@ -649,7 +673,7 @@ async function confirmDelete(deleteFiles: boolean) { ? deleteDialogSessionIds.value.length > 0 : deleteDialogTaskIds.value.length > 0; - if (!deletingConditionalSessions && !deletingEmptySessions && !hasSelection) { + if (!deletingConditionalSessions && !deletingEmptySessions && !deletingMissingFileTasks && !hasSelection) { closeDeleteDialog(); return; } @@ -685,6 +709,11 @@ async function confirmDelete(deleteFiles: boolean) { deleteFiles }); createdCleanupOperation = data; + } else if (deletingMissingFileTasks) { + const { data } = await apiClient.post("/record-tasks/delete-missing-files", { + deleteFiles + }); + deletedTaskResult = data; } else if (deletingMixed) { if (deleteDialogTaskIds.value.length > 0) { const { data } = await apiClient.post("/record-tasks/delete", { @@ -723,7 +752,17 @@ async function confirmDelete(deleteFiles: boolean) { deleteDialogVisible.value = false; resetDeleteDialogState(); - if (deleteDialogMode.value === "tasks") { + if (currentMode === "missing-file-tasks") { + ElMessage.success( + deletedTaskResult + ? `已清理 ${deletedTaskResult.deletedTaskIds.length} 个无文件分片。` + : "未发现可清理的无文件分片。" + ); + await loadSessions({ resetPanels: false, resetSelection: false }); + return; + } + + if (currentMode === "tasks") { ElMessage.success( deletedTaskResult ? `已删除 ${deletedTaskResult.deletedTaskIds.length} 个分片任务。` : "已删除分片任务。" ); @@ -737,11 +776,7 @@ async function confirmDelete(deleteFiles: boolean) { return; } - ElMessage.success( - currentMode === "tasks" - ? "已删除分片任务。" - : "后台清理任务已创建,页面会自动轮询进度。" - ); + ElMessage.success("后台清理任务已创建,页面会自动轮询进度。"); } finally { deleting.value = false; } @@ -931,6 +966,9 @@ onBeforeUnmount(() => { 清理无分片会话 + + 清理无文件分片 + diff --git a/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs index 81284af..a3ba92e 100644 --- a/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs +++ b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs @@ -108,6 +108,11 @@ public sealed class DeleteCompletedRecordTasksRequest public bool DeleteFiles { get; set; } } +public sealed class DeleteMissingFileRecordTasksRequest +{ + public bool DeleteFiles { get; set; } +} + public sealed class DeleteCompletedRecordTasksResultDto { public required IReadOnlyList DeletedTaskIds { get; init; } diff --git a/src/LiveRecorder.Application/Services/RecordService.cs b/src/LiveRecorder.Application/Services/RecordService.cs index 81e74d5..e2ea08f 100644 --- a/src/LiveRecorder.Application/Services/RecordService.cs +++ b/src/LiveRecorder.Application/Services/RecordService.cs @@ -504,6 +504,32 @@ public sealed class RecordService }; } + public async Task DeleteMissingFileTasksAsync( + DeleteMissingFileRecordTasksRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var tasks = await _recordTaskRepository.ListAsync(null, cancellationToken); + var missingFileTaskIds = tasks + .Where(static task => !IsActiveTaskStatus(task.Status) && !HasExistingVideoFile(task)) + .Select(static task => task.Id) + .ToArray(); + + if (missingFileTaskIds.Length == 0) + { + return CreateEmptyDeleteResult(); + } + + return await DeleteTasksAsync( + new DeleteCompletedRecordTasksRequest + { + TaskIds = missingFileTaskIds, + DeleteFiles = request.DeleteFiles + }, + cancellationToken); + } + public async Task CreatePreviewTicketAsync(Guid id, string mediaBaseUrl, CancellationToken cancellationToken = default) { var previewTicket = await _recordMediaService.CreatePreviewTicketAsync(id, cancellationToken); @@ -823,6 +849,24 @@ public sealed class RecordService or RecordTaskStatus.Stopping or RecordTaskStatus.Processing; + private static bool HasExistingVideoFile(RecordTask task) + { + var candidatePath = !string.IsNullOrWhiteSpace(task.Result?.FilePath) + ? task.Result!.FilePath + : task.OutputFilePath; + + if (string.IsNullOrWhiteSpace(candidatePath)) + { + return false; + } + + var resolvedPath = Path.IsPathRooted(candidatePath) + ? candidatePath + : Path.GetFullPath(candidatePath, AppContext.BaseDirectory); + + return File.Exists(resolvedPath); + } + private static bool IsActiveSessionStatus(RecordSessionStatus status) => status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping; diff --git a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs index 7fe5f54..a0c0816 100644 --- a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs +++ b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs @@ -61,6 +61,12 @@ public sealed class RecordTasksController : ControllerBase CancellationToken cancellationToken) => Ok(await _recordService.DeleteTasksAsync(request, cancellationToken)); + [HttpPost("delete-missing-files")] + public async Task> DeleteMissingFiles( + [FromBody] DeleteMissingFileRecordTasksRequest request, + CancellationToken cancellationToken) => + Ok(await _recordService.DeleteMissingFileTasksAsync(request, cancellationToken)); + [HttpPost("{id:guid}/preview-ticket")] public async Task> CreatePreviewTicket(Guid id, CancellationToken cancellationToken) {