Files
jiapuapp/utils/resumable-image-upload.js
T
2026-07-27 06:50:23 +08:00

176 lines
6.1 KiB
JavaScript

import { appApi } from "@/utils/api.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";
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 data = reader.result;
cleanup();
if (!(data instanceof ArrayBuffer)) {
reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID"));
return;
}
resolve({
browserFile,
fileName: browserFile.name || "image",
size: browserFile.size,
contentType: browserFile.type || "image/*",
data,
});
};
reader.onerror = () => {
cleanup();
reject(createUploadError("读取图片数据失败", "IMAGE_READ_FAILED"));
};
reader.readAsArrayBuffer(browserFile);
}, { once: true });
globalThis.document.body?.append(input);
input.click();
});
const readNativeImage = (filePath) => new Promise((resolve, reject) => {
if (!globalThis.plus?.io?.resolveLocalFileSystemURL || !globalThis.plus?.io?.FileReader) {
reject(createUploadError("当前运行环境不支持读取图片", "IMAGE_READ_UNAVAILABLE"));
return;
}
globalThis.plus.io.resolveLocalFileSystemURL(
filePath,
(entry) => entry.file(
(file) => {
const reader = new globalThis.plus.io.FileReader();
reader.onloadend = (event) => {
const data = event?.target?.result ?? reader.result;
if (!(data instanceof ArrayBuffer)) {
reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID"));
return;
}
resolve({
filePath,
fileName: file.name || "image",
size: file.size,
contentType: file.type || "image/*",
data,
});
};
reader.onerror = () => reject(createUploadError("读取图片数据失败", "IMAGE_READ_FAILED"));
reader.readAsArrayBuffer(file);
},
() => reject(createUploadError("读取图片信息失败", "IMAGE_READ_FAILED")),
),
() => reject(createUploadError("无法读取所选图片", "IMAGE_READ_FAILED")),
);
});
export const toConsumerOssId = (ossId) => {
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
}
const numericId = Number(ossId);
if (!Number.isSafeInteger(numericId)) {
throw createUploadError(
"文件已上传,但服务返回的文件 ID 超出 APP 安全整数范围;需由后端统一文件 ID 合同后才能关联业务数据",
"OSS_ID_UNSAFE",
);
}
return numericId;
};
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 readNativeImage(await pickNativeImage())
: await pickBrowserImage();
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
throw createUploadError("所选图片大小无效", "IMAGE_SIZE_INVALID");
}
const fileMd5 = calcMD5Bytes(file.data);
const initPayload = {
uploadId: createUploadId(),
fileName: file.fileName,
fileMd5,
totalSize: file.size,
totalChunks: 1,
chunkSize: file.size,
contentType: file.contentType,
};
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
if (initialized.instant) return toUploadReceipt(initialized);
const chunkPayload = {
uploadId: initialized.uploadId,
chunkIndex: 0,
chunkMd5: fileMd5,
};
if (file.browserFile) {
await appApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
} else {
await appApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
}
const completed = await appApi.completeResumableUpload({
uploadId: initialized.uploadId,
fileName: file.fileName,
fileMd5,
totalSize: file.size,
totalChunks: 1,
}, { requestController });
return toUploadReceipt(completed);
};