import { fileUploadApi } from "@/services/api/file-upload-service.js"; import { calcMD5Bytes } from "@/utils/md5.js"; const createUploadError = (message, code) => { const error = new Error(message); error.code = code; return error; }; export const isImagePickCancelled = (error) => error?.code === "IMAGE_PICK_CANCELLED"; export const isVideoPickCancelled = (error) => error?.code === "VIDEO_PICK_CANCELLED"; const createUploadId = () => { if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID(); return `app-${Date.now()}-${Math.random().toString(16).slice(2)}`; }; const pickNativeImage = () => new Promise((resolve, reject) => { if (!globalThis.plus?.gallery?.pick) { reject(createUploadError("当前运行环境不支持从相册选择图片", "IMAGE_PICK_UNAVAILABLE")); return; } globalThis.plus.gallery.pick( (filePath) => resolve(filePath), () => reject(createUploadError("已取消选择图片", "IMAGE_PICK_CANCELLED")), { filter: "image", multiple: false }, ); }); const pickBrowserImage = () => new Promise((resolve, reject) => { if (typeof globalThis.document?.createElement !== "function" || typeof globalThis.FileReader !== "function") { reject(createUploadError("当前运行环境不支持选择图片", "IMAGE_PICK_UNAVAILABLE")); return; } const input = globalThis.document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.style.display = "none"; const cleanup = () => input.remove(); input.addEventListener("cancel", () => { cleanup(); reject(createUploadError("已取消选择图片", "IMAGE_PICK_CANCELLED")); }, { once: true }); input.addEventListener("change", () => { const browserFile = input.files?.[0]; if (!browserFile) { cleanup(); reject(createUploadError("已取消选择图片", "IMAGE_PICK_CANCELLED")); return; } const reader = new globalThis.FileReader(); reader.onload = () => { const fileData = reader.result; cleanup(); if (!(fileData instanceof ArrayBuffer)) { reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID")); return; } resolve({ browserFile, fileName: browserFile.name || "image", size: browserFile.size, contentType: browserFile.type || "image/*", data: fileData, }); }; reader.onerror = () => { cleanup(); reject(createUploadError("读取图片数据失败", "IMAGE_READ_FAILED")); }; reader.readAsArrayBuffer(browserFile); }, { once: true }); globalThis.document.body?.append(input); input.click(); }); const pickNativeVideo = () => new Promise((resolve, reject) => { if (!globalThis.plus?.gallery?.pick) { reject(createUploadError("当前运行环境不支持从相册选择视频", "VIDEO_PICK_UNAVAILABLE")); return; } globalThis.plus.gallery.pick( (filePath) => resolve(filePath), () => reject(createUploadError("已取消选择视频", "VIDEO_PICK_CANCELLED")), { filter: "video", multiple: false }, ); }); const pickBrowserVideo = () => new Promise((resolve, reject) => { if (typeof globalThis.document?.createElement !== "function" || typeof globalThis.FileReader !== "function") { reject(createUploadError("当前运行环境不支持选择视频", "VIDEO_PICK_UNAVAILABLE")); return; } const input = globalThis.document.createElement("input"); input.type = "file"; input.accept = "video/*"; input.style.display = "none"; const cleanup = () => input.remove(); input.addEventListener("cancel", () => { cleanup(); reject(createUploadError("已取消选择视频", "VIDEO_PICK_CANCELLED")); }, { once: true }); input.addEventListener("change", () => { const browserFile = input.files?.[0]; if (!browserFile) { cleanup(); reject(createUploadError("已取消选择视频", "VIDEO_PICK_CANCELLED")); return; } const reader = new globalThis.FileReader(); reader.onload = () => { const fileData = reader.result; cleanup(); if (!(fileData instanceof ArrayBuffer)) { reject(createUploadError("读取视频数据失败", "VIDEO_READ_INVALID")); return; } resolve({ browserFile, fileName: browserFile.name || "video", size: browserFile.size, contentType: browserFile.type || "video/*", data: fileData, }); }; reader.onerror = () => { cleanup(); reject(createUploadError("读取视频数据失败", "VIDEO_READ_FAILED")); }; reader.readAsArrayBuffer(browserFile); }, { once: true }); globalThis.document.body?.append(input); input.click(); }); const readNativeFile = (filePath, mediaLabel) => new Promise((resolve, reject) => { if (typeof globalThis.uni?.getFileInfo === 'function') { const normalizedPath = String(filePath || '') const fileName = normalizedPath.split(/[\\/]/).pop() || 'file' try { globalThis.uni.getFileInfo({ filePath, digestAlgorithm: 'md5', success: (file) => resolve({ filePath, fileName, size: file.size, contentType: 'application/octet-stream', md5: String(file.digest || '').toLowerCase() }), fail: () => reject(createUploadError(`读取${mediaLabel}信息失败`, 'MEDIA_READ_FAILED')) }) } catch (error) { reject(error) } return } if (!globalThis.plus?.io?.resolveLocalFileSystemURL || !globalThis.plus?.io?.FileReader) { reject(createUploadError(`当前运行环境不支持读取${mediaLabel}`, "MEDIA_READ_UNAVAILABLE")); return; } globalThis.plus.io.resolveLocalFileSystemURL( filePath, (entry) => entry.file( (file) => { const reader = new globalThis.plus.io.FileReader(); reader.onloadend = (event) => { const fileData = event?.target?.result ?? reader.result; if (!(fileData instanceof ArrayBuffer)) { reject(createUploadError(`读取${mediaLabel}数据失败`, "MEDIA_READ_INVALID")); return; } resolve({ filePath, fileName: file.name || "image", size: file.size, contentType: file.type || "image/*", data: fileData, }); }; reader.onerror = () => reject(createUploadError(`读取${mediaLabel}数据失败`, "MEDIA_READ_FAILED")); reader.readAsArrayBuffer(file); }, () => reject(createUploadError(`读取${mediaLabel}信息失败`, "MEDIA_READ_FAILED")), ), () => reject(createUploadError(`无法读取所选${mediaLabel}`, "MEDIA_READ_FAILED")), ); }); const normalizeUploadReceiptOssId = (ossId) => { if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) { throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID"); } return ossId; }; const toUploadReceipt = ({ ossId, url = "", thumbnailUrl = "", fileName = "" }) => ({ // complete 的接口合同是字符串;先原样保留,避免把 int64 提前截断。 ossId: normalizeUploadReceiptOssId(ossId), url, thumbnailUrl, fileName, }); export const pickAndUploadImage = async ({ requestController = null } = {}) => { const file = globalThis.plus?.gallery?.pick ? await readNativeFile(await pickNativeImage(), "图片") : await pickBrowserImage(); const fileName = String(file.fileName || ""); if (!String(file.contentType || "").startsWith("image/") && !/\.(jpe?g|png|gif|webp|heic|heif)$/i.test(fileName)) { throw createUploadError("请选择图片文件", "IMAGE_TYPE_INVALID"); } if (!Number.isSafeInteger(file.size) || file.size <= 0) { throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID"); } const fileMd5 = file.md5 || calcMD5Bytes(file.data); const initPayload = { uploadId: createUploadId(), fileName, fileMd5, totalSize: file.size, totalChunks: 1, chunkSize: file.size, contentType: file.contentType, }; const initialized = await fileUploadApi.initializeResumableUpload(initPayload, { requestController }); if (initialized.instant) return toUploadReceipt(initialized); const chunkPayload = { uploadId: initialized.uploadId, chunkIndex: 0, chunkMd5: fileMd5, }; if (file.browserFile) { await fileUploadApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController }); } else { await fileUploadApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController }); } const completed = await fileUploadApi.completeResumableUpload({ uploadId: initialized.uploadId, fileName, fileMd5, totalSize: file.size, totalChunks: 1, }, { requestController }); return toUploadReceipt(completed); }; export const pickAndUploadVideo = async ({ requestController = null } = {}) => { const file = globalThis.plus?.gallery?.pick ? await readNativeFile(await pickNativeVideo(), "视频") : await pickBrowserVideo(); const fileName = String(file.fileName || ""); if (!String(file.contentType || "").startsWith("video/") && !/\.(mp4|mov|m4v|webm|avi|mkv)$/i.test(fileName)) { throw createUploadError("请选择视频文件", "VIDEO_TYPE_INVALID"); } if (!Number.isSafeInteger(file.size) || file.size <= 0) { throw createUploadError("所选视频大小无效", "VIDEO_SIZE_INVALID"); } const fileMd5 = file.md5 || calcMD5Bytes(file.data); const initPayload = { uploadId: createUploadId(), fileName, fileMd5, totalSize: file.size, totalChunks: 1, chunkSize: file.size, contentType: file.contentType || "video/*", }; const initialized = await fileUploadApi.initializeResumableUpload(initPayload, { requestController }); if (initialized.instant) return toUploadReceipt(initialized); const chunkPayload = { uploadId: initialized.uploadId, chunkIndex: 0, chunkMd5: fileMd5, }; if (file.browserFile) { await fileUploadApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController }); } else { await fileUploadApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController }); } const completed = await fileUploadApi.completeResumableUpload({ uploadId: initialized.uploadId, fileName, fileMd5, totalSize: file.size, totalChunks: 1, }, { requestController }); return toUploadReceipt(completed); };