feat: async session cleanup and fix live room scroll
This commit is contained in:
+40
-10
@@ -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[];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const uploadingSessionId = ref<string | null>(null);
|
||||
const uploadingTaskId = ref<string | null>(null);
|
||||
const triggeringSegmentCompletedTaskId = ref<string | null>(null);
|
||||
const cleanupOperation = ref<CleanupOperation | null>(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<string[]>([]);
|
||||
const deleteDialogSessionIds = ref<string[]>([]);
|
||||
|
||||
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<CleanupOperation["status"], string> = {
|
||||
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<CleanupOperation>(`/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<ManualSegmentCompletedTriggerResult>(
|
||||
`/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<DeleteCompletedRecordTasksResult>("/record-sessions/delete-empty?deleteFiles=" + deleteFiles)
|
||||
: deletingMissingSessions
|
||||
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete-missing-files", {
|
||||
deleteFiles
|
||||
})
|
||||
: deletingSessions
|
||||
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete", {
|
||||
sessionIds: deleteDialogSessionIds.value,
|
||||
deleteFiles
|
||||
})
|
||||
: await apiClient.post<DeleteCompletedRecordTasksResult>("/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<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||
taskIds: deleteDialogTaskIds.value,
|
||||
deleteFiles
|
||||
});
|
||||
deletedTaskResult = data;
|
||||
} else if (deletingSessions) {
|
||||
const { data } = await apiClient.post<CleanupOperation>("/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<CleanupOperation>("/record-sessions/delete-conditional", request);
|
||||
createdCleanupOperation = data;
|
||||
} else if (deletingEmptySessions) {
|
||||
const { data } = await apiClient.post<CleanupOperation>("/record-sessions/delete-empty", {
|
||||
deleteFiles
|
||||
});
|
||||
createdCleanupOperation = data;
|
||||
} else if (deletingMixed) {
|
||||
if (deleteDialogTaskIds.value.length > 0) {
|
||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||
taskIds: deleteDialogTaskIds.value,
|
||||
deleteFiles
|
||||
});
|
||||
deletedTaskResult = data;
|
||||
}
|
||||
|
||||
if (deleteDialogSessionIds.value.length > 0) {
|
||||
const { data } = await apiClient.post<CleanupOperation>("/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();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -614,6 +849,32 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<el-card v-if="cleanupOperation" class="surface-card cleanup-status-card" shadow="never">
|
||||
<div class="cleanup-status-card__header">
|
||||
<div>
|
||||
<div class="cleanup-status-card__eyebrow">后台清理任务</div>
|
||||
<div class="cleanup-status-card__title">当前任务状态:{{ cleanupOperationStatusLabel }}</div>
|
||||
</div>
|
||||
<div class="cleanup-status-card__actions">
|
||||
<el-tag :type="cleanupOperationTagType">{{ cleanupOperationStatusLabel }}</el-tag>
|
||||
<el-button v-if="cleanupOperationFinished" text @click="clearTrackedCleanupOperation">收起</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cleanup-status-card__meta">
|
||||
<span>已处理 {{ cleanupOperationProgressText }}</span>
|
||||
<span>{{ cleanupOperationSummary }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="cleanupOperation.errorMessage" class="cleanup-status-card__error">
|
||||
{{ cleanupOperation.errorMessage }}
|
||||
</div>
|
||||
|
||||
<ul v-if="cleanupOperationWarningsPreview.length > 0" class="cleanup-status-card__warnings">
|
||||
<li v-for="warning in cleanupOperationWarningsPreview" :key="warning">{{ warning }}</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">录制会话</div>
|
||||
@@ -764,6 +1025,14 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="data-card__actions">
|
||||
<el-button size="small" @click="openDetail(task)">详情</el-button>
|
||||
<el-button
|
||||
v-if="canTriggerSegmentCompleted(task)"
|
||||
size="small"
|
||||
:loading="triggeringSegmentCompletedTaskId === task.id"
|
||||
@click="triggerSegmentCompleted(task)"
|
||||
>
|
||||
触发完成事件
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDeletableTask(task)"
|
||||
size="small"
|
||||
@@ -930,10 +1199,18 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="220">
|
||||
<el-table-column label="操作" width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="task-actions-cell">
|
||||
<el-button size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="canTriggerSegmentCompleted(row)"
|
||||
size="small"
|
||||
:loading="triggeringSegmentCompletedTaskId === row.id"
|
||||
@click="triggerSegmentCompleted(row)"
|
||||
>
|
||||
触发事件
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDeletableTask(row)"
|
||||
size="small"
|
||||
@@ -1019,22 +1296,29 @@ onBeforeUnmount(() => {
|
||||
<template #header>
|
||||
<div class="delete-dialog__header">
|
||||
<span class="delete-dialog__eyebrow">按条件清理</span>
|
||||
<h3 class="delete-dialog__title">清理满足条件的无文件会话</h3>
|
||||
<h3 class="delete-dialog__title">按条件清理录制会话</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="delete-dialog__body">
|
||||
<div class="delete-dialog__lead">
|
||||
筛选出所有分片视频文件不存在的会话。可额外按分片状态筛选。
|
||||
条件会同时作用于手动清理和保留清理:只有当会话下所有分片都满足所选条件时,才会命中删除。
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="检查文件存在">
|
||||
<el-switch
|
||||
v-model="conditionalFilter.checkFileExists"
|
||||
active-text="仅删除无实体文件的会话"
|
||||
inactive-text="不检查文件"
|
||||
/>
|
||||
<el-form-item label="视频文件条件">
|
||||
<el-select
|
||||
v-model="conditionalFilter.videoFileCondition"
|
||||
placeholder="请选择文件条件"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in conditionalVideoFileOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="按分片状态筛选(可多选,为空则不限)">
|
||||
@@ -1056,7 +1340,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="delete-dialog__note">
|
||||
<div class="delete-dialog__note-title">提示</div>
|
||||
<p>仅当所有文件检查和不限时全部满足才会命中。此操作不可恢复,请确认后再执行。</p>
|
||||
<p>空会话不会参与这里的文件/状态条件匹配,请继续使用“清理无分片会话”单独处理。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1085,6 +1369,64 @@ onBeforeUnmount(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.cleanup-status-card {
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.cleanup-status-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cleanup-status-card__eyebrow {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cleanup-status-card__title {
|
||||
margin-top: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cleanup-status-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cleanup-status-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.cleanup-status-card__error {
|
||||
margin-top: 12px;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.cleanup-status-card__warnings {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tasks-table-shell {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
+419
-228
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user