From 24fb76793c6f03c063b6ceb138c6a8715eacd545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9A=AE=E7=9A=AE=E8=99=BE=E6=88=91=E4=BB=AC=E8=B5=B0?= <980141374@qq.com> Date: Wed, 17 Jun 2026 14:39:35 +0800 Subject: [PATCH] =?UTF-8?q?Update=20plugin=20=E9=82=AA=E6=81=B6=E7=9A=84?= =?UTF-8?q?=E7=86=8A=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init - init: 初始化 - chore: 修复重启后丢失问题 - chore: 移除注入相关功能 - chore: 新增github流程 - feat: 插件上传相关功能完善 - feat: 实现密码变更功能 - refactor: 重构代码结构 - chore: 添加商店版本检查 - chore: 调整项目按钮大小 - chore: 添加登陆注册的验证码 - chore: 主题获取调整 - feat: 完善插件市场详情展示与上传体验 - refactor: 插件市场接入 ztools-ui 并统一主题适配 - refactor: 精简插件市场详情交互并补齐宿主能力 - feat: 增加插件市场风险确认并统一相关弹窗交互 - refactor: 插件市场改为按平台流式加载并优化刷新状态 - refactor: 插件市场统一历史版本状态与全局弹窗 - feat: 插件市场记录安装 hash 并优化更新检查 - feat: 优化插件市场上传与设置交互 - feat: 优化插件市场上传进度展示 - feat: 新增内部 API 授权引导弹窗并优化账号面板交互 - docs: 简化 README 并移除侧边栏标题 - refactor: 移除冗余通用组件并统一使用 ztools-ui - chore: 补充插件市场风险提示文案 - feat: 新增插件市场分片上传功能并优化上传进度展示 - Merge branch 'main' of github.com:Particaly/bad-bear - release: 发布1.0.2版本 --- plugins/bad-bear/package.json | 4 +- plugins/bad-bear/src/api/pluginMarket.ts | 17 + .../src/api/pluginMarketChunkedUpload.ts | 411 ++++++++++++++++++ .../bad-bear/src/api/pluginMarketRemote.ts | 74 ++++ .../plugin-market/PluginMarketPage.vue | 6 + .../plugin-market/PluginUploadPanel.vue | 79 +++- .../page/usePluginMarketUploads.ts | 69 ++- plugins/bad-bear/src/types/pluginMarket.ts | 57 +++ plugins/bad-bear/yarn.lock | 8 +- 9 files changed, 710 insertions(+), 15 deletions(-) create mode 100644 plugins/bad-bear/src/api/pluginMarketChunkedUpload.ts diff --git a/plugins/bad-bear/package.json b/plugins/bad-bear/package.json index 81d35d50..48c6b232 100644 --- a/plugins/bad-bear/package.json +++ b/plugins/bad-bear/package.json @@ -1,6 +1,6 @@ { "name": "bad-bear", - "version": "1.0.1", + "version": "1.0.2", "description": "轻松分享你的ZTools插件", "type": "module", "scripts": { @@ -11,7 +11,7 @@ "@vueuse/core": "^14.2.1", "marked": "^17.0.5", "vue": "^3.5.13", - "ztools-ui": "0.1.2" + "ztools-ui": "^0.1.3" }, "devDependencies": { "@iconify/utils": "^3.1.0", diff --git a/plugins/bad-bear/src/api/pluginMarket.ts b/plugins/bad-bear/src/api/pluginMarket.ts index 4892b9b5..1cc3a68e 100644 --- a/plugins/bad-bear/src/api/pluginMarket.ts +++ b/plugins/bad-bear/src/api/pluginMarket.ts @@ -15,8 +15,25 @@ export { getMyPluginUpload, deleteMyPluginUpload, uploadPluginPackage, + initChunkedUpload, + uploadChunk, + completeChunkedUpload, + cancelChunkedUpload, + getChunkedUploadProgress, } from './pluginMarketRemote' +export { + uploadPluginWithChunks, + queryChunkedUploadProgress, + abortChunkedUpload, +} from './pluginMarketChunkedUpload' + +export type { + ChunkedUploadConfig, + ChunkedUploadOptions, + ChunkedUploadProgressInfo, +} from './pluginMarketChunkedUpload' + export { applyMarketInstalledPluginHashes, buildMarketPluginUpdateCheckItems, diff --git a/plugins/bad-bear/src/api/pluginMarketChunkedUpload.ts b/plugins/bad-bear/src/api/pluginMarketChunkedUpload.ts new file mode 100644 index 00000000..25bb3039 --- /dev/null +++ b/plugins/bad-bear/src/api/pluginMarketChunkedUpload.ts @@ -0,0 +1,411 @@ +import type { + ChunkedUploadInitRequest, + ChunkedUploadInitResponse, + ChunkedUploadProgressResponse, + PluginUploadResponse, +} from '../types/pluginMarket' +import { + cancelChunkedUpload, + completeChunkedUpload, + getChunkedUploadProgress, + initChunkedUpload, + uploadChunk, +} from './pluginMarketRemote' + +/** + * 分片上传配置 + */ +export interface ChunkedUploadConfig { + /** + * 分片大小(字节),默认 5MB + */ + chunkSize?: number + /** + * 最大并发上传数,默认 3 + */ + maxConcurrency?: number + /** + * 是否计算文件完整哈希(用于秒传),默认 true + */ + calculateFileHash?: boolean + /** + * 重试次数,默认 3 + */ + retryCount?: number + /** + * 重试延迟(毫秒),默认 1000 + */ + retryDelay?: number +} + +/** + * 分片上传进度回调参数 + */ +export interface ChunkedUploadProgressInfo { + /** + * 已上传的字节数 + */ + uploadedBytes: number + /** + * 文件总字节数 + */ + totalBytes: number + /** + * 上传进度百分比 (0-100) + */ + progress: number + /** + * 当前上传的分片索引 + */ + currentChunkIndex: number + /** + * 总分片数 + */ + totalChunks: number + /** + * 当前阶段 + */ + stage: 'hashing' | 'uploading' | 'merging' | 'completed' +} + +/** + * 分片上传选项 + */ +export interface ChunkedUploadOptions extends ChunkedUploadConfig { + /** + * 进度回调 + */ + onProgress?: (progress: ChunkedUploadProgressInfo) => void + /** + * 取消信号 + */ + signal?: AbortSignal +} + +/** + * 计算 Blob 的 SHA-256 哈希值 + */ +async function calculateSHA256(blob: Blob): Promise { + const arrayBuffer = await blob.arrayBuffer() + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + return `sha256:${hashHex}` +} + +/** + * 将文件切分为多个分片 + */ +function sliceFileIntoChunks(file: Blob, chunkSize: number): Blob[] { + const chunks: Blob[] = [] + let offset = 0 + + while (offset < file.size) { + const end = Math.min(offset + chunkSize, file.size) + chunks.push(file.slice(offset, end)) + offset = end + } + + return chunks +} + +/** + * 带重试的上传单个分片 + */ +async function uploadChunkWithRetry( + uploadId: string, + chunk: Blob, + chunkIndex: number, + chunkHash: string, + retryCount: number, + retryDelay: number, + signal?: AbortSignal, +): Promise { + let lastError: Error | null = null + + for (let attempt = 0; attempt <= retryCount; attempt++) { + if (signal?.aborted) { + throw new Error('上传已取消') + } + + try { + await uploadChunk({ + uploadId, + chunk, + chunkIndex, + chunkHash, + }) + return + } catch (error) { + lastError = error instanceof Error ? error : new Error('上传失败') + + if (attempt < retryCount) { + await new Promise((resolve) => setTimeout(resolve, retryDelay * (attempt + 1))) + } + } + } + + throw lastError || new Error('上传失败') +} + +/** + * 并发上传多个分片 + */ +async function uploadChunksConcurrently( + uploadId: string, + chunks: Blob[], + existingChunks: Set, + maxConcurrency: number, + retryCount: number, + retryDelay: number, + onProgress: (chunkIndex: number) => void, + signal?: AbortSignal, +): Promise { + const pendingChunks = chunks + .map((chunk, index) => ({ chunk, index })) + .filter(({ index }) => !existingChunks.has(index)) + + let activeUploads = 0 + let currentIndex = 0 + const errors: Error[] = [] + + return new Promise((resolve, reject) => { + const startNextUpload = async () => { + if (signal?.aborted) { + reject(new Error('上传已取消')) + return + } + + if (currentIndex >= pendingChunks.length) { + if (activeUploads === 0) { + if (errors.length > 0) { + reject(errors[0]) + } else { + resolve() + } + } + return + } + + const { chunk, index } = pendingChunks[currentIndex++] + activeUploads++ + + try { + const chunkHash = await calculateSHA256(chunk) + await uploadChunkWithRetry(uploadId, chunk, index, chunkHash, retryCount, retryDelay, signal) + onProgress(index) + } catch (error) { + errors.push(error instanceof Error ? error : new Error('上传失败')) + reject(errors[0]) + return + } finally { + activeUploads-- + } + + startNextUpload() + } + + // 启动初始并发上传 + for (let i = 0; i < Math.min(maxConcurrency, pendingChunks.length); i++) { + startNextUpload() + } + + // 如果没有待上传的分片,直接完成 + if (pendingChunks.length === 0) { + resolve() + } + }) +} + +/** + * 使用分片上传方式上传插件包 + * + * @param file 插件文件(最大 75MB) + * @param fileName 文件名 + * @param options 上传选项 + * @returns 上传结果 + * + * @example + * ```typescript + * const result = await uploadPluginWithChunks(file, 'my-plugin.zpx', { + * chunkSize: 5 * 1024 * 1024, // 5MB + * maxConcurrency: 3, + * onProgress: (progress) => { + * console.log(`进度: ${progress.progress}%`) + * } + * }) + * ``` + */ +export async function uploadPluginWithChunks( + file: Blob, + fileName: string, + options?: ChunkedUploadOptions, +): Promise { + const { + chunkSize = 5 * 1024 * 1024, // 5MB + maxConcurrency = 3, + calculateFileHash = true, + retryCount = 3, + retryDelay = 1000, + onProgress, + signal, + } = options || {} + + try { + // 阶段 1: 计算文件哈希(可选) + let fileHash: string | undefined + if (calculateFileHash) { + onProgress?.({ + uploadedBytes: 0, + totalBytes: file.size, + progress: 0, + currentChunkIndex: 0, + totalChunks: 0, + stage: 'hashing', + }) + + fileHash = await calculateSHA256(file) + + if (signal?.aborted) { + throw new Error('上传已取消') + } + } + + // 阶段 2: 切分文件 + const chunks = sliceFileIntoChunks(file, chunkSize) + const totalChunks = chunks.length + + // 阶段 3: 初始化分片上传 + const initRequest: ChunkedUploadInitRequest = { + fileName, + totalSize: file.size, + totalChunks, + fileHash, + } + + const initResponse: ChunkedUploadInitResponse = await initChunkedUpload(initRequest) + + // 如果文件已存在(秒传) + if (initResponse.fileExists) { + onProgress?.({ + uploadedBytes: file.size, + totalBytes: file.size, + progress: 100, + currentChunkIndex: totalChunks, + totalChunks, + stage: 'completed', + }) + + return { + success: true, + message: '文件已存在,跳过上传', + data: { + message: '文件已存在', + reviewTaskId: '', + ...initResponse.existingPlugin, + }, + } + } + + const { uploadId, existingChunks } = initResponse + const existingChunksSet = new Set(existingChunks) + + // 阶段 4: 上传分片 + const uploadedChunks = new Set(existingChunks) + let uploadedBytes = existingChunks.reduce((sum, index) => sum + chunks[index].size, 0) + + const handleChunkProgress = (chunkIndex: number) => { + uploadedChunks.add(chunkIndex) + uploadedBytes += chunks[chunkIndex].size + + onProgress?.({ + uploadedBytes, + totalBytes: file.size, + progress: Math.round((uploadedBytes / file.size) * 100), + currentChunkIndex: chunkIndex, + totalChunks, + stage: 'uploading', + }) + } + + try { + await uploadChunksConcurrently( + uploadId, + chunks, + existingChunksSet, + maxConcurrency, + retryCount, + retryDelay, + handleChunkProgress, + signal, + ) + } catch (error) { + // 上传失败,尝试取消 + try { + await cancelChunkedUpload(uploadId) + } catch { + // 忽略取消错误 + } + throw error + } + + if (signal?.aborted) { + try { + await cancelChunkedUpload(uploadId) + } catch { + // 忽略取消错误 + } + throw new Error('上传已取消') + } + + // 阶段 5: 完成上传 + onProgress?.({ + uploadedBytes: file.size, + totalBytes: file.size, + progress: 100, + currentChunkIndex: totalChunks, + totalChunks, + stage: 'merging', + }) + + const completeResponse = await completeChunkedUpload(uploadId) + + onProgress?.({ + uploadedBytes: file.size, + totalBytes: file.size, + progress: 100, + currentChunkIndex: totalChunks, + totalChunks, + stage: 'completed', + }) + + return { + success: true, + message: completeResponse.message || '上传成功', + reviewTaskId: completeResponse.reviewTaskId, + data: completeResponse, + } + } catch (error) { + console.error('[ChunkedUpload] 上传失败:', error) + return { + success: false, + error: error instanceof Error ? error.message : '上传失败', + } + } +} + +/** + * 查询分片上传进度 + */ +export async function queryChunkedUploadProgress( + uploadId: string, +): Promise { + return getChunkedUploadProgress(uploadId) +} + +/** + * 取消分片上传 + */ +export async function abortChunkedUpload(uploadId: string): Promise { + await cancelChunkedUpload(uploadId) +} diff --git a/plugins/bad-bear/src/api/pluginMarketRemote.ts b/plugins/bad-bear/src/api/pluginMarketRemote.ts index c826c952..50ac6748 100644 --- a/plugins/bad-bear/src/api/pluginMarketRemote.ts +++ b/plugins/bad-bear/src/api/pluginMarketRemote.ts @@ -7,6 +7,13 @@ import { deriveFallbackCategories, } from './pluginMarketStorefront' import type { + ChunkedUploadCancelResponse, + ChunkedUploadChunkPayload, + ChunkedUploadChunkResponse, + ChunkedUploadCompleteResponse, + ChunkedUploadInitRequest, + ChunkedUploadInitResponse, + ChunkedUploadProgressResponse, CreatePluginCommentRequest, CreatePluginRatingRequest, MyPluginUploadRecord, @@ -669,3 +676,70 @@ export async function uploadPluginPackage( } } } + +/** + * 初始化分片上传 + */ +export async function initChunkedUpload( + request: ChunkedUploadInitRequest, +): Promise { + return requestJson({ + path: '/api/v1/plugins/chunked-upload/init', + method: 'POST', + body: request, + }) +} + +/** + * 上传单个分片 + */ +export async function uploadChunk( + payload: ChunkedUploadChunkPayload, +): Promise { + const formData = new FormData() + formData.append('chunk', payload.chunk) + formData.append('chunkIndex', payload.chunkIndex.toString()) + formData.append('chunkHash', payload.chunkHash) + + return requestFormData({ + path: `/api/v1/plugins/chunked-upload/${encodeURIComponent(payload.uploadId)}/chunks`, + method: 'POST', + body: formData, + }) +} + +/** + * 完成分片上传 + */ +export async function completeChunkedUpload( + uploadId: string, +): Promise { + return requestJson({ + path: `/api/v1/plugins/chunked-upload/${encodeURIComponent(uploadId)}/complete`, + method: 'POST', + }) +} + +/** + * 取消分片上传 + */ +export async function cancelChunkedUpload( + uploadId: string, +): Promise { + return requestJson({ + path: `/api/v1/plugins/chunked-upload/${encodeURIComponent(uploadId)}/cancel`, + method: 'POST', + }) +} + +/** + * 查询分片上传进度 + */ +export async function getChunkedUploadProgress( + uploadId: string, +): Promise { + return requestJson({ + path: `/api/v1/plugins/chunked-upload/${encodeURIComponent(uploadId)}/progress`, + }) +} + diff --git a/plugins/bad-bear/src/components/plugin-market/PluginMarketPage.vue b/plugins/bad-bear/src/components/plugin-market/PluginMarketPage.vue index fc663569..05d6224f 100644 --- a/plugins/bad-bear/src/components/plugin-market/PluginMarketPage.vue +++ b/plugins/bad-bear/src/components/plugin-market/PluginMarketPage.vue @@ -606,6 +606,8 @@ const { isHashing: uploadIsHashing, isCheckingHash: uploadIsCheckingHash, isUploading: uploadIsUploading, + uploadProgress, + uploadStage, canUpload: uploadCanUpload, uploads: uploadRecords, uploadsTotal: uploadRecordsTotal, @@ -615,6 +617,7 @@ const { deletingIds: uploadDeletingIds, selectFile: uploadSelectFile, performUpload: uploadPerformUpload, + cancelUpload: uploadCancelUpload, loadUploads: uploadLoadRecords, handleDeleteUpload: uploadHandleDelete, } = uploads @@ -1322,6 +1325,8 @@ onUnmounted(() => { :is-hashing="uploadIsHashing" :is-checking-hash="uploadIsCheckingHash" :is-uploading="uploadIsUploading" + :upload-progress="uploadProgress" + :upload-stage="uploadStage" :can-upload="uploadCanUpload" :uploads="uploadRecords" :uploads-total="uploadRecordsTotal" @@ -1332,6 +1337,7 @@ onUnmounted(() => { @select-file="handleUploadSelectFile" @clear-file="handleUploadClearFile" @upload="uploadPerformUpload" + @cancel-upload="uploadCancelUpload" @refresh-uploads="uploadLoadRecords" @delete-upload="uploadHandleDelete" @open-plugin="handleUploadOpenPlugin" diff --git a/plugins/bad-bear/src/components/plugin-market/PluginUploadPanel.vue b/plugins/bad-bear/src/components/plugin-market/PluginUploadPanel.vue index 136dc529..7c4a9d54 100644 --- a/plugins/bad-bear/src/components/plugin-market/PluginUploadPanel.vue +++ b/plugins/bad-bear/src/components/plugin-market/PluginUploadPanel.vue @@ -18,6 +18,8 @@ const props = defineProps<{ isHashing: boolean isCheckingHash: boolean isUploading: boolean + uploadProgress: number + uploadStage: 'hashing' | 'uploading' | 'merging' | 'completed' | null canUpload: boolean uploads: MyPluginUploadRecord[] uploadsTotal: number @@ -31,6 +33,7 @@ const emit = defineEmits<{ (e: 'select-file', file: File): void (e: 'clear-file'): void (e: 'upload'): void + (e: 'cancel-upload'): void (e: 'refresh-uploads'): void (e: 'delete-upload', record: MyPluginUploadRecord): void (e: 'open-plugin', name: string): void @@ -162,6 +165,30 @@ function isDeleting(id: string): boolean { function canDelete(record: MyPluginUploadRecord): boolean { return record.status === 'PUBLISHED' || record.status === 'MANUAL_REVIEW' } + +/** + * 获取上传阶段的显示文本 + */ +function getUploadStageText(): string { + if (props.isHashing) return '计算哈希中...' + if (props.isCheckingHash) return '预检中...' + if (!props.isUploading) return '确认上传' + + switch (props.uploadStage) { + case 'hashing': return '计算文件哈希...' + case 'uploading': return `上传中 ${props.uploadProgress}%` + case 'merging': return '合并分片中...' + case 'completed': return '上传完成' + default: return `上传中 ${props.uploadProgress}%` + } +} + +/** + * 判断是否正在上传分片(大于20MB的文件) + */ +const isChunkedUploading = computed(() => { + return props.isUploading && props.selectedFile && props.selectedFile.size > 20 * 1024 * 1024 +})