import { adapter, DataOpts, Result } from '@kevisual/query'; import path from 'path-browserify-esm'; import { hashContent } from './utils'; type QueryResourcesOptions = { prefix?: string; storage?: Storage; username?: string; [key: string]: any; }; export class QueryResources { prefix: string; // root/resources storage: Storage; constructor(opts: QueryResourcesOptions) { if (opts.username) { this.prefix = `/${opts.username}/resources/`; } else { this.prefix = opts.prefix || ''; } this.storage = opts.storage || localStorage; } setUsername(username: string) { this.prefix = `/${username}/resources/`; } 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), }); } async getList(prefix: string, data?: { recursive?: boolean }, opts?: DataOpts): Promise> { return this.get(data, { url: `${this.prefix}${prefix}`, body: data, ...opts, }); } async fetchFile(filepath: string, opts?: DataOpts): Promise> { const url = `${this.prefix}${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.prefix}${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 }); } const formData = new FormData(); if (isBlob) { formData.append('file', content); } else { formData.append('file', new Blob([content], { type })); } return 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, }, }); } async uploadChunkedFile(filepath: string, file: Blob, hash: string, opts?: DataOpts & { chunkSize?: number }): Promise> { const pathname = `${this.prefix}${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); for (let currentChunk = 0; currentChunk < totalChunks; currentChunk++) { 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 upload failed with code ${res!.code}, message: ${res!.message}`); } console.log(`Chunk ${currentChunk + 1}/${totalChunks} uploaded`, res); } catch (error) { console.error(`Error uploading chunk ${currentChunk + 1}/${totalChunks}`, error); return { code: 500, message: `分块上传失败: ${(error as Error).message}` }; } } return { code: 200, message: '上传成功' }; } 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.prefix}${oldpath}`; const newName = `${this.prefix}${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.prefix}${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; };