Compare commits
25 Commits
ef3034014b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b438364656 | ||
|
|
229c8cb9e4 | ||
|
|
cdec1838ac | ||
|
|
c08cdaee68 | ||
|
|
f52b9f3518 | ||
|
|
6663fd6ab4 | ||
|
|
c56c17589a | ||
|
|
bc025f7f74 | ||
|
|
4afa5ec8cb | ||
|
|
69b0c4e27c | ||
|
|
6e91cc0ef4 | ||
|
|
1c02c72b3b | ||
|
|
548814fe38 | ||
|
|
ede67b61b4 | ||
|
|
1966dbbd51 | ||
|
|
45686d28fc | ||
|
|
09b80d6b78 | ||
|
|
96006164b5 | ||
|
|
36f1cbbf7e | ||
|
|
ed1ee040cc | ||
|
|
4e41a85e04 | ||
|
|
c0fba11103 | ||
|
|
dab2aa2d53 | ||
|
|
23c91267a2 | ||
|
|
3856b6e8c9 |
44
README.md
44
README.md
@@ -1,6 +1,6 @@
|
||||
# scan-code-jssdk
|
||||
|
||||
统一扫码 JSSDK,支持桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、选择图片识别和扫码枪输入。
|
||||
统一扫码 JSSDK,支持桥接扫码、微信小程序、微信 JSSDK 扫码、Web 摄像头扫码、选择图片识别和扫码枪输入。
|
||||
|
||||
## 功能
|
||||
|
||||
@@ -61,9 +61,6 @@ IScan.config({
|
||||
// 自动选择可用扫码方式:桥接 -> 微信 -> Web 摄像头 -> 图片识别
|
||||
IScan.startScan();
|
||||
|
||||
// 仅打开 Web 视频扫码
|
||||
IScan.scanVideo();
|
||||
|
||||
// 仅选择图片识别
|
||||
IScan.scanImage();
|
||||
|
||||
@@ -83,11 +80,17 @@ interface ScanConfigOptions {
|
||||
webScanEnabled?: boolean,
|
||||
webScanCanvasEnabled?: boolean;
|
||||
webScanCanvasStyle?: string;
|
||||
webScanCloseButtonStyle?: string;
|
||||
webScanCanvasClass?: string;
|
||||
webScanCloseButtonClass?: string;
|
||||
webScanType?: ("qrCode" | "barCode")[];
|
||||
webScanVideoMirror?: boolean;
|
||||
webScanVideoMirrorVertical?: boolean;
|
||||
webScanBeepAudio?: string;
|
||||
webScanBeepEnabled?: boolean;
|
||||
webScanImageFallbackOnVideoError?: boolean;
|
||||
webScanVideoAccessTimeout?: number;
|
||||
webScanVideoReadyTimeout?: number;
|
||||
scanBeepAudio?: string;
|
||||
scanBeepEnabled?: boolean;
|
||||
initWechatJssdk?: {
|
||||
apiUrl?: string;
|
||||
sdkConfig?: {
|
||||
@@ -110,12 +113,23 @@ interface ScanConfigOptions {
|
||||
| `bridgeName` | 挂载在 `window` 上的桥接对象名称 | `__bridge_client__` |
|
||||
| `webScanEnabled` | 是否支持 WebScan 扫码 | `true` |
|
||||
| `webScanCanvasEnabled` | 是否显示 WebScan 扫码 canvas;关闭后仍会用隐藏 canvas 识别 | `true` |
|
||||
| `webScanCanvasStyle` | WebScan 扫码 canvas 样式 | `position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999;` |
|
||||
| `webScanCanvasStyle` | WebScan 扫码 canvas 内联样式 | `position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999;` |
|
||||
| `webScanCloseButtonStyle` | WebScan 关闭按钮内联样式(在默认位置上追加);按钮内容为 SVG,`path` 使用 `currentColor`,可通过按钮的 `color` 改图标颜色 | 圆形半透明底、白图标,定位在 canvas 右上角 |
|
||||
| `webScanCanvasClass` | WebScan canvas 根元素 `class`,便于用外部样式表配合 `webScanCanvasStyle` 定制 | 无 |
|
||||
| `webScanCloseButtonClass` | WebScan 关闭按钮 `class`,便于用外部样式表配合 `webScanCloseButtonStyle` 定制 | 无 |
|
||||
| `webScanType` | WebScan 扫码类型 | `["qrCode", "barCode"]` |
|
||||
| `webScanVideoMirror` | WebScan 视频是否水平镜像;不配置时自动判断:前置/PC 镜像,后置不镜像 | 自动 |
|
||||
| `webScanVideoMirrorVertical` | WebScan 视频是否垂直镜像 | `false` |
|
||||
| `webScanBeepAudio` | WebScan 扫码成功提示音地址 | 内置提示音 |
|
||||
| `webScanBeepEnabled` | WebScan 扫码成功是否播放提示音 | `true` |
|
||||
| `webScanImageFallbackOnVideoError` | 摄像头不可用或打开失败时,是否自动弹出拍照/选图(适用于部分安卓内置浏览器) | `true` |
|
||||
| `webScanVideoAccessTimeout` | 打开摄像头超时(毫秒),超时后走图片回退 | `10000` |
|
||||
| `webScanVideoReadyTimeout` | 摄像头已开但无画面超时(毫秒),超时后走图片回退 | `8000` |
|
||||
| `webScanCameraPermissionDialogEnabled` | `startScan` 走 Web 摄像头前是否先展示权限说明弹窗 | `true` |
|
||||
| `webScanCameraPermissionTitle` | 权限说明弹窗标题 | `需要使用摄像头` |
|
||||
| `webScanCameraPermissionMessage` | 权限说明弹窗正文 | 见类型定义默认值 |
|
||||
| `webScanCameraPermissionConfirmText` | 确认按钮文案 | `继续` |
|
||||
| `webScanCameraPermissionCancelText` | 取消按钮文案 | `取消` |
|
||||
| `scanBeepAudio` | 扫码成功提示音地址(任意模式匹配成功时播放) | 内置提示音 |
|
||||
| `scanBeepEnabled` | 扫码成功是否播放提示音 | `true` |
|
||||
| `initWechatJssdk` | 微信 JSSDK 初始化配置,仅微信环境生效 | 无 |
|
||||
|
||||
`initWechatJssdk` 子配置:
|
||||
@@ -212,7 +226,9 @@ IScan.config({
|
||||
|
||||
```js
|
||||
IScan.config({
|
||||
webScanCanvasEnabled: true
|
||||
webScanCanvasEnabled: true,
|
||||
webScanCanvasClass: "my-webscan-canvas",
|
||||
webScanCloseButtonClass: "my-webscan-close"
|
||||
});
|
||||
```
|
||||
|
||||
@@ -278,14 +294,6 @@ console.log(IScan.getStatus());
|
||||
IScan.startScan();
|
||||
```
|
||||
|
||||
### `scanVideo(): void`
|
||||
|
||||
直接开启 Web 摄像头扫码。扫码结果通过 `onScanListener` 回调。
|
||||
|
||||
```js
|
||||
IScan.scanVideo();
|
||||
```
|
||||
|
||||
### `scanImage(): void`
|
||||
|
||||
直接选择图片进行识别。识别结果通过 `onScanListener` 回调。
|
||||
|
||||
18
demo.html
18
demo.html
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" />
|
||||
<title>IScan</title>
|
||||
<title>IScan 使用示例</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vconsole@3.12.0/dist/vconsole.min.js"></script>
|
||||
<script>
|
||||
var vconsole = new VConsole();
|
||||
@@ -180,8 +180,8 @@
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="hero">
|
||||
<h1>IScan 通用扫码 SDK</h1>
|
||||
<p>统一接入桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</p>
|
||||
<h1>IScan 通用扫码SDK</h1>
|
||||
<p>统一接入桥接扫码、微信小程序、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</p>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
@@ -207,7 +207,6 @@
|
||||
<p>点击开始后,会按桥接、微信、Web 摄像头、图片识别的顺序选择可用扫码方式。</p>
|
||||
<div class="actions">
|
||||
<button onclick="startScan()" class="btn">开始扫码</button>
|
||||
<button onclick="scanVideo()" class="btn secondary">开启视频扫码</button>
|
||||
<button onclick="scanImage()" class="btn secondary">选择图片识别</button>
|
||||
<button onclick="stopScan()" class="btn secondary">停止扫码</button>
|
||||
</div>
|
||||
@@ -229,7 +228,8 @@
|
||||
<pre>IScan.config({
|
||||
webScanEnabled: true,
|
||||
webScanCanvasEnabled: true,
|
||||
webScanBeepEnabled: true,
|
||||
webScanCloseButtonStyle: "background: rgba(27, 99, 244, 0.88);",
|
||||
scanBeepEnabled: true,
|
||||
initWechatJssdk: {
|
||||
apiUrl: "https://your-domain.com/wechat/jssdk-config"
|
||||
}
|
||||
@@ -246,7 +246,6 @@
|
||||
|
||||
IScan.startScan();
|
||||
IScan.scanImage();
|
||||
IScan.scanVideo();
|
||||
IScan.stopScan();</pre>
|
||||
</section>
|
||||
</main>
|
||||
@@ -319,7 +318,8 @@ IScan.stopScan();</pre>
|
||||
var url = "https://vet.iqudoo.com/api?action=api.biz.wechat.JSSDKConfig";
|
||||
initSDK({
|
||||
webScanCanvasEnabled: true,
|
||||
webScanBeepEnabled: true,
|
||||
webScanCloseButtonStyle: "background: rgba(27, 99, 244, 0.88);",
|
||||
scanBeepEnabled: true,
|
||||
initWechatJssdk: { apiUrl: url }
|
||||
}, function () {
|
||||
setStatus(IScan.getStatus());
|
||||
@@ -347,10 +347,6 @@ IScan.stopScan();</pre>
|
||||
IScan.scanImage();
|
||||
}
|
||||
|
||||
function scanVideo() {
|
||||
IScan.scanVideo();
|
||||
}
|
||||
|
||||
ready();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
186
dist/index.d.ts
vendored
186
dist/index.d.ts
vendored
@@ -6,6 +6,18 @@ interface ScanConfigOptions {
|
||||
* 扫码重启延迟,单位:毫秒,默认500ms
|
||||
*/
|
||||
scanRestartDelay?: number,
|
||||
/**
|
||||
* iframe 场景下是否将 API 调用转发到父页面同名 SDK(postMessage)。
|
||||
* - `'auto'`(默认):处于子 frame 时 `startScan` 等由父页执行;`onScanListener` 仅注册在 iframe 内,识别结果由父页 `postMessage` 回传
|
||||
* - `true` / `'on'` / `'parent'`:存在父 window 时强制转发
|
||||
* - `false` / `'off'` / `'local'`:始终在本页执行(子页自己要跑扫码时用)
|
||||
*/
|
||||
embedProxyMode?: 'auto' | boolean | 'on' | 'off' | 'local' | 'parent',
|
||||
/**
|
||||
* 请求微信 JS-SDK 签名时使用的页面 URL(不含 hash)。
|
||||
* 跨域 iframe 无法读取父页地址时需手动设为当前微信内打开的页面链接。
|
||||
*/
|
||||
wxJssdkSignatureUrl?: string,
|
||||
/**
|
||||
* 桥接是否启用,默认启用
|
||||
*/
|
||||
@@ -33,6 +45,58 @@ interface ScanConfigOptions {
|
||||
* 2. 结束扫码的方法名称为:stopScan
|
||||
*/
|
||||
bridgeName?: string,
|
||||
/**
|
||||
* 桥接扫码超时(毫秒),超时后回退 Web/图片识别,默认 5000
|
||||
*/
|
||||
bridgeScanTimeout?: number,
|
||||
/**
|
||||
* 是否允许 H5 摄像头扫码:true 强制开启(仍需有媒体 API),false 强制关闭
|
||||
*/
|
||||
webScanCameraEnabled?: boolean,
|
||||
/**
|
||||
* @deprecated 请用 webScanCameraEnabled;保留兼容
|
||||
*/
|
||||
webScanCameraInWechat?: boolean,
|
||||
/**
|
||||
* @deprecated 摄像头权限已后置到 startScan,请用 webScanVideoAccessTimeout
|
||||
*/
|
||||
webScanCameraProbeTimeout?: number,
|
||||
/**
|
||||
* startScan 走 Web 摄像头前是否展示权限说明弹窗,默认 true
|
||||
*/
|
||||
webScanCameraPermissionDialogEnabled?: boolean,
|
||||
/**
|
||||
* 摄像头权限说明弹窗标题
|
||||
*/
|
||||
webScanCameraPermissionTitle?: string,
|
||||
/**
|
||||
* 摄像头权限说明弹窗正文
|
||||
*/
|
||||
webScanCameraPermissionMessage?: string,
|
||||
/**
|
||||
* 摄像头权限说明弹窗确认按钮文案
|
||||
*/
|
||||
webScanCameraPermissionConfirmText?: string,
|
||||
/**
|
||||
* 摄像头权限说明弹窗取消按钮文案
|
||||
*/
|
||||
webScanCameraPermissionCancelText?: string,
|
||||
/**
|
||||
* 选图后延迟读取 file 对象(毫秒),微信/安卓 WebView 默认 100
|
||||
*/
|
||||
webScanFileReadDelay?: number,
|
||||
/**
|
||||
* 强制使用 WASM ponyfill 识别(微信/部分 WebView 建议开启,默认微信内自动开启)
|
||||
*/
|
||||
webScanPreferPonyfill?: boolean,
|
||||
/**
|
||||
* WASM 文件完整 URL;未配置时相对 SDK 脚本地址解析 lib/reader.wasm
|
||||
*/
|
||||
webScanWasmUrl?: string,
|
||||
/**
|
||||
* WASM 基准路径(SDK 脚本 URL 或目录);用于 async/defer 加载时修正 reader.wasm 路径
|
||||
*/
|
||||
webScanWasmBaseUrl?: string,
|
||||
/**
|
||||
* webScan是否启用,默认启用
|
||||
*/
|
||||
@@ -42,9 +106,29 @@ interface ScanConfigOptions {
|
||||
*/
|
||||
webScanCanvasEnabled?: boolean,
|
||||
/**
|
||||
* 网页扫码canvas样式,默认:position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999;
|
||||
* 网页扫码canvas样式,默认:PC 为左上角固定 300×300;移动端为 min(视口宽, 视口高) 的正方形,固定在左上角;z-index: 9999
|
||||
*/
|
||||
webScanCanvasStyle?: string,
|
||||
/**
|
||||
* 网页扫码canvas关闭按钮样式,默认显示在canvas右上角
|
||||
*/
|
||||
webScanCloseButtonStyle?: string,
|
||||
/**
|
||||
* 网页扫码 canvas 根元素的 class,便于配合外部样式表定制布局与外观
|
||||
*/
|
||||
webScanCanvasClass?: string,
|
||||
/**
|
||||
* 网页扫码关闭按钮的 class
|
||||
*/
|
||||
webScanCloseButtonClass?: string,
|
||||
/**
|
||||
* Canvas 开启且支持图片识别时,关闭按钮下方的「选图」按钮样式(fixed 定位基准由 SDK 计算,可与关闭按钮一致覆盖)
|
||||
*/
|
||||
webScanPickImageButtonStyle?: string,
|
||||
/**
|
||||
* 网页扫码选图按钮的 class
|
||||
*/
|
||||
webScanPickImageButtonClass?: string,
|
||||
/**
|
||||
* 网页扫码类型,默认支持二维码和条码
|
||||
*/
|
||||
@@ -58,13 +142,60 @@ interface ScanConfigOptions {
|
||||
*/
|
||||
webScanVideoMirrorVertical?: boolean,
|
||||
/**
|
||||
* 网页扫码成功提示音地址,默认使用内置提示音
|
||||
* 摄像头不可用(如部分安卓内置浏览器禁用 getUserMedia)时是否自动回退为拍照/选图识别,默认启用
|
||||
*/
|
||||
webScanBeepAudio?: string,
|
||||
webScanImageFallbackOnVideoError?: boolean,
|
||||
/**
|
||||
* 网页扫码成功提示音是否启用,默认启用
|
||||
* 打开摄像头超时时间(毫秒),超时后触发图片回退,默认 10000
|
||||
*/
|
||||
webScanBeepEnabled?: boolean,
|
||||
webScanVideoAccessTimeout?: number,
|
||||
/**
|
||||
* 摄像头已打开但长时间无画面时触发图片回退(毫秒),默认 8000
|
||||
*/
|
||||
webScanVideoReadyTimeout?: number,
|
||||
/**
|
||||
* 图片/回退识别时 detect 超时(毫秒),默认 15000
|
||||
*/
|
||||
webScanDetectTimeout?: number,
|
||||
/**
|
||||
* 图片识别是否优先使用 ZXing ponyfill(原生 detect 在部分 WebView 可能卡住),默认 true
|
||||
*/
|
||||
webScanImagePreferPonyfill?: boolean,
|
||||
/**
|
||||
* 移动端图片回退是否使用 capture 拍照(false 为相册选图,兼容性更好),默认 false
|
||||
*/
|
||||
webScanImagePreferCapture?: boolean,
|
||||
/**
|
||||
* 图片识别是否优先 canvas 解码(安卓 WebView 建议开启),默认在安卓/微信内自动开启
|
||||
*/
|
||||
webScanImageDetectPreferCanvas?: boolean,
|
||||
/**
|
||||
* ZXing WASM 加载超时(毫秒),默认 20000
|
||||
*/
|
||||
webScanPrepareTimeout?: number,
|
||||
/**
|
||||
* 选图等待超时(毫秒),默认 120000
|
||||
*/
|
||||
webScanChooseImageTimeout?: number,
|
||||
/**
|
||||
* 选图方式:button=显示「选择图片」按钮(安卓/微信默认,需用户点击);auto=自动弹出系统选图
|
||||
*/
|
||||
webScanImagePickerMode?: 'auto' | 'button',
|
||||
webScanImagePickerTitle?: string,
|
||||
webScanImagePickerButtonText?: string,
|
||||
webScanImagePickerCancelText?: string,
|
||||
/**
|
||||
* 单次扫码会话超时(毫秒),超时后状态恢复 ready,默认 90000
|
||||
*/
|
||||
scanSessionTimeout?: number,
|
||||
/**
|
||||
* 扫码成功提示音地址,默认使用内置提示音;任意识别模式匹配成功时播放
|
||||
*/
|
||||
scanBeepAudio?: string,
|
||||
/**
|
||||
* 扫码成功是否播放提示音,默认启用;任意识别模式匹配成功时生效
|
||||
*/
|
||||
scanBeepEnabled?: boolean,
|
||||
/**
|
||||
* 微信JSSDK配置,微信环境才会生效,配置后会自动初始化微信JSSDK
|
||||
*/
|
||||
@@ -117,6 +248,16 @@ interface ScanResult {
|
||||
key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码错误
|
||||
*/
|
||||
interface ScanErrorInfo {
|
||||
error: string,
|
||||
key: string,
|
||||
source?: string,
|
||||
cancel?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听key
|
||||
*/
|
||||
@@ -153,6 +294,22 @@ type ScanStatus = "scanning" | "ready";
|
||||
*/
|
||||
type ScanResultCallback = (result: ScanResult) => any;
|
||||
|
||||
/**
|
||||
* 监听扫码错误回调
|
||||
*/
|
||||
type ScanErrorCallback = (error: ScanErrorInfo) => any;
|
||||
|
||||
/**
|
||||
* 扫码错误监听信息
|
||||
*/
|
||||
interface ScanErrorListenerInfo {
|
||||
key?: string;
|
||||
match?: string;
|
||||
level?: number;
|
||||
listener: ScanErrorCallback;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听状态回调
|
||||
*/
|
||||
@@ -183,6 +340,19 @@ interface IScan {
|
||||
* @param callback 监听回调,或监听key
|
||||
*/
|
||||
offScanListener(callback: ScanResultCallback | string): void;
|
||||
/**
|
||||
* 添加监听扫码错误(如图片识别失败)
|
||||
* @param callback 错误回调
|
||||
* @param key 监听 key
|
||||
* @param match 可选正则,匹配 error 文本后回调
|
||||
* @param level 优先级
|
||||
*/
|
||||
onScanErrorListener(callback: ScanErrorCallback, key: string, match?: string, level?: number): ScanErrorListenerInfo;
|
||||
/**
|
||||
* 取消监听扫码错误
|
||||
* @param callback 监听回调,或监听 key
|
||||
*/
|
||||
offScanErrorListener(callback: ScanErrorCallback | string): void;
|
||||
/**
|
||||
* 获取扫码状态
|
||||
* @returns ScanStatus
|
||||
@@ -196,14 +366,12 @@ interface IScan {
|
||||
* 开启扫码
|
||||
*/
|
||||
startScan(): void;
|
||||
/**
|
||||
* 开启视频扫码
|
||||
*/
|
||||
scanVideo(): void;
|
||||
/**
|
||||
* 选择图片进行识别
|
||||
*/
|
||||
scanImage(): void;
|
||||
/** 由业务/原生传入已选图片 File 识别(WebView input.files 异常时使用) */
|
||||
scanImageFromFile(file: File | Blob): void;
|
||||
/**
|
||||
* 清除全部监听
|
||||
*/
|
||||
|
||||
15
dist/index.html
vendored
15
dist/index.html
vendored
@@ -1,4 +1,4 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,minimum-scale=1,maximum-scale=1,user-scalable=no"/><title>IScan</title><script src="https://cdn.jsdelivr.net/npm/vconsole@3.12.0/dist/vconsole.min.js"></script><script>var vconsole = new VConsole();</script><style>* {
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,minimum-scale=1,maximum-scale=1,user-scalable=no"/><title>IScan 使用示例</title><script src="https://cdn.jsdelivr.net/npm/vconsole@3.12.0/dist/vconsole.min.js"></script><script>var vconsole = new VConsole();</script><style>* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -161,10 +161,11 @@
|
||||
|
||||
section {
|
||||
margin-bottom: 16px;
|
||||
}</style></head><body><main class="page"><section class="hero"><h1>IScan 通用扫码 SDK</h1><p>统一接入桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</p></section><section class="grid"><div class="card"><h2>功能说明</h2><ul class="feature-list"><li>支持桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</li><li>浏览器环境使用WASM库识别二维码和条形码。</li><li>扫码结果由监听规则统一回调,支持正则匹配和优先级排序。</li></ul></div><div class="card"><h2>当前状态</h2><p>SDK 状态:<span id="status" class="status">loading</span></p><p>运行环境:</p><pre id="output" class="panel"></pre></div></section><section class="card"><h2>操作</h2><p>点击开始后,会按桥接、微信、Web 摄像头、图片识别的顺序选择可用扫码方式。</p><div class="actions"><button onclick="startScan()" class="btn">开始扫码</button> <button onclick="scanVideo()" class="btn secondary">开启视频扫码</button> <button onclick="scanImage()" class="btn secondary">选择图片识别</button> <button onclick="stopScan()" class="btn secondary">停止扫码</button></div></section><section class="card"><h2>扫码结果</h2><pre id="result" class="panel result"></pre></section><section class="card"><h2>错误信息</h2><pre id="error" class="panel error"></pre><p style="color: #919191">错误信息可能来源于扫码结果、扫码过程、扫码初始化等。</p></section><section class="card code"><h2>接入方式</h2><pre>IScan.config({
|
||||
}</style></head><body><main class="page"><section class="hero"><h1>IScan 通用扫码SDK</h1><p>统一接入桥接扫码、微信小程序、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</p></section><section class="grid"><div class="card"><h2>功能说明</h2><ul class="feature-list"><li>支持桥接扫码、微信 JSSDK 扫码、Web 摄像头扫码、图片识别和扫码枪输入。</li><li>浏览器环境使用WASM库识别二维码和条形码。</li><li>扫码结果由监听规则统一回调,支持正则匹配和优先级排序。</li></ul></div><div class="card"><h2>当前状态</h2><p>SDK 状态:<span id="status" class="status">loading</span></p><p>运行环境:</p><pre id="output" class="panel"></pre></div></section><section class="card"><h2>操作</h2><p>点击开始后,会按桥接、微信、Web 摄像头、图片识别的顺序选择可用扫码方式。</p><div class="actions"><button onclick="startScan()" class="btn">开始扫码</button> <button onclick="scanImage()" class="btn secondary">选择图片识别</button> <button onclick="stopScan()" class="btn secondary">停止扫码</button></div></section><section class="card"><h2>扫码结果</h2><pre id="result" class="panel result"></pre></section><section class="card"><h2>错误信息</h2><pre id="error" class="panel error"></pre><p style="color: #919191">错误信息可能来源于扫码结果、扫码过程、扫码初始化等。</p></section><section class="card code"><h2>接入方式</h2><pre>IScan.config({
|
||||
webScanEnabled: true,
|
||||
webScanCanvasEnabled: true,
|
||||
webScanBeepEnabled: true,
|
||||
webScanCloseButtonStyle: "background: rgba(27, 99, 244, 0.88);",
|
||||
scanBeepEnabled: true,
|
||||
initWechatJssdk: {
|
||||
apiUrl: "https://your-domain.com/wechat/jssdk-config"
|
||||
}
|
||||
@@ -181,7 +182,6 @@
|
||||
|
||||
IScan.startScan();
|
||||
IScan.scanImage();
|
||||
IScan.scanVideo();
|
||||
IScan.stopScan();</pre></section></main><script>(function () {
|
||||
output(window.navigator.userAgent);
|
||||
window.onerror = function (message, source, lineno, colno, err) {
|
||||
@@ -249,7 +249,8 @@ IScan.stopScan();</pre></section></main><script>(function () {
|
||||
var url = "https://vet.iqudoo.com/api?action=api.biz.wechat.JSSDKConfig";
|
||||
initSDK({
|
||||
webScanCanvasEnabled: true,
|
||||
webScanBeepEnabled: true,
|
||||
webScanCloseButtonStyle: "background: rgba(27, 99, 244, 0.88);",
|
||||
scanBeepEnabled: true,
|
||||
initWechatJssdk: { apiUrl: url }
|
||||
}, function () {
|
||||
setStatus(IScan.getStatus());
|
||||
@@ -277,8 +278,4 @@ IScan.stopScan();</pre></section></main><script>(function () {
|
||||
IScan.scanImage();
|
||||
}
|
||||
|
||||
function scanVideo() {
|
||||
IScan.scanVideo();
|
||||
}
|
||||
|
||||
ready();</script><script src="index.js"></script></body></html>
|
||||
2
dist/index.js
vendored
2
dist/index.js
vendored
File diff suppressed because one or more lines are too long
342
dist/index.md
vendored
Normal file
342
dist/index.md
vendored
Normal file
@@ -0,0 +1,342 @@
|
||||
# scan-code-jssdk
|
||||
|
||||
统一扫码 JSSDK,支持桥接扫码、微信小程序、微信 JSSDK 扫码、Web 摄像头扫码、选择图片识别和扫码枪输入。
|
||||
|
||||
## 功能
|
||||
|
||||
- App 桥接环境:优先调用原生桥接 `startScan` / `stopScan`。
|
||||
- 微信浏览器:初始化微信 JSSDK 后调用 `wx.scanQRCode`。
|
||||
- Web 浏览器:使用 `BarcodeDetector` 识别二维码和条形码。
|
||||
- 图片识别:选择本地图片后识别二维码/条形码。
|
||||
- 扫码枪:监听键盘快速输入并统一走扫码监听回调。
|
||||
- 监听规则:支持按 `match` 正则和 `level` 优先级分发扫码结果。
|
||||
|
||||
## 引入
|
||||
|
||||
构建后使用 `dist/index.js`:
|
||||
|
||||
```html
|
||||
<script src="./dist/index.js"></script>
|
||||
<script>
|
||||
IScan.config().then(function () {
|
||||
console.log("IScan ready");
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
如果脚本是后加载的,可以监听 `IScanReady`:
|
||||
|
||||
```js
|
||||
function init() {
|
||||
IScan.config().then(function () {
|
||||
// ready
|
||||
});
|
||||
}
|
||||
|
||||
if (window.IScan) {
|
||||
init();
|
||||
} else {
|
||||
window.addEventListener("IScanReady", init, { once: true });
|
||||
}
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```js
|
||||
IScan.config({
|
||||
initWechatJssdk: {
|
||||
apiUrl: "https://your-domain.com/wechat/jssdk-config"
|
||||
}
|
||||
}).then(function () {
|
||||
// 监听识别状态
|
||||
IScan.setStatusListener(function (status) {
|
||||
console.log("status:", status);
|
||||
});
|
||||
// 监听识别结果
|
||||
IScan.onScanListener(function (res) {
|
||||
console.log("scan result:", res.result, res.key);
|
||||
}, "scan", null, 100);
|
||||
});
|
||||
|
||||
// 自动选择可用扫码方式:桥接 -> 微信 -> Web 摄像头 -> 图片识别
|
||||
IScan.startScan();
|
||||
|
||||
// 仅选择图片识别
|
||||
IScan.scanImage();
|
||||
|
||||
// 停止当前扫码
|
||||
IScan.stopScan();
|
||||
```
|
||||
|
||||
## 配置项
|
||||
|
||||
通过 `IScan.config(options)` 配置。
|
||||
|
||||
```ts
|
||||
interface ScanConfigOptions {
|
||||
scanRestartDelay?: number,
|
||||
bridgeEnabled?: boolean;
|
||||
bridgeName?: string;
|
||||
webScanEnabled?: boolean,
|
||||
webScanCanvasEnabled?: boolean;
|
||||
webScanCanvasStyle?: string;
|
||||
webScanCloseButtonStyle?: string;
|
||||
webScanCanvasClass?: string;
|
||||
webScanCloseButtonClass?: string;
|
||||
webScanType?: ("qrCode" | "barCode")[];
|
||||
webScanVideoMirror?: boolean;
|
||||
webScanVideoMirrorVertical?: boolean;
|
||||
webScanImageFallbackOnVideoError?: boolean;
|
||||
webScanVideoAccessTimeout?: number;
|
||||
webScanVideoReadyTimeout?: number;
|
||||
scanBeepAudio?: string;
|
||||
scanBeepEnabled?: boolean;
|
||||
initWechatJssdk?: {
|
||||
apiUrl?: string;
|
||||
sdkConfig?: {
|
||||
debug?: boolean;
|
||||
appId: string;
|
||||
timestamp: number;
|
||||
nonceStr: string;
|
||||
signature: string;
|
||||
};
|
||||
sdkUrl?: string;
|
||||
jsApiList?: string[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
| 配置 | 说明 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `scanRestartDelay` | 扫码重启延迟,单位:毫秒 | `500ms` |
|
||||
| `bridgeEnabled` | 是否启用桥接扫码 | `true` |
|
||||
| `bridgeName` | 挂载在 `window` 上的桥接对象名称 | `__bridge_client__` |
|
||||
| `webScanEnabled` | 是否支持 WebScan 扫码 | `true` |
|
||||
| `webScanCanvasEnabled` | 是否显示 WebScan 扫码 canvas;关闭后仍会用隐藏 canvas 识别 | `true` |
|
||||
| `webScanCanvasStyle` | WebScan 扫码 canvas 内联样式 | `position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999;` |
|
||||
| `webScanCloseButtonStyle` | WebScan 关闭按钮内联样式(在默认位置上追加);按钮内容为 SVG,`path` 使用 `currentColor`,可通过按钮的 `color` 改图标颜色 | 圆形半透明底、白图标,定位在 canvas 右上角 |
|
||||
| `webScanCanvasClass` | WebScan canvas 根元素 `class`,便于用外部样式表配合 `webScanCanvasStyle` 定制 | 无 |
|
||||
| `webScanCloseButtonClass` | WebScan 关闭按钮 `class`,便于用外部样式表配合 `webScanCloseButtonStyle` 定制 | 无 |
|
||||
| `webScanType` | WebScan 扫码类型 | `["qrCode", "barCode"]` |
|
||||
| `webScanVideoMirror` | WebScan 视频是否水平镜像;不配置时自动判断:前置/PC 镜像,后置不镜像 | 自动 |
|
||||
| `webScanVideoMirrorVertical` | WebScan 视频是否垂直镜像 | `false` |
|
||||
| `webScanImageFallbackOnVideoError` | 摄像头不可用或打开失败时,是否自动弹出拍照/选图(适用于部分安卓内置浏览器) | `true` |
|
||||
| `webScanVideoAccessTimeout` | 打开摄像头超时(毫秒),超时后走图片回退 | `10000` |
|
||||
| `webScanVideoReadyTimeout` | 摄像头已开但无画面超时(毫秒),超时后走图片回退 | `8000` |
|
||||
| `webScanCameraPermissionDialogEnabled` | `startScan` 走 Web 摄像头前是否先展示权限说明弹窗 | `true` |
|
||||
| `webScanCameraPermissionTitle` | 权限说明弹窗标题 | `需要使用摄像头` |
|
||||
| `webScanCameraPermissionMessage` | 权限说明弹窗正文 | 见类型定义默认值 |
|
||||
| `webScanCameraPermissionConfirmText` | 确认按钮文案 | `继续` |
|
||||
| `webScanCameraPermissionCancelText` | 取消按钮文案 | `取消` |
|
||||
| `scanBeepAudio` | 扫码成功提示音地址(任意模式匹配成功时播放) | 内置提示音 |
|
||||
| `scanBeepEnabled` | 扫码成功是否播放提示音 | `true` |
|
||||
| `initWechatJssdk` | 微信 JSSDK 初始化配置,仅微信环境生效 | 无 |
|
||||
|
||||
`initWechatJssdk` 子配置:
|
||||
|
||||
| 配置 | 说明 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `apiUrl` | 微信 JSSDK 签名配置接口地址;未传 `sdkConfig` 时会请求该接口,并自动携带当前页面 URL 参数 | 无 |
|
||||
| `sdkConfig` | 直接传入微信 JSSDK 签名配置;配置后不再请求 `apiUrl` | 无 |
|
||||
| `sdkConfig.debug` | 是否开启微信 JSSDK 调试模式 | `false` |
|
||||
| `sdkConfig.appId` | 微信公众平台应用 ID | 必填 |
|
||||
| `sdkConfig.timestamp` | 签名时间戳 | 必填 |
|
||||
| `sdkConfig.nonceStr` | 签名随机字符串 | 必填 |
|
||||
| `sdkConfig.signature` | 微信 JSSDK 签名 | 必填 |
|
||||
| `sdkUrl` | 微信 JSSDK 脚本地址 | `https://res.wx.qq.com/open/js/jweixin-1.6.0.js` |
|
||||
| `jsApiList` | 微信 JSSDK JS-API 列表,SDK 会自动追加 `scanQRCode` | `["scanQRCode"]` |
|
||||
|
||||
## 桥接接入
|
||||
|
||||
桥接对象需要挂载到 `window[bridgeName]`,并实现 `call(method, data)`。
|
||||
|
||||
SDK 会调用:
|
||||
|
||||
- `startScan`
|
||||
- `stopScan`
|
||||
|
||||
异步回调方法名为 `${bridgeName}_handle_callback`。
|
||||
|
||||
```js
|
||||
window.__bridge_client__ = {
|
||||
call: function (method, data) {
|
||||
var requestId = data.request_id;
|
||||
|
||||
if (method === "startScan") {
|
||||
// 调用原生扫码后回调
|
||||
window.__bridge_client___handle_callback({
|
||||
code: 0,
|
||||
method: method,
|
||||
request_id: requestId,
|
||||
payload: {
|
||||
result: "https://example.com"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "stopScan") {
|
||||
window.__bridge_client___handle_callback({
|
||||
code: 0,
|
||||
method: method,
|
||||
request_id: requestId,
|
||||
payload: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 微信 JSSDK 接入
|
||||
|
||||
配置接口方式:
|
||||
|
||||
```js
|
||||
IScan.config({
|
||||
initWechatJssdk: {
|
||||
apiUrl: "https://your-domain.com/wechat/jssdk-config"
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
接口会收到当前页面 URL 参数:`url=location.href.split("#")[0]`。
|
||||
|
||||
也可以直接传入微信签名配置:
|
||||
|
||||
```js
|
||||
IScan.config({
|
||||
initWechatJssdk: {
|
||||
apiUrl: "",
|
||||
sdkConfig: {
|
||||
appId: "wx_app_id",
|
||||
timestamp: 123456,
|
||||
nonceStr: "nonce",
|
||||
signature: "signature"
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`jsApiList` 会默认追加 `scanQRCode`。
|
||||
|
||||
## API
|
||||
|
||||
### `config(options?): Promise<any>`
|
||||
|
||||
配置并初始化 SDK。
|
||||
|
||||
```js
|
||||
IScan.config({
|
||||
webScanCanvasEnabled: true,
|
||||
webScanCanvasClass: "my-webscan-canvas",
|
||||
webScanCloseButtonClass: "my-webscan-close"
|
||||
});
|
||||
```
|
||||
|
||||
### `setStatusListener(callback): void`
|
||||
|
||||
监听扫码状态,状态为:
|
||||
|
||||
- `scanning`
|
||||
- `closed`
|
||||
|
||||
```js
|
||||
IScan.setStatusListener(function (status) {
|
||||
console.log(status);
|
||||
});
|
||||
```
|
||||
|
||||
### `onScanListener(callback, key, match?, level?): ScanListenerInfo`
|
||||
|
||||
添加扫码结果监听。
|
||||
|
||||
```js
|
||||
var listener = IScan.onScanListener(function (res) {
|
||||
console.log(res.result, res.key);
|
||||
}, "order", "^https://", 100);
|
||||
```
|
||||
|
||||
参数说明:
|
||||
|
||||
- `callback`:扫码结果回调。
|
||||
- `key`:监听 key,同 key 会覆盖旧监听。
|
||||
- `match`:可选正则字符串;扫码结果匹配后才回调。
|
||||
- `level`:优先级,数值越大越先匹配。
|
||||
|
||||
### `offScanListener(callbackOrKey): void`
|
||||
|
||||
移除监听,可传 callback 或 key。
|
||||
|
||||
```js
|
||||
IScan.offScanListener("order");
|
||||
listener.cancel();
|
||||
```
|
||||
|
||||
### `getStatus(): "scanning" | "ready"`
|
||||
|
||||
获取当前扫码状态。
|
||||
|
||||
```js
|
||||
console.log(IScan.getStatus());
|
||||
```
|
||||
|
||||
### `startScan(): void`
|
||||
|
||||
开启扫码。SDK 会按以下顺序选择可用能力:
|
||||
|
||||
1. 桥接扫码
|
||||
2. 微信扫码
|
||||
3. Web 摄像头扫码
|
||||
4. 图片识别
|
||||
|
||||
扫码结果通过 `onScanListener` 回调。
|
||||
|
||||
```js
|
||||
IScan.startScan();
|
||||
```
|
||||
|
||||
### `scanImage(): void`
|
||||
|
||||
直接选择图片进行识别。识别结果通过 `onScanListener` 回调。
|
||||
|
||||
```js
|
||||
IScan.scanImage();
|
||||
```
|
||||
|
||||
### `stopScan(): void`
|
||||
|
||||
停止当前扫码。
|
||||
|
||||
```js
|
||||
IScan.stopScan();
|
||||
```
|
||||
|
||||
### `clear(): void`
|
||||
|
||||
清空全部扫码监听。
|
||||
|
||||
```js
|
||||
IScan.clear();
|
||||
```
|
||||
|
||||
## 类型
|
||||
|
||||
```ts
|
||||
interface ScanResult {
|
||||
result: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
type ScanStatus = "scanning" | "ready";
|
||||
|
||||
type ScanResultCallback = (result: ScanResult) => any;
|
||||
type ScanStatusCallback = (status: ScanStatus) => any;
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物输出到 `dist` 目录。
|
||||
42
src/_core.js
42
src/_core.js
@@ -1,11 +1,19 @@
|
||||
import './polyfill';
|
||||
import {
|
||||
onScanListener, offScanListener, setStatusListener, getStatus,
|
||||
startScan, stopScan, scanVideo, scanImage, clear
|
||||
supportList,
|
||||
onScanListener, offScanListener,
|
||||
onScanErrorListener, offScanErrorListener,
|
||||
setStatusListener, getStatus,
|
||||
startScan, stopScan, scanImage, scanImageFromFile, clear
|
||||
} from './services/provider/scan';
|
||||
import { setConfig, getVersion } from './services/config';
|
||||
import { initWxJssdk } from './services/wx';
|
||||
import { printDebug } from './utils/logger';
|
||||
import {
|
||||
isSupportWebScan,
|
||||
prepareWebScanBarcodeDetector,
|
||||
isSupportImageScan
|
||||
} from './services/web';
|
||||
import { printDebug, printWarn } from './utils/logger';
|
||||
|
||||
let _readyPromise = null;
|
||||
let _calledReady = false;
|
||||
@@ -22,17 +30,25 @@ function config(config) {
|
||||
return _readyPromise;
|
||||
}
|
||||
_readyPromise = Promise.resolve().then(() => {
|
||||
return initWxJssdk().catch(err => {
|
||||
printDebug('init wx jssdk failed:', err && err.message ? err.message : err);
|
||||
});
|
||||
}).then(() => {
|
||||
printDebug('-------------------------------------');
|
||||
printDebug('sdk_version:', getVersion());
|
||||
printDebug('support_list:', supportList.map(item => item.name + ':' + item.support).join(', '));
|
||||
printDebug('-------------------------------------');
|
||||
initWxJssdk();
|
||||
return Promise.resolve().then(() => {
|
||||
_calledReady = true;
|
||||
}).catch(err => {
|
||||
_readyPromise = null;
|
||||
throw err;
|
||||
});
|
||||
})
|
||||
if (isSupportWebScan() || isSupportImageScan()) {
|
||||
return prepareWebScanBarcodeDetector().catch(err => {
|
||||
printWarn('prepare barcode detector failed:', err);
|
||||
});
|
||||
}
|
||||
}).then(() => {
|
||||
_calledReady = true;
|
||||
}).catch(err => {
|
||||
_readyPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return _readyPromise;
|
||||
}
|
||||
|
||||
@@ -40,11 +56,13 @@ export default Object.assign({}, {
|
||||
config,
|
||||
onScanListener,
|
||||
offScanListener,
|
||||
onScanErrorListener,
|
||||
offScanErrorListener,
|
||||
setStatusListener,
|
||||
getStatus,
|
||||
startScan,
|
||||
stopScan,
|
||||
scanVideo,
|
||||
scanImage,
|
||||
scanImageFromFile,
|
||||
clear,
|
||||
});
|
||||
442
src/_export.js
442
src/_export.js
@@ -1,4 +1,403 @@
|
||||
import _core, { isReadyCalled } from "./_core";
|
||||
import { isReadyCalled } from "./_core";
|
||||
import { getConfig } from "./services/config";
|
||||
import {
|
||||
readWxLikeEnvFromWindow,
|
||||
setParentWxEnvReport,
|
||||
getParentWxEnvReport,
|
||||
} from "./services/embedEnvProbe";
|
||||
import {
|
||||
hasDistinctParentWindow,
|
||||
resolveUseParentProxy,
|
||||
} from "./services/embedProxy";
|
||||
import { createUUID } from "./utils/uuid";
|
||||
import {
|
||||
dispatchEmbedScanResult,
|
||||
acknowledgeEmbedScanConsumed,
|
||||
dispatchEmbedScanError,
|
||||
setEmbedScanHostEnabled,
|
||||
} from "./services/provider/scan";
|
||||
import {
|
||||
setEmbedScanResultForwarder,
|
||||
setEmbedScanErrorForwarder,
|
||||
} from "./services/embedScanBridge";
|
||||
import { unlockScanBeep, installScanBeepGestureUnlock } from "./services/web";
|
||||
|
||||
const EMBED_SOURCE = "IScanEmbed";
|
||||
const EMBED_V = 1;
|
||||
|
||||
/** 已向父页发起过 invoke 的嵌入子 frame(用于父页识别结果回传) */
|
||||
const embedChildSources = new Set();
|
||||
|
||||
const EMBED_LISTENER_METHODS = new Set([
|
||||
"onScanListener",
|
||||
"offScanListener",
|
||||
"onScanErrorListener",
|
||||
"offScanErrorListener",
|
||||
"clear",
|
||||
]);
|
||||
|
||||
/** 嵌入 iframe 转发到父页时,须在子页用用户手势解锁提示音 */
|
||||
const EMBED_SCAN_GESTURE_METHODS = new Set([
|
||||
"startScan",
|
||||
"scanImage",
|
||||
"scanImageFromFile",
|
||||
]);
|
||||
|
||||
function isEmbedMessage(data) {
|
||||
return data && data.source === EMBED_SOURCE && data.v === EMBED_V;
|
||||
}
|
||||
|
||||
let embedHostInstalled = false;
|
||||
let embedChildInstalled = false;
|
||||
const pendingInvokes = Object.create(null);
|
||||
const childCallbackFns = Object.create(null);
|
||||
|
||||
function cloneArgsReplacingFunctions(val, registry, seen) {
|
||||
if (typeof val === "function") {
|
||||
const id = createUUID();
|
||||
registry[id] = val;
|
||||
return { __IScanEmbedCb__: id };
|
||||
}
|
||||
if (val === null || typeof val !== "object") {
|
||||
return val;
|
||||
}
|
||||
if (!seen) {
|
||||
seen = new WeakMap();
|
||||
}
|
||||
if (seen.has(val)) {
|
||||
throw new Error("[IScan embed]: circular reference in arguments");
|
||||
}
|
||||
seen.set(val, true);
|
||||
if (Array.isArray(val)) {
|
||||
return val.map((item) => cloneArgsReplacingFunctions(item, registry, seen));
|
||||
}
|
||||
const out = {};
|
||||
Object.keys(val).forEach((k) => {
|
||||
out[k] = cloneArgsReplacingFunctions(val[k], registry, seen);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function serializeEmbedParams(params) {
|
||||
const serialized = [];
|
||||
const registry = {};
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
serialized.push(cloneArgsReplacingFunctions(params[i], registry));
|
||||
}
|
||||
return { serialized, registry };
|
||||
}
|
||||
|
||||
function hydrateEmbedParams(params, messageSource, targetOrigin) {
|
||||
function walk(val) {
|
||||
if (val && typeof val === "object" && val.__IScanEmbedCb__) {
|
||||
const cbId = val.__IScanEmbedCb__;
|
||||
return function embedCbProxy() {
|
||||
const args = Array.prototype.slice.call(arguments);
|
||||
messageSource.postMessage(
|
||||
{
|
||||
source: EMBED_SOURCE,
|
||||
v: EMBED_V,
|
||||
kind: "callback",
|
||||
cbId,
|
||||
args,
|
||||
},
|
||||
targetOrigin
|
||||
);
|
||||
};
|
||||
}
|
||||
if (val === null || typeof val !== "object") {
|
||||
return val;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val.map(walk);
|
||||
}
|
||||
const out = {};
|
||||
Object.keys(val).forEach((k) => {
|
||||
out[k] = walk(val[k]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
return params.map(walk);
|
||||
}
|
||||
|
||||
function broadcastScanErrorToEmbedChildren(error) {
|
||||
if (embedChildSources.size === 0 || error == null || error === "") {
|
||||
return;
|
||||
}
|
||||
embedChildSources.forEach((source) => {
|
||||
try {
|
||||
source.postMessage(
|
||||
{
|
||||
source: EMBED_SOURCE,
|
||||
v: EMBED_V,
|
||||
kind: "forwardScanError",
|
||||
error,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
} catch (e) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastScanResultToEmbedChildren(result, meta) {
|
||||
if (embedChildSources.size === 0 || result == null || result === "") {
|
||||
return;
|
||||
}
|
||||
embedChildSources.forEach((source) => {
|
||||
try {
|
||||
source.postMessage(
|
||||
{
|
||||
source: EMBED_SOURCE,
|
||||
v: EMBED_V,
|
||||
kind: "forwardScanResult",
|
||||
result,
|
||||
scanSource: meta && meta.source,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
} catch (e) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let embedWxProbeScheduled = false;
|
||||
let embedWxProbeAttempts = 0;
|
||||
const EMBED_WX_PROBE_MAX = 4;
|
||||
|
||||
function shouldScheduleParentWxProbe() {
|
||||
if (typeof window === "undefined" || !hasDistinctParentWindow()) {
|
||||
return false;
|
||||
}
|
||||
const mode = getConfig("embedProxyMode");
|
||||
if (mode === false || mode === "local" || mode === "off") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 向父页询问是否微信环境(子 iframe 跨域时本地 UA 可能不可靠) */
|
||||
function scheduleEmbedWxEnvProbeIfNeeded() {
|
||||
if (!shouldScheduleParentWxProbe()) {
|
||||
return;
|
||||
}
|
||||
if (embedWxProbeScheduled) {
|
||||
return;
|
||||
}
|
||||
if (embedWxProbeAttempts >= EMBED_WX_PROBE_MAX) {
|
||||
return;
|
||||
}
|
||||
embedWxProbeScheduled = true;
|
||||
embedWxProbeAttempts++;
|
||||
ensureEmbedChildListener();
|
||||
window.parent.postMessage(
|
||||
{
|
||||
source: EMBED_SOURCE,
|
||||
v: EMBED_V,
|
||||
kind: "probeWxEnv",
|
||||
id: createUUID(),
|
||||
},
|
||||
"*"
|
||||
);
|
||||
window.setTimeout(() => {
|
||||
embedWxProbeScheduled = false;
|
||||
if (getParentWxEnvReport() !== null || !shouldScheduleParentWxProbe()) {
|
||||
return;
|
||||
}
|
||||
scheduleEmbedWxEnvProbeIfNeeded();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function embedChildOnMessage(ev) {
|
||||
const data = ev.data;
|
||||
if (!isEmbedMessage(data)) {
|
||||
return;
|
||||
}
|
||||
if (data.kind === "probeWxEnvResult") {
|
||||
setParentWxEnvReport(!!data.wx);
|
||||
return;
|
||||
}
|
||||
if (data.kind === "forwardScanResult") {
|
||||
if (typeof data.result === "string") {
|
||||
const consumed = dispatchEmbedScanResult(data.result, {
|
||||
source: data.scanSource,
|
||||
});
|
||||
if (consumed && resolveUseParentProxy()) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
source: EMBED_SOURCE,
|
||||
v: EMBED_V,
|
||||
kind: "scanResultConsumed",
|
||||
result: data.result,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.kind === "forwardScanError") {
|
||||
if (typeof data.error === "string") {
|
||||
dispatchEmbedScanError(data.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.kind === "invokeResult") {
|
||||
const pending = pendingInvokes[data.id];
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
delete pendingInvokes[data.id];
|
||||
if (data.ok) {
|
||||
pending.resolve(data.result);
|
||||
} else {
|
||||
pending.reject(new Error(data.error || "[IScan embed]: invoke failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.kind === "callback") {
|
||||
const fn = childCallbackFns[data.cbId];
|
||||
if (typeof fn === "function") {
|
||||
fn.apply(null, data.args || []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureEmbedChildListener() {
|
||||
if (embedChildInstalled || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
embedChildInstalled = true;
|
||||
installScanBeepGestureUnlock();
|
||||
window.addEventListener("message", embedChildOnMessage);
|
||||
}
|
||||
|
||||
function embedInvoke(methodKey, params) {
|
||||
ensureEmbedChildListener();
|
||||
scheduleEmbedWxEnvProbeIfNeeded();
|
||||
const id = createUUID();
|
||||
const { serialized, registry } = serializeEmbedParams(params);
|
||||
Object.keys(registry).forEach((cbId) => {
|
||||
childCallbackFns[cbId] = registry[cbId];
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingInvokes[id] = { resolve, reject };
|
||||
window.parent.postMessage(
|
||||
{
|
||||
source: EMBED_SOURCE,
|
||||
v: EMBED_V,
|
||||
kind: "invoke",
|
||||
id,
|
||||
methodKey,
|
||||
params: serialized,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function handleEmbedHostInvoke(lib, ev) {
|
||||
const data = ev.data;
|
||||
const { id, methodKey, params } = data;
|
||||
const hydrated = hydrateEmbedParams(params || [], ev.source, ev.origin);
|
||||
Promise.resolve()
|
||||
.then(() => _exec(lib, methodKey, ...hydrated))
|
||||
.then((result) => {
|
||||
let out = result;
|
||||
if (out && typeof out.then === "function") {
|
||||
return out.then((r) => {
|
||||
out = r;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
})
|
||||
.then((result) => {
|
||||
ev.source.postMessage(
|
||||
{
|
||||
source: EMBED_SOURCE,
|
||||
v: EMBED_V,
|
||||
kind: "invokeResult",
|
||||
id,
|
||||
methodKey,
|
||||
ok: true,
|
||||
result,
|
||||
},
|
||||
ev.origin
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
ev.source.postMessage(
|
||||
{
|
||||
source: EMBED_SOURCE,
|
||||
v: EMBED_V,
|
||||
kind: "invokeResult",
|
||||
id,
|
||||
methodKey,
|
||||
ok: false,
|
||||
error: typeof err === "string" ? err : String((err && err.message) || err),
|
||||
},
|
||||
ev.origin
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在顶层页面注册 iframe 嵌入代理:子页面(嵌入模式)通过 postMessage 调用同一套 SDK。
|
||||
* 需在加载 SDK 后调用一次,并传入与 exportSDK 相同的 lib(通常为 ./_core 默认导出)。
|
||||
*/
|
||||
export function installEmbedHost(lib) {
|
||||
if (embedHostInstalled || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
embedHostInstalled = true;
|
||||
setEmbedScanResultForwarder(broadcastScanResultToEmbedChildren);
|
||||
setEmbedScanErrorForwarder(broadcastScanErrorToEmbedChildren);
|
||||
setEmbedScanHostEnabled(true);
|
||||
window.addEventListener("message", (ev) => {
|
||||
const data = ev.data;
|
||||
if (!isEmbedMessage(data)) {
|
||||
return;
|
||||
}
|
||||
if (data.kind === "probeWxEnv") {
|
||||
if (!ev.source || ev.source === window) {
|
||||
return;
|
||||
}
|
||||
const wx = readWxLikeEnvFromWindow(window);
|
||||
ev.source.postMessage(
|
||||
{
|
||||
source: EMBED_SOURCE,
|
||||
v: EMBED_V,
|
||||
kind: "probeWxEnvResult",
|
||||
id: data.id,
|
||||
wx,
|
||||
},
|
||||
ev.origin
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data.kind === "scanResultConsumed") {
|
||||
if (!ev.source || ev.source === window) {
|
||||
return;
|
||||
}
|
||||
if (typeof data.result === "string") {
|
||||
acknowledgeEmbedScanConsumed(data.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.kind !== "invoke") {
|
||||
return;
|
||||
}
|
||||
if (!data.id || !data.methodKey) {
|
||||
return;
|
||||
}
|
||||
if (!ev.source || ev.source === window) {
|
||||
return;
|
||||
}
|
||||
embedChildSources.add(ev.source);
|
||||
handleEmbedHostInvoke(lib, ev);
|
||||
});
|
||||
}
|
||||
|
||||
function _exec(target, func, ...params) {
|
||||
let instant = target;
|
||||
@@ -52,6 +451,39 @@ function freezeObj(obj) {
|
||||
Object.freeze(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一调用代理:根据 embedProxyMode + 环境决定走父页面转发或本地 _exec。
|
||||
*/
|
||||
function createInvokeTransport(lib, method, methodName, initNames) {
|
||||
return function IScanInvokeProxy(...params) {
|
||||
if (resolveUseParentProxy()) {
|
||||
if (EMBED_LISTENER_METHODS.has(methodName)) {
|
||||
if (!isReadyCalled() && initNames && initNames.indexOf(method) < 0) {
|
||||
throw `[IScan]:Can't call the "IScan.${method}" method, because "IScan" not ready, please confirm that "IScan.ready()" has been called. params: ${JSON.stringify(params)}`
|
||||
}
|
||||
return _exec(lib, methodName, ...params);
|
||||
}
|
||||
if (EMBED_SCAN_GESTURE_METHODS.has(method)) {
|
||||
unlockScanBeep();
|
||||
}
|
||||
return embedInvoke(methodName, params);
|
||||
}
|
||||
if (!isReadyCalled() && initNames && initNames.indexOf(method) < 0) {
|
||||
throw `[IScan]:Can't call the "IScan.${method}" method, because "IScan" not ready, please confirm that "IScan.ready()" has been called. params: ${JSON.stringify(params)}`
|
||||
}
|
||||
return _exec(lib, methodName, ...params);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试:当前解析到的嵌入转发开关(每次读取最新 config)。
|
||||
*/
|
||||
export function getEmbedProxyResolved() {
|
||||
return resolveUseParentProxy();
|
||||
}
|
||||
|
||||
export { resolveUseParentProxy } from "./services/embedProxy";
|
||||
|
||||
export function exportSDK(lib, funcs, ...initNames) {
|
||||
let methods = {};
|
||||
if (funcs && typeof funcs === 'object') {
|
||||
@@ -63,13 +495,9 @@ export function exportSDK(lib, funcs, ...initNames) {
|
||||
Object.keys(methods).forEach(method => {
|
||||
let methodItem = methods[method];
|
||||
let methodName = methodItem && methodItem.method || method;
|
||||
hook(library, method, (...params) => {
|
||||
if (!isReadyCalled() && initNames && initNames.indexOf(method) < 0) {
|
||||
throw `[IScan]:Can't call the "IScan.${method}" method, because "IScan" not ready, please confirm that "IScan.ready()" has been called. params: ${JSON.stringify(params)}`
|
||||
}
|
||||
return _exec(lib, methodName, ...params);
|
||||
});
|
||||
hook(library, method, createInvokeTransport(lib, method, methodName, initNames));
|
||||
});
|
||||
scheduleEmbedWxEnvProbeIfNeeded();
|
||||
freezeObj(library);
|
||||
return library;
|
||||
}
|
||||
|
||||
17
src/index.js
17
src/index.js
@@ -1,15 +1,26 @@
|
||||
import './polyfill';
|
||||
import core from "./_core";
|
||||
import _global from './polyfill/_global';
|
||||
import { exportSDK } from './_export';
|
||||
import { exportSDK, installEmbedHost } from './_export';
|
||||
import { setSdkScriptSrc } from './services/web';
|
||||
|
||||
if (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) {
|
||||
setSdkScriptSrc(document.currentScript.src);
|
||||
}
|
||||
|
||||
const IScan = exportSDK(core, null, "config", "setStatusListener", "onScanListener",
|
||||
"offScanListener", "stopScan", "startScan", "scanImage", "clear");
|
||||
"offScanListener", "onScanErrorListener", "offScanErrorListener", "stopScan", "startScan", "scanImage", "scanImageFromFile", "clear");
|
||||
|
||||
installEmbedHost(core);
|
||||
|
||||
function dispatchIScanReady() {
|
||||
_global.__IScanReady__ && _global.__IScanReady__();
|
||||
if (!_global.dispatchEvent) {
|
||||
return;
|
||||
}
|
||||
if (typeof Event === "function") {
|
||||
_global.dispatchEvent(new Event("IScanReady"));
|
||||
} else {
|
||||
} else if (typeof document !== "undefined") {
|
||||
let event = document.createEvent("Event");
|
||||
event.initEvent("IScanReady", true, true);
|
||||
_global.dispatchEvent(event);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { polyfill } from 'es6-promise';
|
||||
|
||||
// Object.hasOwn (ES2022) — barcode-detector / ZXing 依赖,旧版 WebView 无此方法
|
||||
if (typeof Object.hasOwn !== 'function') {
|
||||
Object.hasOwn = function hasOwn(object, key) {
|
||||
return Object.prototype.hasOwnProperty.call(object, key);
|
||||
};
|
||||
}
|
||||
|
||||
// Object.assign
|
||||
if (typeof Object.assign != 'function') {
|
||||
// Must be writable: true, enumerable: false, configurable: true
|
||||
|
||||
@@ -7,13 +7,23 @@ let _events = {};
|
||||
let _callbacks = {};
|
||||
let _bridge = "__bridge_client__";
|
||||
|
||||
function getWindow() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
function getBridgeName() {
|
||||
return getConfig("bridgeName") ? getConfig("bridgeName") : _bridge;
|
||||
}
|
||||
|
||||
function _callRuntime(func, ...options) {
|
||||
let funcs = func.split('.');
|
||||
let instant = window;
|
||||
let instant = getWindow();
|
||||
if (!instant) {
|
||||
return;
|
||||
}
|
||||
while (funcs.length > 1) {
|
||||
instant = instant[funcs.shift()];
|
||||
}
|
||||
@@ -31,9 +41,13 @@ function onCallback(name, callback) {
|
||||
}
|
||||
|
||||
function _checkInit() {
|
||||
const win = getWindow();
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
let methodName = `${getBridgeName()}_handle_callback`;
|
||||
if (!window[methodName]) {
|
||||
window[methodName] = (res) => {
|
||||
if (!win[methodName]) {
|
||||
win[methodName] = (res) => {
|
||||
let { method, payload, code, request_id } = toAny(res, {});
|
||||
let data = toAny(payload, {});
|
||||
if (request_id) {
|
||||
@@ -48,7 +62,9 @@ function _checkInit() {
|
||||
}
|
||||
|
||||
export function inRuntime() {
|
||||
return !!window[getBridgeName()]
|
||||
const win = getWindow();
|
||||
return !!win
|
||||
&& !!win[getBridgeName()]
|
||||
&& getConfig("bridgeEnabled") !== false;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@ let _defineConfig = {
|
||||
}
|
||||
|
||||
let _defConfig = {
|
||||
/**
|
||||
* iframe / 嵌入场景下是否把调用转发到父页面的同名 SDK(postMessage)。
|
||||
* - 'auto'(默认):处于子 frame 时 startScan 等走父页;onScanListener 仅注册在 iframe 内,识别结果由父页回传
|
||||
* - true | 'on' | 'parent':在存在父 window 时强制转发
|
||||
* - false | 'off' | 'local':始终在本页执行(子页自己要跑扫码时用)
|
||||
*/
|
||||
embedProxyMode: 'auto',
|
||||
}
|
||||
|
||||
let _customConfig = {
|
||||
|
||||
40
src/services/embedEnvProbe.js
Normal file
40
src/services/embedEnvProbe.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 嵌入场景下由父页探测得到的微信环境结论(子页跨域时无法读 top UA)
|
||||
*/
|
||||
let parentWxEnvReport = null;
|
||||
|
||||
export function setParentWxEnvReport(wx) {
|
||||
parentWxEnvReport = wx ? true : false;
|
||||
}
|
||||
|
||||
export function getParentWxEnvReport() {
|
||||
return parentWxEnvReport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在给定 window 上探测微信系 WebView(公众号 / 部分企业微信)
|
||||
* 与外层页面是否为同一套 SDK无关:依赖 UA / WeixinJSBridge
|
||||
*/
|
||||
export function readWxLikeEnvFromWindow(win) {
|
||||
if (!win) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const nav = win.navigator;
|
||||
if (nav) {
|
||||
const ua = nav.userAgent || "";
|
||||
if (/micromessenger/i.test(ua)) {
|
||||
return true;
|
||||
}
|
||||
if (/wxwork/i.test(ua)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (win.WeixinJSBridge) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
37
src/services/embedProxy.js
Normal file
37
src/services/embedProxy.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getConfig } from "./config";
|
||||
|
||||
/** 是否存在可与 postMessage 交互的父 browsing context(自身不是顶层 opener/parent) */
|
||||
export function hasDistinctParentWindow() {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.parent != null && window.parent !== window;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否通过父页面 SDK 代理执行(每次调用重新读取 config)
|
||||
*
|
||||
* embedProxyMode 为 auto(默认)时:只要 window.parent !== window(处于子 frame),
|
||||
* 所有对外 API(含 startScan)均走 postMessage 由父页同一套 SDK 执行,避免子页重复跑扫码逻辑。
|
||||
*/
|
||||
export function resolveUseParentProxy() {
|
||||
const mode = getConfig("embedProxyMode");
|
||||
if (mode === false || mode === "local" || mode === "off") {
|
||||
return false;
|
||||
}
|
||||
const parentOk = hasDistinctParentWindow();
|
||||
if (!parentOk) {
|
||||
return false;
|
||||
}
|
||||
if (mode === true || mode === "on" || mode === "parent") {
|
||||
return true;
|
||||
}
|
||||
if (mode === "auto" || mode === undefined || mode === null) {
|
||||
return parentOk;
|
||||
}
|
||||
return !!mode;
|
||||
}
|
||||
24
src/services/embedScanBridge.js
Normal file
24
src/services/embedScanBridge.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/** 父页识别到扫码结果时,向嵌入 iframe 转发的回调(由 installEmbedHost 注册) */
|
||||
let embedScanResultForwarder = null;
|
||||
/** 父页图片识别失败时,向嵌入 iframe 转发的回调 */
|
||||
let embedScanErrorForwarder = null;
|
||||
|
||||
export function setEmbedScanResultForwarder(fn) {
|
||||
embedScanResultForwarder = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
export function setEmbedScanErrorForwarder(fn) {
|
||||
embedScanErrorForwarder = typeof fn === "function" ? fn : null;
|
||||
}
|
||||
|
||||
export function forwardEmbedScanResultIfNeeded(result, meta) {
|
||||
if (embedScanResultForwarder && result != null && result !== "") {
|
||||
embedScanResultForwarder(result, meta);
|
||||
}
|
||||
}
|
||||
|
||||
export function forwardEmbedScanErrorIfNeeded(error) {
|
||||
if (embedScanErrorForwarder && error != null && error !== "") {
|
||||
embedScanErrorForwarder(error);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,144 @@
|
||||
import { inRuntime, bridgeAsync } from "../bridge";
|
||||
import { isSupportWebScan, startScanForWeb, stopScanForWeb, isSupportImageScan, startScanForImage, unlockScanBeep } from "../web";
|
||||
import { isSupportWxScan, startScanForWx } from "../wx";
|
||||
import {
|
||||
isSupportWebScan,
|
||||
startScanForWeb,
|
||||
stopScanForWeb,
|
||||
isSupportImageScan,
|
||||
startScanForImage,
|
||||
unlockScanBeep,
|
||||
playScanBeep,
|
||||
isWebScanImageFallbackEnabled,
|
||||
canUseWebCameraScan,
|
||||
shouldSkipWebCameraProbe,
|
||||
cleanupWebScanResiduals,
|
||||
detectImageFileForScan
|
||||
} from "../web";
|
||||
import { isSupportWxScan, startScanForWx, isWxEnv } from "../wx";
|
||||
import { startScanner, stopScanner } from "../scanner";
|
||||
import { getConfig } from "../config";
|
||||
import { toAny } from "../../utils/toany";
|
||||
import { printDebug } from "../../utils/logger";
|
||||
import { printDebug, printWarn } from "../../utils/logger";
|
||||
import { forwardEmbedScanResultIfNeeded, forwardEmbedScanErrorIfNeeded } from "../embedScanBridge";
|
||||
|
||||
let _scan_status = "ready";
|
||||
let _scan_status_listener = null;
|
||||
let _scan_listener_list = [];
|
||||
let _scan_error_listener_list = [];
|
||||
let _scan_resolve = null;
|
||||
let _scan_closing = false;
|
||||
let _scan_next_start_time = 0;
|
||||
let _embed_scan_host_enabled = false;
|
||||
|
||||
const SCAN_RESTART_DELAY = 500;
|
||||
const BRIDGE_SCAN_TIMEOUT = 5000;
|
||||
const SCAN_SESSION_TIMEOUT = 90000;
|
||||
|
||||
function getScanRestartDelay() {
|
||||
return toAny(getConfig("scanRestartDelay"), SCAN_RESTART_DELAY);
|
||||
}
|
||||
|
||||
function getBridgeScanTimeout() {
|
||||
const timeout = getConfig("bridgeScanTimeout");
|
||||
return typeof timeout === "number" && timeout > 0 ? timeout : BRIDGE_SCAN_TIMEOUT;
|
||||
}
|
||||
|
||||
function getScanSessionTimeout() {
|
||||
const timeout = getConfig("scanSessionTimeout");
|
||||
return typeof timeout === "number" && timeout > 0 ? timeout : SCAN_SESSION_TIMEOUT;
|
||||
}
|
||||
|
||||
function withScanSessionTimeout(scanPromise) {
|
||||
return Promise.race([
|
||||
scanPromise,
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
printWarn("scan session timeout");
|
||||
resolve({ cancel: 1, scanTimeout: true });
|
||||
}, getScanSessionTimeout());
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
function __fallbackScanAfterBridgeFailure(err) {
|
||||
if (!isScanning()) {
|
||||
throw err;
|
||||
}
|
||||
printWarn("bridge scan unavailable, fallback:", err);
|
||||
return __startNonBridgeScan(err);
|
||||
}
|
||||
|
||||
function __fallbackScanAfterWxFailure(err) {
|
||||
if (!isScanning()) {
|
||||
throw err;
|
||||
}
|
||||
printWarn("wx scan unavailable, fallback to web/image scan:", err);
|
||||
return __startNonBridgeScan(err);
|
||||
}
|
||||
|
||||
function __startNonBridgeScan(err) {
|
||||
if (isSupportWebScan()) {
|
||||
return __startWebScan();
|
||||
}
|
||||
if (isSupportImageScan()) {
|
||||
return __startImageScan();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
function parseBarcodeString(input) {
|
||||
// 标准化的类型映射表:将各种变体映射到统一标识
|
||||
// 这样即使传入 EAN_13、EAN-13、EAN13 都能匹配成功
|
||||
const normalizedTypeMap = {
|
||||
// EAN 系列
|
||||
"ean13": true, "ean-13": true, "ean_13": true,
|
||||
"ean8": true, "ean-8": true, "ean_8": true,
|
||||
// UPC 系列
|
||||
"upca": true, "upc-a": true, "upc_a": true,
|
||||
"upce": true, "upc-e": true, "upc_e": true,
|
||||
// CODE 系列
|
||||
"code128": true, "code-128": true, "code_128": true,
|
||||
"code39": true, "code-39": true, "code_39": true,
|
||||
"code93": true, "code-93": true, "code_93": true,
|
||||
// 其他常见类型
|
||||
"itf": true, "itf14": true, "itf-14": true, "itf_14": true,
|
||||
"codabar": true,
|
||||
"pdf417": true, "pdf-417": true, "pdf_417": true,
|
||||
"qrcode": true, "qr-code": true, "qr_code": true,
|
||||
"datamatrix": true, "data-matrix": true, "data_matrix": true,
|
||||
"aztec": true
|
||||
};
|
||||
// 健壮性检查
|
||||
if (typeof input !== 'string') {
|
||||
return input;
|
||||
}
|
||||
// 按第一个逗号分割
|
||||
const commaIndex = input.indexOf(',');
|
||||
if (commaIndex === -1) {
|
||||
return input;
|
||||
}
|
||||
const possibleType = input.substring(0, commaIndex).trim();
|
||||
const value = input.substring(commaIndex + 1).trim();
|
||||
// 类型或值为空,返回原值
|
||||
if (possibleType === "" || value === "") {
|
||||
return input;
|
||||
}
|
||||
// 标准化类型(转小写,便于匹配)
|
||||
const normalizedType = possibleType.toLowerCase();
|
||||
// 检查是否为已知的条形码类型(支持多种分隔符变体)
|
||||
const isKnownType = normalizedTypeMap.hasOwnProperty(normalizedType);
|
||||
|
||||
if (isKnownType) {
|
||||
return value;
|
||||
} else {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
function __checkScanner() {
|
||||
if (_scan_listener_list.length > 0) {
|
||||
if (_scan_listener_list.length > 0 || _embed_scan_host_enabled) {
|
||||
startScanner((result) => {
|
||||
__scannerResult(result);
|
||||
result = parseBarcodeString(result);
|
||||
__scannerResult(result, { source: "scanner", skipBeep: true });
|
||||
});
|
||||
} else {
|
||||
stopScanner();
|
||||
@@ -40,8 +156,17 @@ function __match(result, match) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function __shouldSkipBeep(meta) {
|
||||
if (meta && meta.skipBeep) {
|
||||
return true;
|
||||
}
|
||||
const source = meta && meta.source;
|
||||
return source === "scanner" || source === "bridge" || source === "wx";
|
||||
}
|
||||
|
||||
function __result(result) {
|
||||
function __result(result, meta) {
|
||||
result = parseBarcodeString(result);
|
||||
forwardEmbedScanResultIfNeeded(result, meta);
|
||||
let matched = false;
|
||||
for (let i = 0; i < _scan_listener_list.length; i++) {
|
||||
const item = _scan_listener_list[i];
|
||||
@@ -52,11 +177,76 @@ function __result(result) {
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
if (!__shouldSkipBeep(meta)) {
|
||||
playScanBeep();
|
||||
}
|
||||
_scan_next_start_time = Date.now() + getScanRestartDelay();
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
function normalizeScanError(raw) {
|
||||
if (typeof raw === "string") {
|
||||
return raw;
|
||||
}
|
||||
if (raw && raw.error != null && raw.error !== "") {
|
||||
return String(raw.error);
|
||||
}
|
||||
if (raw && raw.message) {
|
||||
return String(raw.message);
|
||||
}
|
||||
if (raw == null || raw === "") {
|
||||
return "";
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
function __scanError(raw, meta) {
|
||||
const error = normalizeScanError(raw);
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
forwardEmbedScanErrorIfNeeded(error);
|
||||
let matched = false;
|
||||
for (let i = 0; i < _scan_error_listener_list.length; i++) {
|
||||
const item = _scan_error_listener_list[i];
|
||||
if (item.listener && __match(error, item.match)) {
|
||||
matched = true;
|
||||
item.listener(Object.assign({
|
||||
error,
|
||||
key: item.key
|
||||
}, meta || {}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
function __notifyImageScanFailure(raw, meta) {
|
||||
if (raw && raw.cancel) {
|
||||
return false;
|
||||
}
|
||||
const error = normalizeScanError(raw);
|
||||
const notified = __scanError(raw, Object.assign({ source: "image" }, meta || {}));
|
||||
if (!notified && error) {
|
||||
printWarn("image scan failed:", error);
|
||||
}
|
||||
return notified;
|
||||
}
|
||||
|
||||
function __notifyWebScanFailure(raw, meta) {
|
||||
if (raw && raw.cancel) {
|
||||
return false;
|
||||
}
|
||||
const payload = raw && raw.error != null ? raw.error : raw;
|
||||
const error = normalizeScanError(payload);
|
||||
const notified = __scanError(payload, Object.assign({ source: "web" }, meta || {}));
|
||||
if (!notified && error) {
|
||||
printWarn("web scan failed:", error);
|
||||
}
|
||||
return notified;
|
||||
}
|
||||
|
||||
function __hasMatchedListener(result) {
|
||||
for (let i = 0; i < _scan_listener_list.length; i++) {
|
||||
const item = _scan_listener_list[i];
|
||||
@@ -105,10 +295,49 @@ function __stopCurrentScan() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function __scannerResult(result) {
|
||||
if (!__hasMatchedListener(result)) {
|
||||
/**
|
||||
* 父页通过 postMessage 将识别结果投递到嵌入 iframe 时调用(与本地扫码枪/监听同一链路)。
|
||||
* @returns {boolean} 是否有监听消费了该结果
|
||||
*/
|
||||
export function dispatchEmbedScanResult(raw, meta) {
|
||||
const result =
|
||||
typeof raw === "string" ? parseBarcodeString(raw) : raw;
|
||||
return __scannerResult(result, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 父页通过 postMessage 将识别错误投递到嵌入 iframe 时调用。
|
||||
* @returns {boolean} 是否有监听消费了该错误
|
||||
*/
|
||||
export function dispatchEmbedScanError(raw) {
|
||||
const error = normalizeScanError(raw);
|
||||
return __scanError(error, { source: "image" });
|
||||
}
|
||||
|
||||
/**
|
||||
* 嵌入 iframe 已消费扫码结果时通知父页结束当前识别(关闭摄像头/UI 等)。
|
||||
*/
|
||||
export function acknowledgeEmbedScanConsumed(raw) {
|
||||
const result =
|
||||
typeof raw === "string" ? parseBarcodeString(raw) : raw;
|
||||
if (!isScanning()) {
|
||||
return;
|
||||
}
|
||||
const resolve = __finishScan();
|
||||
_scan_closing = true;
|
||||
_scan_next_start_time = Date.now() + getScanRestartDelay();
|
||||
__stopCurrentScan().then(() => {
|
||||
setTimeout(() => {
|
||||
_scan_closing = false;
|
||||
}, 0);
|
||||
});
|
||||
resolve && resolve({ result });
|
||||
}
|
||||
|
||||
function __scannerResult(result, meta) {
|
||||
if (!__hasMatchedListener(result)) {
|
||||
return __result(result, meta);
|
||||
}
|
||||
if (isScanning()) {
|
||||
const resolve = __finishScan();
|
||||
_scan_closing = true;
|
||||
@@ -117,26 +346,26 @@ function __scannerResult(result) {
|
||||
_scan_closing = false;
|
||||
}, 0);
|
||||
});
|
||||
__result(result);
|
||||
const matched = __result(result, meta);
|
||||
resolve && resolve({
|
||||
result
|
||||
});
|
||||
return;
|
||||
return matched;
|
||||
}
|
||||
__result(result);
|
||||
return __result(result, meta);
|
||||
}
|
||||
|
||||
function __startBridgeScan() {
|
||||
return bridgeAsync("startScan", {
|
||||
closeable: true
|
||||
}).then(resp => {
|
||||
}, getBridgeScanTimeout()).then(resp => {
|
||||
if (!isScanning()) {
|
||||
return resp;
|
||||
}
|
||||
if (!resp || !resp.result) {
|
||||
return resp;
|
||||
}
|
||||
if (__result(resp.result)) {
|
||||
if (__result(resp.result, { source: "bridge" })) {
|
||||
return resp;
|
||||
}
|
||||
if (isScanning()) {
|
||||
@@ -145,12 +374,12 @@ function __startBridgeScan() {
|
||||
return resp;
|
||||
}).catch(err => {
|
||||
if (!isScanning()) {
|
||||
return err;
|
||||
throw err;
|
||||
}
|
||||
if (!err || !err.result) {
|
||||
return err;
|
||||
throw err;
|
||||
}
|
||||
if (__result(err.result)) {
|
||||
if (__result(err.result, { source: "bridge" })) {
|
||||
return err;
|
||||
}
|
||||
if (isScanning()) {
|
||||
@@ -168,29 +397,62 @@ function __startWxScan() {
|
||||
if (!isScanning()) {
|
||||
return resp;
|
||||
}
|
||||
if (resp && resp.error && !resp.result) {
|
||||
throw resp.error;
|
||||
}
|
||||
if (!resp || !resp.result) {
|
||||
return resp;
|
||||
}
|
||||
if (__result(resp.result)) {
|
||||
if (__result(resp.result, { source: "wx" })) {
|
||||
return resp;
|
||||
}
|
||||
if (isScanning()) {
|
||||
return __startWxScan();
|
||||
return resp;
|
||||
}).catch(err => {
|
||||
if (!isScanning()) {
|
||||
throw err;
|
||||
}
|
||||
if (err && err.result) {
|
||||
if (__result(err.result, { source: "wx" })) {
|
||||
return err;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
return __fallbackScanAfterWxFailure(err);
|
||||
});
|
||||
}
|
||||
|
||||
function __startWebScan(useImageScan = false) {
|
||||
return startScanForWeb(__result, __scanError).then(resp => {
|
||||
if (!isScanning()) {
|
||||
return resp;
|
||||
}
|
||||
if (!resp || !resp.result) {
|
||||
if (resp && resp.success === false) {
|
||||
__notifyWebScanFailure(resp);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
__result(resp.result);
|
||||
return resp;
|
||||
}).catch(err => {
|
||||
if (!isScanning()) {
|
||||
return err;
|
||||
}
|
||||
if (!err || !err.result) {
|
||||
if (err && err.cancel) {
|
||||
return err;
|
||||
}
|
||||
if (__result(err.result)) {
|
||||
if (err && err.scanTimeout) {
|
||||
return err;
|
||||
}
|
||||
if (isScanning()) {
|
||||
return __startWxScan();
|
||||
if (err && err.imageFallbackUsed) {
|
||||
__notifyWebScanFailure(err);
|
||||
return err;
|
||||
}
|
||||
if (isWebScanImageFallbackEnabled() && useImageScan) {
|
||||
return __startImageScan();
|
||||
}
|
||||
__notifyWebScanFailure(err);
|
||||
printWarn("web scan failed:", err);
|
||||
return err;
|
||||
});
|
||||
}
|
||||
@@ -200,29 +462,27 @@ function __startImageScan() {
|
||||
if (!isScanning()) {
|
||||
return resp;
|
||||
}
|
||||
if (resp && resp.cancel) {
|
||||
return resp;
|
||||
}
|
||||
if (!resp || !resp.result) {
|
||||
__notifyImageScanFailure(resp);
|
||||
return resp;
|
||||
}
|
||||
if (__result(resp.result)) {
|
||||
return resp;
|
||||
}
|
||||
if (isScanning()) {
|
||||
return __startImageScan();
|
||||
}
|
||||
__result(resp.result);
|
||||
return resp;
|
||||
}).catch(err => {
|
||||
if (!isScanning()) {
|
||||
return err;
|
||||
}
|
||||
if (!err || !err.result) {
|
||||
if (err && err.cancel) {
|
||||
return err;
|
||||
}
|
||||
if (__result(err.result)) {
|
||||
if (err && err.result) {
|
||||
__result(err.result);
|
||||
return err;
|
||||
}
|
||||
if (isScanning()) {
|
||||
return __startImageScan();
|
||||
}
|
||||
__notifyImageScanFailure(err);
|
||||
return err;
|
||||
});
|
||||
}
|
||||
@@ -231,12 +491,26 @@ export function isScanning() {
|
||||
return _scan_status === "scanning";
|
||||
}
|
||||
|
||||
export function setEmbedScanHostEnabled(enabled) {
|
||||
const nextEnabled = enabled !== false;
|
||||
if (_embed_scan_host_enabled === nextEnabled) {
|
||||
return;
|
||||
}
|
||||
_embed_scan_host_enabled = nextEnabled;
|
||||
__checkScanner();
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
for (let i = 0; i < _scan_listener_list.length; i++) {
|
||||
const item = _scan_listener_list[i];
|
||||
item.cancel();
|
||||
}
|
||||
_scan_listener_list.length = 0;
|
||||
for (let i = 0; i < _scan_error_listener_list.length; i++) {
|
||||
const item = _scan_error_listener_list[i];
|
||||
item.cancel();
|
||||
}
|
||||
_scan_error_listener_list.length = 0;
|
||||
__checkScanner();
|
||||
}
|
||||
|
||||
@@ -300,6 +574,68 @@ export function offScanListener(listener) {
|
||||
__checkScanner();
|
||||
}
|
||||
|
||||
function createScanErrorListenerItem(listener, key, match, level) {
|
||||
const item = {
|
||||
key,
|
||||
match: match || "",
|
||||
level: level || 0,
|
||||
listener: listener,
|
||||
cancel: () => {
|
||||
const index = _scan_error_listener_list.indexOf(item);
|
||||
if (index !== -1) {
|
||||
const items = _scan_error_listener_list.splice(index, 1);
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const removed = items[i];
|
||||
removed.listener && removed.listener({ cancel: 1, key: removed.key });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
export function onScanErrorListener(listener, key, match, level) {
|
||||
if (!key || typeof key !== 'string') {
|
||||
return;
|
||||
}
|
||||
if (typeof listener !== 'function') {
|
||||
return;
|
||||
}
|
||||
let item = null;
|
||||
for (let i = 0; i < _scan_error_listener_list.length; i++) {
|
||||
const _i = _scan_error_listener_list[i];
|
||||
if (_i.key === key) {
|
||||
item = _i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (item) {
|
||||
item.level = level;
|
||||
item.match = match;
|
||||
item.listener = listener;
|
||||
} else {
|
||||
item = createScanErrorListenerItem(listener, key, match, level);
|
||||
_scan_error_listener_list.push(item);
|
||||
}
|
||||
_scan_error_listener_list.sort((a, b) => b.level - a.level);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function offScanErrorListener(listener) {
|
||||
for (let i = 0; i < _scan_error_listener_list.length; i++) {
|
||||
const _i = _scan_error_listener_list[i];
|
||||
if (typeof listener === 'string') {
|
||||
if (_i.key === listener) {
|
||||
_i.cancel();
|
||||
break;
|
||||
}
|
||||
} else if (_i.listener === listener) {
|
||||
_i.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setStatusListener(listener) {
|
||||
if (typeof listener !== 'function') {
|
||||
return;
|
||||
@@ -312,11 +648,15 @@ export function getStatus() {
|
||||
}
|
||||
|
||||
export function stopScan() {
|
||||
cleanupWebScanResiduals();
|
||||
if (!isScanning()) {
|
||||
return;
|
||||
}
|
||||
const resolve = __finishScan();
|
||||
_scan_closing = true;
|
||||
__stopCurrentScan().then(() => {
|
||||
__closed();
|
||||
resolve && resolve({ cancel: 1 });
|
||||
_scan_closing = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -324,6 +664,7 @@ export function startScan() {
|
||||
if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) {
|
||||
return;
|
||||
}
|
||||
unlockScanBeep();
|
||||
Promise.resolve().then(() => {
|
||||
__scanning();
|
||||
let scannerPromise = new Promise(resolve => {
|
||||
@@ -331,46 +672,23 @@ export function startScan() {
|
||||
});
|
||||
let scanPromise = Promise.resolve();
|
||||
if (inRuntime()) {
|
||||
scanPromise = __startBridgeScan();
|
||||
scanPromise = __startBridgeScan().catch(__fallbackScanAfterBridgeFailure);
|
||||
} else if (isSupportWxScan()) {
|
||||
scanPromise = __startWxScan();
|
||||
} else if (isSupportWebScan()) {
|
||||
unlockScanBeep();
|
||||
scanPromise = startScanForWeb(getConfig("webScanCanvasStyle"), __result);
|
||||
scanPromise = __startWebScan(true);
|
||||
} else if (isSupportImageScan()) {
|
||||
scanPromise = __startImageScan();
|
||||
} else {
|
||||
printDebug("Not support scanner");
|
||||
printWarn("Not support scanner");
|
||||
}
|
||||
return Promise.race([scanPromise, scannerPromise]);
|
||||
return withScanSessionTimeout(Promise.race([scanPromise, scannerPromise]));
|
||||
}).finally(() => {
|
||||
_scan_resolve = null;
|
||||
__closed();
|
||||
});
|
||||
}
|
||||
|
||||
export function scanVideo() {
|
||||
if (!isSupportWebScan()) {
|
||||
printDebug("Not support video scanner");
|
||||
return;
|
||||
}
|
||||
if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) {
|
||||
return;
|
||||
}
|
||||
unlockScanBeep();
|
||||
Promise.resolve().then(() => {
|
||||
__scanning();
|
||||
return startScanForWeb(getConfig("webScanCanvasStyle"), __result).then(resp => {
|
||||
if (resp && resp.result) {
|
||||
__result(resp.result);
|
||||
}
|
||||
throw resp.error;
|
||||
}).catch(err => { });
|
||||
}).finally(() => {
|
||||
__closed();
|
||||
});
|
||||
}
|
||||
|
||||
export function scanImage() {
|
||||
if (!isSupportImageScan()) {
|
||||
printDebug("Not support image scanner");
|
||||
@@ -379,15 +697,85 @@ export function scanImage() {
|
||||
if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) {
|
||||
return;
|
||||
}
|
||||
Promise.resolve().then(() => {
|
||||
__scanning();
|
||||
return startScanForImage().then(resp => {
|
||||
if (resp && resp.result) {
|
||||
__result(resp.result);
|
||||
}
|
||||
throw resp.error;
|
||||
}).catch(err => { });
|
||||
unlockScanBeep();
|
||||
__scanning();
|
||||
withScanSessionTimeout(__startImageScan()).catch(err => {
|
||||
if (err && err.cancel) {
|
||||
return;
|
||||
}
|
||||
__notifyImageScanFailure(err);
|
||||
}).finally(() => {
|
||||
__closed();
|
||||
});
|
||||
}
|
||||
|
||||
/** 由原生/业务传入已选图片 File,避免 WebView 无法从 input.files 取文件 */
|
||||
export function scanImageFromFile(file) {
|
||||
if (!isSupportImageScan()) {
|
||||
printDebug("Not support image scanner");
|
||||
return;
|
||||
}
|
||||
if (isScanning() || _scan_closing || Date.now() < _scan_next_start_time) {
|
||||
return;
|
||||
}
|
||||
unlockScanBeep();
|
||||
cleanupWebScanResiduals();
|
||||
__scanning();
|
||||
withScanSessionTimeout(
|
||||
detectImageFileForScan(file).then(resp => {
|
||||
if (!isScanning()) {
|
||||
return resp;
|
||||
}
|
||||
if (resp && resp.cancel) {
|
||||
return resp;
|
||||
}
|
||||
if (!resp || !resp.result) {
|
||||
__notifyImageScanFailure(resp);
|
||||
return resp;
|
||||
}
|
||||
__result(resp.result);
|
||||
return resp;
|
||||
})
|
||||
).catch(err => {
|
||||
if (err && err.cancel) {
|
||||
return;
|
||||
}
|
||||
__notifyImageScanFailure(err);
|
||||
}).finally(() => {
|
||||
__closed();
|
||||
});
|
||||
}
|
||||
|
||||
export const supportList = [
|
||||
{
|
||||
name: "bridge",
|
||||
get support() {
|
||||
return !!inRuntime();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "wx",
|
||||
get support() {
|
||||
return !!isSupportWxScan();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "web",
|
||||
get support() {
|
||||
if (shouldSkipWebCameraProbe()) {
|
||||
return false;
|
||||
}
|
||||
return !!canUseWebCameraScan();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
get support() {
|
||||
return !!isSupportImageScan();
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "scanner",
|
||||
support: true
|
||||
}
|
||||
];
|
||||
@@ -6,6 +6,13 @@ let _scannerLastInputTime = 0;
|
||||
|
||||
const SCANNER_INPUT_INTERVAL = 100;
|
||||
|
||||
function getWindow() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
function clearScannerValue() {
|
||||
_scannerValue = "";
|
||||
_scannerLastInputTime = 0;
|
||||
@@ -27,7 +34,21 @@ function delayClearScannerValue() {
|
||||
function normalizeScannerValue(value) {
|
||||
return value.replace(/[\uFF01-\uFF5E]/g, char => {
|
||||
return String.fromCharCode(char.charCodeAt(0) - 0xFEE0);
|
||||
}).replace(/\u3002/g, ".");
|
||||
}).replace(/\u3002/g, ".")
|
||||
.replace(/\u2014/g, "_");
|
||||
}
|
||||
|
||||
function isInputElementFocused() {
|
||||
const win = getWindow();
|
||||
const activeElement = win && win.document && win.document.activeElement;
|
||||
if (!activeElement || activeElement === win.document.body || activeElement === win.document.documentElement) {
|
||||
return false;
|
||||
}
|
||||
const tagName = activeElement.tagName && activeElement.tagName.toLowerCase();
|
||||
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
||||
return true;
|
||||
}
|
||||
return activeElement.isContentEditable === true;
|
||||
}
|
||||
|
||||
function stopScannerEvent(event) {
|
||||
@@ -39,6 +60,10 @@ function onScannerKeydown(event) {
|
||||
if (_scannerStatus !== "scanning") {
|
||||
return;
|
||||
}
|
||||
if (isInputElementFocused()) {
|
||||
clearScannerValue();
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
@@ -69,13 +94,17 @@ export function startScanner(callback){
|
||||
if (!callback || typeof callback !== "function") {
|
||||
return;
|
||||
}
|
||||
const win = getWindow();
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
_scannerCallback = callback;
|
||||
if (_scannerStatus === "scanning") {
|
||||
return;
|
||||
}
|
||||
_scannerStatus = "scanning";
|
||||
clearScannerValue();
|
||||
window.addEventListener("keydown", onScannerKeydown);
|
||||
win.addEventListener("keydown", onScannerKeydown);
|
||||
}
|
||||
|
||||
export function stopScanner(){
|
||||
@@ -85,5 +114,6 @@ export function stopScanner(){
|
||||
_scannerStatus = "ready";
|
||||
_scannerCallback = null;
|
||||
clearScannerValue();
|
||||
window.removeEventListener("keydown", onScannerKeydown);
|
||||
const win = getWindow();
|
||||
win && win.removeEventListener("keydown", onScannerKeydown);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
import { getConfig } from "../config";
|
||||
import { readWxLikeEnvFromWindow, getParentWxEnvReport } from "../embedEnvProbe";
|
||||
import { resolveUseParentProxy } from "../embedProxy";
|
||||
import { request } from "../../utils/request";
|
||||
import { toAny } from "../../utils/toany";
|
||||
|
||||
@@ -9,10 +11,13 @@ let _wxReadyPromise = null;
|
||||
let _wxReady = false;
|
||||
|
||||
function getWx() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
if (typeof wx !== "undefined") {
|
||||
return wx;
|
||||
}
|
||||
return window.wx;
|
||||
if (typeof window !== "undefined") {
|
||||
return window.wx;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadWxScript() {
|
||||
@@ -43,6 +48,27 @@ function loadWxScript() {
|
||||
});
|
||||
}
|
||||
|
||||
/** 微信 JS-SDK 签名 URL:嵌入且走父页代理时用父页地址(与微信内打开的页面一致);可配置 wxJssdkSignatureUrl 覆盖 */
|
||||
function getPageUrlForWxJssdkSignature() {
|
||||
const override = getConfig("wxJssdkSignatureUrl");
|
||||
if (override) {
|
||||
return String(override).split("#")[0];
|
||||
}
|
||||
if (resolveUseParentProxy()) {
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.parent && window.parent !== window) {
|
||||
return window.parent.location.href.split("#")[0];
|
||||
}
|
||||
} catch (e) {
|
||||
// 跨域父页不可读 location,由调用方配置 wxJssdkSignatureUrl
|
||||
}
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
return window.location.href.split("#")[0];
|
||||
}
|
||||
|
||||
function fetchWxConfig() {
|
||||
let initWechatJssdk = toAny(getConfig("initWechatJssdk"), {});
|
||||
if (!!initWechatJssdk.sdkConfig) {
|
||||
@@ -56,7 +82,7 @@ function fetchWxConfig() {
|
||||
url: apiUrl,
|
||||
method: "GET",
|
||||
data: {
|
||||
url: window.location.href.split("#")[0]
|
||||
url: getPageUrlForWxJssdkSignature()
|
||||
}
|
||||
}).then(res => {
|
||||
let data = toAny(res.data, {});
|
||||
@@ -74,16 +100,39 @@ function fetchWxConfig() {
|
||||
}
|
||||
|
||||
export function isWxEnv() {
|
||||
return typeof navigator !== "undefined"
|
||||
&& /micromessenger/i.test(navigator.userAgent || "");
|
||||
if (readWxLikeEnvFromWindow(typeof window !== "undefined" ? window : null)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
if (window.top && window.top !== window && readWxLikeEnvFromWindow(window.top)) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// 跨域不可读 top:依赖父页 probeWxEnv / embedEnvProbe 缓存
|
||||
}
|
||||
}
|
||||
if (resolveUseParentProxy() && getParentWxEnvReport() === true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isWxMiniProgramEnv() {
|
||||
const wx = getWx();
|
||||
return !!(wx && wx.scanCode);
|
||||
}
|
||||
|
||||
export function isSupportWxScan() {
|
||||
const wx = getWx();
|
||||
return isWxEnv()
|
||||
&& _wxReady
|
||||
&& wx
|
||||
&& wx.scanQRCode;
|
||||
if (isWxMiniProgramEnv()) {
|
||||
return true;
|
||||
}
|
||||
// 嵌入且 API 走父页时,子页未必初始化 wx;只要识别为微信环境即视为支持(实际能力由父页 SDK 决定)
|
||||
if (resolveUseParentProxy() && isWxEnv()) {
|
||||
return true;
|
||||
}
|
||||
return !!(isWxEnv() && _wxReady && wx && wx.scanQRCode);
|
||||
}
|
||||
|
||||
export function initWxJssdk() {
|
||||
@@ -128,7 +177,73 @@ export function initWxJssdk() {
|
||||
return _wxReadyPromise;
|
||||
}
|
||||
|
||||
function getWxScanErrorMessage(err) {
|
||||
if (!err) {
|
||||
return "扫码失败";
|
||||
}
|
||||
let errorMsg = err.errMsg || "扫码失败";
|
||||
if (typeof err === "object") {
|
||||
const errorDetails = [];
|
||||
if (err.errCode) {
|
||||
errorDetails.push("错误代码: " + err.errCode);
|
||||
}
|
||||
if (err.errDesc) {
|
||||
errorDetails.push("错误描述: " + err.errDesc);
|
||||
}
|
||||
try {
|
||||
const errorStr = JSON.stringify(err, null, 2);
|
||||
if (errorStr && errorStr !== "{}" && errorStr !== "{\"errMsg\":\"" + errorMsg + "\"}") {
|
||||
errorDetails.push(errorStr);
|
||||
} else {
|
||||
errorDetails.push(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
if (errorDetails.length) {
|
||||
errorMsg = errorDetails.join("\n");
|
||||
}
|
||||
}
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
function startScanForWxMiniProgram(options) {
|
||||
return new Promise(resolve => {
|
||||
const wx = getWx();
|
||||
if (!wx || !wx.scanCode) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: "微信小程序API不可用"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const {
|
||||
scanType = ["qrCode", "barCode"],
|
||||
onlyFromCamera = false
|
||||
} = options || {};
|
||||
wx.scanCode({
|
||||
onlyFromCamera,
|
||||
scanType,
|
||||
success: res => {
|
||||
resolve({
|
||||
success: true,
|
||||
result: res.result,
|
||||
code: res.result
|
||||
});
|
||||
},
|
||||
fail: err => {
|
||||
resolve({
|
||||
success: false,
|
||||
error: getWxScanErrorMessage(err)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function startScanForWx(options) {
|
||||
if (isWxMiniProgramEnv()) {
|
||||
return startScanForWxMiniProgram(options);
|
||||
}
|
||||
return initWxJssdk().then(() => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const {
|
||||
|
||||
186
types/index.d.ts
vendored
186
types/index.d.ts
vendored
@@ -6,6 +6,18 @@ interface ScanConfigOptions {
|
||||
* 扫码重启延迟,单位:毫秒,默认500ms
|
||||
*/
|
||||
scanRestartDelay?: number,
|
||||
/**
|
||||
* iframe 场景下是否将 API 调用转发到父页面同名 SDK(postMessage)。
|
||||
* - `'auto'`(默认):处于子 frame 时 `startScan` 等由父页执行;`onScanListener` 仅注册在 iframe 内,识别结果由父页 `postMessage` 回传
|
||||
* - `true` / `'on'` / `'parent'`:存在父 window 时强制转发
|
||||
* - `false` / `'off'` / `'local'`:始终在本页执行(子页自己要跑扫码时用)
|
||||
*/
|
||||
embedProxyMode?: 'auto' | boolean | 'on' | 'off' | 'local' | 'parent',
|
||||
/**
|
||||
* 请求微信 JS-SDK 签名时使用的页面 URL(不含 hash)。
|
||||
* 跨域 iframe 无法读取父页地址时需手动设为当前微信内打开的页面链接。
|
||||
*/
|
||||
wxJssdkSignatureUrl?: string,
|
||||
/**
|
||||
* 桥接是否启用,默认启用
|
||||
*/
|
||||
@@ -33,6 +45,58 @@ interface ScanConfigOptions {
|
||||
* 2. 结束扫码的方法名称为:stopScan
|
||||
*/
|
||||
bridgeName?: string,
|
||||
/**
|
||||
* 桥接扫码超时(毫秒),超时后回退 Web/图片识别,默认 5000
|
||||
*/
|
||||
bridgeScanTimeout?: number,
|
||||
/**
|
||||
* 是否允许 H5 摄像头扫码:true 强制开启(仍需有媒体 API),false 强制关闭
|
||||
*/
|
||||
webScanCameraEnabled?: boolean,
|
||||
/**
|
||||
* @deprecated 请用 webScanCameraEnabled;保留兼容
|
||||
*/
|
||||
webScanCameraInWechat?: boolean,
|
||||
/**
|
||||
* @deprecated 摄像头权限已后置到 startScan,请用 webScanVideoAccessTimeout
|
||||
*/
|
||||
webScanCameraProbeTimeout?: number,
|
||||
/**
|
||||
* startScan 走 Web 摄像头前是否展示权限说明弹窗,默认 true
|
||||
*/
|
||||
webScanCameraPermissionDialogEnabled?: boolean,
|
||||
/**
|
||||
* 摄像头权限说明弹窗标题
|
||||
*/
|
||||
webScanCameraPermissionTitle?: string,
|
||||
/**
|
||||
* 摄像头权限说明弹窗正文
|
||||
*/
|
||||
webScanCameraPermissionMessage?: string,
|
||||
/**
|
||||
* 摄像头权限说明弹窗确认按钮文案
|
||||
*/
|
||||
webScanCameraPermissionConfirmText?: string,
|
||||
/**
|
||||
* 摄像头权限说明弹窗取消按钮文案
|
||||
*/
|
||||
webScanCameraPermissionCancelText?: string,
|
||||
/**
|
||||
* 选图后延迟读取 file 对象(毫秒),微信/安卓 WebView 默认 100
|
||||
*/
|
||||
webScanFileReadDelay?: number,
|
||||
/**
|
||||
* 强制使用 WASM ponyfill 识别(微信/部分 WebView 建议开启,默认微信内自动开启)
|
||||
*/
|
||||
webScanPreferPonyfill?: boolean,
|
||||
/**
|
||||
* WASM 文件完整 URL;未配置时相对 SDK 脚本地址解析 lib/reader.wasm
|
||||
*/
|
||||
webScanWasmUrl?: string,
|
||||
/**
|
||||
* WASM 基准路径(SDK 脚本 URL 或目录);用于 async/defer 加载时修正 reader.wasm 路径
|
||||
*/
|
||||
webScanWasmBaseUrl?: string,
|
||||
/**
|
||||
* webScan是否启用,默认启用
|
||||
*/
|
||||
@@ -42,9 +106,29 @@ interface ScanConfigOptions {
|
||||
*/
|
||||
webScanCanvasEnabled?: boolean,
|
||||
/**
|
||||
* 网页扫码canvas样式,默认:position: fixed; width: 300px; height: 300px; top: 0; left: 0; z-index: 9999;
|
||||
* 网页扫码canvas样式,默认:PC 为左上角固定 300×300;移动端为 min(视口宽, 视口高) 的正方形,固定在左上角;z-index: 9999
|
||||
*/
|
||||
webScanCanvasStyle?: string,
|
||||
/**
|
||||
* 网页扫码canvas关闭按钮样式,默认显示在canvas右上角
|
||||
*/
|
||||
webScanCloseButtonStyle?: string,
|
||||
/**
|
||||
* 网页扫码 canvas 根元素的 class,便于配合外部样式表定制布局与外观
|
||||
*/
|
||||
webScanCanvasClass?: string,
|
||||
/**
|
||||
* 网页扫码关闭按钮的 class
|
||||
*/
|
||||
webScanCloseButtonClass?: string,
|
||||
/**
|
||||
* Canvas 开启且支持图片识别时,关闭按钮下方的「选图」按钮样式(fixed 定位基准由 SDK 计算,可与关闭按钮一致覆盖)
|
||||
*/
|
||||
webScanPickImageButtonStyle?: string,
|
||||
/**
|
||||
* 网页扫码选图按钮的 class
|
||||
*/
|
||||
webScanPickImageButtonClass?: string,
|
||||
/**
|
||||
* 网页扫码类型,默认支持二维码和条码
|
||||
*/
|
||||
@@ -58,13 +142,60 @@ interface ScanConfigOptions {
|
||||
*/
|
||||
webScanVideoMirrorVertical?: boolean,
|
||||
/**
|
||||
* 网页扫码成功提示音地址,默认使用内置提示音
|
||||
* 摄像头不可用(如部分安卓内置浏览器禁用 getUserMedia)时是否自动回退为拍照/选图识别,默认启用
|
||||
*/
|
||||
webScanBeepAudio?: string,
|
||||
webScanImageFallbackOnVideoError?: boolean,
|
||||
/**
|
||||
* 网页扫码成功提示音是否启用,默认启用
|
||||
* 打开摄像头超时时间(毫秒),超时后触发图片回退,默认 10000
|
||||
*/
|
||||
webScanBeepEnabled?: boolean,
|
||||
webScanVideoAccessTimeout?: number,
|
||||
/**
|
||||
* 摄像头已打开但长时间无画面时触发图片回退(毫秒),默认 8000
|
||||
*/
|
||||
webScanVideoReadyTimeout?: number,
|
||||
/**
|
||||
* 图片/回退识别时 detect 超时(毫秒),默认 15000
|
||||
*/
|
||||
webScanDetectTimeout?: number,
|
||||
/**
|
||||
* 图片识别是否优先使用 ZXing ponyfill(原生 detect 在部分 WebView 可能卡住),默认 true
|
||||
*/
|
||||
webScanImagePreferPonyfill?: boolean,
|
||||
/**
|
||||
* 移动端图片回退是否使用 capture 拍照(false 为相册选图,兼容性更好),默认 false
|
||||
*/
|
||||
webScanImagePreferCapture?: boolean,
|
||||
/**
|
||||
* 图片识别是否优先 canvas 解码(安卓 WebView 建议开启),默认在安卓/微信内自动开启
|
||||
*/
|
||||
webScanImageDetectPreferCanvas?: boolean,
|
||||
/**
|
||||
* ZXing WASM 加载超时(毫秒),默认 20000
|
||||
*/
|
||||
webScanPrepareTimeout?: number,
|
||||
/**
|
||||
* 选图等待超时(毫秒),默认 120000
|
||||
*/
|
||||
webScanChooseImageTimeout?: number,
|
||||
/**
|
||||
* 选图方式:button=显示「选择图片」按钮(安卓/微信默认,需用户点击);auto=自动弹出系统选图
|
||||
*/
|
||||
webScanImagePickerMode?: 'auto' | 'button',
|
||||
webScanImagePickerTitle?: string,
|
||||
webScanImagePickerButtonText?: string,
|
||||
webScanImagePickerCancelText?: string,
|
||||
/**
|
||||
* 单次扫码会话超时(毫秒),超时后状态恢复 ready,默认 90000
|
||||
*/
|
||||
scanSessionTimeout?: number,
|
||||
/**
|
||||
* 扫码成功提示音地址,默认使用内置提示音;任意识别模式匹配成功时播放
|
||||
*/
|
||||
scanBeepAudio?: string,
|
||||
/**
|
||||
* 扫码成功是否播放提示音,默认启用;任意识别模式匹配成功时生效
|
||||
*/
|
||||
scanBeepEnabled?: boolean,
|
||||
/**
|
||||
* 微信JSSDK配置,微信环境才会生效,配置后会自动初始化微信JSSDK
|
||||
*/
|
||||
@@ -117,6 +248,16 @@ interface ScanResult {
|
||||
key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码错误
|
||||
*/
|
||||
interface ScanErrorInfo {
|
||||
error: string,
|
||||
key: string,
|
||||
source?: string,
|
||||
cancel?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听key
|
||||
*/
|
||||
@@ -153,6 +294,22 @@ type ScanStatus = "scanning" | "ready";
|
||||
*/
|
||||
type ScanResultCallback = (result: ScanResult) => any;
|
||||
|
||||
/**
|
||||
* 监听扫码错误回调
|
||||
*/
|
||||
type ScanErrorCallback = (error: ScanErrorInfo) => any;
|
||||
|
||||
/**
|
||||
* 扫码错误监听信息
|
||||
*/
|
||||
interface ScanErrorListenerInfo {
|
||||
key?: string;
|
||||
match?: string;
|
||||
level?: number;
|
||||
listener: ScanErrorCallback;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听状态回调
|
||||
*/
|
||||
@@ -183,6 +340,19 @@ interface IScan {
|
||||
* @param callback 监听回调,或监听key
|
||||
*/
|
||||
offScanListener(callback: ScanResultCallback | string): void;
|
||||
/**
|
||||
* 添加监听扫码错误(如图片识别失败)
|
||||
* @param callback 错误回调
|
||||
* @param key 监听 key
|
||||
* @param match 可选正则,匹配 error 文本后回调
|
||||
* @param level 优先级
|
||||
*/
|
||||
onScanErrorListener(callback: ScanErrorCallback, key: string, match?: string, level?: number): ScanErrorListenerInfo;
|
||||
/**
|
||||
* 取消监听扫码错误
|
||||
* @param callback 监听回调,或监听 key
|
||||
*/
|
||||
offScanErrorListener(callback: ScanErrorCallback | string): void;
|
||||
/**
|
||||
* 获取扫码状态
|
||||
* @returns ScanStatus
|
||||
@@ -196,14 +366,12 @@ interface IScan {
|
||||
* 开启扫码
|
||||
*/
|
||||
startScan(): void;
|
||||
/**
|
||||
* 开启视频扫码
|
||||
*/
|
||||
scanVideo(): void;
|
||||
/**
|
||||
* 选择图片进行识别
|
||||
*/
|
||||
scanImage(): void;
|
||||
/** 由业务/原生传入已选图片 File 识别(WebView input.files 异常时使用) */
|
||||
scanImageFromFile(file: File | Blob): void;
|
||||
/**
|
||||
* 清除全部监听
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,7 @@ const config = {
|
||||
}),
|
||||
new CpWebpackPlugin([
|
||||
{ from: path.resolve('./types'), to: path.resolve('./dist') },
|
||||
{ from: path.resolve('./README.md'), to: path.resolve('./dist/index.md') },
|
||||
{ from: zxingReaderWasm, to: path.resolve('./dist/lib/reader.wasm') },
|
||||
])
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user