import { adapter, DataOpts, Result } from '@kevisual/query'; import path from 'path-browserify-esm'; import { hashContent } from './utils'; type Process = {} type QueryResourcesOptions = { prefix?: string; storage?: Storage; username?: string; onProcess?: (opts?: Process) => void; [key: string]: any; }; export class QueryResources { prefix: string; // root/resources storage: Storage; onProcess?: (opts?: Process) => void; constructor(opts: QueryResourcesOptions) { if (opts.username) { this.prefix = `/${opts.username}/resources/`; } else { this.prefix = opts.prefix || ''; } this.storage = opts.storage || localStorage; this.onProcess = opts.onProcess || (() => { }); } setUsername(username: string) { this.prefix = `/${username}/resources/`; } /** * 设置prefix,类似 /{username}/resources/; * @param prefix */ setPrefix(prefix: string) { this.prefix = prefix; } header(headers?: Record, json = true): Record { const token = this.storage.getItem('token'); const _headers: Record = { 'Content-Type': 'application/json', ...headers, }; if (!json) { delete _headers['Content-Type']; } if (!token) { return _headers; } return { ..._headers, Authorization: `Bearer ${token}`, }; } async get(data: any, opts: DataOpts): Promise { return adapter({ url: opts.url!, method: 'GET', body: data, ...opts, headers: this.header(opts?.headers), }); } getUrl(prefix: string): string { if (prefix.startsWith('http')) { return prefix; } return `${this.prefix}${prefix}`; } async getList(prefix: string, data?: { recursive?: boolean }, opts?: DataOpts): Promise> { return this.get(data, { url: this.getUrl(prefix), body: data, ...opts, }); } async fetchFile(filepath: string, opts?: DataOpts): Promise> { const url = this.getUrl(filepath); return this.get({}, { url, method: 'GET', ...opts, headers: this.header(opts?.headers, false), isText: true }); } async uploadFile(filepath: string, content: string | Blob, opts?: DataOpts & { chunkSize?: number, maxSize?: number }): Promise> { const pathname = this.getUrl(filepath); const filename = path.basename(pathname); const type = getContentType(filename); const url = new URL(pathname, window.location.origin); const hashResult = hashContent(content); // Blob 类型时 hashContent 返回 Promise const hash = hashResult instanceof Promise ? await hashResult : hashResult; url.searchParams.set('hash', hash); const { chunkSize, maxSize, ...restOpts } = opts || {}; // 判断是否需要分块上传(文件大于20MB) const isBlob = content instanceof Blob; const fileSize = isBlob ? content.size : new Blob([content]).size; const CHUNK_THRESHOLD = maxSize ?? 20 * 1024 * 1024; // 20MB if (fileSize > CHUNK_THRESHOLD && isBlob) { // 使用分块上传 return this.uploadChunkedFile(filepath, content, hash, { chunkSize, ...restOpts }); } this.onProcess?.({ type: 'uploadBegin', filename, size: fileSize, process: 0 }); const formData = new FormData(); if (isBlob) { formData.append('file', content); } else { formData.append('file', new Blob([content], { type })); } const res = await adapter({ url: url.toString(), isPostFile: true, method: 'POST', body: formData, timeout: 5 * 60 * 1000, // 5分钟超时 ...restOpts, headers: { ...restOpts?.headers, ...this.header(restOpts?.headers, false) }, params: { hash: hash, ...restOpts?.params, }, }); this.onProcess?.({ type: 'uploadFinish', filename, size: fileSize, process: 100 }); return res; } async uploadChunkedFile(filepath: string, file: Blob, hash: string, opts?: DataOpts & { chunkSize?: number }): Promise> { const pathname = this.getUrl(filepath); const filename = path.basename(pathname); const url = new URL(pathname, window.location.origin); url.searchParams.set('hash', hash); url.searchParams.set('chunk', '1'); // console.log(`url,`, url, hash); // 预留 eventSource 支持(暂不处理) // const createEventSource = opts?.createEventSource; const { chunkSize: _chunkSize, ...restOpts } = opts || {}; const chunkSize = _chunkSize ?? 5 * 1024 * 1024; // 5MB const totalChunks = Math.ceil(file.size / chunkSize); this.onProcess?.({ type: 'uploadBegin', filename, size: file.size, process: 0 }); for (let currentChunk = 0; currentChunk < totalChunks; currentChunk++) { this.onProcess?.({ type: 'uploadChunkedFile', filename, size: file.size, process: 0, totalChunks, currentChunk: currentChunk + 1 }); const start = currentChunk * chunkSize; const end = Math.min(start + chunkSize, file.size); const chunkBlob = file.slice(start, end); // 转换为 File 类型 const chunkFile = new File([chunkBlob], filename, { type: file.type || 'application/octet-stream' }); const formData = new FormData(); formData.append('file', chunkFile, filename); formData.append('chunkIndex', currentChunk.toString()); formData.append('totalChunks', totalChunks.toString()); console.log(`Uploading chunk ${currentChunk + 1}/${totalChunks}`, url.toString()); try { const res = await adapter({ url: url.toString(), isPostFile: true, method: 'POST', body: formData, timeout: 5 * 60 * 1000, // 5分钟超时 ...restOpts, headers: { ...restOpts?.headers, ...this.header(restOpts?.headers, false) }, params: { hash: hash, chunk: '1', chunkIndex: currentChunk, totalChunks, ...restOpts?.params, }, }); if (res.code !== 200) { throw new Error(`Chunk 上传失败 code ${res!.code}, 错误信息是: ${res!.message}`); } console.log(`Chunk ${currentChunk + 1}/${totalChunks} uploaded`, res); this.onProcess?.({ type: 'uploadChunkedFile', filename, size: file.size, process: Math.round(((currentChunk + 1) / totalChunks) * 100), totalChunks, currentChunk: currentChunk + 1 }); } catch (error) { console.error(`Error uploading chunk ${currentChunk + 1}/${totalChunks}`, error); return { code: 500, message: `分块上传失败: ${(error as Error).message}` }; } } this.onProcess?.({ type: 'uploadFinish', filename, size: file.size, process: 100 }); return { code: 200, message: '上传成功' }; } /** * 移除 prefix,获取相对路径 * @param filepath * @returns */ getRelativePath(filepath: string): string { if (filepath.startsWith(this.prefix)) { return filepath.slice(this.prefix.length); } return filepath; } async getStat(filepath: string, opts?: DataOpts): Promise> { const url = this.getUrl(filepath); return adapter({ url, params: { stat: '1', }, method: 'GET' as any, headers: this.header(opts?.headers), }); } /** * @deprecated use getStat instead * @param filepath * @param opts * @returns */ async getState(filepath: string, opts?: DataOpts): Promise> { return this.getStat(filepath, opts); } async createFolder(folderpath: string, opts?: DataOpts): Promise> { const filepath = folderpath.endsWith('/') ? `${folderpath}keep.txt` : `${folderpath}/keep.txt`; return this.uploadFile(filepath, '文件夹占位,其他文件不存在,文件夹不存在,如果有其他文件夹,删除当前文件夹占位文件即可', opts); } async rename(oldpath: string, newpath: string, opts?: DataOpts): Promise> { const pathname = this.getUrl(oldpath); const newName = this.getUrl(newpath); const params = { newName: newName, }; const url = pathname return adapter({ url, method: 'PUT' as any, headers: this.header(opts?.headers), params, }); } async deleteFile(filepath: string, opts?: DataOpts): Promise> { const url = this.getUrl(filepath); return adapter({ url, method: 'DELETE' as any, headers: this.header(opts?.headers), }); } } export const getContentType = (filename: string): string => { const ext = path.extname(filename); let type = 'text/plain'; switch (ext) { case '': type = 'application/octet-stream'; break; case '.json': type = 'application/json'; break; case '.txt': type = 'text/plain'; break; case '.csv': type = 'text/csv'; break; case '.md': type = 'text/markdown'; break; case '.html': case '.htm': type = 'text/html'; break; case '.xml': type = 'application/xml'; break; case '.js': type = 'application/javascript'; break; case '.css': type = 'text/css'; break; case '.ts': type = 'application/typescript'; break; case '.pdf': type = 'application/pdf'; break; case '.zip': type = 'application/zip'; break; case '.docx': type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; break; case '.xlsx': type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; break; case '.mp3': type = 'audio/mpeg'; break; case '.mp4': type = 'video/mp4'; break; case '.png': case '.jpg': case '.jpeg': case '.gif': case '.webp': type = `image/${ext.slice(1)}`; break; case '.svg': type = 'image/svg+xml'; break; } return type; }; type Stat = { "standardHeaders": any, "size": string, "etag": string, "lastModified": string, "metaData": { "app-source": string, "cache-control": string, "content-type": string, "share"?: string }, "versionId": null }