Update plugin 邪恶的熊 v1.0.0#265
Conversation
- 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版本
There was a problem hiding this comment.
Code Review
This pull request introduces chunked upload support for plugin packages larger than 20MB (up to 75MB), implementing file slicing, concurrent chunk uploads with retries, and upload cancellation. The review feedback highlights three important improvements: resolving a concurrency defect in uploadChunksConcurrently where failed chunk uploads do not halt subsequent chunk uploads, improving cancellation responsiveness by listening to the abort signal, preventing a potential TypeScript compilation error when accessing uploadAbortController.value.signal without null checks, and adding a safety check for crypto.subtle to prevent runtime crashes in non-secure (non-HTTPS) environments.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async function uploadChunksConcurrently( | ||
| uploadId: string, | ||
| chunks: Blob[], | ||
| existingChunks: Set<number>, | ||
| maxConcurrency: number, | ||
| retryCount: number, | ||
| retryDelay: number, | ||
| onProgress: (chunkIndex: number) => void, | ||
| signal?: AbortSignal, | ||
| ): Promise<void> { | ||
| 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() | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
在 uploadChunksConcurrently 中存在两个关于并发和取消的改进空间:
- 并发错误处理缺陷:如果某个分片上传失败,会立即调用
reject。然而,其他正在进行的并发上传仍在继续运行,当它们完成时会调用startNextUpload()。由于此时currentIndex仍小于pendingChunks.length,它们会继续获取并上传新的分片,即使整个上传任务已经失败并被拒绝。这会浪费用户的带宽和服务器资源。建议在startNextUpload的开头添加if (errors.length > 0) return校验,一旦发生错误就停止启动新的分片上传。 - 取消操作延迟响应:目前仅在
startNextUpload开始时和uploadChunkWithRetry内部重试循环中检查了signal?.aborted。如果用户在上传过程中点击取消,取消操作不会立即响应,而是需要等待当前正在上传的某个分片完成或失败后才会触发 reject。建议在Promise初始化时为signal绑定abort事件监听器,以便在用户取消时能够立即 reject 响应,并在 Promise 结束(resolve/reject)时清除该监听器以避免内存泄漏。
async function uploadChunksConcurrently(
uploadId: string,
chunks: Blob[],
existingChunks: Set<number>,
maxConcurrency: number,
retryCount: number,
retryDelay: number,
onProgress: (chunkIndex: number) => void,
signal?: AbortSignal,
): Promise<void> {
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 onAbort = () => {
cleanup()
reject(new Error('上传已取消'))
}
const cleanup = () => {
signal?.removeEventListener('abort', onAbort)
}
if (signal?.aborted) {
onAbort()
return
}
signal?.addEventListener('abort', onAbort)
const startNextUpload = async () => {
if (signal?.aborted) {
onAbort()
return
}
if (errors.length > 0) {
return
}
if (currentIndex >= pendingChunks.length) {
if (activeUploads === 0) {
cleanup()
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('上传失败'))
cleanup()
reject(errors[0])
return
} finally {
activeUploads--
}
startNextUpload()
}
// 启动初始并发上传
for (let i = 0; i < Math.min(maxConcurrency, pendingChunks.length); i++) {
startNextUpload()
}
// 如果没有待上传的分片,直接完成
if (pendingChunks.length === 0) {
cleanup()
resolve()
}
})
}| uploadAbortController.value = new AbortController() | ||
|
|
||
| try { | ||
| const result = await uploadPluginPackage({ | ||
| file, | ||
| fileName: file.name, | ||
| }) | ||
| let result | ||
|
|
||
| // 根据文件大小选择上传方式 | ||
| if (file.size > CHUNKED_UPLOAD_THRESHOLD) { | ||
| // 大于 20MB 使用分片上传 | ||
| result = await uploadPluginWithChunks(file, file.name, { | ||
| chunkSize: 5 * 1024 * 1024, // 5MB 每片 | ||
| maxConcurrency: 3, | ||
| onProgress: (progress: ChunkedUploadProgressInfo) => { | ||
| uploadProgress.value = progress.progress | ||
| uploadStage.value = progress.stage | ||
| }, | ||
| signal: uploadAbortController.value.signal, | ||
| }) |
There was a problem hiding this comment.
uploadAbortController.value 的类型是 AbortController | null。在未进行空值校验的情况下直接访问 uploadAbortController.value.signal 会导致 TypeScript 编译错误(特别是在启用了严格空值检查的项目中,如 vue-tsc 编译时)。
建议先将新创建的 AbortController 赋值给局部变量,然后使用该局部变量进行后续操作,这样既安全又符合 TypeScript 的类型安全要求。
| uploadAbortController.value = new AbortController() | |
| try { | |
| const result = await uploadPluginPackage({ | |
| file, | |
| fileName: file.name, | |
| }) | |
| let result | |
| // 根据文件大小选择上传方式 | |
| if (file.size > CHUNKED_UPLOAD_THRESHOLD) { | |
| // 大于 20MB 使用分片上传 | |
| result = await uploadPluginWithChunks(file, file.name, { | |
| chunkSize: 5 * 1024 * 1024, // 5MB 每片 | |
| maxConcurrency: 3, | |
| onProgress: (progress: ChunkedUploadProgressInfo) => { | |
| uploadProgress.value = progress.progress | |
| uploadStage.value = progress.stage | |
| }, | |
| signal: uploadAbortController.value.signal, | |
| }) | |
| const abortController = new AbortController() | |
| uploadAbortController.value = abortController | |
| try { | |
| let result | |
| // 根据文件大小选择上传方式 | |
| if (file.size > CHUNKED_UPLOAD_THRESHOLD) { | |
| // 大于 20MB 使用分片上传 | |
| result = await uploadPluginWithChunks(file, file.name, { | |
| chunkSize: 5 * 1024 * 1024, // 5MB 每片 | |
| maxConcurrency: 3, | |
| onProgress: (progress: ChunkedUploadProgressInfo) => { | |
| uploadProgress.value = progress.progress | |
| uploadStage.value = progress.stage | |
| }, | |
| signal: abortController.signal, | |
| }) |
| async function calculateSHA256(blob: Blob): Promise<string> { | ||
| 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}` | ||
| } |
There was a problem hiding this comment.
crypto.subtle 仅在安全上下文(如 HTTPS、localhost、file:// 等)中可用。如果插件在非安全上下文中加载,crypto.subtle 将为 undefined,从而导致 TypeError: Cannot read properties of undefined (reading 'digest') 运行时错误,使整个上传流程崩溃。
建议在 calculateSHA256 中添加对 crypto?.subtle 的存在性校验,并在不支持时抛出清晰的错误信息,以便于调试和优雅降级。
async function calculateSHA256(blob: Blob): Promise<string> {
if (typeof crypto === 'undefined' || !crypto.subtle) {
throw new Error('当前环境不支持 SubtleCrypto(可能处于非安全上下文,如非 HTTPS 环境)')
}
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}`
}
插件信息
本次变更
截图 / 演示
自检清单
plugins/bad-bear/目录此 PR 由 ztools-plugin-cli 自动管理:每次
ztools publish在分支上追加一个 commit,PR 链接保持不变。