diff --git a/desktop/renderer-app/src/apiMiddleware/flatServer/storage.ts b/desktop/renderer-app/src/apiMiddleware/flatServer/storage.ts index 5cfbc6078e0..d02abfad166 100644 --- a/desktop/renderer-app/src/apiMiddleware/flatServer/storage.ts +++ b/desktop/renderer-app/src/apiMiddleware/flatServer/storage.ts @@ -103,3 +103,13 @@ export interface ConvertFinishResult {} export async function convertFinish(payload: ConvertFinishPayload): Promise { return await post("cloud-storage/convert/finish", payload); } + +export interface CancelUploadPayload { + fileUUIDs: string[]; +} + +export interface CancelUploadResult {} + +export async function cancelUpload(payload: CancelUploadPayload): Promise { + await post("cloud-storage/upload/cancel", payload); +} diff --git a/desktop/renderer-app/src/pages/CloudStoragePage/store.tsx b/desktop/renderer-app/src/pages/CloudStoragePage/store.tsx index 9a830227ae4..1bea1c36ada 100644 --- a/desktop/renderer-app/src/pages/CloudStoragePage/store.tsx +++ b/desktop/renderer-app/src/pages/CloudStoragePage/store.tsx @@ -3,6 +3,7 @@ import { CloudStorageFile, CloudStorageFileName, CloudStorageStore as CloudStorageStoreBase, + CloudStorageUploadStatus, } from "flat-components"; import { action, makeObservable } from "mobx"; import React, { ReactNode } from "react"; @@ -19,6 +20,7 @@ import { convertStepToType, download, getFileUrl, + isFakeID, pickFiles, queryTask, uploadManager, @@ -27,12 +29,7 @@ import { type FileMenusKey = "download" | "rename" | "delete"; export class CloudStorageStore extends CloudStorageStoreBase { - pulling = { - queue: [] as CloudFile[], - index: 0, - timer: NaN, - }; - + pulling = new Map(); retryable = new Map(); fileMenus = ( @@ -53,19 +50,35 @@ export class CloudStorageStore extends CloudStorageStoreBase { this.setCompact(compact); makeObservable(this, { updateFiles: action, + updateTotalUsage: action, updateFileName: action, expandUploadPanel: action, + clearUploadStatusesMap: action, + deleteFileFromUploadStatusesMap: action, onUploadInit: action, onUploadEnd: action, onUploadError: action, onUploadProgress: action, + unselectAll: action, }); } + unselectAll(): void { + this.selectedFileUUIDs = []; + } + expandUploadPanel(): void { this.isUploadPanelExpand = true; } + clearUploadStatusesMap(): void { + this.uploadStatusesMap.clear(); + } + + deleteFileFromUploadStatusesMap(fileUUID: string): void { + this.uploadStatusesMap.delete(fileUUID); + } + onUpload = async (): Promise => { this.expandUploadPanel(); const files = await pickFiles({ @@ -78,7 +91,7 @@ export class CloudStorageStore extends CloudStorageStoreBase { } }; - onItemMenuClick = async (fileUUID: string, menuKey: React.Key): Promise => { + onItemMenuClick = (fileUUID: string, menuKey: React.Key): void => { const key = menuKey as FileMenusKey; console.log("[cloud-storage] onItemMenuClick", fileUUID, key); switch (key) { @@ -92,9 +105,15 @@ export class CloudStorageStore extends CloudStorageStoreBase { break; } case "delete": { - this.updateFiles({ files: this.files.filter(file => file.fileUUID !== fileUUID) }); - await removeFiles({ fileUUIDs: [fileUUID] }); - await this.refreshFiles(); + Modal.confirm({ + content: "确定删除所选课件?课件删除后不可恢复", + onOk: async () => { + this.updateFiles(this.files.filter(file => file.fileUUID !== fileUUID)); + await removeFiles({ fileUUIDs: [fileUUID] }); + await this.refreshFiles(); + this.unselectAll(); + }, + }); break; } default: @@ -139,22 +158,30 @@ export class CloudStorageStore extends CloudStorageStoreBase { } }; - onBatchDelete = async (): Promise => { - const fileUUIDs = this.selectedFileUUIDs; - console.log("[cloud-storage] onBatchDelete", fileUUIDs); - this.updateFiles({ files: this.files.filter(file => !fileUUIDs.includes(file.fileUUID)) }); - await removeFiles({ fileUUIDs }); - await this.refreshFiles(); + onBatchDelete = (): void => { + Modal.confirm({ + content: "确定删除所选课件?课件删除后不可恢复", + onOk: async () => { + const fileUUIDs = this.selectedFileUUIDs; + console.log("[cloud-storage] onBatchDelete", fileUUIDs); + this.updateFiles(this.files.filter(file => !fileUUIDs.includes(file.fileUUID))); + await removeFiles({ fileUUIDs }); + await this.refreshFiles(); + this.unselectAll(); + }, + }); + }; + + isUploadNotFinished = ({ status }: CloudStorageUploadStatus): boolean => { + return status !== "success" && status !== "error"; }; onUploadPanelClose = (): void => { - for (const { status } of this.uploadStatusesMap.values()) { - if (status !== "success") { - message.warning("there are tasks not finished"); - return; - } + if (Array.from(this.uploadStatusesMap.values()).some(this.isUploadNotFinished)) { + message.warning("there are tasks not finished"); + } else { + this.clearUploadStatusesMap(); } - this.uploadStatusesMap.clear(); }; onUploadRetry = (fileUUID: string): void => { @@ -168,26 +195,34 @@ export class CloudStorageStore extends CloudStorageStoreBase { } }; - onUploadCancel = (fileUUID: string): void => { + onUploadCancel = async (fileUUID: string): Promise => { console.log("[cloud-storage] onUploadCancel", fileUUID); - console.log(`[cloud-storage] TODO: remove zombie task ${fileUUID}`); uploadManager.clean([fileUUID]); - this.uploadStatusesMap.delete(fileUUID); + this.deleteFileFromUploadStatusesMap(fileUUID); + await this.refreshFiles(); }; setupUpload(file: File): void { const task = uploadManager.upload(file); + const { fileUUID: fakeID } = task; + this.onUploadInit(fakeID, file); task.onInit = fileUUID => { console.log("[cloud-storage] start uploading", fileUUID, file.name, file.size); + this.deleteFileFromUploadStatusesMap(fakeID); this.onUploadInit(fileUUID, file); + this.refreshFiles(); }; task.onError = error => { console.error(error); message.error(error.message); - if (task.fileUUID) { + if (!isFakeID(task.fileUUID)) { this.onUploadError(task.fileUUID, file); + this.refreshFiles(); } }; + task.onCancel = () => { + this.refreshFiles(); + }; task.onEnd = () => { console.log("[cloud-storage] finish uploading", task.fileUUID); this.onUploadEnd(task.fileUUID); @@ -231,11 +266,12 @@ export class CloudStorageStore extends CloudStorageStoreBase { }); } - updateFiles({ files, totalUsage }: { files: CloudStorageFile[]; totalUsage?: number }): void { + updateFiles(files: CloudStorageFile[]): void { this.files = files; - if (totalUsage) { - this.totalUsage = totalUsage; - } + } + + updateTotalUsage(totalUsage: number): void { + this.totalUsage = totalUsage; } async initialize(): Promise { @@ -243,82 +279,93 @@ export class CloudStorageStore extends CloudStorageStoreBase { } destroy(): void { - const { timer } = this.pulling; - if (!Number.isNaN(timer)) { - this.pulling.timer = NaN; + this.clearPullingTasks(); + } + + clearPullingTasks(): void { + for (const timer of this.pulling.values()) { window.clearTimeout(timer); } + this.pulling.clear(); } async refreshFiles(): Promise { + this.clearPullingTasks(); const { files, totalUsage } = await listFiles({ page: 1 }); const tempFiles = files.map(file => ({ ...file, convert: convertStepToType(file.convertStep), })); - const { timer } = this.pulling; - if (!Number.isNaN(timer)) { - this.pulling.timer = NaN; - window.clearTimeout(timer); - } - const queue: CloudFile[] = []; for (const file of tempFiles) { if (file.fileName.endsWith(".pptx") || file.fileName.endsWith(".pdf")) { - switch (file.convertStep) { - case FileConvertStep.None: { + let shouldPull = false; + if (file.convertStep === FileConvertStep.None) { + try { console.log("[cloud-storage] convert start", file.fileUUID, file.fileName); - const task = await convertStart({ fileUUID: file.fileUUID }); + const { taskToken, taskUUID } = await convertStart({ + fileUUID: file.fileUUID, + }); file.convertStep = FileConvertStep.Converting; file.convert = "converting"; - queue.push({ ...file, ...task }); - break; - } - case FileConvertStep.Converting: { - queue.push(file); - break; + file.taskToken = taskToken; + file.taskUUID = taskUUID; + shouldPull = true; + } catch { + // ignore convert start failed } - default: + } else if (file.convertStep === FileConvertStep.Converting) { + shouldPull = true; + } + if (shouldPull) { + this.setupPullingFile(file); } } } - this.updateFiles({ - files: tempFiles, - totalUsage, - }); - this.pulling = { - queue, - index: 0, - timer: window.setTimeout(this.pullConvertSteps, 1000), - }; + this.updateFiles(tempFiles); + this.updateTotalUsage(totalUsage === 0 ? NaN : totalUsage); } - pullConvertSteps = async (): Promise => { - const { queue, index } = this.pulling; - if (queue.length === 0) return; - const { fileName, fileUUID, taskUUID, taskToken } = queue[index]; - // TODO: if queryTask failed because of network, retry? - const { status, progress } = await queryTask( - taskUUID, - taskToken, - fileName.endsWith(".pptx"), - ); - console.log("[cloud-storage] convert", fileUUID, status, progress?.convertedPercentage); - switch (status) { - case "Fail": - case "Finished": { - try { - await convertFinish({ fileUUID }); - } catch { - message.error(`${fileName} convert failed`); - } finally { - await this.refreshFiles(); - } - break; + setupPullingFile({ fileName, fileUUID, taskUUID, taskToken }: CloudFile): void { + let task: () => Promise; + const next = (): void => { + const timer = this.pulling.get(fileUUID); + if (timer) { + window.clearTimeout(timer); } - default: { - this.pulling.index = (index + 1) % queue.length; - this.pulling.timer = window.setTimeout(this.pullConvertSteps, 1000); + this.pulling.set(fileUUID, window.setTimeout(task, 1000)); + }; + task = async (): Promise => { + const { status, progress } = await queryTask( + taskUUID, + taskToken, + fileName.endsWith(".pptx"), + ); + console.log( + "[cloud-storage] convert", + fileUUID, + fileName, + status, + progress?.convertedPercentage, + ); + switch (status) { + case "Fail": + case "Finished": { + try { + await convertFinish({ fileUUID }); + } catch (e) { + if (status === "Fail") { + message.error(`${fileName} convert failed`); + } + } finally { + await this.refreshFiles(); + } + break; + } + default: { + next(); + } } - } - }; + }; + next(); + } } diff --git a/desktop/renderer-app/src/pages/CloudStoragePage/utils.ts b/desktop/renderer-app/src/pages/CloudStoragePage/utils.ts index 925ad2c1ab5..46dad17223e 100644 --- a/desktop/renderer-app/src/pages/CloudStoragePage/utils.ts +++ b/desktop/renderer-app/src/pages/CloudStoragePage/utils.ts @@ -2,10 +2,12 @@ import Axios from "axios"; import { CloudStorageConvertStatusType } from "flat-components"; import { FileConvertStep } from "../../apiMiddleware/flatServer/constants"; import { + cancelUpload, uploadFinish, uploadStart, UploadStartResult, } from "../../apiMiddleware/flatServer/storage"; +import { OSS_CONFIG } from "../../constants/Process"; export const shuntConversionTaskURL = "https://api.netless.link/v5/services/conversion/tasks"; @@ -90,6 +92,14 @@ export function convertStepToType(convertStep: FileConvertStep): CloudStorageCon } } +function createFakeID(): string { + return "fake-" + Math.random().toString(36).substring(2); +} + +export function isFakeID(str: string): boolean { + return str.startsWith("fake-"); +} + // n.b. https://caniuse.com/mdn-api_eventtarget // n.b. https://ungap.github.io/event-target /** @@ -101,13 +111,27 @@ export function convertStepToType(convertStep: FileConvertStep): CloudStorageCon * .onProgress(e => console.log(e.loaded, e.total)) */ class UploadTask { - fileUUID = ""; + fileUUID = createFakeID(); + source = Axios.CancelToken.source(); onError?: (error: Error) => void; onInit?: (fileUUID: string) => void; onEnd?: () => void; onProgress?: (e: ProgressEvent) => void; + onCancel?: () => void; } +/** + * queue: [...].length <= MaxConcurrent + * pending: [...] + * upload(file): + * task = Task(file) + * task.onfinish: + * queue << pending.pop().start() + * if queue is not empty: + * queue << task.start() + * else: + * pending << task + */ class UploadManager { private static readonly StorageKey = "upload"; private static readonly MaxConcurrent = 3; @@ -118,13 +142,22 @@ class UploadManager { get tasks(): string[] { const result: string[] = []; for (const { fileUUID } of this._tasks) { - if (fileUUID) { + if (!isFakeID(fileUUID)) { result.push(fileUUID); } } return result; } + getTask(fileUUID: string): UploadTask | undefined { + for (const task of this._tasks) { + if (task.fileUUID === fileUUID) { + return task; + } + } + return; + } + _syncTasks(): void { localStorage.setItem(UploadManager.StorageKey, JSON.stringify(this.tasks)); } @@ -138,7 +171,12 @@ class UploadManager { } clean(fileUUIDs: string[]): void { - console.log(`TODO: remove zombie tasks ${fileUUIDs}`); + fileUUIDs = fileUUIDs.filter(id => !isFakeID(id)); + if (fileUUIDs.length === 0) return; + cancelUpload({ fileUUIDs }); + for (const fileUUID of fileUUIDs) { + this.getTask(fileUUID)?.source.cancel(); + } } upload(file: File, task?: UploadTask): UploadTask { @@ -148,16 +186,27 @@ class UploadManager { } else { this._tasks.add(currentTask); this._upload(file, currentTask) - .catch(error => currentTask.onError?.(error)) + .catch(error => { + if (error instanceof Axios.Cancel) { + currentTask.onCancel?.(); + } else { + currentTask.onError?.(error); + } + }) .then(() => { this._tasks.delete(currentTask); const { pending } = this; this.pending = []; - for (const { file, task } of pending) { - this.upload(file, task); - } + // prevent upload too often + window.setTimeout(() => { + for (const { file, task } of pending) { + this.upload(file, task); + } + this._syncTasks(); + }, 200); }); } + this._syncTasks(); return currentTask; } @@ -175,7 +224,7 @@ class UploadManager { formData.append("key", filePath); formData.append("name", fileName); formData.append("policy", policy); - formData.append("OSSAccessKeyId", "LTAI5tGuF6ibwB91CWEiKNvJ"); + formData.append("OSSAccessKeyId", OSS_CONFIG.accessKeyId); formData.append("success_action_status", "200"); formData.append("callback", ""); formData.append("signature", signature); @@ -190,6 +239,7 @@ class UploadManager { onUploadProgress: (e: ProgressEvent) => { task.onProgress?.(e); }, + cancelToken: task.source.token, }); await uploadFinish({ fileUUID });