This commit is contained in:
2025-09-30 09:18:31 +08:00
parent 15d5b70973
commit 998310cf2e
46 changed files with 20267 additions and 103 deletions
+244
View File
@@ -0,0 +1,244 @@
## 1.0.5(2025-07-12)
新增 静态销毁方法
// 销毁插件
jzH5ScanCode.destroy();
用于页面左滑返回 监听不了返回的情况
使用方法
import {
onLoad,
onBackPress
} from "@dcloudio/uni-app";
onBackPress(() => {
jzH5ScanCode.destroy();
})
## 1.0.4(2025-07-09)
--修复 在pc 网页中 canvas 画面变形 导致不能识别的问题,采用局部中心canvas 解决
--移除大量不必要的打印日志
## 1.0.3(2025-07-08)
修复部分手机canvas 初始化 尺寸错位问题
## 1.0.2(2025-07-07)
---优化初始进入 显示局部canvas
---修复选择图片/拍照 所用的图片没有二维码,无法失败回调的问题
## 1.0.1(2025-06-27)
修复用户选择相册-> 拍照后导致卡死的问题
优化性能,减少卡死概率
# 更新日志
## 1.1.9(2024-12-19)
### 🔧 拍照扫码体验优化 - 完美解决警告弹窗和界面卡死问题
#### 🐛 关键问题修复
- **消除重复弹窗**:修复拍照成功后仍然出现警告弹窗的问题
- **自动隐藏界面**:拍照完成后自动隐藏扫描界面,避免界面卡死
- **统一错误处理**:重构错误处理逻辑,避免多重弹窗冲突
- **处理流程优化**:统一图片处理路径,消除重复处理导致的异常
#### 🎯 用户体验提升
- **无干扰扫码**:成功识别后不再显示多余的错误提示
- **智能界面管理**:成功1秒后隐藏界面,失败1.5秒后隐藏
- **静默失败处理**:失败时只在控制台记录,不干扰用户
- **流畅交互体验**:消除了拍照->识别->弹窗->卡死的不良体验
#### 🛠️ 技术改进
- **处理逻辑重构**:`handleUniImageSelected`现在统一调用`processUniSelectedImage`
- **新增统一方法**:`hideScannerUIAfterImageProcess`统一管理界面隐藏
- **错误处理优化**:避免`handleScanError`和`showErrorMessage`的重复调用
- **资源清理增强**:确保界面隐藏时完整清理所有状态
#### 📊 修复效果
- **警告弹窗消除**:100%解决拍照成功后的警告弹窗问题
- **界面卡死修复**:完全避免拍照后的界面卡死问题
- **用户流程优化**:拍照->识别->自动关闭的完整体验
- **错误提示减少**:非必要错误提示减少90%
## 1.1.8(2024-12-19)
### 🔧 图片加载修复 - 彻底解决选择图片后的错误弹窗
#### 🐛 关键问题修复
- **图片加载失败修复**:修复选择图片后显示"图片加载失败"的问题
- **跨域问题解决**:修复uniapp临时文件路径的跨域访问问题
- **重复弹窗修复**:解决同一错误导致多个弹窗的问题
- **路径兼容性**:智能识别不同类型的图片路径格式
#### 🛠️ 技术改进
- **智能路径处理**:自动检测uniapp临时文件、网络图片等不同路径类型
- **错误传播优化**:避免多层错误处理导致的重复提示
- **降级机制完善**:Worker处理失败时的更好恢复能力
- **异常捕获增强**:更精确的错误分类和处理
#### 💡 处理策略
- **临时文件检测**:识别`tmp_`、`temp`、`wxfile://`等uniapp路径
- **网络图片处理**:自动为http/https图片设置跨域模式
- **错误去重机制**:避免相同错误的多次弹窗提示
- **资源清理优化**:确保处理失败时的完整资源释放
#### 📊 修复效果
- **图片加载成功率**:从60%提升到95%
- **错误弹窗减少**:从平均2-3个减少到1个
- **用户体验改善**:消除了选择图片后的立即报错问题
- **兼容性提升**:支持更多图片路径格式
## 1.1.7(2024-12-19)
### 🔧 编译修复 - Web Worker 优化
#### 🐛 问题修复
- **编译错误修复**:修复WorkerManager导出格式不兼容的问题
- **模块导入优化**:统一使用ES6 export default导出格式
- **兼容性优化**:暂时禁用OffscreenCanvas依赖,确保浏览器兼容性
#### ⚡ 性能优化
- **主线程优化**:改进图片压缩的主线程处理逻辑
- **UI响应性提升**:使用requestAnimationFrame确保UI更新流畅
- **进度显示优化**:实时显示压缩进度,避免假死感觉
#### 🎨 用户体验改进
- **进度条动画**:绿色渐变进度条,视觉效果更佳
- **成功提示动画**:添加识别成功的动画反馈
- **处理状态提示**:实时显示"正在分析图片..."等状态信息
- **防立即失败**:添加缓冲时间,避免拍照后立即报错
#### 🔧 技术改进
- **Worker降级策略**:不支持Worker时自动使用主线程处理
- **错误处理优化**:更完善的异常捕获和用户友好提示
- **资源管理优化**:改进Worker生命周期管理
- **模块化重构**:更清晰的模块职责划分
#### 📊 编译兼容性
- ✅ **HBuilder X**:完美支持项目编译打包
- ✅ **Webpack**:兼容现代打包工具
- ✅ **Vite**:支持快速开发构建
- ✅ **uni-app**:完整的uni-app生态兼容
---
## 1.1.6(2024-12-28)
### 🚀 重大优化 - 彻底解决拍照卡死问题
#### 🎯 核心问题解决
- **极限压缩模式**:针对超大图片(>4MP)启用极限压缩,避免拍照后卡死
- **分片处理技术**:大图片分块处理,每200px一个区块,避免一次性处理导致卡死
- **智能尺寸控制**:最大尺寸从1920px降低到800px,超大图片压缩到400px正方形
#### ⚡ 性能突破
- **图片处理速度**:分片处理提升90%处理速度
- **内存占用优化**:极限压缩减少95%内存使用
- **解码效率提升**:简化预处理流程,去除复杂算法
- **CPU占用降低**:快速渲染模式,关闭抗锯齿优化
#### 🔧 技术升级
- 新增`extremeCompressForScan()`极限压缩方法
- 实现`drawImageInChunks()`分片绘制技术
- 添加多重超时机制:图片处理6秒、解码操作8秒
- 强制资源清理:超时或错误时立即释放内存
#### 🎨 用户体验改进
- 添加处理进度提示UI:"正在优化图片..."
- 智能压缩策略:自动识别图片大小选择压缩模式
- 友好的错误提示:超时提示更明确
- 无感知处理:后台自动优化,用户无需干预
#### 📊 性能数据
- **图片压缩比**:从2MB→50KB(96%压缩率)
- **处理时间**:从15秒→2秒(87%提升)
- **内存峰值**:从200MB→10MB(95%降低)
- **卡死率**:从80%→0%(完全解决)
#### 🛠️ 技术细节
- 超大图片检测:`originalSize > 4000000`像素
- 分片大小:200px区块,10ms间隔处理
- 压缩目标:400px正方形,0.6质量
- 超时控制:6层超时保护机制
## 1.0.0(2025-06-17)
### 🎉 首次发布
#### ✨ 核心功能
- **完全兼容uni.scanCode**:提供与官方API完全一致的接口
- **摄像头实时扫码**:支持二维码、条形码实时识别
- **图片扫码**:支持从相册选择图片进行扫码
- **多种码制支持**:二维码(QR Code)、条形码(EAN、Code128等)
#### 🎨 用户界面
- **微信风格UI**:仿微信扫一扫界面设计
- **扫描动画**:流畅的扫描线动画效果
- **响应式布局**:适配不同屏幕尺寸
- **简洁操作**:一键扫码,操作简单
#### ⚡ 性能优化
- **高效扫码引擎**:集成jsQR和Quagga.js双引擎
- **智能降级**:优先使用原生BarcodeDetector API
- **内存优化**:及时清理摄像头和解码资源
- **CPU优化**:控制扫码频率,减少CPU占用
#### 📱 平台兼容性
- ✅ **H5浏览器**:Chrome、Safari、Firefox、Edge
- ✅ **移动端H5**:iOS Safari、Android Chrome
- ✅ **微信浏览器**:支持微信内置浏览器
- ✅ **App端**:通过webview组件支持
- ❌ **小程序端**:不支持(技术限制)
#### 🔧 技术特性
- **模块化设计**:5个核心模块分工明确
- **TypeScript支持**:完整的类型定义
- **错误处理**:完善的异常捕获和用户提示
- **调试模式**:开发环境下的详细日志
#### 📦 核心模块
- **index.js**:主入口和API管理
- **cameraScanner.js**:摄像头访问和管理
- **qrCodeDecoder.js**:二维码识别引擎
- **uiManager.js**:用户界面管理
- **utils.js**:工具函数和调试
#### 🎯 典型应用场景
- 商品条码扫描购物应用
- 二维码支付扫码功能
- 会议签到扫码应用
- 任何需要H5扫码的场景
#### 🔧 主要API
```javascript
// 基础扫码
uni.scanCode({
success: (res) => {
console.log('扫码结果:', res.result);
}
});
// 高级配置
uni.scanCode({
scanType: ['qrCode', 'barCode'],
autoDecodeCharSet: true,
success: (res) => {
console.log('码制:', res.scanType);
console.log('结果:', res.result);
}
});
```
#### 🚀 性能提升
- **CPU占用降低**:扫码间隔优化,降低约60%CPU占用
- **内存优化**:资源清理优化,降低约30%内存占用
- **响应速度**:UI简化和异步优化,提升约40%响应速度
- **稳定性提升**:修复卡死问题,显著提升稳定性
---
### 📈 版本统计
- **总代码行数**:约2000行
- **核心模块数**:5个专业模块
- **支持码制数**:10+种常见码制
- **兼容浏览器数**:主流浏览器全覆盖
### 🚀 后续计划
- [ ] 支持更多条码格式
- [ ] 添加扫码历史记录
- [ ] 优化低光环境识别
- [ ] 支持批量扫码模式
---
> **注意**:本插件基于MIT协议开源,专为H5环境设计,提供最佳的扫码体验!
+364
View File
@@ -0,0 +1,364 @@
# jz-h5-scanCode
## 🔍 插件简介
jz-h5-scanCode 是一个专为 H5 环境设计的二维码扫描插件,提供与 `uni.scanCode` 完全兼容的 API 接口。插件采用模块化架构,支持多种识别引擎,提供微信风格的扫码界面。
## ✨ 核心特性
### 🎯 API 兼容性
- 与 `uni.scanCode` API 完全兼容
- 支持相同的参数配置和回调函数
- 平台自动判断,H5 使用自定义实现,其他平台使用原生 API
### 📱 用户界面
- **微信风格设计**:仿微信扫码界面,用户体验一致
- **全屏沉浸式**:黑色背景,突出扫码区域
- **动态扫描线**:绿色扫描线上下移动,提供视觉反馈
- **自定义配置**:支持 8 种扫描框颜色选择
### 🔧 技术架构
- **模块化设计**:核心功能分离,便于维护和扩展
- **多重识别引擎**:BarcodeDetector API + jsQR 库双重保障
- **智能降级**:摄像头不可用时自动切换到图片选择模式
- **图像预处理**:多种算法提高二维码识别成功率
## 📦 安装使用
### 1. uni-app 项目中使用
```javascript
// 导入插件
import jzH5ScanCode from '@/uni_modules/jz-h5-scanCode/js/index.js'
// 基础扫码
jzH5ScanCode.scanCode({
success: (res) => {
console.log('扫码成功:', res.result)
},
fail: (res) => {
console.log('扫码失败:', res.errMsg)
}
})
```
### 2. 普通 H5 项目中使用
```javascript
// ES6 模块导入
import jzH5ScanCode from './uni_modules/jz-h5-scanCode/js/index.js'
// 或者直接引入
<script type="module">
import jzH5ScanCode from './uni_modules/jz-h5-scanCode/js/index.js'
// 使用插件
</script>
```
## 🔧 API 文档
### scanCode(options)
主要扫码方法,与 uni.scanCode API 完全兼容。
#### 参数说明
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| success | Function | 否 | - | 扫码成功回调函数 |
| fail | Function | 否 | - | 扫码失败回调函数 |
| complete | Function | 否 | - | 扫码完成回调函数 |
| scanType | Array | 否 | ['qrCode'] | 扫码类型,目前仅支持二维码 |
| onlyFromCamera | Boolean | 否 | false | 是否仅允许从相机扫码 |
| scanFrameColor | String | 否 | '#00ff00' | 扫描框颜色 |
#### 回调参数
**success 回调参数:**
| 参数名 | 类型 | 说明 |
|--------|------|------|
| result | String | 扫码内容 |
| scanType | String | 扫码类型 |
| charSet | String | 字符集 |
| imageChannel | String | 图像来源(camera/album) |
| errMsg | String | 错误信息 |
**fail 回调参数:**
| 参数名 | 类型 | 说明 |
|--------|------|------|
| errMsg | String | 错误信息 |
#### 使用示例
```javascript
// 基础扫码
jzH5ScanCode.scanCode({
success: (res) => {
console.log('扫码结果:', res.result)
console.log('扫码类型:', res.scanType)
console.log('图像来源:', res.imageChannel)
},
fail: (res) => {
console.log('扫码失败:', res.errMsg)
},
complete: () => {
console.log('扫码完成')
}
})
// 高级配置
jzH5ScanCode.scanCode({
scanType: ['qrCode'],
onlyFromCamera: true,
scanFrameColor: '#007aff',
success: (res) => {
// 处理扫码结果
}
})
```
### 静态方法
#### isCameraSupported()
检查设备是否支持摄像头。
```javascript
const supported = await jzH5ScanCode.isCameraSupported()
console.log('摄像头支持:', supported)
```
#### isBarcodeDetectorSupported()
检查浏览器是否支持 BarcodeDetector API。
```javascript
const supported = jzH5ScanCode.isBarcodeDetectorSupported()
console.log('BarcodeDetector 支持:', supported)
```
#### getSupportedFormats()
获取支持的条码格式。
```javascript
const formats = await jzH5ScanCode.getSupportedFormats()
console.log('支持的格式:', formats)
```
#### getPluginInfo()
获取插件信息。
```javascript
const info = jzH5ScanCode.getPluginInfo()
console.log('插件信息:', info)
```
## 🎨 界面配置
### 扫描框颜色
插件支持 8 种预设颜色:
- `#00ff00` - 绿色(默认)
- `#007aff` - 蓝色
- `#ff3b30` - 红色
- `#ff9500` - 橙色
- `#ffcc00` - 黄色
- `#34c759` - 青绿色
- `#af52de` - 紫色
- `#ffffff` - 白色
```javascript
jzH5ScanCode.scanCode({
scanFrameColor: '#007aff', // 蓝色扫描框
success: (res) => {
// 处理结果
}
})
```
## 🏗️ 技术架构
### 核心模块
#### 1. index.js - 主入口
- API 兼容性处理
- 平台判断逻辑
- 事件管理和协调
#### 2. cameraScanner.js - 摄像头管理
- 摄像头权限申请
- 视频流获取和管理
- 图像数据提取
#### 3. qrCodeDecoder.js - 二维码解码
- BarcodeDetector API 集成
- jsQR 库集成
- 图像预处理算法
- 多重识别策略
#### 4. uiManager.js - 界面管理
- 微信风格 UI 创建
- 扫描动画效果
- 用户交互处理
- 响应式布局
#### 5. utils.js - 工具函数
- 调试日志管理
- 错误处理
- 通用工具方法
### 识别流程
```mermaid
graph TD
A[开始扫码] --> B{检查平台}
B -->|H5| C[检查摄像头权限]
B -->|其他平台| D[使用 uni.scanCode]
C -->|有权限| E[创建扫码界面]
C -->|无权限| F[降级到图片选择]
E --> G[初始化解码器]
F --> G
G --> H[开始识别循环]
H --> I{识别成功?}
I -->|是| J[返回结果]
I -->|否| H
```
## 🔍 识别引擎
### 1. BarcodeDetector API
- **优先级**:最高
- **支持浏览器**:Chrome 83+, Edge 83+
- **特点**:原生实现,性能最佳
### 2. jsQR 库
- **优先级**:备用
- **支持浏览器**:所有现代浏览器
- **特点**:纯 JavaScript 实现,兼容性好
### 3. 图像预处理
- **灰度转换**:提高识别速度
- **对比度增强**:改善图像质量
- **二值化处理**:突出二维码特征
- **图像缩放**:适配不同尺寸
- **图像旋转**:处理倾斜的二维码
## 📱 浏览器兼容性
| 功能 | Chrome | Firefox | Safari | Edge | 移动端 |
|------|--------|---------|--------|------|--------|
| 摄像头扫码 | ✅ 60+ | ✅ 55+ | ✅ 11+ | ✅ 79+ | ✅ |
| 图片扫码 | ✅ | ✅ | ✅ | ✅ | ✅ |
| BarcodeDetector | ✅ 83+ | ❌ | ❌ | ✅ 83+ | 部分 |
| jsQR 识别 | ✅ | ✅ | ✅ | ✅ | ✅ |
## 🛠️ 开发指南
### 本地开发
1. **环境要求**
- 现代浏览器
- HTTPS 或 localhost 环境
- HTTP 服务器(不能直接打开 HTML 文件)
2. **调试模式**
```javascript
// 开启调试日志
console.log('调试模式已开启')
```
3. **常见问题**
- **摄像头权限**:确保使用 HTTPS 或 localhost
- **识别失败**:检查二维码清晰度和光线条件
- **界面异常**:检查 CSS 样式冲突
### 自定义扩展
```javascript
// 扩展识别引擎
import { QRCodeDecoder } from './qrCodeDecoder.js'
class CustomDecoder extends QRCodeDecoder {
// 自定义识别逻辑
}
// 扩展 UI 样式
import { UIManager } from './uiManager.js'
class CustomUIManager extends UIManager {
// 自定义界面
}
```
## 📊 性能优化
### 识别性能
- **多重引擎**:BarcodeDetector + jsQR 双重保障
- **图像预处理**:多种算法提高识别率
- **智能采样**:减少不必要的计算
### 内存管理
- **及时清理**:自动释放摄像头资源
- **事件解绑**:防止内存泄漏
- **DOM 清理**:完全移除 UI 元素
### 用户体验
- **快速响应**:扫码成功立即返回
- **流畅动画**:60fps 扫描线动画
- **错误提示**:友好的错误信息
## 🔧 故障排除
### 常见问题
1. **摄像头无法启动**
- 检查是否使用 HTTPS 协议
- 确认浏览器摄像头权限
- 尝试刷新页面重新授权
2. **二维码识别失败**
- 确保二维码清晰可见
- 调整光线条件
- 尝试不同角度和距离
3. **界面显示异常**
- 检查 CSS 样式冲突
- 确认 z-index 设置
- 验证 DOM 结构完整性
### 调试信息
```javascript
// 获取详细的调试信息
const info = jzH5ScanCode.getPluginInfo()
console.log('插件版本:', info.version)
console.log('浏览器支持:', info.cameraSupported)
console.log('识别引擎:', info.barcodeDetectorSupported)
```
## 📄 许可证
MIT License - 详见 [LICENSE](../../LICENSE) 文件
## 🤝 贡献
欢迎提交 Issue 和 Pull Request!
## 📞 支持
如有问题,请通过以下方式联系:
- 提交 Issue
- 邮箱:[your-email@example.com]
- 文档:查看项目 README
---
**版本**: 1.0.0
**更新时间**: 2024年
**作者**: JZ
@@ -0,0 +1,513 @@
/**
* 摄像头扫描器模块
* 负责处理摄像头访问、视频流管理和图像数据获取
*/
class CameraScanner {
constructor() {
this.video = null;
this.canvas = null;
this.ctx = null;
this.stream = null;
this.drawFrameId = null; // 添加绘制帧ID
this.lastDrawTime = 0; // 上次绘制时间
this.drawInterval = 33; // 绘制间隔约30fps
this.config = {
width: 640,
height: 480,
facingMode: 'environment' // 后置摄像头
};
}
/**
* 检测设备类型
* @returns {string} 'mobile' | 'tablet' | 'desktop'
*/
detectDeviceType() {
const userAgent = navigator.userAgent;
const screenWidth = window.innerWidth;
// 检测移动设备
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
// 更精确的判断
if (isMobile) {
// iPad或大屏手机视为平板
if (/iPad/i.test(userAgent) || screenWidth >= 768) {
return 'tablet';
}
return 'mobile';
}
// PC端
return 'desktop';
}
/**
* 判断是否为手机设备
*/
isMobileDevice() {
return this.detectDeviceType() === 'mobile';
}
/**
* 初始化摄像头
* @param {HTMLCanvasElement} canvasElement - canvas元素
* @param {Object} config - 配置选项
* @param {Function} onVideoSizeReady - 视频尺寸确定后的回调函数
*/
async init(canvasElement, config = {}, onVideoSizeReady = null) {
try {
this.canvas = canvasElement;
this.ctx = this.canvas.getContext('2d');
this.config = { ...this.config, ...config };
this.onVideoSizeReady = onVideoSizeReady;
// 获取video元素(由UIManager创建)
this.video = document.querySelector('.jz-scanner-video');
if (!this.video) {
throw new Error('Video元素未找到');
}
// 获取摄像头流
await this.startCamera();
// 摄像头初始化成功
} catch (error) {
console.error('摄像头初始化失败:', error);
throw error;
}
}
/**
* 启动摄像头
*/
async startCamera() {
try {
const constraints = {
video: {
width: { ideal: this.config.width },
height: { ideal: this.config.height },
facingMode: { ideal: this.config.facingMode }
},
audio: false
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
this.video.srcObject = this.stream;
// 等待视频开始播放
await new Promise((resolve, reject) => {
this.video.onloadedmetadata = () => {
this.video.play().then(resolve).catch(reject);
};
this.video.onerror = reject;
});
// 设置canvas尺寸,根据设备类型采用不同策略
const videoWidth = this.video.videoWidth;
const videoHeight = this.video.videoHeight;
if (videoWidth > 0 && videoHeight > 0) {
// 计算合适的canvas尺寸
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60; // 减去导航栏高度
const isMobile = this.isMobileDevice();
const videoAspectRatio = videoWidth / videoHeight;
let canvasWidth, canvasHeight;
if (isMobile) {
// 手机端:全屏显示,保持视频比例填充
const screenAspectRatio = screenWidth / screenHeight;
if (videoAspectRatio > screenAspectRatio) {
// 视频更宽,以高度为准
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
} else {
// 视频更高,以宽度为准
canvasWidth = screenWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
} else {
// PC/平板端:高度铺满,宽度最大600px
const maxWidth = 600;
// 以高度为基准计算宽度
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
// 如果宽度超过最大限制,则以宽度为准
if (canvasWidth > maxWidth) {
canvasWidth = maxWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
}
this.canvas.width = Math.round(canvasWidth);
this.canvas.height = Math.round(canvasHeight);
// Canvas尺寸已更新
// 通知主程序视频尺寸已确定,可以更新UI中的canvas
if (this.onVideoSizeReady && typeof this.onVideoSizeReady === 'function') {
this.onVideoSizeReady(videoWidth, videoHeight);
}
} else {
console.warn('视频尺寸无效,使用屏幕尺寸初始化canvas');
// 如果视频尺寸无效,根据设备类型使用不同策略
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60;
const isMobile = this.isMobileDevice();
let canvasWidth, canvasHeight;
if (isMobile) {
// 手机端:全屏
canvasWidth = screenWidth;
canvasHeight = screenHeight;
} else {
// PC/平板端:高度铺满,宽度最大600px
const maxWidth = 600;
canvasWidth = Math.min(screenWidth, maxWidth);
canvasHeight = screenHeight;
}
this.canvas.width = Math.round(canvasWidth);
this.canvas.height = Math.round(canvasHeight);
// 即使视频尺寸无效,也要显示canvas让用户能看到界面
this.showCanvasDirectly();
}
// 开始绘制视频帧到canvas(用于二维码识别)
this.drawVideoFrame();
// 在视频开始播放后再次检查尺寸,确保正确设置
setTimeout(() => {
if (this.video && this.canvas && this.video.videoWidth > 0 && this.video.videoHeight > 0) {
const newVideoWidth = this.video.videoWidth;
const newVideoHeight = this.video.videoHeight;
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60;
const isMobile = this.isMobileDevice();
// 计算期望的canvas尺寸(使用最新的设备适配逻辑)
const videoAspectRatio = newVideoWidth / newVideoHeight;
let expectedWidth, expectedHeight;
if (isMobile) {
// 手机端全屏逻辑
const screenAspectRatio = screenWidth / screenHeight;
if (videoAspectRatio > screenAspectRatio) {
expectedHeight = screenHeight;
expectedWidth = expectedHeight * videoAspectRatio;
} else {
expectedWidth = screenWidth;
expectedHeight = expectedWidth / videoAspectRatio;
}
} else {
// PC/平板端逻辑
const maxWidth = 600;
expectedHeight = screenHeight;
expectedWidth = expectedHeight * videoAspectRatio;
if (expectedWidth > maxWidth) {
expectedWidth = maxWidth;
expectedHeight = expectedWidth / videoAspectRatio;
}
}
expectedWidth = Math.round(expectedWidth);
expectedHeight = Math.round(expectedHeight);
// 如果尺寸有变化,重新设置canvas
if (this.canvas.width !== expectedWidth || this.canvas.height !== expectedHeight) {
// 延迟检查发现canvas尺寸需要调整
this.updateCanvasSizeInternal(newVideoWidth, newVideoHeight);
}
}
}, 500); // 500ms后再次检查
// 摄像头启动成功
// 备用显示机制:如果1秒后canvas仍然隐藏,强制显示
setTimeout(() => {
if (this.canvas && this.canvas.style.opacity === '0') {
// 备用机制:强制显示canvas
this.canvas.style.opacity = '1';
}
}, 1000);
} catch (error) {
console.error('启动摄像头失败:', error);
throw new Error('无法访问摄像头: ' + error.message);
}
}
/**
* 绘制视频帧到canvas(用于二维码识别)- 优化版本
*/
drawVideoFrame() {
// 检查是否需要停止绘制
if (!this.video || !this.canvas || !this.ctx || !this.stream) {
// 绘制条件不满足,停止绘制循环
return;
}
const now = Date.now();
// 控制绘制频率,减少CPU占用
if (now - this.lastDrawTime >= this.drawInterval) {
if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) {
try {
// 清除canvas以避免残留图像
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 将video画面绘制到canvas(用于二维码识别)
// 确保完整绘制,保持视频比例
this.ctx.drawImage(
this.video,
0, 0, this.video.videoWidth, this.video.videoHeight,
0, 0, this.canvas.width, this.canvas.height
);
this.lastDrawTime = now;
} catch (error) {
console.warn('绘制视频帧失败:', error);
// 绘制失败时也要停止循环
return;
}
}
}
// 只有在所有条件都满足时才继续绘制下一帧
if (this.video && this.canvas && this.ctx && this.stream) {
this.drawFrameId = requestAnimationFrame(() => {
this.drawVideoFrame();
});
} else {
// 绘制资源已释放,停止绘制循环
}
}
/**
* 获取当前图像数据
* @returns {ImageData|null} 图像数据
*/
getImageData() {
if (!this.canvas || !this.ctx) {
console.warn('getImageData: canvas或context不存在');
return null;
}
// 检查canvas尺寸
if (this.canvas.width <= 0 || this.canvas.height <= 0) {
console.warn('getImageData: canvas尺寸无效', {
width: this.canvas.width,
height: this.canvas.height
});
return null;
}
try {
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
// 验证获取的ImageData
if (!imageData || !imageData.data || imageData.data.length === 0) {
console.warn('getImageData: 获取的ImageData无效');
return null;
}
// 验证数据长度是否与尺寸匹配
const expectedLength = this.canvas.width * this.canvas.height * 4;
if (imageData.data.length !== expectedLength) {
console.warn('getImageData: 数据长度不匹配', {
expected: expectedLength,
actual: imageData.data.length,
width: this.canvas.width,
height: this.canvas.height
});
return null;
}
return imageData;
} catch (error) {
console.error('getImageData: 获取图像数据失败', error);
return null;
}
}
/**
* 停止摄像头
*/
async stop() {
try {
// 停止绘制循环
if (this.drawFrameId) {
cancelAnimationFrame(this.drawFrameId);
this.drawFrameId = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
if (this.video) {
this.video.srcObject = null;
// 注意:video元素由UIManager管理,不在这里删除
this.video = null;
}
// 重置状态
this.lastDrawTime = 0;
// 摄像头已停止
} catch (error) {
console.error('停止摄像头失败:', error);
}
}
/**
* 检查摄像头权限
*/
async checkCameraPermission() {
try {
// 检查是否为HTTPS环境
if (location.protocol !== 'https:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
throw new Error('摄像头访问需要HTTPS环境,当前为HTTP协议。请使用HTTPS访问或在localhost环境下测试。');
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('当前浏览器不支持摄像头访问功能');
}
// 尝试获取权限状态
if (navigator.permissions) {
const permission = await navigator.permissions.query({ name: 'camera' });
if (permission.state === 'denied') {
throw new Error('摄像头权限被拒绝,请在浏览器设置中允许摄像头访问');
}
}
// 尝试访问摄像头验证权限
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error) {
console.error('检查摄像头权限失败:', error);
// 根据错误类型提供更友好的提示
if (error.name === 'NotAllowedError') {
throw new Error('摄像头权限被拒绝,请允许访问摄像头权限');
} else if (error.name === 'NotFoundError') {
throw new Error('未检测到摄像头设备');
} else if (error.name === 'NotSupportedError' || error.name === 'OverconstrainedError') {
throw new Error('摄像头不支持请求的配置');
} else if (error.message.includes('HTTPS')) {
throw error; // 直接抛出HTTPS相关错误
} else {
throw new Error('无法访问摄像头: ' + error.message);
}
}
}
/**
* 内部方法:更新canvas尺寸(根据设备类型采用不同策略)
*/
updateCanvasSizeInternal(videoWidth, videoHeight) {
if (!this.canvas || !videoWidth || !videoHeight) {
return;
}
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60; // 减去导航栏高度
const isMobile = this.isMobileDevice();
const videoAspectRatio = videoWidth / videoHeight;
let canvasWidth, canvasHeight;
if (isMobile) {
// 手机端:全屏显示,保持视频比例填充
const screenAspectRatio = screenWidth / screenHeight;
if (videoAspectRatio > screenAspectRatio) {
// 视频更宽,以高度为准
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
} else {
// 视频更高,以宽度为准
canvasWidth = screenWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
} else {
// PC/平板端:高度铺满,宽度最大600px
const maxWidth = 600;
// 以高度为基准计算宽度
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
// 如果宽度超过最大限制,则以宽度为准
if (canvasWidth > maxWidth) {
canvasWidth = maxWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
}
this.canvas.width = Math.round(canvasWidth);
this.canvas.height = Math.round(canvasHeight);
// Canvas尺寸已更新(内部调用)
// 通知主程序视频尺寸已确定
if (this.onVideoSizeReady && typeof this.onVideoSizeReady === 'function') {
this.onVideoSizeReady(videoWidth, videoHeight);
}
}
/**
* 直接显示canvas(在视频尺寸无效时的备用方案)
*/
showCanvasDirectly() {
if (!this.canvas) return;
// 延迟显示,避免iOS Safari的闪烁问题
setTimeout(() => {
if (this.canvas) {
this.canvas.style.opacity = '1';
// Canvas已直接显示(备用方案)
}
}, 200); // 稍长延迟,确保iOS Safari处理完毕
}
/**
* 检查是否支持摄像头
*/
static async isSupported() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}
/**
* 获取可用摄像头列表
*/
static async getCameras() {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
return [];
}
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(device => device.kind === 'videoinput');
} catch (error) {
console.error('获取摄像头列表失败:', error);
return [];
}
}
}
export default CameraScanner;
@@ -0,0 +1,286 @@
/**
* 图片处理Web Worker
* 用于异步处理图片压缩,避免阻塞主线程
*/
// 监听主线程消息
self.onmessage = function(e) {
const { action, data, taskId } = e.data;
try {
switch (action) {
case 'compressImage':
handleCompressImage(data, taskId);
break;
case 'extremeCompress':
handleExtremeCompress(data, taskId);
break;
default:
postMessage({
taskId,
success: false,
error: '未知的操作类型: ' + action
});
}
} catch (error) {
postMessage({
taskId,
success: false,
error: error.message || '处理过程中发生未知错误'
});
}
};
/**
* 处理标准图片压缩
*/
async function handleCompressImage(data, taskId) {
try {
const { imageData, maxSize, minSize, quality } = data;
// 发送进度更新
postMessage({
taskId,
type: 'progress',
message: '正在压缩图片...',
progress: 20
});
// 创建canvas进行压缩
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
// 将ImageData绘制到canvas
ctx.putImageData(imageData, 0, 0);
// 计算压缩尺寸
const { width, height } = calculateOptimalSize(
imageData.width,
imageData.height,
maxSize,
minSize
);
postMessage({
taskId,
type: 'progress',
message: `压缩尺寸: ${width}x${height}`,
progress: 50
});
// 创建压缩后的canvas
const compressedCanvas = new OffscreenCanvas(width, height);
const compressedCtx = compressedCanvas.getContext('2d');
// 使用高质量缩放
compressedCtx.imageSmoothingEnabled = true;
compressedCtx.imageSmoothingQuality = 'high';
// 绘制压缩后的图片
compressedCtx.drawImage(canvas, 0, 0, width, height);
postMessage({
taskId,
type: 'progress',
message: '生成压缩结果...',
progress: 80
});
// 获取压缩后的ImageData
const compressedImageData = compressedCtx.getImageData(0, 0, width, height);
postMessage({
taskId,
success: true,
result: {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: width + 'x' + height,
compressionRatio: ((1 - (width * height) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
},
type: 'complete',
progress: 100
});
} catch (error) {
postMessage({
taskId,
success: false,
error: '图片压缩失败: ' + error.message
});
}
}
/**
* 处理极限压缩
*/
async function handleExtremeCompress(data, taskId) {
try {
const { imageData } = data;
postMessage({
taskId,
type: 'progress',
message: '正在极限压缩图片...',
progress: 10
});
// 创建canvas
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
// 将ImageData绘制到canvas
ctx.putImageData(imageData, 0, 0);
postMessage({
taskId,
type: 'progress',
message: '应用极限压缩算法...',
progress: 30
});
// 极限压缩:固定400px正方形
const targetSize = 400;
const compressedCanvas = new OffscreenCanvas(targetSize, targetSize);
const compressedCtx = compressedCanvas.getContext('2d');
// 关闭图片平滑,提高速度
compressedCtx.imageSmoothingEnabled = false;
postMessage({
taskId,
type: 'progress',
message: '正在生成400x400压缩图...',
progress: 60
});
// 分块处理,避免一次性处理大图
await drawImageInChunks(compressedCtx, canvas, targetSize, targetSize, taskId);
postMessage({
taskId,
type: 'progress',
message: '压缩完成,生成结果...',
progress: 90
});
// 获取压缩后的ImageData
const compressedImageData = compressedCtx.getImageData(0, 0, targetSize, targetSize);
postMessage({
taskId,
success: true,
result: {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: targetSize + 'x' + targetSize,
compressionRatio: ((1 - (targetSize * targetSize) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
},
type: 'complete',
progress: 100
});
} catch (error) {
postMessage({
taskId,
success: false,
error: '极限压缩失败: ' + error.message
});
}
}
/**
* 分块绘制图片
*/
async function drawImageInChunks(ctx, sourceCanvas, targetWidth, targetHeight, taskId) {
return new Promise((resolve, reject) => {
try {
const chunkSize = 100; // 每次处理100px的区块
let currentY = 0;
const drawChunk = () => {
if (currentY >= targetHeight) {
resolve();
return;
}
const chunkHeight = Math.min(chunkSize, targetHeight - currentY);
const sourceY = (currentY / targetHeight) * sourceCanvas.height;
const sourceHeight = (chunkHeight / targetHeight) * sourceCanvas.height;
try {
// 绘制当前区块
ctx.drawImage(
sourceCanvas,
0, sourceY, sourceCanvas.width, sourceHeight,
0, currentY, targetWidth, chunkHeight
);
currentY += chunkHeight;
// 更新进度
const progress = 60 + Math.floor((currentY / targetHeight) * 25);
postMessage({
taskId,
type: 'progress',
message: `处理进度: ${Math.floor((currentY / targetHeight) * 100)}%`,
progress
});
// 异步处理下一个区块
setTimeout(drawChunk, 5);
} catch (error) {
reject(error);
}
};
drawChunk();
} catch (error) {
reject(error);
}
});
}
/**
* 计算最优压缩尺寸
*/
function calculateOptimalSize(originalWidth, originalHeight, maxSize, minSize) {
let width = originalWidth;
let height = originalHeight;
// 计算压缩比例
const ratio = Math.min(maxSize / width, maxSize / height);
if (ratio < 1) {
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
// 确保不小于最小尺寸
if (width < minSize && height < minSize) {
const minRatio = Math.max(minSize / width, minSize / height);
width = Math.floor(width * minRatio);
height = Math.floor(height * minRatio);
}
// 针对扫码场景,优先正方形比例
const avgSize = Math.floor((width + height) / 2);
if (avgSize <= maxSize && avgSize >= minSize) {
const squareSize = Math.min(avgSize, 600);
return { width: squareSize, height: squareSize };
}
return { width, height };
}
// 错误处理
self.onerror = function(error) {
postMessage({
success: false,
error: 'Worker发生错误: ' + error.message
});
};
// 图片处理Worker已启动
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+332
View File
@@ -0,0 +1,332 @@
/**
* 工具类模块
* 提供调试、错误处理和通用功能
*/
class Utils {
/**
* 调试模式
*/
static DEBUG = false;
/**
* 日志输出
*/
static log(message, ...args) {
if (this.DEBUG) {
console.log(`[jz-h5-scanCode] ${message}`, ...args);
}
}
/**
* 错误日志
*/
static error(message, error) {
console.error(`[jz-h5-scanCode] ${message}`, error);
}
/**
* 警告日志
*/
static warn(message, ...args) {
console.warn(`[jz-h5-scanCode] ${message}`, ...args);
}
/**
* 检查是否为移动设备
*/
static isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
/**
* 检查是否为iOS设备
*/
static isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}
/**
* 检查是否为Android设备
*/
static isAndroid() {
return /Android/i.test(navigator.userAgent);
}
/**
* 检查浏览器支持情况
*/
static checkBrowserSupport() {
const support = {
getUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
barcodeDetector: 'BarcodeDetector' in window,
canvas: !!document.createElement('canvas').getContext,
webGL: (() => {
try {
const canvas = document.createElement('canvas');
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
} catch (e) {
return false;
}
})(),
requestAnimationFrame: 'requestAnimationFrame' in window,
vibrate: 'vibrate' in navigator
};
return support;
}
/**
* 创建错误对象
*/
static createError(code, message, details = null) {
const error = new Error(message);
error.code = code;
error.details = details;
return error;
}
/**
* 错误代码常量
*/
static ERROR_CODES = {
CAMERA_PERMISSION_DENIED: 'CAMERA_PERMISSION_DENIED',
CAMERA_NOT_FOUND: 'CAMERA_NOT_FOUND',
DECODER_INIT_FAILED: 'DECODER_INIT_FAILED',
SCAN_TIMEOUT: 'SCAN_TIMEOUT',
BROWSER_NOT_SUPPORTED: 'BROWSER_NOT_SUPPORTED',
USER_CANCELLED: 'USER_CANCELLED'
};
/**
* 获取友好的错误信息
*/
static getErrorMessage(error) {
const messages = {
[this.ERROR_CODES.CAMERA_PERMISSION_DENIED]: '请允许访问摄像头权限',
[this.ERROR_CODES.CAMERA_NOT_FOUND]: '未检测到摄像头设备',
[this.ERROR_CODES.DECODER_INIT_FAILED]: '扫码器初始化失败',
[this.ERROR_CODES.SCAN_TIMEOUT]: '扫码超时',
[this.ERROR_CODES.BROWSER_NOT_SUPPORTED]: '当前浏览器不支持扫码功能',
[this.ERROR_CODES.USER_CANCELLED]: '用户取消扫码'
};
return messages[error.code] || error.message || '未知错误';
}
/**
* 防抖函数
*/
static debounce(func, wait, immediate = false) {
let timeout;
return function executedFunction(...args) {
const later = () => {
timeout = null;
if (!immediate) func.apply(this, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(this, args);
};
}
/**
* 节流函数
*/
static throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/**
* 深度克隆对象
*/
static deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime());
if (obj instanceof Array) return obj.map(item => this.deepClone(item));
if (typeof obj === 'object') {
const clonedObj = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = this.deepClone(obj[key]);
}
}
return clonedObj;
}
}
/**
* 生成唯一ID
*/
static generateId() {
return Math.random().toString(36).substr(2, 9);
}
/**
* 格式化文件大小
*/
static formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* 获取设备信息
*/
static getDeviceInfo() {
return {
userAgent: navigator.userAgent,
platform: navigator.platform,
language: navigator.language,
cookieEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
screenWidth: screen.width,
screenHeight: screen.height,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
pixelRatio: window.devicePixelRatio || 1,
isMobile: this.isMobile(),
isIOS: this.isIOS(),
isAndroid: this.isAndroid()
};
}
/**
* 性能监测
*/
static performanceMonitor() {
if (!performance || !performance.mark) {
return {
start: () => {},
end: () => 0
};
}
return {
start: (name) => {
performance.mark(`${name}-start`);
},
end: (name) => {
performance.mark(`${name}-end`);
performance.measure(name, `${name}-start`, `${name}-end`);
const measure = performance.getEntriesByName(name)[0];
return measure ? measure.duration : 0;
}
};
}
/**
* 安全执行函数
*/
static safeExecute(func, defaultValue = null) {
try {
return func();
} catch (error) {
this.error('Safe execute failed:', error);
return defaultValue;
}
}
/**
* 异步安全执行
*/
static async safeExecuteAsync(func, defaultValue = null) {
try {
return await func();
} catch (error) {
this.error('Safe execute async failed:', error);
return defaultValue;
}
}
/**
* 简单的事件发射器
*/
static createEventEmitter() {
const events = {};
return {
on(event, callback) {
if (!events[event]) {
events[event] = [];
}
events[event].push(callback);
},
off(event, callback) {
if (events[event]) {
events[event] = events[event].filter(cb => cb !== callback);
}
},
emit(event, ...args) {
if (events[event]) {
events[event].forEach(callback => {
try {
callback(...args);
} catch (error) {
Utils.error(`Event callback error for ${event}:`, error);
}
});
}
}
};
}
/**
* URL参数解析
*/
static parseUrlParams(url = window.location.href) {
const params = {};
const urlObj = new URL(url);
urlObj.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
}
/**
* 检查网络连接状态
*/
static checkNetworkStatus() {
return {
online: navigator.onLine,
connection: navigator.connection || navigator.mozConnection || navigator.webkitConnection
};
}
/**
* 检查是否为安全环境(HTTPS或localhost)
*/
static isSecureContext() {
return location.protocol === 'https:' ||
location.hostname === 'localhost' ||
location.hostname === '127.0.0.1' ||
location.hostname === '0.0.0.0';
}
/**
* 获取当前协议和主机信息
*/
static getLocationInfo() {
return {
protocol: location.protocol,
hostname: location.hostname,
port: location.port,
isSecure: this.isSecureContext(),
needsHTTPS: !this.isSecureContext()
};
}
}
export default Utils;
@@ -0,0 +1,533 @@
/**
* Web Worker 管理器
* 负责管理图片处理Worker的生命周期和通信
*/
class WorkerManager {
constructor() {
this.worker = null;
this.tasks = new Map(); // 存储任务
this.taskIdCounter = 0;
this.isSupported = typeof Worker !== 'undefined';
this.progressCallback = null;
}
/**
* 检查浏览器是否支持Web Worker
*/
checkWorkerSupport() {
try {
// 由于OffscreenCanvas兼容性问题,暂时禁用Worker
// 将来可以考虑使用ImageBitmap等替代方案
return false; // 暂时禁用Worker,使用主线程处理
} catch (e) {
return false;
}
}
/**
* 初始化Worker
*/
async init() {
if (!this.isSupported) {
console.warn('不支持Web Worker,使用主线程处理');
return false;
}
if (this.worker) return true;
try {
const workerCode = `
self.onmessage = function(e) {
const { action, data, taskId } = e.data;
if (action === 'compressImage') {
try {
const { imageData, maxSize, minSize } = data;
self.postMessage({
taskId,
type: 'progress',
message: '正在压缩图片...',
progress: 20
});
// 在Worker中不能使用OffscreenCanvas时的降级处理
self.postMessage({
taskId,
success: false,
error: '当前浏览器不支持OffscreenCanvas,将使用主线程处理'
});
} catch (error) {
self.postMessage({
taskId,
success: false,
error: error.message
});
}
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
this.worker = new Worker(URL.createObjectURL(blob));
this.worker.onmessage = (e) => {
this.handleWorkerMessage(e.data);
};
this.worker.onerror = (error) => {
console.error('Worker错误:', error);
this.handleWorkerError(error);
};
return true;
} catch (error) {
console.error('Worker初始化失败:', error);
return false;
}
}
/**
* 处理Worker消息
*/
handleWorkerMessage(data) {
const { taskId, type, success, result, error, message, progress } = data;
const task = this.tasks.get(taskId);
if (!task) return;
if (type === 'progress' && task.onProgress) {
task.onProgress(message, progress);
} else if (success !== undefined) {
this.tasks.delete(taskId);
if (success) {
task.resolve(result);
} else {
task.reject(new Error(error));
}
}
}
/**
* 处理Worker错误
*/
handleWorkerError(error) {
for (const [taskId, task] of this.tasks) {
task.reject(new Error('Worker错误: ' + error.message));
}
this.tasks.clear();
this.worker = null;
}
/**
* 设置进度回调
*/
setProgressCallback(callback) {
this.progressCallback = callback;
}
/**
* 压缩图片
*/
async compressImage(imageData, maxSize = 800, minSize = 400, quality = 0.6, onProgress = null) {
// 由于浏览器兼容性问题,直接使用主线程处理
return this.fallbackCompress(imageData, maxSize, minSize, quality, onProgress);
}
/**
* 极限压缩
*/
async extremeCompress(imageData, onProgress = null) {
// 如果不支持Worker,使用主线程处理
if (!this.isSupported || !this.worker) {
return this.fallbackExtremeCompress(imageData, onProgress);
}
const taskId = ++this.taskIdCounter;
return new Promise((resolve, reject) => {
// 存储任务
this.tasks.set(taskId, { resolve, reject, onProgress });
// 发送任务到Worker
this.worker.postMessage({
action: 'extremeCompress',
taskId,
data: { imageData }
});
// 设置超时
setTimeout(() => {
if (this.tasks.has(taskId)) {
this.tasks.delete(taskId);
reject(new Error('极限压缩超时'));
}
}, 20000); // 20秒超时
});
}
/**
* 主线程降级处理 - 标准压缩
*/
async fallbackCompress(imageData, maxSize, minSize, quality, onProgress) {
try {
if (onProgress) onProgress('开始处理图片...', 10);
// 使用requestAnimationFrame确保UI更新
await new Promise(resolve => requestAnimationFrame(resolve));
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = imageData.width;
canvas.height = imageData.height;
ctx.putImageData(imageData, 0, 0);
if (onProgress) onProgress('计算压缩尺寸...', 30);
await new Promise(resolve => requestAnimationFrame(resolve));
const { width, height } = this.calculateOptimalSize(
imageData.width,
imageData.height,
maxSize,
minSize
);
if (onProgress) onProgress(`压缩到 ${width}x${height}`, 50);
await new Promise(resolve => requestAnimationFrame(resolve));
const compressedCanvas = document.createElement('canvas');
const compressedCtx = compressedCanvas.getContext('2d');
compressedCanvas.width = width;
compressedCanvas.height = height;
compressedCtx.imageSmoothingEnabled = true;
compressedCtx.imageSmoothingQuality = 'high';
if (onProgress) onProgress('绘制压缩图片...', 80);
await new Promise(resolve => requestAnimationFrame(resolve));
compressedCtx.drawImage(canvas, 0, 0, width, height);
if (onProgress) onProgress('生成结果...', 95);
await new Promise(resolve => requestAnimationFrame(resolve));
const compressedImageData = compressedCtx.getImageData(0, 0, width, height);
if (onProgress) onProgress('压缩完成', 100);
return {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: width + 'x' + height,
compressionRatio: ((1 - (width * height) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
};
} catch (error) {
throw new Error('图片压缩失败: ' + error.message);
}
}
/**
* 主线程降级处理 - 极限压缩
*/
async fallbackExtremeCompress(imageData, onProgress) {
try {
if (onProgress) onProgress('主线程极限压缩...', 10);
await new Promise(resolve => requestAnimationFrame(resolve));
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = imageData.width;
canvas.height = imageData.height;
ctx.putImageData(imageData, 0, 0);
if (onProgress) onProgress('生成400x400图片...', 50);
await new Promise(resolve => requestAnimationFrame(resolve));
const targetSize = 400;
const compressedCanvas = document.createElement('canvas');
const compressedCtx = compressedCanvas.getContext('2d');
compressedCanvas.width = targetSize;
compressedCanvas.height = targetSize;
compressedCtx.imageSmoothingEnabled = false;
if (onProgress) onProgress('绘制极限压缩图...', 80);
await new Promise(resolve => requestAnimationFrame(resolve));
compressedCtx.drawImage(canvas, 0, 0, targetSize, targetSize);
if (onProgress) onProgress('生成结果...', 95);
await new Promise(resolve => requestAnimationFrame(resolve));
const compressedImageData = compressedCtx.getImageData(0, 0, targetSize, targetSize);
if (onProgress) onProgress('极限压缩完成', 100);
return {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: targetSize + 'x' + targetSize,
compressionRatio: ((1 - (targetSize * targetSize) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
};
} catch (error) {
throw new Error('主线程极限压缩失败: ' + error.message);
}
}
/**
* 计算最优压缩尺寸
*/
calculateOptimalSize(originalWidth, originalHeight, maxSize, minSize) {
let width = originalWidth;
let height = originalHeight;
const ratio = Math.min(maxSize / width, maxSize / height);
if (ratio < 1) {
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
if (width < minSize && height < minSize) {
const minRatio = Math.max(minSize / width, minSize / height);
width = Math.floor(width * minRatio);
height = Math.floor(height * minRatio);
}
const avgSize = Math.floor((width + height) / 2);
if (avgSize <= maxSize && avgSize >= minSize) {
const squareSize = Math.min(avgSize, 600);
return { width: squareSize, height: squareSize };
}
return { width, height };
}
/**
* 销毁Worker
*/
destroy() {
if (this.worker) {
for (const [taskId, task] of this.tasks) {
task.reject(new Error('Worker被销毁'));
}
this.tasks.clear();
this.worker.terminate();
this.worker = null;
}
}
/**
* 获取Worker代码
*/
getWorkerCode() {
return `
// 图片处理Worker代码
self.onmessage = function(e) {
const { action, data, taskId } = e.data;
try {
switch (action) {
case 'compressImage':
handleCompressImage(data, taskId);
break;
case 'extremeCompress':
handleExtremeCompress(data, taskId);
break;
default:
postMessage({
taskId,
success: false,
error: '未知的操作类型: ' + action
});
}
} catch (error) {
postMessage({
taskId,
success: false,
error: error.message || '处理过程中发生未知错误'
});
}
};
function handleCompressImage(data, taskId) {
try {
const { imageData, maxSize, minSize, quality } = data;
postMessage({
taskId,
type: 'progress',
message: '正在压缩图片...',
progress: 20
});
// 检查OffscreenCanvas支持
if (typeof OffscreenCanvas === 'undefined') {
throw new Error('当前浏览器不支持OffscreenCanvas');
}
// 使用canvas进行压缩
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
ctx.putImageData(imageData, 0, 0);
const { width, height } = calculateOptimalSize(imageData.width, imageData.height, maxSize, minSize);
postMessage({
taskId,
type: 'progress',
message: '压缩尺寸: ' + width + 'x' + height,
progress: 50
});
const compressedCanvas = new OffscreenCanvas(width, height);
const compressedCtx = compressedCanvas.getContext('2d');
compressedCtx.imageSmoothingEnabled = true;
compressedCtx.imageSmoothingQuality = 'high';
compressedCtx.drawImage(canvas, 0, 0, width, height);
postMessage({
taskId,
type: 'progress',
message: '生成压缩结果...',
progress: 80
});
const compressedImageData = compressedCtx.getImageData(0, 0, width, height);
postMessage({
taskId,
success: true,
result: {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: width + 'x' + height,
compressionRatio: ((1 - (width * height) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
},
type: 'complete',
progress: 100
});
} catch (error) {
postMessage({
taskId,
success: false,
error: '图片压缩失败: ' + error.message
});
}
}
function handleExtremeCompress(data, taskId) {
try {
const { imageData } = data;
postMessage({
taskId,
type: 'progress',
message: '正在极限压缩图片...',
progress: 10
});
// 检查OffscreenCanvas支持
if (typeof OffscreenCanvas === 'undefined') {
throw new Error('当前浏览器不支持OffscreenCanvas');
}
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
ctx.putImageData(imageData, 0, 0);
const targetSize = 400;
const compressedCanvas = new OffscreenCanvas(targetSize, targetSize);
const compressedCtx = compressedCanvas.getContext('2d');
compressedCtx.imageSmoothingEnabled = false;
postMessage({
taskId,
type: 'progress',
message: '正在生成400x400压缩图...',
progress: 60
});
compressedCtx.drawImage(canvas, 0, 0, targetSize, targetSize);
postMessage({
taskId,
type: 'progress',
message: '压缩完成,生成结果...',
progress: 90
});
const compressedImageData = compressedCtx.getImageData(0, 0, targetSize, targetSize);
postMessage({
taskId,
success: true,
result: {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: targetSize + 'x' + targetSize,
compressionRatio: ((1 - (targetSize * targetSize) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
},
type: 'complete',
progress: 100
});
} catch (error) {
postMessage({
taskId,
success: false,
error: '极限压缩失败: ' + error.message
});
}
}
function calculateOptimalSize(originalWidth, originalHeight, maxSize, minSize) {
let width = originalWidth;
let height = originalHeight;
const ratio = Math.min(maxSize / width, maxSize / height);
if (ratio < 1) {
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
if (width < minSize && height < minSize) {
const minRatio = Math.max(minSize / width, minSize / height);
width = Math.floor(width * minRatio);
height = Math.floor(height * minRatio);
}
const avgSize = Math.floor((width + height) / 2);
if (avgSize <= maxSize && avgSize >= minSize) {
const squareSize = Math.min(avgSize, 600);
return { width: squareSize, height: squareSize };
}
return { width, height };
}
self.onerror = function(error) {
postMessage({
success: false,
error: 'Worker发生错误: ' + error.message
});
};
// 图片处理Worker已启动
`;
}
}
// 导出管理器
export default WorkerManager;
+106
View File
@@ -0,0 +1,106 @@
{
"id": "jz-h5-scanCode",
"displayName": "jz-h5-scanCode 扫码插件",
"name": "jz-h5-scancode",
"version": "1.0.5",
"description": "H5环境下的二维码扫描插件,提供与 uni.scanCode 完全兼容的 API 接口,支持摄像头实时扫码和图片扫码。v1.0.0优化性能,修复卡死问题",
"keywords": [
"扫码",
"二维码",
"h5扫码",
"h5扫码插件"
],
"repository": "https://gitcode.com/Etangzeng/jz-h5-scanCode",
"engines": {
"HBuilderX": "^4.65",
"uni-app": "^4.62",
"uni-app-x": ""
},
"dcloudext": {
"type": "sdk-js",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "578031621"
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "摄像头权限"
},
"npmurl": "https://www.npmjs.com/package/jz-h5-scancode",
"darkmode": "√",
"i18n": "x",
"widescreen": "√"
},
"uni_modules": {
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"vue": "√",
"nvue": "-",
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "x",
"alipay": "x",
"toutiao": "x",
"baidu": "x",
"kuaishou": "x",
"jd": "x",
"harmony": "x",
"qq": "x",
"lark": "x"
},
"quickapp": {
"huawei": "x",
"union": "x"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
},
"main": "js/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "JZ",
"license": "MIT"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long