diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..74681a2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+dist
+node_modules
+
+.DS_Store
+.env*
+!.env.example
+.turbo
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..a5aa07b
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
+//registry.npmjs.org/:_authToken=${NPM_TOKEN}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..4b15b32
--- /dev/null
+++ b/index.html
@@ -0,0 +1,11 @@
+
+
+
+ Query Upload
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..dfc885e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@kevisual/query-upload",
+ "version": "0.0.1",
+ "description": "",
+ "main": "dist/query-upload.js",
+ "types": "dist/query-upload.d.ts",
+ "scripts": {
+ "build": "tsup",
+ "watch": "tsup --watch",
+ "dev": "tsup --watch",
+ "dev:lib": "pnpm run dev"
+ },
+ "files": [
+ "dist",
+ "react",
+ "src"
+ ],
+ "keywords": [],
+ "author": "abearxiong ",
+ "license": "MIT",
+ "packageManager": "pnpm@10.6.5",
+ "type": "module",
+ "devDependencies": {
+ "@types/node": "^22.13.14",
+ "eventsource": "^3.0.6",
+ "tsup": "^8.4.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "exports": {
+ ".": "./dist/query-upload.js",
+ "./query-upload": "./dist/query-upload.js",
+ "./query-upload-node": "./dist/query-upload-node.js"
+ }
+}
\ No newline at end of file
diff --git a/src/core/upload-chunk.ts b/src/core/upload-chunk.ts
new file mode 100644
index 0000000..6b7a4dd
--- /dev/null
+++ b/src/core/upload-chunk.ts
@@ -0,0 +1,134 @@
+import { randomId } from '../utils/random-id.ts';
+import { UploadProgress } from './upload-progress.ts';
+export type ConvertOpts = {
+ appKey?: string;
+ version?: string;
+ username?: string;
+ directory?: string;
+ isPublic?: boolean;
+ filename?: string;
+ /**
+ * 是否不检查应用文件, 默认 true,默认不检测
+ */
+ noCheckAppFiles?: boolean;
+};
+
+// createEventSource: (baseUrl: string, searchParams: URLSearchParams) => {
+// return new EventSource(baseUrl + '/api/s1/events?' + searchParams.toString());
+// },
+export type UploadOpts = {
+ uploadProgress: UploadProgress;
+ /**
+ * 创建 EventSource 兼容 nodejs
+ * @param baseUrl 基础 URL
+ * @param searchParams 查询参数
+ * @returns EventSource
+ */
+ createEventSource: (baseUrl: string, searchParams: URLSearchParams) => EventSource;
+ baseUrl?: string;
+ token: string;
+ FormDataFn: any;
+};
+export const uploadFileChunked = async (file: File, opts: ConvertOpts, opts2: UploadOpts) => {
+ const { directory, appKey, version, username, isPublic, noCheckAppFiles = true } = opts;
+ const { uploadProgress, createEventSource, baseUrl = '', token, FormDataFn } = opts2 || {};
+ return new Promise(async (resolve, reject) => {
+ const taskId = randomId();
+ const filename = opts.filename || file.name;
+ uploadProgress?.start(`${filename} 上传中...`);
+
+ const searchParams = new URLSearchParams();
+ searchParams.set('taskId', taskId);
+ if (isPublic) {
+ searchParams.set('public', 'true');
+ }
+ if (noCheckAppFiles) {
+ searchParams.set('noCheckAppFiles', '1');
+ }
+ const eventSource = createEventSource(baseUrl + '/api/s1/events', searchParams);
+ let isError = false;
+ // 监听服务器推送的进度更新
+ eventSource.onmessage = function (event) {
+ console.log('Progress update:', event.data);
+ const parseIfJson = (data: string) => {
+ try {
+ return JSON.parse(data);
+ } catch (e) {
+ return data;
+ }
+ };
+ const receivedData = parseIfJson(event.data);
+ if (typeof receivedData === 'string') return;
+ const progress = Number(receivedData.progress);
+ const progressFixed = progress.toFixed(2);
+ uploadProgress?.set(progress, { ...receivedData, progressFixed, filename, taskId });
+ };
+ eventSource.onerror = function (event) {
+ console.log('eventSource.onerror', event);
+ isError = true;
+ reject(event);
+ };
+
+ const chunkSize = 1 * 1024 * 1024; // 1MB
+ const totalChunks = Math.ceil(file.size / chunkSize);
+
+ for (let currentChunk = 0; currentChunk < totalChunks; currentChunk++) {
+ const start = currentChunk * chunkSize;
+ const end = Math.min(start + chunkSize, file.size);
+ const chunk = file.slice(start, end);
+
+ const formData = new FormDataFn();
+ formData.append('file', chunk, filename);
+ formData.append('chunkIndex', currentChunk.toString());
+ formData.append('totalChunks', totalChunks.toString());
+ const isLast = currentChunk === totalChunks - 1;
+ if (directory) {
+ formData.append('directory', directory);
+ }
+ if (appKey && version) {
+ formData.append('appKey', appKey);
+ formData.append('version', version);
+ }
+ if (username) {
+ formData.append('username', username);
+ }
+ try {
+ const res = await fetch(baseUrl + '/api/s1/resources/upload/chunk?taskId=' + taskId, {
+ method: 'POST',
+ body: formData,
+ headers: {
+ 'task-id': taskId,
+ Authorization: `Bearer ${token}`,
+ },
+ }).then((response) => response.json());
+
+ if (res?.code !== 200) {
+ console.log('uploadChunk error', res);
+ uploadProgress?.error(res?.message || '上传失败');
+ isError = true;
+ eventSource.close();
+
+ uploadProgress?.done();
+ reject(new Error(res?.message || '上传失败'));
+ return;
+ }
+ if (isLast) {
+ fetch(baseUrl + '/api/s1/events/close?taskId=' + taskId);
+ eventSource.close();
+ uploadProgress?.done();
+ resolve(res);
+ }
+ // console.log(`Chunk ${currentChunk + 1}/${totalChunks} uploaded`, res);
+ } catch (error) {
+ console.log('Error uploading chunk', error);
+ fetch(baseUrl + '/api/s1/events/close?taskId=' + taskId);
+ reject(error);
+ return;
+ }
+ }
+ // 循环结束
+ if (!uploadProgress?.end) {
+ uploadProgress?.done();
+ }
+ });
+};
diff --git a/src/core/upload-progress.ts b/src/core/upload-progress.ts
new file mode 100644
index 0000000..9b9b6f0
--- /dev/null
+++ b/src/core/upload-progress.ts
@@ -0,0 +1,103 @@
+interface UploadNProgress {
+ start: (msg?: string) => void;
+ done: () => void;
+ set: (progress: number) => void;
+}
+type UploadProgressData = {
+ progress: number;
+ progressFixed: number;
+ filename?: string;
+ taskId?: string;
+};
+type UploadProgressOpts = {
+ onStart?: () => void;
+ onDone?: () => void;
+ onProgress?: (progress: number, data?: UploadProgressData) => void;
+};
+export class UploadProgress implements UploadNProgress {
+ /**
+ * 进度
+ */
+ progress: number;
+ /**
+ * 开始回调
+ */
+ onStart: (() => void) | undefined;
+ /**
+ * 结束回调
+ */
+ onDone: (() => void) | undefined;
+ /**
+ * 消息回调
+ */
+ onProgress: ((progress: number, data?: UploadProgressData) => void) | undefined;
+ /**
+ * 数据
+ */
+ data: any;
+ /**
+ * 是否结束
+ */
+ end: boolean;
+ constructor(uploadOpts: UploadProgressOpts) {
+ this.progress = 0;
+ this.end = false;
+ const mockFn = () => {};
+ this.onStart = uploadOpts.onStart || mockFn;
+ this.onDone = uploadOpts.onDone || mockFn;
+ this.onProgress = uploadOpts.onProgress || mockFn;
+ }
+ start(msg?: string) {
+ this.progress = 0;
+ msg && this.info(msg);
+ this.end = false;
+ this.onStart?.();
+ }
+ done() {
+ this.progress = 100;
+ this.end = true;
+ this.onDone?.();
+ }
+ set(progress: number, data?: UploadProgressData) {
+ this.progress = progress;
+ this.data = data;
+ this.onProgress?.(progress, data);
+ console.log('uploadProgress set', progress, data);
+ }
+ /**
+ * 开始回调
+ */
+ setOnStart(callback: () => void) {
+ this.onStart = callback;
+ }
+ /**
+ * 结束回调
+ */
+ setOnDone(callback: () => void) {
+ this.onDone = callback;
+ }
+ /**
+ * 消息回调
+ */
+ setOnProgress(callback: (progress: number, data?: UploadProgressData) => void) {
+ this.onProgress = callback;
+ }
+ /**
+ * 打印信息
+ */
+ info(msg: string) {
+ console.log(msg);
+ }
+ /**
+ * 打印错误
+ */
+ error(msg: string) {
+ console.error(msg);
+ }
+ /**
+ * 打印警告
+ */
+ warn(msg: string) {
+ console.warn(msg);
+ }
+}
diff --git a/src/core/upload.ts b/src/core/upload.ts
new file mode 100644
index 0000000..d545a3d
--- /dev/null
+++ b/src/core/upload.ts
@@ -0,0 +1,113 @@
+import { randomId } from '../utils/random-id.ts';
+import type { UploadOpts } from './upload-chunk.ts';
+type ConvertOpts = {
+ appKey?: string;
+ version?: string;
+ username?: string;
+ directory?: string;
+ /**
+ * 文件大小限制
+ */
+ maxSize?: number;
+ /**
+ * 文件数量限制
+ */
+ maxCount?: number;
+ /**
+ * 是否不检查应用文件, 默认 true,默认不检测
+ */
+ noCheckAppFiles?: boolean;
+};
+
+export const uploadFiles = async (files: File[], opts: ConvertOpts, opts2: UploadOpts) => {
+ const { directory, appKey, version, username, noCheckAppFiles = true } = opts;
+ const { uploadProgress, createEventSource, baseUrl = '', token, FormDataFn } = opts2 || {};
+ const length = files.length;
+ const maxSize = opts.maxSize || 20 * 1024 * 1024; // 20MB
+ const totalSize = files.reduce((acc, file) => acc + file.size, 0);
+ if (totalSize > maxSize) {
+ const maxSizeMB = maxSize / 1024 / 1024;
+ uploadProgress?.error('有文件大小不能超过' + maxSizeMB + 'MB');
+ return;
+ }
+ const maxCount = opts.maxCount || 10;
+ if (length > maxCount) {
+ uploadProgress?.error(`最多只能上传${maxCount}个文件`);
+ return;
+ }
+ uploadProgress?.info(`上传中,共${length}个文件`);
+ return new Promise((resolve, reject) => {
+ const formData = new FormDataFn();
+ const webkitRelativePath = files[0]?.webkitRelativePath;
+ const keepDirectory = webkitRelativePath !== '';
+ const root = keepDirectory ? webkitRelativePath.split('/')[0] : '';
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ if (keepDirectory) {
+ // relativePath 去除第一级
+ const webkitRelativePath = file.webkitRelativePath.replace(root + '/', '');
+ formData.append('file', file, webkitRelativePath); // 保留文件夹路径
+ } else {
+ formData.append('file', files[i], files[i].name);
+ }
+ }
+ if (directory) {
+ formData.append('directory', directory);
+ }
+ if (appKey && version) {
+ formData.append('appKey', appKey);
+ formData.append('version', version);
+ }
+ if (username) {
+ formData.append('username', username);
+ }
+ const searchParams = new URLSearchParams();
+ const taskId = randomId();
+ searchParams.set('taskId', taskId);
+
+ if (noCheckAppFiles) {
+ searchParams.set('noCheckAppFiles', '1');
+ }
+ const eventSource = new EventSource('/api/s1/events?taskId=' + taskId);
+
+ uploadProgress?.start('上传中...');
+ eventSource.onopen = async function (event) {
+ const res = await fetch('/api/s1/resources/upload?' + searchParams.toString(), {
+ method: 'POST',
+ body: formData,
+ headers: {
+ 'task-id': taskId,
+ Authorization: `Bearer ${token}`,
+ },
+ }).then((response) => response.json());
+
+ console.log('upload success', res);
+ fetch('/api/s1/events/close?taskId=' + taskId);
+ eventSource.close();
+ uploadProgress?.done();
+ resolve(res);
+ };
+ // 监听服务器推送的进度更新
+ eventSource.onmessage = function (event) {
+ console.log('Progress update:', event.data);
+ const parseIfJson = (data: string) => {
+ try {
+ return JSON.parse(data);
+ } catch (e) {
+ return data;
+ }
+ };
+ const receivedData = parseIfJson(event.data);
+ if (typeof receivedData === 'string') return;
+ const progress = Number(receivedData.progress);
+ const progressFixed = progress.toFixed(2);
+ console.log('progress', progress);
+ uploadProgress?.set(progress, { ...receivedData, taskId, progressFixed });
+ };
+
+ eventSource.onerror = function (event) {
+ console.log('eventSource.onerror', event);
+ reject(event);
+ };
+ });
+};
diff --git a/src/query-upload-node.ts b/src/query-upload-node.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/query-upload.ts b/src/query-upload.ts
new file mode 100644
index 0000000..03d6b8b
--- /dev/null
+++ b/src/query-upload.ts
@@ -0,0 +1,6 @@
+import { uploadFiles } from './core/upload.ts';
+
+import { uploadFileChunked } from './core/upload-chunk.ts';
+import { UploadProgress } from './core/upload-progress.ts';
+
+export { uploadFiles, uploadFileChunked, UploadProgress };
diff --git a/src/utils/filter-files.ts b/src/utils/filter-files.ts
new file mode 100644
index 0000000..71ab8f0
--- /dev/null
+++ b/src/utils/filter-files.ts
@@ -0,0 +1,23 @@
+/**
+ * 过滤文件, 过滤 .DS_Store, node_modules, 以.开头的文件, 过滤 __开头的文件
+ * @param files
+ * @returns
+ */
+export const filterFiles = (files: File[]) => {
+ files = files.filter((file) => {
+ if (file.webkitRelativePath.startsWith('__MACOSX')) {
+ return false;
+ }
+ // 过滤node_modules
+ if (file.webkitRelativePath.includes('node_modules')) {
+ return false;
+ }
+ // 过滤文件 .DS_Store
+ if (file.name === '.DS_Store') {
+ return false;
+ }
+ // 过滤以.开头的文件
+ return !file.name.startsWith('.');
+ });
+ return files;
+};
diff --git a/src/utils/index.ts b/src/utils/index.ts
new file mode 100644
index 0000000..86bd60c
--- /dev/null
+++ b/src/utils/index.ts
@@ -0,0 +1,3 @@
+export * from './to-file.ts';
+export * from './filter-files.ts';
+export * from './random-id.ts';
diff --git a/src/utils/random-id.ts b/src/utils/random-id.ts
new file mode 100644
index 0000000..c36e4f0
--- /dev/null
+++ b/src/utils/random-id.ts
@@ -0,0 +1,3 @@
+export const randomId = () => {
+ return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
+};
\ No newline at end of file
diff --git a/src/utils/to-file.ts b/src/utils/to-file.ts
new file mode 100644
index 0000000..1072e24
--- /dev/null
+++ b/src/utils/to-file.ts
@@ -0,0 +1,105 @@
+const getFileExtension = (filename: string) => {
+ return filename.split('.').pop();
+};
+const getFileType = (extension: string) => {
+ switch (extension) {
+ case 'js':
+ return 'text/javascript';
+ case 'css':
+ return 'text/css';
+ case 'html':
+ return 'text/html';
+ case 'json':
+ return 'application/json';
+ case 'png':
+ return 'image/png';
+ case 'jpg':
+ return 'image/jpeg';
+ case 'jpeg':
+ return 'image/jpeg';
+ case 'gif':
+ return 'image/gif';
+ case 'svg':
+ return 'image/svg+xml';
+ case 'webp':
+ return 'image/webp';
+ case 'ico':
+ return 'image/x-icon';
+ default:
+ return 'text/plain';
+ }
+};
+const checkIsBase64 = (content: string) => {
+ return content.startsWith('data:');
+};
+/**
+ * 获取文件的目录和文件名
+ * @param filename 文件名
+ * @returns 目录和文件名
+ */
+export const getDirectoryAndName = (filename: string) => {
+ if (!filename) {
+ return null;
+ }
+ if (filename.startsWith('.')) {
+ return null;
+ } else {
+ filename = filename.replace(/^\/+/, ''); // Remove all leading slashes
+ }
+ const hasDirectory = filename.includes('/');
+ if (!hasDirectory) {
+ return { directory: '', name: filename };
+ }
+ const parts = filename.split('/');
+ const name = parts.pop()!; // Get the last part as the file name
+ const directory = parts.join('/'); // Join the remaining parts as the directory
+ return { directory, name };
+};
+/**
+ * 把字符串转为文件流,并返回文件流,根据filename的扩展名,自动设置文件类型.
+ * 当不是文本类型,自动需要把base64的字符串转为blob
+ * @param content 字符串
+ * @param filename 文件名
+ * @returns 文件流
+ */
+export const toFile = (content: string, filename: string) => {
+ // 如果文件名是 a/d/a.js 格式的,则需要把d作为目录,a.js作为文件名
+ const directoryAndName = getDirectoryAndName(filename);
+ if (!directoryAndName) {
+ throw new Error('Invalid filename');
+ }
+ const { name } = directoryAndName;
+ const extension = getFileExtension(name);
+ if (!extension) {
+ throw new Error('Invalid filename');
+ }
+ const isBase64 = checkIsBase64(content);
+ const type = getFileType(extension);
+
+ if (isBase64) {
+ // Decode base64 string
+ const base64Data = content.split(',')[1]; // Remove the data URL prefix
+ const byteCharacters = atob(base64Data);
+ const byteNumbers = new Array(byteCharacters.length);
+ for (let i = 0; i < byteCharacters.length; i++) {
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
+ }
+ const byteArray = new Uint8Array(byteNumbers);
+ const blob = new Blob([byteArray], { type });
+ return new File([blob], filename, { type });
+ } else {
+ const blob = new Blob([content], { type });
+ return new File([blob], filename, { type });
+ }
+};
+
+/**
+ * 把字符串转为文本文件
+ * @param content 字符串
+ * @param filename 文件名
+ * @returns 文件流
+ */
+export const toTextFile = (content: string = 'keep directory exist', filename: string = 'keep.txt') => {
+ const file = toFile(content, filename);
+ return file;
+};
diff --git a/test/upload.ts b/test/upload.ts
new file mode 100644
index 0000000..50791ea
--- /dev/null
+++ b/test/upload.ts
@@ -0,0 +1,43 @@
+import { uploadFiles, UploadProgress } from '../src/query-upload.ts';
+
+const div = document.createElement('div');
+div.innerHTML = `
+
+
+`;
+document.body.appendChild(div);
+
+const upload = document.getElementById('upload');
+upload.addEventListener('click', () => {
+ const file = document.getElementById('file') as HTMLInputElement;
+ const files = file.files;
+ // console.log(files);
+ const uploadProgress = new UploadProgress({
+ onStart: () => {
+ console.log('开始上传');
+ },
+ onDone: () => {
+ console.log('上传完成');
+ },
+ onProgress: (progress: number, data: any) => {
+ console.log('上传进度', progress);
+ },
+ });
+ uploadFiles(
+ Array.from(files),
+ {
+ appKey: 'test',
+ version: '1.0.0',
+ username: 'test',
+ directory: 'test',
+ },
+ {
+ uploadProgress,
+ token: 'test',
+ createEventSource: (baseUrl: string, searchParams: URLSearchParams) => {
+ return new EventSource(baseUrl + '/api/s1/events?' + searchParams.toString());
+ },
+ FormDataFn: FormData,
+ },
+ );
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8f70d0f
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "module": "nodenext",
+ "target": "esnext",
+ "noImplicitAny": false,
+ "outDir": "./dist",
+ "sourceMap": false,
+ "allowJs": true,
+ "newLine": "LF",
+ "baseUrl": "./",
+ "typeRoots": [
+ "node_modules/@types",
+ ],
+ "declaration": true,
+ "noEmit": false,
+ "allowImportingTsExtensions": true,
+ "emitDeclarationOnly": true,
+ "moduleResolution": "NodeNext",
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true,
+ "esModuleInterop": true,
+ "paths": {
+ "@/*": [
+ "src/*"
+ ]
+ }
+ },
+}
\ No newline at end of file
diff --git a/tsup.config.ts b/tsup.config.ts
new file mode 100644
index 0000000..8ed809d
--- /dev/null
+++ b/tsup.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ entry: ['src/query-upload.ts'],
+
+ splitting: false,
+ sourcemap: false,
+ clean: true,
+ format: 'esm',
+ dts: true,
+ outDir: 'dist',
+ tsconfig: 'tsconfig.json',
+ define: {
+ IS_BROWSER: 'false',
+ },
+});