完善生产查询与登录权限配置
@@ -0,0 +1,61 @@
|
||||
# 更新日志
|
||||
|
||||
## 1.3.0(2026-08-29)
|
||||
|
||||
### 新增
|
||||
|
||||
- `scanStyle.cornerStyle` 新增 `rounded` 完整圆角边框和 `rounded-corner` 四角圆角边框。
|
||||
- 新增 `scanStyle.cornerRadius`,单位 dp,范围 `0 ~ 160`,默认 `20`。
|
||||
- 两种圆角扫描框启用遮罩时,遮罩缺口和扫描线同步按同一圆角路径裁剪。
|
||||
|
||||
### 兼容性
|
||||
|
||||
- 保留 `corner` 四角边框与 `full` 完整矩形边框的原有绘制和调用方式。
|
||||
- 圆角半径会自动限制在扫描框短边的一半以内,适配窄条码扫描框。
|
||||
|
||||
## 1.2.0(2026-08-28)
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增独立的 `scanStyle` 配置,不再将视觉表现与 `scanRegion` 识别区域混合。
|
||||
- 支持 `showMask`,可控制扫描框外遮罩的显示与隐藏。
|
||||
- 支持 `showBeam`,可控制扫描线的显示与隐藏。
|
||||
- 支持 `borderColor`、`maskColor`、`beamColor` 自定义边框、遮罩和扫描线颜色。
|
||||
- 支持 `beamDuration` 配置扫描线移动周期,原生层限制为 `100 ~ 60000` 毫秒。
|
||||
- 支持 `cornerStyle`:`corner` 四角边框、`full` 完整矩形边框。
|
||||
- README 补充扫描区域、视觉样式、扫码页交互、生命周期回调和错误码说明。
|
||||
- 新增 `9021005 ~ 9021011` 细化错误码,覆盖相机占用/初始化、相册读取与无条码、非法 `formats` 和单次扫码超时。
|
||||
- 新增可选 `scanTimeout`,单次扫码可设置 `1000 ~ 600000` 毫秒超时;连续扫码不启用超时。
|
||||
|
||||
### 兼容性
|
||||
|
||||
- 保持原有 `openCodeScan` 调用方式不变。
|
||||
- 未传 `scanStyle` 时使用默认样式:绿色边框、半透明黑色遮罩、绿色扫描线、四角边框。
|
||||
- `scanStyle` 仅影响 Android 扫描页绘制,不改变条码识别逻辑。
|
||||
- 同屏多码定格后的绿色点选标记增加呼吸波纹动画,帮助用户快速定位可点击目标。
|
||||
|
||||
## 1.1.0(2026-07-22)
|
||||
|
||||
### 新增
|
||||
|
||||
- 增加相册识图能力,支持打开扫码页后直接进入相册,也支持扫码页内手动选图。
|
||||
- 增加 `formats` 码制过滤,支持二维码、一维码、商品码、Data Matrix、PDF417、Aztec 等格式。
|
||||
- 增加 `vibrate` 成功震动开关。
|
||||
- 增加 `continuous` 连续扫码和 `showPauseButton` 暂停/继续控制。
|
||||
- 增加 `torch` 手电筒策略:`off`、`on`、`auto`。
|
||||
- 增加 `resultMode` 结果处理策略:`immediate`、`confirm`、`pause`。
|
||||
- 区分 Activity 获取失败、相机权限拒绝、用户取消和无有效结果等错误码。
|
||||
|
||||
### 增强
|
||||
|
||||
- 支持同屏多个条码定格并点击选择。
|
||||
- 增加 `smartZoom` 智能变焦和双指手势缩放。
|
||||
- 拆分 Kotlin 原生实现,改善扫码页、相册识图、结果处理和码制解析的维护性。
|
||||
|
||||
## 1.0.0(2026-07-22)
|
||||
|
||||
### 新增
|
||||
|
||||
- 初始化 Android 扫码 UTS API 插件。
|
||||
- 基于 CameraX + Google ML Kit 实现实时条码识别。
|
||||
- 支持通过 `success`、`fail`、`complete` 回调接收扫码结果和流程状态。
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"id": "mas-code-scan",
|
||||
"displayName": "android端扫码功能,uts插件,支持多二维码",
|
||||
"version": "1.3.0",
|
||||
"description": "Android 扫码 UTS 插件:全格式、多码点选、相册识图、连续扫、码制过滤、圆角扫描框视觉配置、错误码细分和扫码超时",
|
||||
"keywords": [
|
||||
"code-scan",
|
||||
"扫码",
|
||||
"scan-code",
|
||||
"android"
|
||||
],
|
||||
"repository": "",
|
||||
"engines": {
|
||||
"HBuilderX": "^3.6.8",
|
||||
"uni-app": "^5.0",
|
||||
"uni-app-x": "^5.0"
|
||||
},
|
||||
"dcloudext": {
|
||||
"type": "uts",
|
||||
"sale": {
|
||||
"regular": {
|
||||
"price": "0.00"
|
||||
},
|
||||
"sourcecode": {
|
||||
"price": "0.00"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"qq": "20871209"
|
||||
},
|
||||
"declaration": {
|
||||
"ads": "无",
|
||||
"data": "无",
|
||||
"permissions": "相机、震动"
|
||||
},
|
||||
"npmurl": "",
|
||||
"darkmode": "x",
|
||||
"i18n": "x",
|
||||
"widescreen": "√"
|
||||
},
|
||||
"uni_modules": {
|
||||
"dependencies": [],
|
||||
"encrypt": [],
|
||||
"platforms": {
|
||||
"cloud": {
|
||||
"tcb": "x",
|
||||
"aliyun": "x",
|
||||
"alipay": "x"
|
||||
},
|
||||
"client": {
|
||||
"uni-app": {
|
||||
"vue": {
|
||||
"vue2": "√",
|
||||
"vue3": "√"
|
||||
},
|
||||
"web": {
|
||||
"safari": "x",
|
||||
"chrome": "x"
|
||||
},
|
||||
"app": {
|
||||
"vue": "√",
|
||||
"nvue": "x",
|
||||
"android": "√",
|
||||
"ios": "x",
|
||||
"harmony": "x"
|
||||
},
|
||||
"mp": {
|
||||
"weixin": "x",
|
||||
"alipay": "x",
|
||||
"toutiao": "x",
|
||||
"baidu": "x",
|
||||
"kuaishou": "x",
|
||||
"jd": "x",
|
||||
"harmony": "x",
|
||||
"qq": "x",
|
||||
"lark": "x",
|
||||
"xhs": "x"
|
||||
},
|
||||
"quickapp": {
|
||||
"huawei": "x",
|
||||
"union": "x"
|
||||
}
|
||||
},
|
||||
"uni-app-x": {
|
||||
"web": {
|
||||
"safari": "-",
|
||||
"chrome": "-"
|
||||
},
|
||||
"app": {
|
||||
"android": "-",
|
||||
"ios": "-",
|
||||
"harmony": "-"
|
||||
},
|
||||
"mp": {
|
||||
"weixin": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
# mas-code-scan
|
||||
|
||||
Android 端扫码 **UTS API 插件**(非 Vue 组件)。
|
||||
基于 CameraX + Google ML Kit,支持全格式条码/二维码、多码同屏点选、识别区域过滤、扫描框视觉配置、智能变焦、手势缩放、相册识图、连续扫码、手电筒策略、结果确认和暂停控制。
|
||||
|
||||
插件对外只提供 `openCodeScan` 一个 UTS API。扫码页由原生 Android Activity 提供,识别结果通过 `success`、`fail`、`complete` 回调返回。
|
||||
|
||||
支持:**Android App(uni-app Vue2/Vue3)**。
|
||||
暂不支持:iOS / 鸿蒙 / H5 / 小程序 / nvue。
|
||||
|
||||
> 依赖原生三方库,**必须使用自定义调试基座或正式云打包**后才能真机调用。
|
||||
|
||||
---
|
||||
|
||||
## 插件定位与工作流程
|
||||
|
||||
`mas-code-scan` 是一个打开原生 Android 扫码 Activity 的 UTS API 插件,不是 Vue 组件,也不是对 `uni.scanCode` 的简单包装。一次扫码流程如下:
|
||||
|
||||
1. 业务页面调用 `openCodeScan(options)`。
|
||||
2. 插件启动全屏竖屏原生扫码页,并根据参数初始化 CameraX、ML Kit 和界面控件。
|
||||
3. 相机帧交给 ML Kit 识别;如果配置了 `scanRegion`,实时结果还会经过扫描框中心点过滤。
|
||||
4. 单码按照 `resultMode` 返回;多码进入定格点选;连续模式通过 `success` 多次上报。
|
||||
5. Activity 结束后通过 `complete` 收尾;失败场景同时通过 `fail` 和 `complete` 通知。
|
||||
|
||||
### 能力边界
|
||||
|
||||
- 识别引擎:Google ML Kit Barcode Scanning。
|
||||
- 相机管线:CameraX,使用后置摄像头,预览采用 `FILL_CENTER` 填充显示。
|
||||
- 图片来源:实时相机帧、系统相册图片。
|
||||
- 原生页面:全屏、竖屏、无标题栏;关闭、相册、手电筒和连续扫码暂停按钮由插件绘制。
|
||||
- 生命周期:每次调用创建一个扫码会话;关闭、成功、权限失败、超时或初始化失败后会话结束。
|
||||
|
||||
## 目录
|
||||
|
||||
1. [功能一览](#功能一览)
|
||||
2. [安装](#安装)
|
||||
3. [制作自定义基座(必做)](#制作自定义基座必做)
|
||||
4. [快速上手](#快速上手)
|
||||
5. [进阶用法](#进阶用法)
|
||||
6. [API](#api)
|
||||
7. [错误码](#错误码)
|
||||
8. [扫码页交互说明](#扫码页交互说明)
|
||||
9. [码制 formats 取值](#码制-formats-取值)
|
||||
10. [常见问题](#常见问题)
|
||||
11. [注意事项](#注意事项)
|
||||
12. [目录结构](#目录结构)
|
||||
13. [版本](#版本)
|
||||
|
||||
---
|
||||
|
||||
## 功能一览
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| 全格式识别 | QR / EAN / CODE_128 / PDF417 / AZTEC 等 |
|
||||
| 码制过滤 | `formats` 只扫指定类型 |
|
||||
| 扫描区域 | `scanRegion` 配置扫描框尺寸、位置,并过滤框外条码 |
|
||||
| 扫描框样式 | `scanStyle` 配置遮罩、扫描线、边框颜色、动画周期、边框形态和圆角大小 |
|
||||
| 单码自动返回 | 识别成功后关闭并回调 |
|
||||
| 多码点选 | 同屏多个码定格,点选其中一个 |
|
||||
| 智能变焦 | `smartZoom`:码偏小时自动放大 |
|
||||
| 手势缩放 | 双指捏合 |
|
||||
| 手电筒 | 扫码页右下角开关,支持关闭、启动时开启和低照度自动补光 |
|
||||
| 相册识图 | 页内相册按钮,或 `fromAlbum: true` |
|
||||
| 连续扫码 | `continuous: true`,多次 `success`,点关闭结束;可显示暂停/继续按钮 |
|
||||
| 结果处理 | 单次扫码支持立即返回、确认后返回、暂停后选择 |
|
||||
| 成功震动 | `vibrate`,可关 |
|
||||
| 错误码区分 | 权限拒绝 `9021002` ≠ 用户取消 `9021003` |
|
||||
| 原生回收 | 页面结束时停止分析、释放相机、关闭 ML Kit Scanner、关闭手电筒并清理临时图片 |
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
将插件拷贝到业务项目:
|
||||
|
||||
```text
|
||||
你的项目/uni_modules/mas-code-scan/
|
||||
```
|
||||
|
||||
UTS 插件需 **import 后调用**,不会 easycom 自动注册组件。
|
||||
|
||||
要求:
|
||||
|
||||
- HBuilderX ≥ 3.6.8(建议较新版本)
|
||||
- uni-app Vue2 / Vue3 均可
|
||||
- **仅 Android**;需制作自定义基座
|
||||
|
||||
本仓库演示页:`frontend/scanapp/pages/scan/prototype.vue`,用于在 Android 自定义基座中验证插件能力。页面包含:
|
||||
|
||||
- 智能变焦、成功震动、手电筒策略、结果处理模式和连续扫码暂停按钮。
|
||||
- 扫码页标题、扫描引导语、成功提示语、无结果提示语自定义。
|
||||
- 连续扫码成功提示停留时长、单次扫码超时配置。
|
||||
- 二维码正方形框、横向条码框和全屏识别三种识别区域预设;横向条码框支持上/中/下位置、自定义上下微调和三档高度。
|
||||
- 扫描框遮罩、扫描线、边框颜色、遮罩颜色、扫描线颜色、扫描线周期、四种边框形态和圆角半径配置。
|
||||
- 二维码、CODE 128、EAN-13 码制筛选,相册识图、单次扫码、连续扫码和本地结果列表。
|
||||
|
||||
演示页的“最近结果”仅保存在当前页面内,用于展示回调结果,不会提交业务接口。
|
||||
|
||||
---
|
||||
|
||||
## 制作自定义基座(必做)
|
||||
|
||||
插件依赖:
|
||||
|
||||
- `androidx.camera:*` 1.4.1
|
||||
- `com.google.mlkit:barcode-scanning` 17.3.0
|
||||
|
||||
标准基座不含上述库,**不制作自定义基座会调用失败 / 编译不过**。
|
||||
|
||||
### 步骤
|
||||
|
||||
1. HBuilderX 菜单:**运行** → **运行到手机或模拟器** → **制作自定义调试基座**
|
||||
2. 选择当前项目,填写 Android 包名、证书(可用公共测试证书)
|
||||
3. 云打包,等待完成并安装到手机
|
||||
4. 之后每次运行选择 **「自定义调试基座」**,再打开演示页或业务页调试扫码
|
||||
|
||||
正式发版:使用 **云打包 / 本地打包** 生成安装包即可(打包流程会带上 UTS 原生依赖)。
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
|
||||
### 1. uni-app(Vue3 `<script setup>`)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view>
|
||||
<button type="primary" @click="doScan">扫码</button>
|
||||
<text>{{ tip }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { openCodeScan } from '@/uni_modules/mas-code-scan'
|
||||
|
||||
const tip = ref('')
|
||||
|
||||
const doScan = () => {
|
||||
openCodeScan({
|
||||
success: (res) => {
|
||||
tip.value = res.content + ' / ' + res.format
|
||||
},
|
||||
fail: (err) => {
|
||||
tip.value = err.errMsg + '(' + err.errCode + ')'
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. uni-app(Options API)
|
||||
|
||||
```js
|
||||
import { openCodeScan } from '@/uni_modules/mas-code-scan'
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
doScan() {
|
||||
openCodeScan({
|
||||
success: (res) => {
|
||||
console.log(res.content, res.format, res.fromAlbum)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log(err.errCode, err.errMsg)
|
||||
},
|
||||
complete: () => {
|
||||
console.log('扫码流程结束')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 最少参数
|
||||
|
||||
```ts
|
||||
import { openCodeScan } from '@/uni_modules/mas-code-scan'
|
||||
|
||||
openCodeScan({
|
||||
success: (res) => console.log(res.content)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 进阶用法
|
||||
|
||||
### 自定义扫码页文案
|
||||
|
||||
以下参数只影响原生扫码页显示文字,不影响识别逻辑:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
title: '扫描商品码',
|
||||
hint: '请将商品条码放入框内',
|
||||
successHint: '识别成功',
|
||||
emptyHint: '图片中没有商品条码',
|
||||
success: (res) => console.log(res.content)
|
||||
})
|
||||
```
|
||||
|
||||
- `title`:顶部标题,默认 `扫一扫`。
|
||||
- `hint`:扫描框下方引导语,默认 `请将条码放入扫描框内`。
|
||||
- `successHint`:连续扫码成功提示层标题,也用于部分原生提示,默认 `扫码成功`。
|
||||
- `emptyHint`:相册图片未识别到条码时的提示,默认 `未识别到有效条码`。
|
||||
- 空字符串或全空格会回退到默认文案;原生层会去除首尾空格。
|
||||
|
||||
### 只扫二维码
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
formats: ['QR_CODE'],
|
||||
success: (res) => console.log(res.content)
|
||||
})
|
||||
```
|
||||
|
||||
### 手电筒和结果处理
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
torch: 'auto', // 'off' | 'on' | 'auto'
|
||||
resultMode: 'confirm', // 'immediate' | 'confirm' | 'pause'
|
||||
success: (res) => console.log(res.content)
|
||||
})
|
||||
```
|
||||
|
||||
`confirm` 会弹出结果确认框;`pause` 会暂停识别并提供“继续扫描/确认”操作。两种模式下,点击确认才会结束并触发 `success`、`complete`;点击重新扫描或继续扫描会恢复相机识别,不会触发失败回调。
|
||||
|
||||
### 设置单次扫码超时
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
scanTimeout: 15000,
|
||||
success: (res) => console.log(res.content),
|
||||
fail: (err) => {
|
||||
if (err.errCode === 9021011) console.log('15 秒内未识别到条码')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
`scanTimeout` 单位为毫秒。只有单次扫码且值在 `1000 ~ 600000` 时启用;不传、传 `0` 或小于 `1000` 时不启用。连续扫码始终不启用单次超时。
|
||||
|
||||
### 关闭智能变焦 / 震动
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
smartZoom: false,
|
||||
vibrate: false,
|
||||
success: (res) => console.log(res.content)
|
||||
})
|
||||
```
|
||||
|
||||
### 配置扫描区域
|
||||
|
||||
`scanRegion` 同时决定扫描框的位置和实时识别过滤区域。宽度、高度、中心点均使用 `0 ~ 1` 的比例值;宽高以预览宽度为参考,越界值会由原生层收敛到可显示范围。`scanRegion` 只作用于实时相机,不裁剪或过滤相册图片。
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
scanRegion: {
|
||||
width: 0.96,
|
||||
height: 0.24,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
},
|
||||
success: (res) => console.log(res.content)
|
||||
})
|
||||
```
|
||||
|
||||
- 不传 `scanRegion`:保持全屏识别,同时保留默认扫描框显示。
|
||||
- `scanRegion: { fullScreen: true }`:整画面识别,不显示扫描框,也不进行区域过滤。
|
||||
- 配置扫描区域后,条码中心点必须落在扫描框内才会被实时相机识别结果接受。
|
||||
- 相册识图不受 `scanRegion` 限制。
|
||||
|
||||
### 配置扫描框视觉样式
|
||||
|
||||
`scanStyle` 只控制扫描页视觉表现,不改变识别区域。颜色支持 `#RRGGBB`、`#AARRGGBB`;遮罩还支持 `rgba(r, g, b, a)`。
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
scanRegion: {
|
||||
width: 0.96,
|
||||
height: 0.24,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
},
|
||||
scanStyle: {
|
||||
showMask: true,
|
||||
showBeam: true,
|
||||
borderColor: '#19be6b',
|
||||
maskColor: 'rgba(0, 0, 0, 0.45)',
|
||||
beamColor: '#12e880',
|
||||
beamDuration: 1800,
|
||||
cornerStyle: 'rounded',
|
||||
cornerRadius: 20
|
||||
},
|
||||
success: (res) => console.log(res.content)
|
||||
})
|
||||
```
|
||||
|
||||
- `showMask: false` 仅隐藏框外遮罩,不会扩大或改变识别范围。
|
||||
- `showBeam: false` 隐藏扫描线;扫描线周期 `beamDuration` 单位为毫秒,原生层限制在 `100 ~ 60000`。
|
||||
- `cornerStyle: 'corner'` 显示直角四角边框,`'full'` 显示完整矩形边框,`'rounded'` 显示完整圆角边框,`'rounded-corner'` 显示四角圆角边框。
|
||||
- `cornerRadius` 为圆角半径,单位为 dp,范围 `0 ~ 160`,在两种圆角边框样式下均生效;圆角遮罩缺口和扫描线会同步裁剪为相同圆角。
|
||||
- `rounded` 是四边完整的圆角边框;`rounded-corner` 只绘制四个带圆弧过渡的角,中间不连接四条边。
|
||||
- 圆角半径最终还会限制在扫描框短边的一半以内,适合二维码框和横向条码框。
|
||||
- 当未启用 `scanRegion` 时,视觉样式仅作用于默认扫描框;`fullScreen: true` 时不会显示扫描框。
|
||||
|
||||
### 打开后直接进相册
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
fromAlbum: true,
|
||||
success: (res) => {
|
||||
console.log(res.content, res.fromAlbum) // fromAlbum === true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
扫码页内也可随时点左下角 **相册** 按钮选图识别。
|
||||
|
||||
### 连续扫码
|
||||
|
||||
页内不关闭,每识别一次触发一次 `success`;用户点左上角关闭(或系统返回)后走 `complete`(若已有命中,**不会**再走 `fail`)。
|
||||
|
||||
```ts
|
||||
const list = []
|
||||
|
||||
openCodeScan({
|
||||
continuous: true,
|
||||
showPauseButton: true,
|
||||
vibrate: true,
|
||||
success: (res) => {
|
||||
list.push(res.content)
|
||||
console.log('命中', res.content, res.format)
|
||||
},
|
||||
fail: (err) => {
|
||||
// 一次都没扫到就关闭 → 一般是 9021003 取消
|
||||
console.log('失败', err.errCode, err.errMsg)
|
||||
},
|
||||
complete: (res) => {
|
||||
if (res && res.closed) {
|
||||
console.log('结束,共', res.hitCount, '次', list)
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 组合:仅二维码 + 连续扫 + 相册入口
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
formats: ['QR_CODE'],
|
||||
continuous: true,
|
||||
smartZoom: true,
|
||||
vibrate: true,
|
||||
success: (res) => console.log(res),
|
||||
complete: (res) => console.log('done', res)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### openCodeScan(options?)
|
||||
|
||||
打开全屏扫码页。
|
||||
|
||||
#### CodeScanOptions
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
| --- | --- | :---: | --- | --- |
|
||||
| `title` | `string` | 否 | `扫一扫` | 原生扫码页顶部标题 |
|
||||
| `hint` | `string` | 否 | `请将条码放入扫描框内` | 扫描框下方引导语 |
|
||||
| `successHint` | `string` | 否 | `扫码成功` | 连续扫码成功提示层标题 |
|
||||
| `emptyHint` | `string` | 否 | `未识别到有效条码` | 图片无条码时提示语 |
|
||||
| `smartZoom` | `boolean` | 否 | `true` | 条码占画面较小时自动平滑放大;最多尝试 6 次 |
|
||||
| `formats` | `string[]` | 否 | 全部 | 限定码制,见下方取值表;空/不传 = 全格式 |
|
||||
| `vibrate` | `boolean` | 否 | `true` | 识别成功后是否短震动约 40ms |
|
||||
| `scanRegion` | `ScanRegion` | 否 | 未启用 | 实时相机识别区域,控制扫描框尺寸、位置和中心点过滤 |
|
||||
| `scanStyle` | `CodeScanStyle` | 否 | 见下表 | 扫描框视觉样式,不改变识别区域 |
|
||||
| `torch` | `'off' \| 'on' \| 'auto'` | 否 | `'off'` | 手电筒策略;`on` 启动后开启,`auto` 低照度连续满足条件后自动开启 |
|
||||
| `resultMode` | `'immediate' \| 'confirm' \| 'pause'` | 否 | `'immediate'` | 单次扫码结果处理方式;连续扫码固定按立即上报处理 |
|
||||
| `continuous` | `boolean` | 否 | `false` | 连续扫:页内不关闭,每次命中触发一次 `success` |
|
||||
| `continuousSuccessDuration` | `number` | 否 | `1000` | 连续扫码成功提示停留时长,单位毫秒,范围 `0 ~ 600000` |
|
||||
| `showPauseButton` | `boolean` | 否 | `false` | 仅 `continuous: true` 时显示右上角暂停/继续按钮 |
|
||||
| `fromAlbum` | `boolean` | 否 | `false` | `true` 时打开扫码页后立即拉起系统相册 |
|
||||
| `scanTimeout` | `number` | 否 | 不启用 | 单次扫码超时,单位毫秒,范围 `1000 ~ 600000` |
|
||||
| `success` | `(res: CodeScanSuccess) => void` | 否 | - | 成功;连续模式下可多次触发 |
|
||||
| `fail` | `(err: CodeScanFail) => void` | 否 | - | 失败或取消;连续模式已有命中后关闭时不触发 |
|
||||
| `complete` | `(res: any) => void` | 否 | - | 会话结束;成功、失败、取消和连续扫码关闭都会触发 |
|
||||
|
||||
#### ScanRegion
|
||||
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `fullScreen` | `boolean` | `false` | 为 `true` 时整画面识别,不显示扫描框,也不做中心点过滤;优先级高于其他区域参数 |
|
||||
| `width` | `number` | `0.74` | 扫描框宽度相对预览宽度的比例,原生限制在 `0.1 ~ 1` |
|
||||
| `height` | `number` | `0.74` | 扫描框高度相对预览宽度的比例,原生限制在 `0.1 ~ 1` |
|
||||
| `centerX` | `number` | `0.5` | 扫描框中心横坐标比例,范围 `0 ~ 1`,越界会收敛 |
|
||||
| `centerY` | `number` | `0.5` | 扫描框中心纵坐标比例,范围 `0 ~ 1`,越界会收敛 |
|
||||
|
||||
区域映射会补偿 `PreviewView` 的 `FILL_CENTER` 裁剪偏移和图像旋转;最终判断的是 ML Kit 包围盒的中心点是否在区域内,而不是要求整个条码完全位于框内。
|
||||
|
||||
建议:二维码使用接近正方形的区域,横向条码使用较宽且较矮的区域;如果业务只需要识别、不需要框选提示,可使用 `fullScreen: true`。
|
||||
|
||||
#### 组合约束与默认行为
|
||||
|
||||
| 组合 | 实际行为 |
|
||||
| --- | --- |
|
||||
| 不传 `scanRegion` | 实时相机按全画面识别;使用原生默认扫描框布局 |
|
||||
| `scanRegion.fullScreen: true` | 全画面识别,不显示扫描框,不过滤框外结果 |
|
||||
| `fromAlbum: true` | 先打开相册;选图识别失败后,在相机可用时仍可继续相机扫码 |
|
||||
| `continuous: true` | 页内持续识别,`resultMode` 不参与单码确认;关闭页结束会话 |
|
||||
| `continuous: true` + `showPauseButton: true` | 右上角显示暂停/继续按钮,暂停期间不处理相机帧 |
|
||||
| `continuous: true` + `scanTimeout` | 忽略 `scanTimeout`,连续扫码没有会话超时 |
|
||||
| `continuous: true` + `resultMode` | 连续扫码固定立即上报,每次命中通过 `success` 返回 |
|
||||
| `formats` 含未知值和有效值 | 忽略未知值,使用有效码制 |
|
||||
| `formats` 非空且全部未知 | 立即失败并返回 `9021010` |
|
||||
| 单次识别到多个有定位框的条码 | 定格当前画面,显示绿色点选标记,点击后返回选中条码 |
|
||||
| 连续扫码短时间重复同一内容 | 约 1.2 秒内去重,避免重复触发 `success` |
|
||||
|
||||
### CodeScanStyle
|
||||
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `showMask` | `boolean` | `true` | 是否显示扫描框外遮罩 |
|
||||
| `showBeam` | `boolean` | `true` | 是否显示扫描线 |
|
||||
| `borderColor` | `string` | `#19be6b` | 边框颜色,支持 `#RRGGBB`、`#AARRGGBB` |
|
||||
| `maskColor` | `string` | `rgba(0, 0, 0, 0.45)` | 遮罩颜色和透明度,支持 `rgba()` |
|
||||
| `beamColor` | `string` | `#12e880` | 扫描线颜色,支持 `#RRGGBB`、`#AARRGGBB` |
|
||||
| `beamDuration` | `number` | `1800` | 扫描线移动周期,单位为毫秒,范围 `100 ~ 60000` |
|
||||
| `cornerStyle` | `'corner' \| 'full' \| 'rounded' \| 'rounded-corner'` | `'corner'` | `corner` 为直角四角边框,`full` 为完整边框,`rounded` 为完整圆角边框,`rounded-corner` 为四角圆角边框 |
|
||||
| `cornerRadius` | `number` | `20` | 圆角半径,单位 dp,范围 `0 ~ 160`,两种圆角样式均生效 |
|
||||
|
||||
默认值:`showMask: true`、`showBeam: true`、`borderColor: '#19be6b'`、`maskColor: 'rgba(0, 0, 0, 0.45)'`、`beamColor: '#12e880'`、`beamDuration: 1800`、`cornerStyle: 'corner'`、`cornerRadius: 20`。非法颜色会回退到默认值,`beamDuration` 会限制在 `100 ~ 60000` 毫秒,`cornerRadius` 会限制在 `0 ~ 160` dp。
|
||||
|
||||
示例:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
scanRegion: {
|
||||
width: 0.96,
|
||||
height: 0.24,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
},
|
||||
scanStyle: {
|
||||
showMask: true,
|
||||
showBeam: true,
|
||||
borderColor: '#19be6b',
|
||||
maskColor: 'rgba(0, 0, 0, 0.45)',
|
||||
beamColor: '#12e880',
|
||||
beamDuration: 1800,
|
||||
cornerStyle: 'rounded',
|
||||
cornerRadius: 20
|
||||
},
|
||||
success: (res) => console.log(res.content)
|
||||
})
|
||||
```
|
||||
|
||||
`scanRegion` 控制实时识别区域,`scanStyle` 只控制扫描页的视觉表现;两者可以独立配置。不传 `scanRegion` 时实时相机按全画面识别,但仍显示原生默认扫描框;需要隐藏扫描框时请传 `scanRegion: { fullScreen: true }`。
|
||||
|
||||
#### 回调返回值与生命周期
|
||||
|
||||
单次扫码成功:
|
||||
|
||||
```ts
|
||||
success({ content: '...', format: 'QR_CODE', fromAlbum: false })
|
||||
complete({ content: '...', format: 'QR_CODE', fromAlbum: false })
|
||||
```
|
||||
|
||||
单次扫码失败或用户取消:
|
||||
|
||||
```ts
|
||||
fail({ errCode: 9021003, errMsg: '用户取消扫码', errSubject: 'mas-code-scan' })
|
||||
complete({ errCode: 9021003, ... })
|
||||
```
|
||||
|
||||
连续扫码关闭:
|
||||
|
||||
```ts
|
||||
complete({ closed: true, hitCount: 3 })
|
||||
```
|
||||
|
||||
连续扫码如果一次命中都没有就关闭,通常会先触发 `fail(9021003)`,随后触发 `complete({ closed: true, hitCount: 0, failed: true, errCode: 9021003 })`。已有命中后关闭只触发 `complete`,不会触发 `fail`。
|
||||
|
||||
| 回调 | 触发时机 | 注意 |
|
||||
| --- | --- | --- |
|
||||
| `success` | 单次确认成功;或连续模式每次命中 | 多码点选只有点击某个码后触发 |
|
||||
| `fail` | Activity 无法启动、权限失败、相机失败、相册失败、超时、用户取消等 | 用户取消错误码为 `9021003` |
|
||||
| `complete` | 每次会话结束 | 不能只依赖 `success` 清理页面状态 |
|
||||
|
||||
#### CodeScanSuccess
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `content` | `string` | 识别到的文本内容 |
|
||||
| `format` | `string` | 码制名,如 `QR_CODE`、`CODE_128` |
|
||||
| `fromAlbum` | `boolean` | 是否来自相册识图(可选) |
|
||||
|
||||
#### CodeScanFail
|
||||
|
||||
继承 `UniError`:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `errCode` | `number` | 见错误码表 |
|
||||
| `errMsg` | `string` | 错误描述 |
|
||||
| `errSubject` | `string` | 固定 `"mas-code-scan"` |
|
||||
|
||||
回调规则:
|
||||
|
||||
- 单次扫码成功:依次调用 `success`、`complete`,成功对象包含 `content`、`format` 和 `fromAlbum`。
|
||||
- 单次扫码失败或取消:依次调用 `fail`、`complete`。
|
||||
- 连续扫码:每次命中调用一次 `success`;关闭页面时调用 `complete({ closed: true, hitCount })`。
|
||||
- 连续扫码在已有命中后关闭:不会再调用 `fail`,只调用 `complete`。
|
||||
- 相册识图成功:`fromAlbum` 为 `true`;相机实时识别成功时为 `false`。
|
||||
|
||||
#### 类型定义(摘录)
|
||||
|
||||
```ts
|
||||
type CodeScanOptions = {
|
||||
title?: string
|
||||
hint?: string
|
||||
successHint?: string
|
||||
emptyHint?: string
|
||||
smartZoom?: boolean
|
||||
formats?: string[]
|
||||
vibrate?: boolean
|
||||
torch?: 'off' | 'on' | 'auto'
|
||||
resultMode?: 'immediate' | 'confirm' | 'pause'
|
||||
continuous?: boolean
|
||||
continuousSuccessDuration?: number
|
||||
showPauseButton?: boolean
|
||||
fromAlbum?: boolean
|
||||
scanRegion?: {
|
||||
fullScreen?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
centerX?: number
|
||||
centerY?: number
|
||||
}
|
||||
scanStyle?: {
|
||||
showMask?: boolean
|
||||
showBeam?: boolean
|
||||
borderColor?: string
|
||||
maskColor?: string
|
||||
beamColor?: string
|
||||
beamDuration?: number
|
||||
cornerStyle?: 'corner' | 'full' | 'rounded' | 'rounded-corner'
|
||||
cornerRadius?: number
|
||||
}
|
||||
// 单次扫码超时(毫秒);小于 1000 或不传则不启用,最大 600000;连续扫码不启用超时
|
||||
scanTimeout?: number
|
||||
success?: (res: CodeScanSuccess) => void
|
||||
fail?: (res: CodeScanFail) => void
|
||||
complete?: (res: any) => void
|
||||
}
|
||||
|
||||
type CodeScanSuccess = {
|
||||
content: string
|
||||
format: string
|
||||
fromAlbum?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误码
|
||||
|
||||
| 错误码 | 说明 | 常见场景 |
|
||||
| --- | --- | --- |
|
||||
| `9021001` | 无法获取页面 Activity / 平台不支持 | 非 Android、页面上下文异常 |
|
||||
| `9021002` | 相机权限被拒绝 | 用户拒绝相机权限(非「仅相册」场景) |
|
||||
| `9021003` | 用户取消扫码 | 点关闭、系统返回、未扫就退出 |
|
||||
| `9021004` | 未获取到有效扫码结果 | 成功关页但内容为空(少见) |
|
||||
| `9021005` | 相机被其他应用占用或当前不可用 | 相机被其他应用、系统功能或设备状态占用 |
|
||||
| `9021006` | 设备不支持手电筒 | 点击手电筒时提示;不结束当前扫码会话 |
|
||||
| `9021007` | 相机初始化失败 | CameraX 初始化、绑定预览或分析管线失败 |
|
||||
| `9021008` | 相册图片无法读取 | 所选 URI 无法读取、复制或解码图片 |
|
||||
| `9021009` | 图片中未识别到条码 | `fromAlbum: true` 且无可回退相机时,图片中不存在有效码 |
|
||||
| `9021010` | 指定码制不支持 | `formats` 非空但其中没有插件支持的码制 |
|
||||
| `9021011` | 扫码超时 | 单次扫码设置了有效 `scanTimeout` 且超时未成功;连续扫码不启用 |
|
||||
|
||||
说明:
|
||||
|
||||
- `9021001` ~ `9021004` 为已有错误码,含义保持不变;新增错误码从 `9021005` 开始。
|
||||
- `fail` 与 `complete` 在失败时都会触发(连续扫「已有命中后关闭」除外:只 `complete`)。
|
||||
- 打开即相册且无相机权限时,仍可用相册;点关闭且无命中 → 一般为 `9021003`。
|
||||
- 无闪光灯不影响扫码,因此 `9021006` 仅通过扫码页提示展示,不会触发 `fail`。
|
||||
|
||||
---
|
||||
|
||||
## 扫码页交互说明
|
||||
|
||||
| 控件 / 手势 | 说明 |
|
||||
| --- | --- |
|
||||
| 左上关闭 | 退出;连续扫时结束会话 |
|
||||
| 左下相册 | 选图识别;多码时同样可点选 |
|
||||
| 右下手电筒 | 有闪光灯时可用;可由 `torch` 设置初始策略,`auto` 会在低照度时自动开启 |
|
||||
| 右上暂停按钮 | 仅连续扫码且 `showPauseButton: true` 时显示,暂停/继续识别 |
|
||||
| 结果确认框 | `resultMode: 'confirm'` 或 `'pause'` 时,确认返回或继续扫描 |
|
||||
| 双指捏合 | 手动变焦 |
|
||||
| 多码绿点 | 同屏多个有效码时定格,显示绿色呼吸波纹,点击选中 |
|
||||
| 系统返回键 | 等同关闭 |
|
||||
|
||||
---
|
||||
|
||||
## 码制 formats 取值
|
||||
|
||||
传入字符串数组;空/缺省时按全格式。若 `formats` 非空但全部不受支持,调用失败并返回 `9021010`。
|
||||
|
||||
| 取值 | 说明 |
|
||||
| --- | --- |
|
||||
| `QR_CODE` / `QR` | 二维码 |
|
||||
| `EAN_13` / `EAN_8` | 商品条码 |
|
||||
| `CODE_128` / `CODE_39` / `CODE_93` | 一维码 |
|
||||
| `CODABAR` | Codabar |
|
||||
| `DATA_MATRIX` | Data Matrix |
|
||||
| `UPC_A` / `UPC_E` | UPC |
|
||||
| `ITF` | ITF |
|
||||
| `PDF417` | PDF417 |
|
||||
| `AZTEC` | Aztec |
|
||||
| `ALL` | 全格式(与具体码制混用时按全格式处理) |
|
||||
|
||||
补充规则:插件会忽略未知 token;只要数组中存在一个有效 token 就使用有效 token。数组非空但全部无效时返回 `9021010`。`ALL` 与其他有效码制同时传入时最终使用全格式。
|
||||
|
||||
示例:
|
||||
|
||||
```ts
|
||||
formats: ['QR_CODE', 'CODE_128', 'EAN_13']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 点击没反应 / 报找不到类?
|
||||
|
||||
未使用自定义基座。请按上文制作并选择「自定义调试基座」再运行。
|
||||
|
||||
### 2. 提示相机权限被拒绝(9021002)?
|
||||
|
||||
到系统设置打开应用相机权限后重试。
|
||||
|
||||
### 3. 连续扫只回调一次?
|
||||
|
||||
确认传了 `continuous: true`。同一内容约 1.2 秒内会去重,避免狂刷;换个码或稍等再扫。
|
||||
|
||||
### 4. 相册选了图但提示未识别?
|
||||
|
||||
图中可能没有清晰条码,或被 `formats` 过滤掉。可先不传 `formats` 试全格式;打开即相册且没有可用相机时,会以 `9021009` 返回无条码错误。
|
||||
|
||||
### 5. 为什么会提示扫码超时(9021011)?
|
||||
|
||||
仅在单次扫码传入 `scanTimeout`(1000 ~ 600000 毫秒)后启用。超时不会影响连续扫码;不需要限制时不传该参数或传 `0`。
|
||||
|
||||
### 6. iOS / 鸿蒙能用吗?
|
||||
|
||||
当前版本仅 Android。目录未包含 iOS/鸿蒙实现。
|
||||
|
||||
### 7. 能否当组件标签用?
|
||||
|
||||
不能。这是 **UTS API**,请 `import { openCodeScan } from '@/uni_modules/mas-code-scan'` 后调用。
|
||||
|
||||
### 8. 演示页可以直接当业务页面使用吗?
|
||||
|
||||
`prototype.vue` 是能力验证页面,结果列表是本地状态,不包含业务接口提交、权限引导和业务字段转换。正式业务建议复制 `openScan` 调用方式,在自己的页面中处理 `success`、`fail` 和 `complete`,并在 `complete` 中恢复 loading、页面状态等资源。
|
||||
|
||||
### 9. `scanStyle` 不生效怎么办?
|
||||
|
||||
确认调用的是包含当前插件的自定义调试基座或正式打包应用。修改 UTS 原生代码后需要重新制作自定义基座或重新云打包,普通标准基座不会加载插件原生依赖和最新实现。
|
||||
|
||||
### 10. 扫描框显示了但框外条码没有识别?
|
||||
|
||||
这是 `scanRegion` 的预期行为。扫描框不仅是视觉提示,也会作为实时识别过滤区域;如需整屏识别,请不传 `scanRegion` 或传入 `fullScreen: true`。
|
||||
|
||||
### 11. `resultMode` 和 `continuous` 一起传会怎样?
|
||||
|
||||
连续扫码以连续模式为准,不显示单次扫码确认框,每次识别成功都会通过 `success` 上报;如需单个结果确认,请不要传 `continuous: true`。
|
||||
|
||||
### 12. `fromAlbum` 和相机权限有什么关系?
|
||||
|
||||
Android 13 及以上优先使用系统 Photo Picker;Android 12 及以下使用系统图片选择器,可能需要读取相册权限。打开即相册时,即使没有相机权限,也可以先完成相册识图;相册无结果且无法继续使用相机时返回 `9021009`。
|
||||
|
||||
### 13. 为什么识别框位置和相机画面看起来不完全一致?
|
||||
|
||||
预览采用 `FILL_CENTER`,相机画面会按比例填充并裁剪边缘;插件会在识别过滤时补偿裁剪和旋转,但不同设备的相机传感器比例、状态栏和厂商裁剪策略可能存在少量视觉差异。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 仅 Android App-Plus;发布市场包请走正式打包,勿依赖标准基座调试结果。
|
||||
2. 首次使用实时相机需要相机权限;插件不会主动跳转系统设置,权限被拒绝后由业务决定是否引导用户开启。
|
||||
3. 震动权限由插件 Manifest 声明;设置 `vibrate: false` 可关闭成功震动。
|
||||
4. 相册选图走系统选择器;Android 12 及以下可能需要读取相册权限,Android 13 及以上优先使用 Photo Picker。
|
||||
5. `scanRegion` 只过滤实时相机,且按条码包围盒中心点判断;相册识图始终分析整张图片。
|
||||
6. 连续扫依赖原生命中队列和 UTS 桥接轮询,业务应在 `complete` 中收尾,不要只在 `success` 中恢复状态。
|
||||
7. 多码点选依赖 ML Kit 返回定位框;多码中只有一个可解码码时会直接返回,无法定位的结果不能作为可点击目标。
|
||||
8. 智能变焦和双指缩放共享相机变焦能力;不支持变焦的设备会保持原始比例。
|
||||
9. 大尺寸相册图片会先缩放到最长边约 1920 像素再识别,以降低内存压力。
|
||||
10. 文档与演示不保证覆盖所有机型相机差异,建议使用真实目标机型回归。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
mas-code-scan/
|
||||
├── package.json
|
||||
├── readme.md
|
||||
├── changelog.md
|
||||
└── utssdk/
|
||||
├── interface.uts # 对外类型与 API 声明
|
||||
├── unierror.uts # 错误实现
|
||||
└── app-android/
|
||||
├── index.uts # JS/UTS 桥接
|
||||
├── config.json # 原生依赖
|
||||
├── AndroidManifest.xml
|
||||
├── CodeCaptureActivity.kt
|
||||
├── MultiCodePickView.kt
|
||||
├── ScanFrameDecorView.kt
|
||||
├── YuvFrames.kt
|
||||
├── SymbologyUtil.kt
|
||||
├── CodeScanHitBus.kt
|
||||
└── res/drawable/ # 关闭/相册等资源
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 版本
|
||||
|
||||
| 版本 | 说明 |
|
||||
| --- | --- |
|
||||
| 1.3.0 | 新增 `rounded` 完整圆角边框、`rounded-corner` 四角圆角边框和自定义 `cornerRadius`,遮罩与扫描线同步适配圆角 |
|
||||
| 1.2.0 | 新增 `scanStyle` 扫描框视觉配置,支持遮罩、扫描线、颜色、动画周期、四角/完整边框;完善 `scanRegion` 与回调说明 |
|
||||
| 1.1.0 | 权限码区分、关闭/相册、码制过滤、震动、连续扫、Kotlin 拆分 |
|
||||
| 1.0.0 | Android 扫码初版 |
|
||||
|
||||
更多变更见 `changelog.md`。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [UTS 语法](https://uniapp.dcloud.net.cn/tutorial/syntax-uts.html)
|
||||
- [UTS API 插件](https://uniapp.dcloud.net.cn/plugin/uts-plugin.html)
|
||||
- [制作自定义调试基座](https://uniapp.dcloud.net.cn/tutorial/run/run-app.html#customplayground)
|
||||
@@ -0,0 +1,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="uts.sdk.modules.masCodeScan">
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="uts.sdk.modules.masCodeScan.CodeCaptureActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,32 @@
|
||||
package uts.sdk.modules.masCodeScan
|
||||
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
/**
|
||||
* 连续扫码命中总线:Activity 入队,index.uts 轮询出队并触发 success。
|
||||
*/
|
||||
object CodeScanHitBus {
|
||||
private val queue = ConcurrentLinkedQueue<Array<String>>()
|
||||
|
||||
@JvmStatic
|
||||
fun detach() {
|
||||
queue.clear()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun emitHit(content: String, format: String, fromAlbum: Boolean) {
|
||||
queue.offer(
|
||||
arrayOf(
|
||||
content,
|
||||
format,
|
||||
if (fromAlbum) "1" else "0"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** 取出一条命中:[content, format, fromAlbumFlag];无数据返回 null */
|
||||
@JvmStatic
|
||||
fun pollHit(): Array<String>? {
|
||||
return queue.poll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package uts.sdk.modules.masCodeScan
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.view.View
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 连续扫码成功提示层:覆盖扫描框中部,显示成功状态和本次扫码值。
|
||||
*/
|
||||
class ContinuousSuccessOverlayView(
|
||||
context: Context,
|
||||
private val successHint: String
|
||||
) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private val scaledDensity = resources.displayMetrics.scaledDensity
|
||||
private val panelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.argb(218, 48, 48, 48)
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 16f * scaledDensity
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
private val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 15f * scaledDensity
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
private val panelRect = RectF()
|
||||
private var frameRect = RectF()
|
||||
private var stillFrame: Bitmap? = null
|
||||
private var value = ""
|
||||
|
||||
fun showSuccess(scanFrame: RectF, content: String, bitmap: Bitmap?) {
|
||||
stillFrame?.recycle()
|
||||
stillFrame = bitmap
|
||||
frameRect = RectF(scanFrame)
|
||||
value = content
|
||||
visibility = VISIBLE
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun hideSuccess() {
|
||||
stillFrame?.recycle()
|
||||
stillFrame = null
|
||||
value = ""
|
||||
visibility = GONE
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (value.isBlank() || width <= 0 || height <= 0) return
|
||||
|
||||
stillFrame?.takeIf { !it.isRecycled && it.width > 0 && it.height > 0 }?.let { bitmap ->
|
||||
val bitmapWidth = bitmap.width.toFloat()
|
||||
val bitmapHeight = bitmap.height.toFloat()
|
||||
val scale = maxOf(width / bitmapWidth, height / bitmapHeight)
|
||||
val drawWidth = bitmapWidth * scale
|
||||
val drawHeight = bitmapHeight * scale
|
||||
val left = (width - drawWidth) / 2f
|
||||
val top = (height - drawHeight) / 2f
|
||||
canvas.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), null)
|
||||
}
|
||||
|
||||
val availableWidth = if (frameRect.isEmpty) width.toFloat() else frameRect.width()
|
||||
val panelWidth = min(300f * density, maxOf(0f, availableWidth - 24f * density))
|
||||
val panelHeight = 68f * density
|
||||
val centerX = if (frameRect.isEmpty) width / 2f else frameRect.centerX()
|
||||
val centerY = if (frameRect.isEmpty) height / 2f else frameRect.centerY()
|
||||
panelRect.set(
|
||||
centerX - panelWidth / 2f,
|
||||
centerY - panelHeight / 2f,
|
||||
centerX + panelWidth / 2f,
|
||||
centerY + panelHeight / 2f
|
||||
)
|
||||
panelRect.left = panelRect.left.coerceAtLeast(12f * density)
|
||||
panelRect.right = panelRect.right.coerceAtMost(width - 12f * density)
|
||||
panelRect.top = panelRect.top.coerceAtLeast(24f * density)
|
||||
panelRect.bottom = panelRect.bottom.coerceAtMost(height - 24f * density)
|
||||
|
||||
canvas.drawRoundRect(panelRect, 12f * density, 12f * density, panelPaint)
|
||||
val titleBaseline = panelRect.centerY() - 3f * density
|
||||
val valueBaseline = panelRect.centerY() + 19f * density
|
||||
canvas.drawText(successHint, panelRect.centerX(), titleBaseline, titlePaint)
|
||||
canvas.drawText(value, panelRect.centerX(), valueBaseline, valuePaint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package uts.sdk.modules.masCodeScan
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
data class HitMark(
|
||||
val rawValue: String,
|
||||
val format: Int,
|
||||
val formatName: String,
|
||||
val screenRect: Rect
|
||||
) {
|
||||
val centerX: Float get() = screenRect.exactCenterX()
|
||||
val centerY: Float get() = screenRect.exactCenterY()
|
||||
}
|
||||
|
||||
/**
|
||||
* 多码点选覆盖层:定格帧 + 圆点标记。
|
||||
*/
|
||||
class MultiCodePickView(context: Context) : View(context) {
|
||||
|
||||
private var stillFrame: Bitmap? = null
|
||||
private var rawHits: List<Barcode> = emptyList()
|
||||
private var hitMarks: List<HitMark> = emptyList()
|
||||
private var visualCrop: Rect = Rect()
|
||||
private var stillW: Int = 0
|
||||
private var stillH: Int = 0
|
||||
private var layerW: Int = 0
|
||||
private var layerH: Int = 0
|
||||
private var recognizing: Boolean = false
|
||||
private var recognizingStartedAt: Long = 0L
|
||||
|
||||
private val density: Float = resources.displayMetrics.density
|
||||
private val dotOuterR: Float = 18f * density
|
||||
private val dotInnerR: Float = 6f * density
|
||||
private val touchR: Float = 28f * density
|
||||
private val breatheDurationMs = 1500L
|
||||
|
||||
private val haloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(60, 7, 193, 96)
|
||||
}
|
||||
private val shellPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.WHITE
|
||||
}
|
||||
private val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.rgb(7, 193, 96)
|
||||
}
|
||||
private val shadePaint = Paint().apply {
|
||||
color = Color.argb(40, 0, 0, 0)
|
||||
}
|
||||
private val promptBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.argb(190, 0, 0, 0)
|
||||
}
|
||||
private val promptTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 16f * resources.displayMetrics.scaledDensity
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
}
|
||||
private val loadingTrackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 3f * density
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
color = Color.argb(100, 255, 255, 255)
|
||||
}
|
||||
private val loadingArcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 3f * density
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
color = Color.rgb(7, 193, 96)
|
||||
}
|
||||
private val loadingTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 15f * resources.displayMetrics.scaledDensity
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
layerW = right - left
|
||||
layerH = bottom - top
|
||||
if (changed && rawHits.isNotEmpty()) {
|
||||
rebuildHitMarks()
|
||||
}
|
||||
}
|
||||
|
||||
fun showRecognizing(bitmap: Bitmap) {
|
||||
if (stillFrame !== bitmap) {
|
||||
stillFrame?.recycle()
|
||||
}
|
||||
stillFrame = bitmap
|
||||
rawHits = emptyList()
|
||||
hitMarks = emptyList()
|
||||
visualCrop.setEmpty()
|
||||
stillW = bitmap.width
|
||||
stillH = bitmap.height
|
||||
recognizing = true
|
||||
recognizingStartedAt = SystemClock.elapsedRealtime()
|
||||
visibility = VISIBLE
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun presentStillFrame(bitmap: Bitmap, barcodes: List<Barcode>, visualCropRect: Rect) {
|
||||
if (stillFrame !== bitmap) {
|
||||
stillFrame?.recycle()
|
||||
}
|
||||
stillFrame = bitmap
|
||||
rawHits = barcodes
|
||||
visualCrop = Rect(visualCropRect)
|
||||
stillW = bitmap.width
|
||||
stillH = bitmap.height
|
||||
recognizing = false
|
||||
rebuildHitMarks()
|
||||
visibility = VISIBLE
|
||||
invalidate()
|
||||
}
|
||||
|
||||
private fun rebuildHitMarks() {
|
||||
if (layerW <= 0 || layerH <= 0 || stillW <= 0 || stillH <= 0) {
|
||||
hitMarks = emptyList()
|
||||
return
|
||||
}
|
||||
hitMarks = rawHits.mapNotNull { barcode ->
|
||||
val box = barcode.boundingBox ?: return@mapNotNull null
|
||||
val adjustedBox = Rect(
|
||||
box.left - visualCrop.left,
|
||||
box.top - visualCrop.top,
|
||||
box.right - visualCrop.left,
|
||||
box.bottom - visualCrop.top
|
||||
)
|
||||
val clippedBox = Rect(adjustedBox)
|
||||
if (!clippedBox.intersect(0, 0, stillW, stillH)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val screenRect = mapBoxToScreen(clippedBox, stillW, stillH, layerW, layerH)
|
||||
HitMark(
|
||||
rawValue = barcode.rawValue ?: "",
|
||||
format = barcode.format,
|
||||
formatName = SymbologyUtil.nameOf(barcode.format),
|
||||
screenRect = screenRect
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
stillFrame?.recycle()
|
||||
stillFrame = null
|
||||
rawHits = emptyList()
|
||||
hitMarks = emptyList()
|
||||
visualCrop.setEmpty()
|
||||
stillW = 0
|
||||
stillH = 0
|
||||
recognizing = false
|
||||
recognizingStartedAt = 0L
|
||||
visibility = GONE
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun pickHitAt(x: Float, y: Float): HitMark? {
|
||||
return hitMarks.minByOrNull {
|
||||
val dx = x - it.centerX
|
||||
val dy = y - it.centerY
|
||||
dx * dx + dy * dy
|
||||
}?.takeIf {
|
||||
val dx = x - it.centerX
|
||||
val dy = y - it.centerY
|
||||
dx * dx + dy * dy <= touchR * touchR
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapBoxToScreen(
|
||||
boundingBox: Rect,
|
||||
sourceW: Int, sourceH: Int,
|
||||
viewW: Int, viewH: Int
|
||||
): Rect {
|
||||
val scale = max(viewW.toFloat() / sourceW, viewH.toFloat() / sourceH)
|
||||
val offsetX = (viewW - sourceW * scale) / 2f
|
||||
val offsetY = (viewH - sourceH * scale) / 2f
|
||||
return Rect(
|
||||
(boundingBox.left * scale + offsetX).toInt(),
|
||||
(boundingBox.top * scale + offsetY).toInt(),
|
||||
(boundingBox.right * scale + offsetX).toInt(),
|
||||
(boundingBox.bottom * scale + offsetY).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
stillFrame?.let { bmp ->
|
||||
if (layerW <= 0 || layerH <= 0) return@let
|
||||
val bmpW = bmp.width.toFloat()
|
||||
val bmpH = bmp.height.toFloat()
|
||||
val scale = max(layerW.toFloat() / bmpW, layerH.toFloat() / bmpH)
|
||||
val drawW = (bmpW * scale).toInt()
|
||||
val drawH = (bmpH * scale).toInt()
|
||||
val left = (layerW - drawW) / 2
|
||||
val top = (layerH - drawH) / 2
|
||||
val destRect = Rect(left, top, left + drawW, top + drawH)
|
||||
canvas.drawBitmap(bmp, null, destRect, null)
|
||||
if (!recognizing) {
|
||||
canvas.drawRect(destRect, shadePaint)
|
||||
}
|
||||
}
|
||||
if (recognizing) {
|
||||
drawRecognizing(canvas)
|
||||
postInvalidateDelayed(16)
|
||||
return
|
||||
}
|
||||
if (hitMarks.size >= 2) {
|
||||
val prompt = "已识别到多个码,请选择一个"
|
||||
val horizontalPadding = 18f * density
|
||||
val verticalPadding = 10f * density
|
||||
val textBounds = Rect()
|
||||
promptTextPaint.getTextBounds(prompt, 0, prompt.length, textBounds)
|
||||
val promptWidth = textBounds.width() + horizontalPadding * 2
|
||||
val promptHeight = textBounds.height() + verticalPadding * 2
|
||||
val promptLeft = (layerW - promptWidth) / 2f
|
||||
// 放在底部操作按钮上方,避免遮挡条码和顶部关闭按钮。
|
||||
val promptTop = layerH - promptHeight - 100f * density
|
||||
canvas.drawRoundRect(
|
||||
promptLeft,
|
||||
promptTop,
|
||||
promptLeft + promptWidth,
|
||||
promptTop + promptHeight,
|
||||
promptHeight / 2,
|
||||
promptHeight / 2,
|
||||
promptBackgroundPaint
|
||||
)
|
||||
val baseline = promptTop + verticalPadding - textBounds.top
|
||||
canvas.drawText(prompt, layerW / 2f, baseline, promptTextPaint)
|
||||
}
|
||||
val breatheProgress = (SystemClock.elapsedRealtime() % breatheDurationMs).toFloat() / breatheDurationMs
|
||||
val breatheWave = ((sin(breatheProgress * Math.PI * 2.0) + 1.0) / 2.0).toFloat()
|
||||
for (mark in hitMarks) {
|
||||
haloPaint.alpha = (34 + breatheWave * 70).toInt()
|
||||
canvas.drawCircle(mark.centerX, mark.centerY, touchR + breatheWave * 8f * density, haloPaint)
|
||||
canvas.drawCircle(mark.centerX, mark.centerY, dotOuterR, shellPaint)
|
||||
canvas.drawCircle(mark.centerX, mark.centerY, dotInnerR, corePaint)
|
||||
}
|
||||
if (hitMarks.isNotEmpty()) {
|
||||
postInvalidateDelayed(16)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawRecognizing(canvas: Canvas) {
|
||||
val cx = layerW / 2f
|
||||
val cy = layerH / 2f
|
||||
val radius = 24f * density
|
||||
val bounds = android.graphics.RectF(cx - radius, cy - radius, cx + radius, cy + radius)
|
||||
val elapsed = SystemClock.elapsedRealtime() - recognizingStartedAt
|
||||
val startAngle = (elapsed % 1000L) * 360f / 1000f - 90f
|
||||
canvas.drawCircle(cx, cy, radius, loadingTrackPaint)
|
||||
canvas.drawArc(bounds, startAngle, 110f, false, loadingArcPaint)
|
||||
canvas.drawText("识别中", cx, cy + radius + 30f * density, loadingTextPaint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package uts.sdk.modules.masCodeScan
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.BlurMaskFilter
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Path
|
||||
import android.graphics.Shader
|
||||
import android.view.View
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 扫描框视觉样式。颜色支持 #RRGGBB、#AARRGGBB 和 rgba()。
|
||||
*/
|
||||
data class ScanStyleConfig(
|
||||
val showMask: Boolean = true,
|
||||
val showBeam: Boolean = true,
|
||||
val borderColor: Int = Color.rgb(25, 190, 107),
|
||||
val maskColor: Int = Color.argb(115, 0, 0, 0),
|
||||
val beamColor: Int = Color.rgb(18, 232, 128),
|
||||
val beamDuration: Long = 1800L,
|
||||
val cornerStyle: String = "corner",
|
||||
val cornerRadius: Float = 20f
|
||||
) {
|
||||
companion object {
|
||||
private const val DEFAULT_BORDER_COLOR = "#19be6b"
|
||||
private const val DEFAULT_MASK_COLOR = "rgba(0, 0, 0, 0.45)"
|
||||
private const val DEFAULT_BEAM_COLOR = "#12e880"
|
||||
private const val DEFAULT_DURATION = 1800L
|
||||
|
||||
fun safe(
|
||||
showMask: Boolean,
|
||||
showBeam: Boolean,
|
||||
borderColor: String,
|
||||
maskColor: String,
|
||||
beamColor: String,
|
||||
beamDuration: Int,
|
||||
cornerStyle: String,
|
||||
cornerRadius: Int
|
||||
): ScanStyleConfig {
|
||||
return ScanStyleConfig(
|
||||
showMask = showMask,
|
||||
showBeam = showBeam,
|
||||
borderColor = parseColor(borderColor, DEFAULT_BORDER_COLOR),
|
||||
maskColor = parseColor(maskColor, DEFAULT_MASK_COLOR),
|
||||
beamColor = parseColor(beamColor, DEFAULT_BEAM_COLOR),
|
||||
beamDuration = beamDuration.toLong().coerceIn(100L, 60000L),
|
||||
cornerStyle = when (cornerStyle) {
|
||||
"full" -> "full"
|
||||
"rounded" -> "rounded"
|
||||
"rounded-corner" -> "rounded-corner"
|
||||
else -> "corner"
|
||||
},
|
||||
cornerRadius = cornerRadius.toFloat().coerceIn(0f, 160f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseColor(value: String, fallback: String): Int {
|
||||
val input = value.trim()
|
||||
if (input.startsWith("rgba(") && input.endsWith(")")) {
|
||||
val parts = input.substring(5, input.length - 1).split(',').map { it.trim() }
|
||||
if (parts.size == 4) {
|
||||
val red = parts[0].toIntOrNull()
|
||||
val green = parts[1].toIntOrNull()
|
||||
val blue = parts[2].toIntOrNull()
|
||||
val alpha = parts[3].toFloatOrNull()
|
||||
if (red != null && green != null && blue != null && alpha != null) {
|
||||
return Color.argb(
|
||||
(alpha.coerceIn(0f, 1f) * 255f).toInt(),
|
||||
red.coerceIn(0, 255),
|
||||
green.coerceIn(0, 255),
|
||||
blue.coerceIn(0, 255)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try {
|
||||
Color.parseColor(input)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
Color.parseColor(fallback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取景框光条装饰层。
|
||||
*/
|
||||
class ScanFrameDecorView(
|
||||
context: Context,
|
||||
private val scanRegion: ScanRegionConfig,
|
||||
private val scanStyle: ScanStyleConfig,
|
||||
private val hint: String
|
||||
) : View(context) {
|
||||
private val density = resources.displayMetrics.density
|
||||
private val beamH = 4f * density
|
||||
private val beamInset = 12f * density
|
||||
private val cornerLength = 24f * density
|
||||
private val finderRect = RectF()
|
||||
private val roundedFinderPath = Path()
|
||||
private var beamProgress = 0f
|
||||
private var beamAnimator: ValueAnimator? = null
|
||||
|
||||
private val beamPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val regionMaskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = scanStyle.maskColor
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val regionBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = scanStyle.borderColor
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2f * density
|
||||
strokeCap = Paint.Cap.SQUARE
|
||||
}
|
||||
private val beamGlowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.argb(
|
||||
(Color.alpha(scanStyle.beamColor) * 0.2f).toInt(),
|
||||
Color.red(scanStyle.beamColor),
|
||||
Color.green(scanStyle.beamColor),
|
||||
Color.blue(scanStyle.beamColor)
|
||||
)
|
||||
style = Paint.Style.FILL
|
||||
maskFilter = BlurMaskFilter(18f * density, BlurMaskFilter.Blur.NORMAL)
|
||||
}
|
||||
private val hintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 14f * resources.displayMetrics.scaledDensity
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
init {
|
||||
setLayerType(LAYER_TYPE_SOFTWARE, null)
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
runBeamLoop()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
haltBeamLoop()
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
layoutFinder(w, h)
|
||||
updateFinderPath()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
if (width <= 0 || height <= 0) return
|
||||
if (scanRegion.enabled) {
|
||||
if (scanStyle.showMask) {
|
||||
paintRegionMask(canvas)
|
||||
}
|
||||
paintRegionBorder(canvas)
|
||||
}
|
||||
if (scanStyle.showBeam) {
|
||||
paintBeam(canvas)
|
||||
}
|
||||
if (hint.isNotBlank()) {
|
||||
val hintBaseline = min(height - 24f * density, finderRect.bottom + 30f * density)
|
||||
canvas.drawText(hint, finderRect.centerX(), hintBaseline, hintPaint)
|
||||
}
|
||||
}
|
||||
|
||||
fun haltBeamLoop() {
|
||||
beamAnimator?.cancel()
|
||||
beamAnimator = null
|
||||
}
|
||||
|
||||
private fun runBeamLoop() {
|
||||
if (beamAnimator != null) return
|
||||
beamAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = scanStyle.beamDuration
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
repeatMode = ValueAnimator.RESTART
|
||||
addUpdateListener {
|
||||
beamProgress = it.animatedValue as Float
|
||||
invalidate()
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun layoutFinder(w: Int, h: Int) {
|
||||
if (scanRegion.enabled) {
|
||||
finderRect.set(scanRegion.resolveViewRect(w, h))
|
||||
return
|
||||
}
|
||||
// 未配置 scanRegion 时保持旧版默认扫描框,避免影响现有调用方。
|
||||
val horizontalMargin = 36f * density
|
||||
val maxFrameWidth = min(w * 0.74f, 320f * density)
|
||||
val frameWidth = min(maxFrameWidth, w - horizontalMargin * 2f)
|
||||
val frameHeight = frameWidth
|
||||
val left = (w - frameWidth) / 2f
|
||||
val top = (h - frameHeight) / 2f - 44f * density
|
||||
finderRect.set(left, max(24f * density, top), left + frameWidth, max(24f * density, top) + frameHeight)
|
||||
}
|
||||
|
||||
private fun updateFinderPath() {
|
||||
val maxRadius = min(finderRect.width(), finderRect.height()) / 2f
|
||||
val radius = min(scanStyle.cornerRadius * density, maxRadius)
|
||||
roundedFinderPath.reset()
|
||||
roundedFinderPath.addRoundRect(finderRect, radius, radius, Path.Direction.CW)
|
||||
roundedFinderPath.close()
|
||||
}
|
||||
|
||||
private fun paintRegionMask(canvas: Canvas) {
|
||||
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
|
||||
canvas.drawColor(scanStyle.maskColor)
|
||||
canvas.drawPath(roundedFinderPath, Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.CLEAR)
|
||||
})
|
||||
return
|
||||
}
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), finderRect.top, regionMaskPaint)
|
||||
canvas.drawRect(0f, finderRect.bottom, width.toFloat(), height.toFloat(), regionMaskPaint)
|
||||
canvas.drawRect(0f, finderRect.top, finderRect.left, finderRect.bottom, regionMaskPaint)
|
||||
canvas.drawRect(finderRect.right, finderRect.top, width.toFloat(), finderRect.bottom, regionMaskPaint)
|
||||
}
|
||||
|
||||
private fun paintRegionBorder(canvas: Canvas) {
|
||||
if (scanStyle.cornerStyle == "full") {
|
||||
canvas.drawRect(finderRect, regionBorderPaint)
|
||||
return
|
||||
}
|
||||
if (scanStyle.cornerStyle == "rounded") {
|
||||
canvas.drawPath(roundedFinderPath, regionBorderPaint)
|
||||
return
|
||||
}
|
||||
if (scanStyle.cornerStyle == "rounded-corner") {
|
||||
paintRoundedCorners(canvas)
|
||||
return
|
||||
}
|
||||
val left = finderRect.left
|
||||
val top = finderRect.top
|
||||
val right = finderRect.right
|
||||
val bottom = finderRect.bottom
|
||||
canvas.drawLine(left, top, left + cornerLength, top, regionBorderPaint)
|
||||
canvas.drawLine(left, top, left, top + cornerLength, regionBorderPaint)
|
||||
canvas.drawLine(right - cornerLength, top, right, top, regionBorderPaint)
|
||||
canvas.drawLine(right, top, right, top + cornerLength, regionBorderPaint)
|
||||
canvas.drawLine(left, bottom - cornerLength, left, bottom, regionBorderPaint)
|
||||
canvas.drawLine(left, bottom, left + cornerLength, bottom, regionBorderPaint)
|
||||
canvas.drawLine(right - cornerLength, bottom, right, bottom, regionBorderPaint)
|
||||
canvas.drawLine(right, bottom - cornerLength, right, bottom, regionBorderPaint)
|
||||
}
|
||||
|
||||
private fun paintRoundedCorners(canvas: Canvas) {
|
||||
val radius = min(scanStyle.cornerRadius * density, min(finderRect.width(), finderRect.height()) / 2f)
|
||||
val left = finderRect.left
|
||||
val top = finderRect.top
|
||||
val right = finderRect.right
|
||||
val bottom = finderRect.bottom
|
||||
val segmentLength = min(this.cornerLength, radius + this.cornerLength)
|
||||
val cornerOnlyPath = Path()
|
||||
val topLeft = RectF(left, top, left + radius * 2f, top + radius * 2f)
|
||||
val topRight = RectF(right - radius * 2f, top, right, top + radius * 2f)
|
||||
val bottomLeft = RectF(left, bottom - radius * 2f, left + radius * 2f, bottom)
|
||||
val bottomRight = RectF(right - radius * 2f, bottom - radius * 2f, right, bottom)
|
||||
|
||||
cornerOnlyPath.moveTo(left + segmentLength, top)
|
||||
cornerOnlyPath.lineTo(left + radius, top)
|
||||
cornerOnlyPath.arcTo(topLeft, 270f, -90f)
|
||||
cornerOnlyPath.lineTo(left, top + segmentLength)
|
||||
|
||||
cornerOnlyPath.moveTo(right - segmentLength, top)
|
||||
cornerOnlyPath.lineTo(right - radius, top)
|
||||
cornerOnlyPath.arcTo(topRight, 270f, 90f)
|
||||
cornerOnlyPath.lineTo(right, top + segmentLength)
|
||||
|
||||
cornerOnlyPath.moveTo(left, bottom - segmentLength)
|
||||
cornerOnlyPath.lineTo(left, bottom - radius)
|
||||
cornerOnlyPath.arcTo(bottomLeft, 180f, -90f)
|
||||
cornerOnlyPath.lineTo(left + segmentLength, bottom)
|
||||
|
||||
cornerOnlyPath.moveTo(right, bottom - segmentLength)
|
||||
cornerOnlyPath.lineTo(right, bottom - radius)
|
||||
cornerOnlyPath.arcTo(bottomRight, 0f, 90f)
|
||||
cornerOnlyPath.lineTo(right - segmentLength, bottom)
|
||||
canvas.drawPath(cornerOnlyPath, regionBorderPaint)
|
||||
}
|
||||
|
||||
private fun paintBeam(canvas: Canvas) {
|
||||
val availableHeight = finderRect.height() - beamInset * 2f
|
||||
if (availableHeight <= 0f) return
|
||||
|
||||
val y = finderRect.top + beamInset + availableHeight * beamProgress
|
||||
val lineTop = y - beamH / 2f
|
||||
val lineBottom = y + beamH / 2f
|
||||
val lineLeft = finderRect.left + beamInset
|
||||
val lineRight = finderRect.right - beamInset
|
||||
|
||||
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
|
||||
canvas.save()
|
||||
canvas.clipPath(roundedFinderPath)
|
||||
}
|
||||
|
||||
beamPaint.shader = LinearGradient(
|
||||
lineLeft, y, lineRight, y,
|
||||
intArrayOf(
|
||||
scanStyle.beamColor and 0x00ffffff,
|
||||
scanStyle.beamColor,
|
||||
scanStyle.beamColor and 0x00ffffff
|
||||
),
|
||||
floatArrayOf(0f, 0.5f, 1f),
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
canvas.drawRect(
|
||||
lineLeft, lineTop - 10f * density, lineRight, lineBottom + 10f * density, beamGlowPaint
|
||||
)
|
||||
canvas.drawRect(lineLeft, lineTop, lineRight, lineBottom, beamPaint)
|
||||
|
||||
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package uts.sdk.modules.masCodeScan
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 相对于实时预览层的有效扫码区域配置。
|
||||
* 由 Activity 统一校验后同时用于扫描框绘制和条码结果过滤。
|
||||
*/
|
||||
data class ScanRegionConfig(
|
||||
val enabled: Boolean,
|
||||
val widthRatio: Float = DEFAULT_SIZE_RATIO,
|
||||
val heightRatio: Float = DEFAULT_SIZE_RATIO,
|
||||
val centerXRatio: Float = DEFAULT_CENTER_RATIO,
|
||||
val centerYRatio: Float = DEFAULT_CENTER_RATIO
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_SIZE_RATIO = 0.74f
|
||||
const val DEFAULT_CENTER_RATIO = 0.5f
|
||||
private const val MIN_SIZE_RATIO = 0.1f
|
||||
private const val MAX_SIZE_RATIO = 1f
|
||||
|
||||
fun safe(
|
||||
enabled: Boolean,
|
||||
widthRatio: Float,
|
||||
heightRatio: Float,
|
||||
centerXRatio: Float,
|
||||
centerYRatio: Float
|
||||
): ScanRegionConfig {
|
||||
val safeWidth = widthRatio.sanitize(DEFAULT_SIZE_RATIO).coerceIn(MIN_SIZE_RATIO, MAX_SIZE_RATIO)
|
||||
val safeHeight = heightRatio.sanitize(DEFAULT_SIZE_RATIO).coerceIn(MIN_SIZE_RATIO, MAX_SIZE_RATIO)
|
||||
return ScanRegionConfig(
|
||||
enabled = enabled,
|
||||
widthRatio = safeWidth,
|
||||
heightRatio = safeHeight,
|
||||
centerXRatio = centerXRatio.sanitize(DEFAULT_CENTER_RATIO).coerceIn(0f, 1f),
|
||||
centerYRatio = centerYRatio.sanitize(DEFAULT_CENTER_RATIO).coerceIn(0f, 1f)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Float.sanitize(defaultValue: Float): Float {
|
||||
return if (isFinite()) this else defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回扫描框在预览 View 坐标系中的实际位置,越界部分会收敛到 View 内。 */
|
||||
fun resolveViewRect(viewWidth: Int, viewHeight: Int): RectF {
|
||||
if (viewWidth <= 0 || viewHeight <= 0) return RectF()
|
||||
// width / height 都以预览宽度为基准,并使用同一缩放因子收敛,确保始终保持配置比例。
|
||||
val desiredWidth = viewWidth * widthRatio
|
||||
val desiredHeight = viewWidth * heightRatio
|
||||
val sizeScale = min(1f, min(viewWidth / desiredWidth, viewHeight / desiredHeight))
|
||||
val frameWidth = desiredWidth * sizeScale
|
||||
val frameHeight = desiredHeight * sizeScale
|
||||
val wantedLeft = viewWidth * centerXRatio - frameWidth / 2f
|
||||
val wantedTop = viewHeight * centerYRatio - frameHeight / 2f
|
||||
val left = wantedLeft.coerceIn(0f, max(0f, viewWidth - frameWidth))
|
||||
val top = wantedTop.coerceIn(0f, max(0f, viewHeight - frameHeight))
|
||||
return RectF(left, top, left + frameWidth, top + frameHeight)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 PreviewView 的扫描框映射到 ML Kit 返回 boundingBox 所在的旋转后图像坐标。
|
||||
* PreviewView 使用 FILL_CENTER,因此必须补偿等比填充产生的裁剪偏移。
|
||||
*/
|
||||
object ScanRegionMapper {
|
||||
fun mapViewRectToImage(
|
||||
viewRect: RectF,
|
||||
viewWidth: Int,
|
||||
viewHeight: Int,
|
||||
imageWidth: Int,
|
||||
imageHeight: Int,
|
||||
cropRect: Rect,
|
||||
rotationDegrees: Int
|
||||
): Rect? {
|
||||
if (viewRect.isEmpty || viewWidth <= 0 || viewHeight <= 0 || imageWidth <= 0 || imageHeight <= 0) {
|
||||
return null
|
||||
}
|
||||
val visualCrop = YuvFrames.rectToVisualSpace(cropRect, imageWidth, imageHeight, rotationDegrees)
|
||||
val cropWidth = visualCrop.width()
|
||||
val cropHeight = visualCrop.height()
|
||||
if (cropWidth <= 0 || cropHeight <= 0) return null
|
||||
|
||||
val scale = max(viewWidth.toFloat() / cropWidth, viewHeight.toFloat() / cropHeight)
|
||||
if (!scale.isFinite() || scale <= 0f) return null
|
||||
val offsetX = (viewWidth - cropWidth * scale) / 2f
|
||||
val offsetY = (viewHeight - cropHeight * scale) / 2f
|
||||
val mapped = Rect(
|
||||
((viewRect.left - offsetX) / scale).toInt() + visualCrop.left,
|
||||
((viewRect.top - offsetY) / scale).toInt() + visualCrop.top,
|
||||
((viewRect.right - offsetX) / scale).toInt() + visualCrop.left,
|
||||
((viewRect.bottom - offsetY) / scale).toInt() + visualCrop.top
|
||||
)
|
||||
return mapped.apply {
|
||||
left = left.coerceIn(visualCrop.left, visualCrop.right)
|
||||
top = top.coerceIn(visualCrop.top, visualCrop.bottom)
|
||||
right = right.coerceIn(left, visualCrop.right)
|
||||
bottom = bottom.coerceIn(top, visualCrop.bottom)
|
||||
}.takeIf { it.width() > 0 && it.height() > 0 }
|
||||
}
|
||||
|
||||
fun containsBarcodeCenter(region: Rect, barcodeBox: Rect): Boolean {
|
||||
return region.contains(barcodeBox.centerX(), barcodeBox.centerY())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package uts.sdk.modules.masCodeScan
|
||||
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
|
||||
object SymbologyUtil {
|
||||
fun nameOf(format: Int): String = when (format) {
|
||||
Barcode.FORMAT_QR_CODE -> "QR_CODE"
|
||||
Barcode.FORMAT_EAN_13 -> "EAN_13"
|
||||
Barcode.FORMAT_EAN_8 -> "EAN_8"
|
||||
Barcode.FORMAT_CODE_128 -> "CODE_128"
|
||||
Barcode.FORMAT_CODE_39 -> "CODE_39"
|
||||
Barcode.FORMAT_CODE_93 -> "CODE_93"
|
||||
Barcode.FORMAT_CODABAR -> "CODABAR"
|
||||
Barcode.FORMAT_DATA_MATRIX -> "DATA_MATRIX"
|
||||
Barcode.FORMAT_UPC_A -> "UPC_A"
|
||||
Barcode.FORMAT_UPC_E -> "UPC_E"
|
||||
Barcode.FORMAT_ITF -> "ITF"
|
||||
Barcode.FORMAT_PDF417 -> "PDF417"
|
||||
Barcode.FORMAT_AZTEC -> "AZTEC"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
|
||||
fun hasSupportedFormat(csv: String?): Boolean {
|
||||
if (csv.isNullOrBlank()) return true
|
||||
return csv.split(',').any { tokenToFormat(it.trim().uppercase()) != null }
|
||||
}
|
||||
|
||||
fun parseFormatList(csv: String?): IntArray {
|
||||
if (csv.isNullOrBlank()) {
|
||||
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
|
||||
}
|
||||
val mapped = csv.split(',')
|
||||
.map { it.trim().uppercase() }
|
||||
.mapNotNull { tokenToFormat(it) }
|
||||
.distinct()
|
||||
if (mapped.isEmpty()) {
|
||||
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
|
||||
}
|
||||
// ALL 与具体码制混用时以全格式为准,避免 ML Kit 参数异常
|
||||
if (mapped.contains(Barcode.FORMAT_ALL_FORMATS)) {
|
||||
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
|
||||
}
|
||||
return mapped.toIntArray()
|
||||
}
|
||||
|
||||
private fun tokenToFormat(token: String): Int? = when (token) {
|
||||
"QR_CODE", "QR" -> Barcode.FORMAT_QR_CODE
|
||||
"EAN_13" -> Barcode.FORMAT_EAN_13
|
||||
"EAN_8" -> Barcode.FORMAT_EAN_8
|
||||
"CODE_128" -> Barcode.FORMAT_CODE_128
|
||||
"CODE_39" -> Barcode.FORMAT_CODE_39
|
||||
"CODE_93" -> Barcode.FORMAT_CODE_93
|
||||
"CODABAR" -> Barcode.FORMAT_CODABAR
|
||||
"DATA_MATRIX" -> Barcode.FORMAT_DATA_MATRIX
|
||||
"UPC_A" -> Barcode.FORMAT_UPC_A
|
||||
"UPC_E" -> Barcode.FORMAT_UPC_E
|
||||
"ITF" -> Barcode.FORMAT_ITF
|
||||
"PDF417" -> Barcode.FORMAT_PDF417
|
||||
"AZTEC" -> Barcode.FORMAT_AZTEC
|
||||
"ALL", "FORMAT_ALL_FORMATS" -> Barcode.FORMAT_ALL_FORMATS
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isDecodedText(barcode: Barcode): Boolean {
|
||||
return !barcode.rawValue.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package uts.sdk.modules.masCodeScan
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageFormat
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import android.graphics.YuvImage
|
||||
import android.media.Image
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
object YuvFrames {
|
||||
fun imageToBitmap(
|
||||
image: Image,
|
||||
width: Int,
|
||||
height: Int,
|
||||
cropRect: Rect,
|
||||
rotationDegrees: Int
|
||||
): Bitmap? {
|
||||
return try {
|
||||
val planes = image.planes
|
||||
val yPlane = planes[0]
|
||||
val uPlane = planes[1]
|
||||
val vPlane = planes[2]
|
||||
|
||||
val yRowStride = yPlane.rowStride
|
||||
val yPixelStride = yPlane.pixelStride
|
||||
val uRowStride = uPlane.rowStride
|
||||
val uPixelStride = uPlane.pixelStride
|
||||
val vRowStride = vPlane.rowStride
|
||||
val vPixelStride = vPlane.pixelStride
|
||||
|
||||
val nv21Size = width * height * 3 / 2
|
||||
val nv21 = ByteArray(nv21Size)
|
||||
|
||||
var destOffset = 0
|
||||
if (yPixelStride == 1) {
|
||||
for (row in 0 until height) {
|
||||
yPlane.buffer.position(row * yRowStride)
|
||||
yPlane.buffer.get(nv21, destOffset, width)
|
||||
destOffset += width
|
||||
}
|
||||
} else {
|
||||
val yBuffer = yPlane.buffer
|
||||
for (row in 0 until height) {
|
||||
for (col in 0 until width) {
|
||||
nv21[destOffset++] = yBuffer.get(row * yRowStride + col * yPixelStride)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var uvOffset = width * height
|
||||
if (uPixelStride == 1 && vPixelStride == 1) {
|
||||
for (row in 0 until height / 2) {
|
||||
vPlane.buffer.position(row * vRowStride)
|
||||
vPlane.buffer.get(nv21, uvOffset + row * width, width / 2)
|
||||
}
|
||||
for (row in 0 until height / 2) {
|
||||
uPlane.buffer.position(row * uRowStride)
|
||||
uPlane.buffer.get(nv21, uvOffset + width / 2 + row * width, width / 2)
|
||||
}
|
||||
} else {
|
||||
val uBuffer = uPlane.buffer
|
||||
val vBuffer = vPlane.buffer
|
||||
for (row in 0 until height / 2) {
|
||||
for (col in 0 until width / 2) {
|
||||
val uvIndex = row * uRowStride + col * uPixelStride
|
||||
nv21[uvOffset++] = vBuffer.get(uvIndex)
|
||||
nv21[uvOffset++] = uBuffer.get(uvIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val safeCropRect = Rect(cropRect).apply {
|
||||
left = left.coerceIn(0, width)
|
||||
top = top.coerceIn(0, height)
|
||||
right = right.coerceIn(left, width)
|
||||
bottom = bottom.coerceIn(top, height)
|
||||
}
|
||||
val jpegRect = if (safeCropRect.width() > 0 && safeCropRect.height() > 0) {
|
||||
safeCropRect
|
||||
} else {
|
||||
Rect(0, 0, width, height)
|
||||
}
|
||||
|
||||
val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
|
||||
val baos = ByteArrayOutputStream()
|
||||
yuvImage.compressToJpeg(jpegRect, 85, baos)
|
||||
val jpegData = baos.toByteArray()
|
||||
baos.close()
|
||||
|
||||
var bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size)
|
||||
if (bitmap != null && rotationDegrees != 0) {
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(rotationDegrees.toFloat())
|
||||
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
if (rotated != bitmap) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
bitmap = rotated
|
||||
}
|
||||
bitmap
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun rectToVisualSpace(
|
||||
rect: Rect,
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
rotationDegrees: Int
|
||||
): Rect {
|
||||
return when (rotationDegrees) {
|
||||
90 -> Rect(
|
||||
sourceHeight - rect.bottom,
|
||||
rect.left,
|
||||
sourceHeight - rect.top,
|
||||
rect.right
|
||||
)
|
||||
180 -> Rect(
|
||||
sourceWidth - rect.right,
|
||||
sourceHeight - rect.bottom,
|
||||
sourceWidth - rect.left,
|
||||
sourceHeight - rect.top
|
||||
)
|
||||
270 -> Rect(
|
||||
rect.top,
|
||||
sourceWidth - rect.right,
|
||||
rect.bottom,
|
||||
sourceWidth - rect.left
|
||||
)
|
||||
else -> Rect(rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"dependencies": [
|
||||
"androidx.activity:activity:1.8.0",
|
||||
"androidx.camera:camera-core:1.4.1",
|
||||
"androidx.camera:camera-camera2:1.4.1",
|
||||
"androidx.camera:camera-lifecycle:1.4.1",
|
||||
"androidx.camera:camera-view:1.4.1",
|
||||
"androidx.appcompat:appcompat:1.7.0",
|
||||
"com.google.mlkit:barcode-scanning:17.3.0"
|
||||
],
|
||||
"minSdkVersion": 21
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import Activity from 'android.app.Activity'
|
||||
import Intent from 'android.content.Intent'
|
||||
|
||||
import { OpenCodeScan, CodeScanErrorCode, CodeScanOptions, CodeScanSuccess } from '../interface.uts'
|
||||
import { CodeScanFailImpl } from '../unierror.uts'
|
||||
|
||||
const LAUNCH_REQ: Int = 53011
|
||||
const EXTRA_PAYLOAD = 'mas_content'
|
||||
const EXTRA_FORMAT = 'mas_format'
|
||||
const EXTRA_TITLE = 'mas_title'
|
||||
const EXTRA_HINT = 'mas_hint'
|
||||
const EXTRA_SUCCESS_HINT = 'mas_success_hint'
|
||||
const EXTRA_EMPTY_HINT = 'mas_empty_hint'
|
||||
const EXTRA_SMART_ZOOM = 'mas_smart_zoom'
|
||||
const EXTRA_TORCH_MODE = 'mas_torch_mode'
|
||||
const EXTRA_RESULT_MODE = 'mas_result_mode'
|
||||
const EXTRA_SHOW_PAUSE_BUTTON = 'mas_show_pause_button'
|
||||
const EXTRA_VIBRATE = 'mas_vibrate'
|
||||
const EXTRA_CONTINUOUS = 'mas_continuous'
|
||||
const EXTRA_FROM_ALBUM = 'mas_from_album'
|
||||
const EXTRA_FORMATS = 'mas_formats'
|
||||
const EXTRA_SCAN_REGION_ENABLED = 'mas_scan_region_enabled'
|
||||
const EXTRA_SCAN_REGION = 'mas_scan_region'
|
||||
const EXTRA_SCAN_STYLE_SHOW_MASK = 'mas_scan_style_show_mask'
|
||||
const EXTRA_SCAN_STYLE_SHOW_BEAM = 'mas_scan_style_show_beam'
|
||||
const EXTRA_SCAN_STYLE_BORDER_COLOR = 'mas_scan_style_border_color'
|
||||
const EXTRA_SCAN_STYLE_MASK_COLOR = 'mas_scan_style_mask_color'
|
||||
const EXTRA_SCAN_STYLE_BEAM_COLOR = 'mas_scan_style_beam_color'
|
||||
const EXTRA_SCAN_STYLE_BEAM_DURATION = 'mas_scan_style_beam_duration'
|
||||
const EXTRA_SCAN_STYLE_CORNER = 'mas_scan_style_corner'
|
||||
const EXTRA_SCAN_STYLE_CORNER_RADIUS = 'mas_scan_style_corner_radius'
|
||||
const EXTRA_SCAN_TIMEOUT = 'mas_scan_timeout'
|
||||
const EXTRA_CONTINUOUS_SUCCESS_DURATION = 'mas_continuous_success_duration'
|
||||
const EXTRA_ERR = 'mas_err'
|
||||
const EXTRA_FROM_ALBUM_RESULT = 'mas_result_from_album'
|
||||
const EXTRA_HAD_HITS = 'mas_had_hits'
|
||||
const CAPTURE_ACTIVITY = 'uts.sdk.modules.masCodeScan.CodeCaptureActivity'
|
||||
|
||||
let activityResultHook: ((requestCode: Int, resultCode: Int, data?: Intent) => void) | null = null
|
||||
let activeOptions: CodeScanOptions | null = null
|
||||
let continuousHitCount: Int = 0
|
||||
let pollTimerId: number = -1
|
||||
let sessionContinuous: boolean = false
|
||||
let continuousSuccess: ((res: CodeScanSuccess) => void) | null = null
|
||||
|
||||
@UTSJS.keepAlive
|
||||
export function openCodeScan(options?: CodeScanOptions | null): void {
|
||||
const opts = options != null ? options : null
|
||||
const host = UTSAndroid.getUniActivity()
|
||||
if (host == null) {
|
||||
emitFailure(opts, 9021001)
|
||||
return
|
||||
}
|
||||
launchCapture(host, opts)
|
||||
}
|
||||
|
||||
function clearPollTimer() {
|
||||
if (pollTimerId >= 0) {
|
||||
clearInterval(pollTimerId)
|
||||
pollTimerId = -1
|
||||
}
|
||||
}
|
||||
|
||||
function drainHits() {
|
||||
const current = activeOptions
|
||||
const success = continuousSuccess
|
||||
if (current == null || success == null) return
|
||||
while (true) {
|
||||
const hit = CodeScanHitBus.pollHit()
|
||||
if (hit == null) break
|
||||
const content = hit[0]
|
||||
const format = hit[1]
|
||||
const fromAlbumFlag = hit[2]
|
||||
if (content == null || format == null || fromAlbumFlag == null) continue
|
||||
continuousHitCount++
|
||||
const ok: CodeScanSuccess = {
|
||||
content: content,
|
||||
format: format,
|
||||
fromAlbum: fromAlbumFlag == '1'
|
||||
}
|
||||
success(ok)
|
||||
}
|
||||
}
|
||||
|
||||
function launchCapture(host: Activity, opts: CodeScanOptions | null): void {
|
||||
if (activityResultHook != null) {
|
||||
UTSAndroid.offAppActivityResult(activityResultHook)
|
||||
activityResultHook = null
|
||||
}
|
||||
clearPollTimer()
|
||||
CodeScanHitBus.detach()
|
||||
activeOptions = opts
|
||||
continuousHitCount = 0
|
||||
sessionContinuous = opts != null && opts.continuous == true
|
||||
continuousSuccess = sessionContinuous && opts != null ? opts.success : null
|
||||
if (sessionContinuous) {
|
||||
pollTimerId = setInterval(() => {
|
||||
drainHits()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const onResult = (requestCode: Int, resultCode: Int, data?: Intent) => {
|
||||
if (requestCode != LAUNCH_REQ) {
|
||||
return
|
||||
}
|
||||
|
||||
UTSAndroid.offAppActivityResult()
|
||||
activityResultHook = null
|
||||
drainHits()
|
||||
clearPollTimer()
|
||||
CodeScanHitBus.detach()
|
||||
|
||||
const current = activeOptions
|
||||
const wasContinuous = sessionContinuous
|
||||
sessionContinuous = false
|
||||
|
||||
if (resultCode != Activity.RESULT_OK) {
|
||||
let errCode: CodeScanErrorCode = 9021003
|
||||
if (data != null) {
|
||||
const nativeErr = data.getIntExtra(EXTRA_ERR, 0)
|
||||
if (nativeErr >= 9021002 && nativeErr <= 9021011) {
|
||||
errCode = nativeErr as CodeScanErrorCode
|
||||
}
|
||||
}
|
||||
// 连续扫已有命中:关闭只 complete,不 fail
|
||||
if (wasContinuous && continuousHitCount > 0) {
|
||||
if (current != null) {
|
||||
current.complete?.({ closed: true, hitCount: continuousHitCount })
|
||||
}
|
||||
activeOptions = null
|
||||
continuousSuccess = null
|
||||
return
|
||||
}
|
||||
if (wasContinuous && data != null && data.getBooleanExtra(EXTRA_HAD_HITS, false)) {
|
||||
if (current != null) {
|
||||
current.complete?.({ closed: true, hitCount: continuousHitCount })
|
||||
}
|
||||
activeOptions = null
|
||||
continuousSuccess = null
|
||||
return
|
||||
}
|
||||
if (wasContinuous && current != null) {
|
||||
emitFailure(current, errCode)
|
||||
const complete = current.complete
|
||||
if (complete != null) {
|
||||
complete({ closed: true, hitCount: continuousHitCount, failed: true, errCode: errCode })
|
||||
}
|
||||
activeOptions = null
|
||||
continuousSuccess = null
|
||||
return
|
||||
}
|
||||
emitFailure(current, errCode)
|
||||
activeOptions = null
|
||||
continuousSuccess = null
|
||||
return
|
||||
}
|
||||
|
||||
if (wasContinuous) {
|
||||
if (current != null) {
|
||||
current.complete?.({ closed: true, hitCount: continuousHitCount })
|
||||
}
|
||||
activeOptions = null
|
||||
continuousSuccess = null
|
||||
return
|
||||
}
|
||||
|
||||
let payload = ''
|
||||
let formatName = ''
|
||||
let fromAlbum = false
|
||||
if (data != null) {
|
||||
const rawPayload = data.getStringExtra(EXTRA_PAYLOAD)
|
||||
const rawFormat = data.getStringExtra(EXTRA_FORMAT)
|
||||
payload = rawPayload != null ? rawPayload : ''
|
||||
formatName = rawFormat != null ? rawFormat : ''
|
||||
fromAlbum = data.getBooleanExtra(EXTRA_FROM_ALBUM_RESULT, false)
|
||||
}
|
||||
|
||||
if (payload == '') {
|
||||
emitFailure(current, 9021004)
|
||||
activeOptions = null
|
||||
continuousSuccess = null
|
||||
return
|
||||
}
|
||||
|
||||
const ok: CodeScanSuccess = {
|
||||
content: payload,
|
||||
format: formatName,
|
||||
fromAlbum: fromAlbum
|
||||
}
|
||||
if (current != null) {
|
||||
current.success?.(ok)
|
||||
current.complete?.(ok)
|
||||
}
|
||||
activeOptions = null
|
||||
continuousSuccess = null
|
||||
}
|
||||
|
||||
activityResultHook = onResult
|
||||
UTSAndroid.onAppActivityResult(onResult)
|
||||
|
||||
let pageTitle = '扫一扫'
|
||||
let scanHint = '请将条码放入扫描框内'
|
||||
let successHint = '扫码成功'
|
||||
let emptyHint = '未识别到有效条码'
|
||||
let useSmartZoom = true
|
||||
let useTorchMode = 'off'
|
||||
let useResultMode = 'immediate'
|
||||
let useShowPauseButton = false
|
||||
let useVibrate = true
|
||||
let useContinuous = false
|
||||
let useFromAlbum = false
|
||||
let useScanRegion = false
|
||||
let regionWidth = 0.74
|
||||
let regionHeight = 0.74
|
||||
let regionCenterX = 0.5
|
||||
let regionCenterY = 0.5
|
||||
let formatsCsv = ''
|
||||
let styleShowMask = true
|
||||
let styleShowBeam = true
|
||||
let styleBorderColor = '#19be6b'
|
||||
let styleMaskColor = 'rgba(0, 0, 0, 0.45)'
|
||||
let styleBeamColor = '#12e880'
|
||||
let styleBeamDuration = 1800
|
||||
let styleCorner = 'corner'
|
||||
let styleCornerRadius = 20
|
||||
let scanTimeout = 0
|
||||
let continuousSuccessDuration = 1000
|
||||
if (opts != null) {
|
||||
if (opts.title != null && opts.title!.trim().length > 0) {
|
||||
pageTitle = opts.title!.trim()
|
||||
}
|
||||
if (opts.hint != null && opts.hint!.trim().length > 0) {
|
||||
scanHint = opts.hint!.trim()
|
||||
}
|
||||
if (opts.successHint != null && opts.successHint!.trim().length > 0) {
|
||||
successHint = opts.successHint!.trim()
|
||||
}
|
||||
if (opts.emptyHint != null && opts.emptyHint!.trim().length > 0) {
|
||||
emptyHint = opts.emptyHint!.trim()
|
||||
}
|
||||
if (opts.smartZoom != null) {
|
||||
useSmartZoom = opts.smartZoom!
|
||||
}
|
||||
if (opts.torch == 'on' || opts.torch == 'auto' || opts.torch == 'off') {
|
||||
useTorchMode = opts.torch!
|
||||
}
|
||||
if (opts.resultMode == 'confirm' || opts.resultMode == 'pause' || opts.resultMode == 'immediate') {
|
||||
useResultMode = opts.resultMode!
|
||||
}
|
||||
if (opts.showPauseButton != null) {
|
||||
useShowPauseButton = opts.showPauseButton!
|
||||
}
|
||||
if (opts.vibrate != null) {
|
||||
useVibrate = opts.vibrate!
|
||||
}
|
||||
if (opts.continuous != null) {
|
||||
useContinuous = opts.continuous!
|
||||
}
|
||||
if (opts.fromAlbum != null) {
|
||||
useFromAlbum = opts.fromAlbum!
|
||||
}
|
||||
if (opts.scanRegion != null) {
|
||||
useScanRegion = opts.scanRegion!.fullScreen != true
|
||||
if (opts.scanRegion!.width != null) {
|
||||
regionWidth = opts.scanRegion!.width!
|
||||
}
|
||||
if (opts.scanRegion!.height != null) {
|
||||
regionHeight = opts.scanRegion!.height!
|
||||
}
|
||||
if (opts.scanRegion!.centerX != null) {
|
||||
regionCenterX = opts.scanRegion!.centerX!
|
||||
}
|
||||
if (opts.scanRegion!.centerY != null) {
|
||||
regionCenterY = opts.scanRegion!.centerY!
|
||||
}
|
||||
}
|
||||
if (opts.scanTimeout != null) {
|
||||
scanTimeout = Math.floor(opts.scanTimeout!)
|
||||
}
|
||||
if (opts.continuousSuccessDuration != null) {
|
||||
continuousSuccessDuration = Math.max(0, Math.min(600000, Math.floor(opts.continuousSuccessDuration!)))
|
||||
}
|
||||
if (opts.scanStyle != null) {
|
||||
if (opts.scanStyle!.showMask != null) {
|
||||
styleShowMask = opts.scanStyle!.showMask!
|
||||
}
|
||||
if (opts.scanStyle!.showBeam != null) {
|
||||
styleShowBeam = opts.scanStyle!.showBeam!
|
||||
}
|
||||
if (opts.scanStyle!.borderColor != null) {
|
||||
styleBorderColor = opts.scanStyle!.borderColor!
|
||||
}
|
||||
if (opts.scanStyle!.maskColor != null) {
|
||||
styleMaskColor = opts.scanStyle!.maskColor!
|
||||
}
|
||||
if (opts.scanStyle!.beamColor != null) {
|
||||
styleBeamColor = opts.scanStyle!.beamColor!
|
||||
}
|
||||
if (opts.scanStyle!.beamDuration != null) {
|
||||
styleBeamDuration = opts.scanStyle!.beamDuration!
|
||||
}
|
||||
if (opts.scanStyle!.cornerStyle == 'full' || opts.scanStyle!.cornerStyle == 'corner' || opts.scanStyle!.cornerStyle == 'rounded' || opts.scanStyle!.cornerStyle == 'rounded-corner') {
|
||||
styleCorner = opts.scanStyle!.cornerStyle!
|
||||
}
|
||||
if (opts.scanStyle!.cornerRadius != null) {
|
||||
styleCornerRadius = Math.max(0, Math.min(160, Math.floor(opts.scanStyle!.cornerRadius!)))
|
||||
}
|
||||
}
|
||||
if (opts.formats != null && opts.formats!.length > 0) {
|
||||
const parts: string[] = []
|
||||
for (let i = 0; i < opts.formats!.length; i++) {
|
||||
const item = opts.formats![i]
|
||||
if (item != null && item.length > 0) {
|
||||
parts.push(item)
|
||||
}
|
||||
}
|
||||
formatsCsv = parts.join(',')
|
||||
}
|
||||
}
|
||||
|
||||
const intent = new Intent()
|
||||
intent.setClassName(host, CAPTURE_ACTIVITY)
|
||||
intent.putExtra(EXTRA_TITLE, pageTitle)
|
||||
intent.putExtra(EXTRA_HINT, scanHint)
|
||||
intent.putExtra(EXTRA_SUCCESS_HINT, successHint)
|
||||
intent.putExtra(EXTRA_EMPTY_HINT, emptyHint)
|
||||
intent.putExtra(EXTRA_SMART_ZOOM, useSmartZoom)
|
||||
intent.putExtra(EXTRA_TORCH_MODE, useTorchMode)
|
||||
intent.putExtra(EXTRA_RESULT_MODE, useResultMode)
|
||||
intent.putExtra(EXTRA_SHOW_PAUSE_BUTTON, useShowPauseButton)
|
||||
intent.putExtra(EXTRA_VIBRATE, useVibrate)
|
||||
intent.putExtra(EXTRA_CONTINUOUS, useContinuous)
|
||||
intent.putExtra(EXTRA_FROM_ALBUM, useFromAlbum)
|
||||
intent.putExtra(EXTRA_FORMATS, formatsCsv)
|
||||
intent.putExtra(EXTRA_SCAN_REGION_ENABLED, useScanRegion)
|
||||
intent.putExtra(EXTRA_SCAN_REGION, `${regionWidth},${regionHeight},${regionCenterX},${regionCenterY}`)
|
||||
intent.putExtra(EXTRA_SCAN_STYLE_SHOW_MASK, styleShowMask)
|
||||
intent.putExtra(EXTRA_SCAN_STYLE_SHOW_BEAM, styleShowBeam)
|
||||
intent.putExtra(EXTRA_SCAN_STYLE_BORDER_COLOR, styleBorderColor)
|
||||
intent.putExtra(EXTRA_SCAN_STYLE_MASK_COLOR, styleMaskColor)
|
||||
intent.putExtra(EXTRA_SCAN_STYLE_BEAM_COLOR, styleBeamColor)
|
||||
intent.putExtra(EXTRA_SCAN_STYLE_BEAM_DURATION, styleBeamDuration)
|
||||
intent.putExtra(EXTRA_SCAN_STYLE_CORNER, styleCorner)
|
||||
intent.putExtra(EXTRA_SCAN_STYLE_CORNER_RADIUS, styleCornerRadius)
|
||||
intent.putExtra(EXTRA_SCAN_TIMEOUT, scanTimeout)
|
||||
intent.putExtra(EXTRA_CONTINUOUS_SUCCESS_DURATION, continuousSuccessDuration)
|
||||
host.startActivityForResult(intent, LAUNCH_REQ)
|
||||
}
|
||||
|
||||
function emitFailure(opts: CodeScanOptions | null, code: CodeScanErrorCode): void {
|
||||
const err = new CodeScanFailImpl(code)
|
||||
if (opts != null) {
|
||||
opts.fail?.(err)
|
||||
opts.complete?.(err)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 254 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 736 B |
|
After Width: | Height: | Size: 634 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 788 B |
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,90 @@
|
||||
export type CodeScanSuccess = {
|
||||
content : string
|
||||
format : string
|
||||
fromAlbum ?: boolean
|
||||
}
|
||||
|
||||
export type CodeScanErrorCode = 9021001 | 9021002 | 9021003 | 9021004 | 9021005 | 9021006 | 9021007 | 9021008 | 9021009 | 9021010 | 9021011
|
||||
export type CodeScanTorchMode = 'off' | 'on' | 'auto'
|
||||
export type CodeScanResultMode = 'immediate' | 'confirm' | 'pause'
|
||||
export type CodeScanCornerStyle = 'corner' | 'full' | 'rounded' | 'rounded-corner'
|
||||
|
||||
export type CodeScanStyle = {
|
||||
/** 是否显示扫描框外遮罩,默认 true */
|
||||
showMask ?: boolean
|
||||
/** 是否显示扫描线,默认 true */
|
||||
showBeam ?: boolean
|
||||
/** 扫描框边框颜色,支持 #RRGGBB / #AARRGGBB */
|
||||
borderColor ?: string
|
||||
/** 遮罩颜色,支持 #RRGGBB / #AARRGGBB 或 rgba() */
|
||||
maskColor ?: string
|
||||
/** 扫描线颜色,支持 #RRGGBB / #AARRGGBB */
|
||||
beamColor ?: string
|
||||
/** 扫描线移动周期,单位毫秒,默认 1800 */
|
||||
beamDuration ?: number
|
||||
/** 扫描框样式:corner 四角边框,full 完整边框,rounded 完整圆角边框,rounded-corner 四角圆角边框,默认 corner */
|
||||
cornerStyle ?: CodeScanCornerStyle
|
||||
/** 圆角半径,单位 dp,rounded 和 rounded-corner 样式生效,默认 20,范围 0 ~ 160 */
|
||||
cornerRadius ?: number
|
||||
}
|
||||
|
||||
export interface CodeScanFail extends IUniError {
|
||||
errCode : CodeScanErrorCode
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时相机扫描区域。宽度和高度均以预览宽度为参考比例,因此可稳定锁定宽高比。
|
||||
* 不传时保持原有整画面识别;相册识图不受该配置限制。
|
||||
*/
|
||||
export type ScanRegion = {
|
||||
/** 全屏识别:不绘制扫描框、不进行区域过滤 */
|
||||
fullScreen ?: boolean
|
||||
/** 扫描框宽度比例,建议 0.1 ~ 1 */
|
||||
width ?: number
|
||||
/** 扫描框高度比例,建议 0.1 ~ 1 */
|
||||
height ?: number
|
||||
/** 扫描框中心横坐标比例,0 为最左、1 为最右 */
|
||||
centerX ?: number
|
||||
/** 扫描框中心纵坐标比例,0 为顶部、1 为底部 */
|
||||
centerY ?: number
|
||||
}
|
||||
|
||||
export type CodeScanOptions = {
|
||||
/** 扫码页标题,默认“扫一扫” */
|
||||
title ?: string
|
||||
/** 扫描框下方引导语,默认“请将条码放入扫描框内” */
|
||||
hint ?: string
|
||||
/** 识别成功提示语,默认“扫码成功” */
|
||||
successHint ?: string
|
||||
/** 未识别到条码时的提示语,默认“未识别到有效条码” */
|
||||
emptyHint ?: string
|
||||
/** 条码偏小时自动放大,默认 true */
|
||||
smartZoom ?: boolean
|
||||
/** 手电筒策略:off 关闭、on 默认开启、auto 根据环境亮度自动补光,默认 off */
|
||||
torch ?: CodeScanTorchMode
|
||||
/** 单次扫码结果策略:immediate 立即返回、confirm 确认后返回、pause 暂停后可继续扫描,默认 immediate */
|
||||
resultMode ?: CodeScanResultMode
|
||||
/** 连续扫码页是否显示暂停/继续按钮,默认 false */
|
||||
showPauseButton ?: boolean
|
||||
/** 限定码制,如 ['QR_CODE','CODE_128'];空/缺省为全格式 */
|
||||
formats ?: string[]
|
||||
/** 识别成功是否震动,默认 true */
|
||||
vibrate ?: boolean
|
||||
/** 连续扫码:页内不关闭,多次 success;点关闭结束;可通过 showPauseButton 显示暂停按钮 */
|
||||
continuous ?: boolean
|
||||
/** 连续扫码成功提示停留时长,单位毫秒,默认 1000,范围 0 ~ 600000 */
|
||||
continuousSuccessDuration ?: number
|
||||
/** 打开后直接进入相册选图 */
|
||||
fromAlbum ?: boolean
|
||||
/** 实时相机有效识别区域;fullScreen 为 true 时整画面均可识别 */
|
||||
scanRegion ?: ScanRegion
|
||||
/** 扫描框视觉样式 */
|
||||
scanStyle ?: CodeScanStyle
|
||||
/** 扫描会话超时毫秒数;不传或小于 1000 时不启用,最大 600000 */
|
||||
scanTimeout ?: number
|
||||
success ?: (res : CodeScanSuccess) => void
|
||||
fail ?: (res : CodeScanFail) => void
|
||||
complete ?: (res : any) => void
|
||||
}
|
||||
|
||||
export type OpenCodeScan = (options ?: CodeScanOptions | null) => void
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CodeScanErrorCode, CodeScanFail } from './interface.uts'
|
||||
|
||||
export const CodeScanErrorSubject = 'mas-code-scan'
|
||||
|
||||
export const CodeScanErrorText: Map<CodeScanErrorCode, string> = new Map([
|
||||
[9021001, '当前无法获取页面 Activity'],
|
||||
[9021002, '相机权限被拒绝'],
|
||||
[9021003, '用户取消扫码'],
|
||||
[9021004, '未获取到有效的扫码结果'],
|
||||
[9021005, '相机被其他应用占用或当前不可用'],
|
||||
[9021006, '设备不支持手电筒'],
|
||||
[9021007, '相机初始化失败'],
|
||||
[9021008, '相册图片无法读取'],
|
||||
[9021009, '图片中未识别到条码'],
|
||||
[9021010, '指定码制不支持'],
|
||||
[9021011, '扫码超时']
|
||||
])
|
||||
|
||||
export class CodeScanFailImpl extends UniError implements CodeScanFail {
|
||||
override errCode: CodeScanErrorCode
|
||||
|
||||
constructor(errCode: CodeScanErrorCode) {
|
||||
super()
|
||||
this.errSubject = CodeScanErrorSubject
|
||||
this.errCode = errCode
|
||||
this.errMsg = CodeScanErrorText.get(errCode) ?? ''
|
||||
}
|
||||
}
|
||||