更新记录
1.0.6(2026-09-30)
- 新增
bindViewBelowPage()铺底模式绑定:相机预览自动插入到页面内容之下、页面根容器背景之上(自动下探定位,兼容 nvue 根节点默认白底),页面控件绘制在预览上层,实现"预览铺满屏幕 + 控件悬浮上层"效果;预览底色为黑色(相机未启动时不透白); - 新增
setScanFramePosition(ratio):支持动态调整扫描框的竖直排版位置(相对预览高度比例(范围0~1),0.0=顶部、0.5=居中默认、1.0=底部),扫描框边框、扫描线动画、提示文案整体跟随移动,运行时即时生效;此前扫描框固定绘制在预览竖直中心不可移动
1.0.5(2026-08-27)
- 添加支持回调当前扫描帧的图片(支持开关控制是否回调图片以及清理缓存图片接口)
1.0.4(2026-08-06)
- 添加支持设置扫描框的宽高
scanFrameWidthPx、scanFrameHeightPx
平台兼容性
uni-app(5.14)
| Vue2 | Vue2插件版本 | Vue3 | Vue3插件版本 | Chrome | Safari | app-vue | app-nvue | app-nvue插件版本 | Android | Android插件版本 | iOS | 鸿蒙 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| √ | 1.0.0 | √ | 1.0.0 | - | - | - | √ | 1.0.0 | 5.0 | 1.0.0 | - | - |
| 微信小程序 | 支付宝小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 京东小程序 | 鸿蒙元服务 | QQ小程序 | 飞书小程序 | 小红书小程序 | 快应用-华为 | 快应用-联盟 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| - | - | - | - | - | - | - | - | - | - | - | - |
其他
| 多语言 | 暗黑模式 | 宽屏模式 | 蒸汽模式 |
|---|---|---|---|
| × | × | × | √ |
功能特性
- 支持二维码和条形码扫描
- 支持 OCR 文本识别,并返回原始识别文本
- 支持条码/二维码和文字同时出现在扫描区域时同时识别并返回
- 支持闪光灯控制
- 支持相机缩放(放大/缩小)
- 支持自定义扫描线颜色、扫描框、提示文案
- 支持扫描完成提示音控制
- 支持根据扫描线带宽范围精准扫描
- 支持回调扫描的当前帧图片文件(完整相机画面,不裁剪)
- 支持手动触发单次扫描(自定义扫描时机)
- 支持动态调整扫描框绝对像素尺寸、overlay 位置与扫描框竖直排版位置
- 支持铺底模式:相机预览铺满全屏,页面控件悬浮在上层作蒙层(
bindViewBelowPage)
适用范围
适用于 uni-app 非 x 项目的 nvue 页面,支持 vue2 和 vue3。若是uni-app x项目请使用 yuange-scanner插件。
本插件需制作自定义基座后运行。
快速开始
1. 导入 NativeScanner
nvue 页面直接导入 UTS 原生模块,无需组件层:
import { NativeScanner } from "@/uni_modules/yuange-scanneruniapp";
铺底模式(推荐):1.0.6 起支持
bindViewBelowPage(),相机预览自动铺满全屏、页面控件悬浮其上层,无需占位区与位置测量(见 API 列表bindViewBelowPage,文末完整 Demo 即此模式)。以下第 2~3 步为悬浮 overlay 模式(bindView())的接入方式。
2. 放置扫描占位区并测量位置
在模板中放一个 <view ref="scanArea">,扫描器 overlay 会精确覆盖此区域:
<template>
<view class="container">
<!-- 扫描占位区:相机预览 overlay 会按测量结果覆盖此区域 -->
<view ref="scanArea" class="scan-area"></view>
<!-- 控制按钮 -->
<view class="controls">
<button type="primary" size="mini" @tap="startScanner">启动扫描</button>
<button type="warn" size="mini" @tap="stopScanner">停止扫描</button>
</view>
</view>
</template>
3. 测量占位区并初始化
onReady 触发时 flexbox 布局可能尚未完成,需延迟测量(flex:1 的 view 首帧尺寸为 0):
export default {
data() {
return {
scannerWidth: 0,
scannerHeight: 0,
scannerLeft: 0,
scannerTop: 0,
// 设备像素比:getComponentRect 返回 CSS px,overlay 需要 device px,需乘 dpr
dpr: 1,
nativeScanner: null,
isInitialized: false
};
},
onReady() {
// 获取设备像素比:getComponentRect 返回 CSS px(逻辑像素),overlay 用 device px,需乘 dpr
try {
const sys = uni.getSystemInfoSync();
this.dpr = sys.pixelRatio || 1;
} catch (e) {
this.dpr = 1;
}
this.measureAndInit();
},
onUnload() {
this.stopScanner();
// 清理帧图片缓存,避免 cacheDir 下 scan_frame_*.jpg 堆积
if (this.nativeScanner) {
try { this.nativeScanner.clearFrameImageCache(); } catch (e) {}
}
this.destroyScanner();
},
onHide() {
this.stopScanner();
},
methods: {
measureAndInit(retryCount) {
const retry = retryCount == null ? 0 : retryCount;
const dom = uni.requireNativePlugin('dom');
const doMeasure = () => {
if (dom && this.$refs.scanArea) {
dom.getComponentRect(this.$refs.scanArea, (res) => {
if (res && res.size && res.size.width > 0 && res.size.height > 0) {
// getComponentRect 返回 CSS px(逻辑像素),overlay 用 device px,需乘 dpr
this.scannerWidth = Math.floor(res.size.width * this.dpr);
this.scannerHeight = Math.floor(res.size.height * this.dpr);
this.scannerLeft = Math.floor((res.size.left || 0) * this.dpr);
this.scannerTop = Math.floor((res.size.top || 0) * this.dpr);
this.initScanner();
} else if (retry < 5) {
// 布局未就绪,50ms 后重试
setTimeout(() => this.measureAndInit(retry + 1), 50);
} else {
// 回退到屏幕宽 × 55% 居中
this.fallbackSize();
this.initScanner();
}
});
} else {
this.fallbackSize();
this.initScanner();
}
};
if (retry === 0) {
setTimeout(doMeasure, 50); // 首次延迟一帧让布局跑完
} else {
doMeasure();
}
},
fallbackSize() {
const sys = uni.getSystemInfoSync();
this.scannerWidth = sys.windowWidth;
this.scannerHeight = Math.floor(sys.windowHeight * 0.55);
this.scannerLeft = 0;
this.scannerTop = Math.floor((sys.windowHeight - this.scannerHeight) / 2);
},
initScanner() {
// 创建实例(参数为宽高,Double 类型)
this.nativeScanner = new NativeScanner(this.scannerWidth, this.scannerHeight);
// 设置 overlay 位置和尺寸(参数为 left, top, width, height)
this.nativeScanner.setViewFrame(
this.scannerLeft, this.scannerTop,
this.scannerWidth, this.scannerHeight
);
// 绑定视图到 Activity
this.nativeScanner.bindView();
// 设置扫描视图样式
this.nativeScanner.setScanViewOptions({
scanFrameWidth: 0.7,
scanFrameHeight: 0.5,
frameBorderColor: '#FFFFFF',
frameBorderWidth: 2,
scanLineStyle: { color: '#00FF00', width: 2, height: 4 },
hintText: '请将二维码/条形码置于框内',
hintColor: '#FFFFFF',
hintTextSize: 14,
maskOpacity: 0.5,
maskColor: '#000000'
});
// 设置扫描线带宽(按扫描线位置过滤 OCR/条码,0.12=±12%,1.0=不限制)
this.nativeScanner.setOcrLineBandRatio(0.12);
// 设置扫描反馈(震动功能已禁用,仅 playSound / autoStop 生效)
this.nativeScanner.setScanFeedbackOptions({ playSound: true, autoStop: false });
// 设置是否回调当前扫描帧图片(完整相机画面,不裁剪),默认 true
// 开启后扫描成功会通过结果中的 frameImage 字段返回 JPEG 文件路径
this.nativeScanner.setEnableFrameImageCallback(true);
// 设置扫描结果回调
this.setupScanResultCallback();
this.isInitialized = true;
}
}
};
4. 设置扫描结果回调
setupScanResultCallback() {
this.nativeScanner.setScanResultCallback({
success: (res) => {
// res 是 HashMap,用 res.get("key") 取值
const rawValue = res.get("rawValue") || '';
const format = res.get("format") || '';
const ocrText = res.get("ocrText") || ''; // 原始 OCR 识别文本
const frameImage = res.get("frameImage") || ''; // 当前帧图片路径(开启帧图片回调时返回)
console.log('内容:', rawValue);
console.log('格式:', format);
if (ocrText != '') {
console.log('原始OCR:', ocrText);
}
if (frameImage != '') {
console.log('帧图片:', frameImage);
}
// 遍历 results 数组(同时扫描到的所有条码和文字)
const results = res.get("results"); // ArrayList<HashMap>
if (results != null) {
for (let i = 0; i < results.size(); i++) {
const item = results.get(i);
const type = item.get("type"); // "barcode" 或 "text"
const itemFormat = item.get("format");
const itemValue = item.get("rawValue");
console.log(`[${type}] ${itemFormat}: ${itemValue}`);
}
}
},
fail: (err) => {
console.log('错误:', err.get("msg"));
}
});
}
5. 启动/停止扫描
startScanner() {
this.nativeScanner.start({
cameraId: 0, // 0=后摄(当前仅支持后置摄像头)
torch: false, // 是否开启闪光灯
requestPermission: true // 是否请求相机权限
}, {
success: (res) => { console.log('相机启动成功'); },
fail: (err) => { console.log('启动失败:', err.get("msg")); }
});
}
stopScanner() {
this.nativeScanner.stop();
}
destroyScanner() {
if (this.nativeScanner) {
this.nativeScanner.destroy();
this.nativeScanner = null;
}
}
API 列表
new NativeScanner(width, height)
创建扫描器实例。
| 参数 | 类型 | 说明 |
|---|---|---|
| width | Double | 扫描器宽度(像素) |
| height | Double | 扫描器高度(像素) |
setViewFrame(left, top, width, height)
设置 overlay 的位置和尺寸(相对 Activity contentView 的像素坐标)。可在 bindView 前或后调用,运行时动态调整也会立即生效。
bindView()
将扫描器原生视图绑定到 Activity(悬浮模式:预览视图覆盖在页面之上)。必须在 setViewFrame 之后调用。
bindViewBelowPage()
以铺底模式绑定扫描器原生视图:预览自动插入到页面内容之下、页面根容器背景之上(自动下探定位插入点,兼容 nvue 页面根节点默认白底),nvue 页面内容(控件)绘制在预览上层。适用于"相机预览铺满屏幕 + 控件悬浮在上层"的场景。无需调用 setViewFrame(预览尺寸取构造参数,宽/高 ≤ 0 时自动铺满 MATCH_PARENT)。
说明:
- 页面根节点背景色不影响预览显示,无需强制配置透明背景;页面根元素建议保持
background-color: rgba(0,0,0,0) - 相机未启动时预览区域显示黑色底色,启动后显示相机画面
start(options, callback)
启动相机和扫描。
nativeScanner.start({
cameraId: 0, // 摄像头 ID:0=后摄(当前仅支持后置摄像头,参数保留)
torch: false, // 是否开启闪光灯,默认 false
requestPermission: true // 是否申请相机权限,默认 true
}, {
success: (res) => {},
fail: (err) => { console.log(err.get("msg")); }
});
stop()
停止扫描,相机保持绑定。
destroy()
销毁扫描器并释放资源,从 Activity 移除 overlay 视图。
setTorch(on, callback)
nativeScanner.setTorch(true, { // true=开启,false=关闭
success: (res) => {},
fail: (err) => {}
});
getTorch(callback)
获取当前闪光灯状态。
nativeScanner.getTorch({
success: (res) => { console.log('闪光灯:', res.get("torch")); },
fail: (err) => {}
});
setZoom(zoom, callback)
// zoom: 1.0 ~ maxZoom(通过 getMaxZoom 获取)
nativeScanner.setZoom(2.0, {
success: (res) => {},
fail: (err) => {}
});
getMaxZoom(callback)
nativeScanner.getMaxZoom({
success: (res) => { console.log("最大缩放:", res.get("maxZoom")); },
fail: () => {}
});
getZoom(callback)
获取当前缩放倍数。
nativeScanner.getZoom({
success: (res) => { console.log('当前缩放:', res.get("zoom")); },
fail: (err) => {}
});
setScanViewOptions(options)
更新扫描视图配置,运行时可随时调用。
nativeScanner.setScanViewOptions({
scanFrameWidth: 0.7, // 扫描框宽度比例(0.0~1.0),默认 0.7
scanFrameHeight: 0.5, // 扫描框高度比例(0.0~1.0),默认 0.5
scanFrameWidthPx: 0, // 扫描框宽度(像素),>0 时优先于 scanFrameWidth,0=按比例,默认 0
scanFrameHeightPx: 0, // 扫描框高度(像素),>0 时优先于 scanFrameHeight,0=按比例,默认 0
frameBorderColor: '#FFFFFF', // 边框颜色,默认 '#FFFFFF'
frameBorderWidth: 2, // 边框宽度(像素),默认 2
scanLineStyle: {
color: '#00FF00', // 扫描线颜色,默认 '#00FF00'
width: 2, // 扫描线宽度(像素),默认 2
height: 4 // 扫描线高度(像素),默认 2
},
hintText: '请将二维码/条形码置于框内', // 提示文案
hintColor: '#FFFFFF', // 提示文案颜色,默认 '#FFFFFF'
hintTextSize: 14, // 提示文案大小(sp),默认 14
maskOpacity: 0.5, // 背景遮罩透明度(0.0~1.0),0=完全透明,默认 0.5
maskColor: '#000000' // 背景遮罩颜色,默认 '#000000'
});
setOcrLineBandRatio(ratio)
设置扫描线带宽比例,按扫描线位置过滤 OCR/条码结果(仅命中扫描线附近带宽内的内容,带宽中心自动跟随 setScanFramePosition 的扫描框排版位置)。运行时可随时调用,下一帧即生效。
// ratio: 0.01~1.0,默认 0.12。0.12 = 扫描线中线 ±12%,1.0 = 不限制(整帧命中)
// 低于 0.01 会被钳制为 0.01
nativeScanner.setOcrLineBandRatio(0.12);
setScanFramePosition(ratio)
设置扫描框的竖直排版位置。扫描框边框、扫描线动画、提示文案整体移动,识别带宽中心自动跟随框位置。运行时可随时调用,即时生效。
// ratio: 0.0~1.0,默认 0.5(竖直居中)。0.0 = 预览顶部,1.0 = 预览底部
// 例:配合"顶部 20% 干净预览窗"布局,设 0.1 可让扫描框落在顶部窗口中心
// 超出 [0, 1] 会被钳制
nativeScanner.setScanFramePosition(0.5);
识别过滤带宽的中心自动跟随
setScanFramePosition设置的扫描框排版位置。
setScanFeedbackOptions(options)
nativeScanner.setScanFeedbackOptions({
playSound: true, // 扫描成功后是否播放提示音,默认 true
autoStop: false // 扫描成功后是否自动停止,默认 false
// vibrate 字段接口保留,但当前版本震动功能已禁用,传入不生效
});
setEnableFrameImageCallback(enable)
设置是否回调当前扫描帧(完整相机画面,不裁剪)的图片。默认 true:回调。开启后扫描成功会保存当前完整帧(旋转到直立,不裁剪)为 JPEG 文件,通过结果中的 frameImage 字段返回文件绝对路径。运行时即时生效,下一次扫描即按新设置处理。
// true=回调帧图片(默认),false=不回调
nativeScanner.setEnableFrameImageCallback(true);
开启后每次扫描成功都会在
cacheDir下生成一个scan_frame_*.jpg文件且不会自动删除,建议在不再需要时(如onUnload)调用clearFrameImageCache()清理,避免缓存无限堆积。
clearFrameImageCache()
清除所有缓存的扫描帧图片(cacheDir 下的 scan_frame_*.jpg)。返回值为已删除文件数(Int)。
const deleted = nativeScanner.clearFrameImageCache();
console.log('已清理帧图片:', deleted, '个');
manualScan(callback)
手动触发一次扫描,适用于自定义扫描时机(例如点击按钮再扫描一次,而不是依赖相机的连续自动识别)。
nativeScanner.manualScan({
success: (res) => { console.log('手动扫描结果:', res.get("rawValue")); },
fail: (err) => { console.log('手动扫描失败:', err.get("msg")); }
});
setScanResultCallback(callback)
设置扫描结果回调。扫描到条码或 OCR 文本时触发。
nativeScanner.setScanResultCallback({
success: (res) => {
// res 是 HashMap,用 res.get("key") 取值
const rawValue = res.get("rawValue"); // 扫描内容
const format = res.get("format"); // 条码格式(QR_CODE/EAN_13/TEXT 等)
const ocrText = res.get("ocrText"); // 原始 OCR 识别文本(完整识别结果)
},
fail: (err) => {
const msg = err.get("msg");
}
});
setBarcodeFormats(formats)
设置支持的条码格式(默认支持全部)。
nativeScanner.setBarcodeFormats(['QR_CODE', 'EAN_13', 'CODE_128']);
扫描结果数据结构
同时扫描结果(条码 + 文字)
当扫描区域内同时存在条码/二维码和文字时,插件会同时执行条码扫描和 OCR 文本识别,并将所有结果合并到一个回调中返回。results 数组包含同一帧识别到的所有条码和文字结果,同时保留顶层兼容字段。
{
code: 200,
msg: "识别成功",
results: [
{ type: "barcode", format: "QR_CODE", rawValue: "二维码内容", valueType: 0 },
{ type: "barcode", format: "EAN_13", rawValue: "6901234567892", valueType: 0 },
{ type: "text", format: "TEXT", rawValue: "原始识别文本" }
],
// 以下为向后兼容字段(取第一个结果的值)
rawValue: "二维码内容",
format: "QR_CODE",
valueType: 0,
ocrText: "原始识别文本", // 原始 OCR 识别文本(始终为完整 OCR 结果,不受条码影响)
frameImage: "/path/to/scan_frame_xxx.jpg" // 当前帧图片路径(开启帧图片回调时返回)
}
注意:
results是 JavaArrayList<HashMap<String, any>>,不是 JS 数组。遍历用.size()+.get(i),每项是 HashMap,用.get("key")取值。
遍历 results 示例:
const results = res.get("results");
if (results != null) {
const size = results.size();
for (let i = 0; i < size; i++) {
const item = results.get(i);
const type = item.get("type"); // "barcode" 或 "text"
const format = item.get("format"); // "QR_CODE" / "EAN_13" / "TEXT" 等
const rawValue = item.get("rawValue");
console.log(`[${type}] ${format}: ${rawValue}`);
}
}
条码扫描结果
当仅识别到条码时,results 数组包含 barcode 项,顶层兼容字段取第一个条码的值:
{
code: 200,
msg: "识别成功",
results: [
{ type: "barcode", format: "QR_CODE", rawValue: "扫码内容", valueType: 0 }
],
rawValue: "扫码内容",
format: "QR_CODE", // QR_CODE/EAN_13/CODE_128 等
valueType: 0,
frameImage: "/path/to/scan_frame_xxx.jpg" // 当前帧图片路径(开启帧图片回调时返回)
}
OCR 文本识别结果
当仅识别到文字时,results 数组包含 text 项,顶层兼容字段取该文本的值:
{
code: 200,
msg: "识别成功",
results: [
{ type: "text", format: "TEXT", rawValue: "原始识别文本" }
],
rawValue: "原始识别文本",
format: "TEXT",
valueType: 0,
ocrText: "原始识别文本", // 原始 OCR 识别文本(完整识别结果)
frameImage: "/path/to/scan_frame_xxx.jpg" // 当前帧图片路径(开启帧图片回调时返回)
}
支持的条码格式
| 格式 | 说明 |
|---|---|
| QR_CODE | 二维码 |
| AZTEC | Aztec 码 |
| EAN_13 | EAN-13 条形码 |
| EAN_8 | EAN-8 条形码 |
| UPC_A | UPC-A 条形码 |
| UPC_E | UPC-E 条形码 |
| CODE_128 | Code 128 条形码 |
| CODE_39 | Code 39 条形码 |
| CODE_93 | Code 93 条形码 |
| CODABAR | Codabar 条形码 |
| ITF | ITF 条形码 |
| DATA_MATRIX | Data Matrix 码 |
| PDF_417 | PDF 417 条形码 |
注意事项
- 必须制作自定义基座:UTS 插件涉及原生 Kotlin 代码,标准基座无法运行。
- nvue 专用:本插件通过原生 overlay 实现,不适用于 vue 页面。
onReady时 flexbox 布局可能未完成,需延迟测量占位区。 - overlay 机制:扫描器原生视图浮在 nvue 页面之上,靠
setViewFrame精确对齐<view ref="scanArea">的位置。scan-area的flex:1会覆盖显式height,如需固定尺寸请去掉flex:1。 - dpr(设备像素比):
getComponentRect返回的是 CSS px(逻辑像素),而 overlay 的setViewFrame需要的是 device px(物理像素),两者必须通过uni.getSystemInfoSync().pixelRatio换算。若不乘dpr,在高分屏(pixelRatio=2/3)上 overlay 尺寸会比实际占位区小一半,导致相机预览只覆盖左上角。建议统一在测量时Math.floor(value * dpr)。 - 数值类型:UTS 在 nvue 下把 JS
number装成BigDecimal/Double,setScanViewOptions已用UTSJSONObject接收并内部转换,JS 侧直接传 number 即可。 maskOpacity:0= 完全透明(无遮罩),0.5= 半透明黑(默认),1= 完全不透明。- 页面生命周期:
onHide时停止扫描,onUnload时销毁扫描器,避免后台占用相机。 - 仅支持后置摄像头:
start的cameraId参数当前保留,插件始终使用后置摄像头(setCamera已禁用)。 - 帧图片缓存清理:开启
setEnableFrameImageCallback(true)后,每次扫描成功都会在cacheDir下生成一个scan_frame_*.jpg且不会自动删除。建议在onUnload或切换"帧图"开关关闭时调用clearFrameImageCache(),避免缓存无限堆积。 - 震动反馈已禁用:
ScanFeedbackOptions的vibrate字段接口保留,但当前版本震动功能已禁用,传入不生效,仅playSound/autoStop生效。 - 扫描线带宽钳制:
setOcrLineBandRatio传入值低于0.01会被钳制为0.01,有效范围0.01~1.0。 - 铺底模式免测量:使用
bindViewBelowPage()时预览自动铺满全屏,无需占位区测量与 dpr 换算(上述 2~4 条主要针对bindView()悬浮模式)。
完整 Demo 使用示例(scanner-demo.nvue)
<template>
<view class="container">
<!-- 顶部20%纯净预览区(扫描窗):不放任何控件,相机画面完整清晰露出 -->
<view class="top-gap" :style="{ height: topGapH + 'px' }"></view>
<!-- 控制条(下移到顶部预览区之下,悬浮蒙层:控件绘制在预览上层) -->
<view class="top-bar">
<button :type="torchOn ? 'warn' : 'default'" size="mini" @tap="toggleTorch">
<text>{{ torchOn ? '关闪光' : '开闪光' }}</text>
</button>
<view class="zoom-control">
<text class="zoom-label">{{ currentZoom.toFixed(1) }}x</text>
<slider class="zoom-slider" :min="10" :max="maxZoom * 10" :value="currentZoom * 10" @change="onZoomSliderChange" />
</view>
<view class="setting-item">
<text class="setting-label">音效</text>
<switch :checked="playSound == true" @change="togglePlaySound" />
</view>
<view class="setting-item">
<text class="setting-label">帧图</text>
<switch :checked="enableFrameImage == true" @change="toggleFrameImageCallback" />
</view>
</view>
<!-- 中部弹性区:露出相机预览,并把控制面板压到屏幕底部 -->
<view class="mid-gap"></view>
<!-- 控制面板(可滚动悬浮蒙层,固定在屏幕底部,防止小屏显示不全) -->
<scroll-view scroll-y class="bottom-scroll">
<view class="bottom-panel">
<view class="action-row">
<button type="primary" size="mini" @tap="startScanner" :disabled="isScanning">启动扫描</button>
<button type="warn" size="mini" @tap="stopScanner" :disabled="!isScanning">停止扫描</button>
</view>
<!-- 扫描线带宽控制 -->
<view class="band-section">
<view class="band-header">
<text class="section-title">扫描线带宽</text>
<text class="band-value">{{ (ocrLineBandRatio * 100).toFixed(0) }}%</text>
</view>
<slider class="band-slider" :min="1" :max="100" :value="ocrLineBandRatio * 100" @change="onBandRatioChange" />
</view>
<!-- 扫描框排版控制(尺寸 + 竖直位置) -->
<view class="frame-section">
<view class="frame-header">
<text class="section-title">扫描框排版</text>
<text class="frame-value">{{ scanFrameWidthPx }} × {{ scanFrameHeightPx }} px</text>
</view>
<view class="frame-row">
<text class="frame-label">宽</text>
<slider class="frame-slider" :min="80" :max="maxFrameWidth" :value="scanFrameWidthPx" @change="onFrameWidthChange" />
</view>
<view class="frame-row">
<text class="frame-label">高</text>
<slider class="frame-slider" :min="80" :max="maxFrameHeight" :value="scanFrameHeightPx" @change="onFrameHeightChange" />
</view>
<view class="frame-row">
<text class="frame-label">位置</text>
<slider class="frame-slider" :min="0" :max="100" :value="scanFramePosition * 100" @change="onFramePositionChange" />
</view>
</view>
<view class="log-section">
<view class="log-header">
<text class="section-title">日志</text>
<button size="mini" type="default" @tap="clearLogs"><text>清空</text></button>
</view>
<scroll-view scroll-y class="log-scroll">
<text v-for="(log, index) in logs" :key="index" class="log-item">{{ log }}</text>
</scroll-view>
</view>
<!-- 帧图片回调控制 + 当前帧图片预览 -->
<view class="frame-section">
<view class="frame-header">
<text class="section-title">帧图片</text>
<button size="mini" type="default" @tap="clearFrameCache"><text>清理缓存</text></button>
</view>
<view v-if="frameImage">
<text class="frame-value">{{ frameImage }}</text>
<image class="frame-preview" :src="frameImage" mode="aspectFit" />
</view>
<text class="frame-hint" v-else>开启"帧图"开关后,扫描成功将回传当前完整帧图片</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
// 直接导入 UTS 原生模块(绕过 .vue 组件层,避免 nvue 下组件不实例化的问题)
import { NativeScanner } from "@/uni_modules/yuange-scanneruniapp";
export default {
data() {
return {
// 扫描器预览的尺寸(device px;铺底模式下 onReady 直接按全屏赋值)
scannerWidth: 0,
scannerHeight: 0,
scannerLeft: 0,
scannerTop: 0,
// 顶部纯净预览区高度(nvue px,onReady 按可视区高度的 20% 换算)
topGapH: 0,
torchOn: false,
currentZoom: 1.0,
maxZoom: 10.0,
isScanning: false,
scanFrameWidthRatio: 0.7,
scanFrameHeightRatio: 0.5,
// 扫描框绝对像素尺寸(>0 时优先于比例);initScanner 测量后按默认比例换算赋初值
scanFrameWidthPx: 1006,
scanFrameHeightPx: 341,
maxFrameWidth: 1080,
maxFrameHeight: 1000,
scanLineColor: '#00FF00',
frameBorderColor: '#FFFFFF',
hintText: '请将二维码/条形码置于框内',
hintColor: '#FFFFFF',
playSound: false,
showScanLine: true,
// 是否回调当前扫描帧(完整相机画面)的图片,默认 true
enableFrameImage: true,
// 设备像素比(CSS px -> device px),onReady 中获取
dpr: 1,
ocrLineBandRatio: 0.01,
// 扫描框竖直排版位置(框中心相对预览高度比例:0=顶部,0.5=居中默认,1=底部;识别带宽自动跟随)
scanFramePosition: 0.25,
lineColors: ['#FFFFFF', '#00FF00', '#FF0000', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'],
// 扫描成功时回传的当前帧图片路径(插件保存到 cacheDir 的 JPEG)
frameImage: '',
logs: [],
// NativeScanner 实例(直接管理,不经过组件层)
nativeScanner: null,
isInitialized: false,
initError: '',
isPageReady: false,
initFailed: false
}
},
onLoad() {
this.addLog('页面加载完成');
},
onReady() {
this.isPageReady = true;
// 预览铺满全屏:按屏幕尺寸(device px)初始化,铺底模式下无需测量占位区
try {
const sys = uni.getSystemInfoSync();
this.dpr = sys.pixelRatio || 1;
this.scannerWidth = Math.floor(sys.windowWidth * this.dpr);
this.scannerHeight = Math.floor(sys.windowHeight * this.dpr);
// 顶部纯净预览区 = 可视区高度的 20%(nvue 的 px 以 750 = 屏幕宽度为基准换算)
this.topGapH = Math.floor(sys.windowHeight / sys.windowWidth * 750 * 0.2);
} catch (e) {
this.dpr = 1;
}
this.scannerLeft = 0;
this.scannerTop = 0;
this.addLog('页面就绪,开始初始化扫描器... 全屏预览 ' + this.scannerWidth + 'x' + this.scannerHeight + ' (dpr=' + this.dpr + ')');
this.initScanner();
},
onUnload() {
this.stopScanner();
// 清理帧图片缓存,避免 cacheDir 下 scan_frame_*.jpg 堆积
if (this.nativeScanner) {
try {
this.nativeScanner.clearFrameImageCache();
} catch (err) {}
}
this.destroyScanner();
this.addLog('扫描器已销毁');
},
onHide() {
this.stopScanner();
},
methods: {
// 把任意错误对象转成可读字符串(兼容 UTS HashMap)
toMsg(err) {
if (err == null) return '未知错误';
if (typeof err === 'string') return err;
try {
if (typeof err.get === 'function') {
return err.get('msg') || err.get('message') || String(err);
}
} catch (e) {}
return err.message || err.msg || String(err);
},
// 直接创建并初始化 NativeScanner 实例
initScanner() {
this.addLog('开始创建 NativeScanner...');
// 检查 UTS 模块是否可用
if (typeof NativeScanner !== 'function') {
const msg = 'NativeScanner 未定义 (typeof=' + typeof NativeScanner + ')。请确认已制作自定义基座且 UTS 编译无错误。';
this.initError = msg;
this.initFailed = true;
this.isInitialized = true;
this.addLog(msg);
uni.showModal({ title: '初始化失败', content: msg, showCancel: false });
return;
}
try {
this.nativeScanner = new NativeScanner(this.scannerWidth, this.scannerHeight);
this.addLog('NativeScanner 已创建');
} catch (err) {
this.initError = '创建NativeScanner失败: ' + this.toMsg(err);
this.initFailed = true;
this.isInitialized = true;
this.addLog(this.initError);
uni.showModal({ title: '初始化失败', content: this.initError, showCancel: false });
return;
}
// 确保尺寸有效
let w = this.scannerWidth;
let h = this.scannerHeight;
if (w <= 0 || h <= 0) {
w = w > 0 ? w : 300;
h = h > 0 ? h : 400;
}
// 扫描框默认尺寸:取预览区较短边的 70% 作为正方形边长,dispatchDraw 会自动居中显示
if (this.scanFrameWidthPx <= 0 || this.scanFrameHeightPx <= 0) {
const frameSize = this.computeDefaultFrameSize(w, h);
this.scanFrameWidthPx = frameSize;
this.scanFrameHeightPx = frameSize;
}
this.maxFrameWidth = w;
this.maxFrameHeight = h;
// 铺底模式绑定:预览自动插入到页面内容之下、根容器背景之上,铺满全屏
// (尺寸为 0 时插件内部按 MATCH_PARENT 处理,页面控件绘制在预览上层作蒙层)
try {
this.nativeScanner.bindViewBelowPage();
this.addLog('原生视图已绑定(铺底模式)');
} catch (err) {
this.initError = '绑定视图失败: ' + this.toMsg(err);
this.initFailed = true;
this.isInitialized = true;
this.addLog(this.initError);
uni.showModal({ title: '初始化失败', content: this.initError, showCancel: false });
return;
}
// 初始化扫描视图配置
this.applyScanViewOptions();
// 设置扫描线带宽(OCR/条码按线条位置过滤)
try {
this.nativeScanner.setOcrLineBandRatio(this.ocrLineBandRatio);
} catch (err) {
this.addLog('设置带宽失败(非致命): ' + this.toMsg(err));
}
// 设置扫描框竖直排版位置(0=顶部,0.5=居中,1=底部;识别带宽自动跟随框中心)
try {
this.nativeScanner.setScanFramePosition(this.scanFramePosition);
} catch (err) {
this.addLog('设置扫描框位置失败(非致命): ' + this.toMsg(err));
}
// 初始化反馈配置
try {
this.nativeScanner.setScanFeedbackOptions({
playSound: this.playSound,
autoStop: false
});
} catch (err) {
this.addLog('设置反馈配置失败(非致命): ' + this.toMsg(err));
}
// 设置是否回调当前帧图片(完整相机画面,不裁剪)
try {
this.nativeScanner.setEnableFrameImageCallback(this.enableFrameImage);
} catch (err) {
this.addLog('设置帧图片回调失败(非致命): ' + this.toMsg(err));
}
// 设置扫描结果回调
this.setupScanResultCallback();
this.isInitialized = true;
this.initFailed = false;
this.addLog('扫描器初始化完成');
// 获取最大缩放
this.loadMaxZoom();
},
// 统一构造扫描视图配置对象(init 与运行时复用)
applyScanViewOptions() {
if (!this.nativeScanner) return;
try {
this.nativeScanner.setScanViewOptions({
scanFrameWidth: this.scanFrameWidthRatio,
scanFrameHeight: this.scanFrameHeightRatio,
scanFrameWidthPx: this.scanFrameWidthPx,
scanFrameHeightPx: this.scanFrameHeightPx,
frameBorderColor: this.frameBorderColor,
frameBorderWidth: 2,
scanLineStyle: {
color: this.scanLineColor,
width: 2,
height: 4
},
hintText: this.hintText,
hintColor: this.hintColor,
hintTextSize: 14,
maskOpacity: 0,
maskColor: '#000000'
});
} catch (err) {
this.addLog('设置扫描视图配置失败(非致命): ' + this.toMsg(err));
}
},
// 设置扫描结果回调
setupScanResultCallback() {
if (!this.nativeScanner) return;
try {
this.nativeScanner.setScanResultCallback({
success: (res) => {
let rawValue = '';
let format = '';
let ocrText = '';
let frameImage = '';
try {
if (typeof res.get === 'function') {
rawValue = res.get("rawValue") || '';
format = res.get("format") || '';
ocrText = res.get("ocrText") || '';
frameImage = res.get("frameImage") || '';
} else {
rawValue = res["rawValue"] || res.rawValue || '';
format = res["format"] || res.format || '';
ocrText = res["ocrText"] || res.ocrText || '';
frameImage = res["frameImage"] || res.frameImage || '';
}
} catch (e) {
rawValue = '';
format = '';
}
this.addLog('扫描成功: ' + format);
this.addLog('内容: ' + rawValue);
if (ocrText != '') {
this.addLog('原始OCR: ' + ocrText);
}
if (frameImage != '') {
this.frameImage = frameImage;
this.addLog('帧图片: ' + frameImage);
}
// 遍历 results 数组(同时扫描的所有结果)
let jsResults = [];
try {
const results = res.get("results");
if (results != null) {
const rSize = typeof results.size === 'function' ? results.size() : 0;
if (rSize > 1) {
this.addLog('共识别到 ' + rSize + ' 个结果:');
}
for (let ri = 0; ri < rSize; ri++) {
const item = results.get(ri);
const rType = item.get("type");
const rFmt = item.get("format") || '';
const rVal = item.get("rawValue") || '';
const rItem = { type: rType, format: rFmt, rawValue: rVal };
if (rType === 'barcode') {
this.addLog(' [条码] ' + rFmt + ': ' + rVal);
rItem.valueType = item.get("valueType");
} else if (rType === 'text') {
this.addLog(' [文字]: ' + rVal);
}
jsResults.push(rItem);
}
}
} catch (e3) {}
// 回调出去:通过全局事件通知其他页面或组件
uni.$emit('scannerResult', {
rawValue: rawValue,
format: format,
ocrText: ocrText,
frameImage: frameImage,
results: jsResults
});
uni.showToast({ title: '扫描成功', icon: 'success', duration: 1500 });
uni.setClipboardData({ data: rawValue, showToast: false });
},
fail: (err) => {
let msg = '';
try {
if (typeof err.get === 'function') {
msg = err.get("msg") || '';
} else {
msg = err["msg"] || err.msg || '';
}
} catch (e) {
msg = '未知错误';
}
this.addLog('错误: ' + msg);
}
});
this.addLog('扫描结果回调已设置');
} catch (err) {
this.addLog('设置回调失败: ' + this.toMsg(err));
}
},
// 获取最大缩放倍数
loadMaxZoom() {
if (!this.nativeScanner) return;
try {
this.nativeScanner.getMaxZoom({
success: (res) => {
let zoom = 10;
try {
if (typeof res.get === 'function') {
zoom = res.get("maxZoom") || 10;
} else {
zoom = res["maxZoom"] || res.maxZoom || 10;
}
} catch (e) {}
this.maxZoom = zoom;
this.addLog('最大缩放: ' + zoom.toFixed(0) + 'x');
},
fail: () => {
this.addLog('获取最大缩放失败,使用默认值');
}
});
} catch (err) {
this.addLog('获取最大缩放异常: ' + this.toMsg(err));
}
},
addLog(msg) {
const now = new Date();
const time = now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getSeconds()).padStart(2, '0');
const line = '[' + time + '] ' + msg;
this.logs.unshift(line);
if (this.logs.length > 50) {
this.logs.pop();
}
// 同步输出到控制台
console.log(line);
},
clearLogs() {
this.logs = [];
},
startScanner() {
if (this.isScanning) return;
if (!this.nativeScanner) {
this.addLog('扫描器不可用');
return;
}
if (this.initFailed) {
const reason = this.initError || '初始化失败,请重新进入页面';
this.addLog('启动失败: ' + reason);
uni.showModal({ title: '无法启动', content: reason, showCancel: false });
return;
}
if (!this.isInitialized) {
this.addLog('扫描器未初始化完成');
return;
}
this.addLog('启动相机...');
try {
this.nativeScanner.start({
cameraId: 0,
torch: this.torchOn,
requestPermission: true
}, {
success: (res) => {
this.addLog('相机启动成功');
},
fail: (err) => {
let msg = '';
try {
if (typeof err.get === 'function') {
msg = err.get("msg") || '';
} else {
msg = err["msg"] || err.msg || '';
}
} catch (e) {
msg = '未知错误';
}
this.addLog('启动失败: ' + msg);
}
});
} catch (err) {
this.addLog('启动异常: ' + this.toMsg(err));
}
this.isScanning = true;
this.addLog('启动扫描...');
},
stopScanner() {
if (!this.isScanning) return;
if (this.nativeScanner) {
try {
this.nativeScanner.stop();
} catch (err) {
this.addLog('停止异常: ' + this.toMsg(err));
}
}
this.isScanning = false;
this.addLog('停止扫描');
},
destroyScanner() {
if (this.nativeScanner) {
try {
this.nativeScanner.destroy();
} catch (err) {
this.addLog('销毁异常: ' + this.toMsg(err));
}
this.nativeScanner = null;
}
},
toggleTorch() {
this.torchOn = !this.torchOn;
if (this.nativeScanner) {
try {
this.nativeScanner.setTorch(this.torchOn, {
success: function(res) {},
fail: (err) => {
this.addLog('设置闪光灯失败: ' + this.toMsg(err));
}
});
} catch (err) {
this.addLog('设置闪光灯异常: ' + this.toMsg(err));
}
}
this.addLog('闪光灯: ' + (this.torchOn ? '开启' : '关闭'));
},
onZoomSliderChange(e) {
const rawValue = this.parseSliderValue(e);
if (rawValue == null) {
this.addLog('缩放值无效,已忽略');
return;
}
const zoom = rawValue / 10.0;
this.currentZoom = zoom;
if (this.nativeScanner) {
try {
this.nativeScanner.setZoom(zoom, {
success: function(res) {},
fail: (err) => {
this.addLog('设置缩放失败: ' + this.toMsg(err));
}
});
} catch (err) {
this.addLog('设置缩放异常: ' + this.toMsg(err));
}
}
this.addLog('设置缩放: ' + zoom.toFixed(1) + 'x');
},
togglePlaySound() {
this.playSound = !this.playSound;
if (this.nativeScanner) {
try {
this.nativeScanner.setScanFeedbackOptions({
playSound: this.playSound,
autoStop: false
});
} catch (err) {
this.addLog('设置反馈异常: ' + this.toMsg(err));
}
}
this.addLog('提示音: ' + (this.playSound ? '开启' : '关闭'));
},
// 切换是否回调当前扫描帧(完整相机画面)的图片
toggleFrameImageCallback() {
this.enableFrameImage = !this.enableFrameImage;
if (this.nativeScanner) {
try {
this.nativeScanner.setEnableFrameImageCallback(this.enableFrameImage);
} catch (err) {
this.addLog('设置帧图片回调异常: ' + this.toMsg(err));
}
}
this.addLog('帧图片回调: ' + (this.enableFrameImage ? '开启' : '关闭'));
// 关闭时清空当前预览,避免显示过期路径
if (!this.enableFrameImage) {
this.frameImage = '';
}
},
// 清理 cacheDir 下所有 scan_frame_*.jpg 缓存文件
clearFrameCache() {
if (!this.nativeScanner) {
this.addLog('扫描器不可用');
return;
}
try {
this.nativeScanner.clearFrameImageCache();
this.frameImage = '';
this.addLog('已清理帧图片缓存');
} catch (err) {
this.addLog('清理缓存异常: ' + this.toMsg(err));
}
},
toggleScanLine() {
this.showScanLine = !this.showScanLine;
this.addLog('扫描线: ' + (this.showScanLine ? '显示' : '隐藏'));
},
setScanLineColor(color) {
this.scanLineColor = color;
this.applyScanViewOptions();
this.addLog('扫描线颜色: ' + color);
},
onBandRatioChange(e) {
const rawValue = this.parseSliderValue(e);
if (rawValue == null) {
this.addLog('带宽值无效,已忽略');
return;
}
const ratio = rawValue / 100.0;
this.ocrLineBandRatio = ratio;
if (this.nativeScanner) {
try {
this.nativeScanner.setOcrLineBandRatio(ratio);
} catch (err) {
this.addLog('设置带宽异常: ' + this.toMsg(err));
}
}
this.addLog('扫描线带宽: ' + (ratio * 100).toFixed(0) + '%');
},
// 调整扫描框竖直排版位置(0=预览顶部,1=预览底部),识别带宽跟随框中心
onFramePositionChange(e) {
const v = this.parseSliderValue(e);
if (v == null) {
this.addLog('扫描框位置值无效,已忽略');
return;
}
const ratio = v / 100.0;
this.scanFramePosition = ratio;
if (this.nativeScanner) {
try {
this.nativeScanner.setScanFramePosition(ratio);
} catch (err) {
this.addLog('设置扫描框位置异常: ' + this.toMsg(err));
}
}
this.addLog('扫描框位置: ' + (ratio * 100).toFixed(0) + '%');
},
// 通用 slider @change 值解析(兼容 nvue 各版本事件结构)
parseSliderValue(e) {
const num = (v) => {
if (v == null) return null;
if (typeof v === 'number') return (isNaN(v) || !isFinite(v)) ? null : v;
const n = Number(v);
return (isNaN(n) || !isFinite(n)) ? null : n;
};
// 直接是值(number 或字符串数字)
let n = num(e);
if (n != null) return n;
if (e != null && typeof e === 'object') {
n = num(e.value); if (n != null) return n;
// detail 可能直接是值(number/string),也可能是 {value:...}
n = num(e.detail); if (n != null) return n;
if (e.detail != null && typeof e.detail === 'object') {
n = num(e.detail.value); if (n != null) return n;
}
n = num(e.currentValue); if (n != null) return n;
// weex 风格:值可能在 target.attr.value
const t = (e.target != null) ? e.target : e.currentTarget;
if (t != null) {
n = num(t.value); if (n != null) return n;
if (t.attr != null) { n = num(t.attr.value); if (n != null) return n; }
}
}
// 解析失败:输出原始事件结构便于定位
let dump = '';
try {
const keys = (typeof e === 'object' && e != null) ? Object.keys(e).join(',') : '';
dump = typeof e + ' [' + keys + '] ' + JSON.stringify(e);
} catch (ex) {
dump = typeof e + ' ' + String(e);
}
this.addLog('slider原始事件: ' + dump);
return null;
},
onFrameWidthChange(e) {
const v = this.parseSliderValue(e);
if (v == null) {
this.addLog('扫描框宽度值无效,已忽略');
return;
}
this.scanFrameWidthPx = Math.floor(v);
this.applyScanViewOptions();
this.addLog('扫描框宽度: ' + this.scanFrameWidthPx + 'px');
},
onFrameHeightChange(e) {
const v = this.parseSliderValue(e);
if (v == null) {
this.addLog('扫描框高度值无效,已忽略');
return;
}
this.scanFrameHeightPx = Math.floor(v);
this.applyScanViewOptions();
this.addLog('扫描框高度: ' + this.scanFrameHeightPx + 'px');
},
// 默认扫描框尺寸:取预览区较短边的 70% 作为正方形边长(dispatchDraw 自动居中)
computeDefaultFrameSize(w, h) {
const ww = w > 0 ? w : 300;
const hh = h > 0 ? h : 400;
return Math.floor(Math.min(ww, hh) * 0.7);
},
resetFrameSize() {
const frameSize = this.computeDefaultFrameSize(this.scannerWidth, this.scannerHeight);
this.scanFrameWidthPx = frameSize;
this.scanFrameHeightPx = frameSize;
this.applyScanViewOptions();
this.addLog('扫描框已重置: ' + this.scanFrameWidthPx + 'x' + this.scanFrameHeightPx);
}
}
}
</script>
<style>
/* 容器必须透明,铺底的相机预览才能透出 */
.container { background-color: rgba(0,0,0,0); flex: 1; }
/* 顶部20%纯净预览区与中部弹性区均透明,完整透出预览画面 */
.top-gap { background-color: rgba(0,0,0,0); }
.mid-gap { flex: 1; background-color: rgba(0,0,0,0); }
.top-bar { flex-direction: row; align-items: center; padding: 16rpx 20rpx; background-color: rgba(0,0,0,0.45); }
.zoom-control { flex: 1; flex-direction: row; align-items: center; margin-left: 16rpx; padding: 6rpx 16rpx; background-color: #3a3a3a; border-radius: 8rpx; }
.zoom-label { color: #aaaaaa; font-size: 22rpx; width: 70rpx; }
.zoom-slider { flex: 1; }
.setting-item { flex-direction: row; align-items: center; margin-left: 16rpx; }
.setting-label { color: #aaaaaa; font-size: 22rpx; margin-right: 8rpx; }
.color-section { background-color: #2c2c2c; padding: 16rpx; border-radius: 8rpx; margin-bottom: 16rpx; }
.color-section-header { flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.color-preview { flex-direction: row; align-items: center; }
.color-preview-line { width: 60rpx; height: 4rpx; margin-right: 10rpx; border-radius: 2rpx; }
.color-preview-text { color: #aaaaaa; font-size: 22rpx; }
.color-row { flex-direction: row; align-items: center; flex-wrap: wrap; }
.color-btn { width: 56rpx; height: 56rpx; border-radius: 28rpx; margin-right: 16rpx; margin-bottom: 8rpx; border-width: 2px; border-style: solid; border-color: #555555; justify-content: center; align-items: center; }
.color-btn-active { width: 72rpx; height: 72rpx; border-radius: 36rpx; border-width: 3px; border-color: #00FF00; }
.color-btn-check { font-size: 32rpx; color: #ffffff; font-weight: bold; }
.band-section { background-color: #2c2c2c; padding: 16rpx; border-radius: 8rpx; margin-bottom: 16rpx; }
.band-header { flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.band-value { color: #00FF00; font-size: 24rpx; }
.band-slider { flex: 1; }
.frame-section { background-color: #2c2c2c; padding: 16rpx; border-radius: 8rpx; margin-bottom: 16rpx; }
.frame-header { flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.frame-value { color: #00FF00; font-size: 24rpx; }
.frame-row { flex-direction: row; align-items: center; margin-bottom: 8rpx; }
.frame-label { color: #aaaaaa; font-size: 22rpx; width: 50rpx; }
.frame-slider { flex: 1; }
.frame-reset-row { flex-direction: row; justify-content: center; margin-top: 4rpx; }
.bottom-scroll { height: 700rpx; }
.bottom-panel { background-color: rgba(0,0,0,0.55); padding: 16rpx 20rpx 48rpx 20rpx; }
.action-row { flex-direction: row; justify-content: space-around; margin-bottom: 16rpx; }
.log-section { background-color: #252525; padding: 12rpx; border-radius: 8rpx; }
.log-header { flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.section-title { font-size: 24rpx; font-weight: bold; color: #eeeeee; }
.log-scroll { height: 150rpx; }
.log-item { font-size: 20rpx; color: #aaaaaa; line-height: 36rpx; }
.frame-preview { flex: 1; height: 100rpx; margin-top: 8rpx; border-radius: 4rpx; background-color: #000000; }
.frame-hint { font-size: 22rpx; color: #777777; margin-top: 8rpx; }
</style>
完整Demo2示例(xxx.nvue)
<template>
<view class="container">
<!-- 自定义标题栏:使用 custom 导航,避免原生导航栏造成坐标偏移;不透明底,悬浮于铺底预览之上 -->
<view class="page-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-inner">
<text class="header-title">包裹到站</text>
<text class="header-subtitle">快递包裹入库登记</text>
</view>
</view>
<!-- 扫描预览区:透明区域,露出铺底的全屏相机预览;
扫描框/扫描线由原生层绘制(位置可在高级设置"扫描框排版"里调整) -->
<view class="scan-stage">
<view class="scan-controls">
<view :class="['ctrl-btn', isScanning ? 'ctrl-disabled' : 'ctrl-teal']" @tap="startScanner">
<text class="ctrl-btn-text-w">启动扫描</text>
</view>
<view :class="['ctrl-btn', isScanning ? 'ctrl-danger' : 'ctrl-disabled']" @tap="stopScanner">
<text class="ctrl-btn-text-w">停止</text>
</view>
<view :class="['ctrl-btn', 'ctrl-ghost']" @tap="toggleTorch">
<text class="ctrl-btn-text-d">{{ torchOn ? '闪光开' : '闪光关' }}</text>
</view>
</view>
</view>
<!-- 表单与记录滚动区:不透明浅色底,覆盖预览下半部分,保证表单可读性 -->
<scroll-view class="form-scroll" scroll-y="true">
<!-- 运单号行 -->
<view class="waybill-row">
<view class="input-wrap">
<text class="input-label">运单号</text>
<input class="form-input" type="text" v-model="form.waybillNo" placeholder="扫描或手动输入运单号" placeholder-style="color:#B5C2C2" />
</view>
<view class="scan-btn" @tap="startScanner">
<text class="scan-btn-text">扫描</text>
</view>
</view>
<!-- 表单卡片 -->
<view class="form-card">
<view class="field-row">
<text class="field-label">收件人</text>
<input class="field-input" type="text" v-model="form.recipient" placeholder="请输入收件人姓名" placeholder-style="color:#B5C2C2" />
</view>
<view class="field-divider"></view>
<view class="field-row">
<text class="field-label">***</text>
<input class="field-input" type="number" v-model="form.phone" placeholder="请输入手机号" placeholder-style="color:#B5C2C2" />
</view>
<view class="field-divider"></view>
<view class="field-row">
<text class="field-label">快递公司</text>
<picker class="field-picker" :range="courierOptions" :value="form.courierIndex" @change="onCourierChange">
<text :class="['field-picker-text', form.courierIndex < 0 ? 'ph' : 'field-value']">{{ form.courierIndex >= 0 ? courierOptions[form.courierIndex] : '请选择快递公司' }}</text>
</picker>
</view>
<view class="field-divider"></view>
<view class="field-row">
<text class="field-label">包裹规格</text>
<picker class="field-picker" :range="sizeOptions" :value="form.sizeIndex" @change="onSizeChange">
<text :class="['field-picker-text', form.sizeIndex < 0 ? 'ph' : 'field-value']">{{ form.sizeIndex >= 0 ? sizeOptions[form.sizeIndex] : '请选择规格' }}</text>
</picker>
</view>
<view class="field-divider"></view>
<view class="field-row">
<text class="field-label">货架/库位</text>
<input class="field-input" type="text" v-model="form.shelfNo" placeholder="存放货架或库位号" placeholder-style="color:#B5C2C2" />
</view>
<view class="field-divider"></view>
<view class="field-row" @tap="refreshArrivalTime">
<text class="field-label">到站时间</text>
<text :class="['field-picker-text', form.arrivalTime ? 'field-value' : 'ph']">{{ form.arrivalTime || '点击获取当前时间' }}</text>
</view>
<view class="field-divider"></view>
<view class="remark-block">
<text class="field-label">备注</text>
<textarea class="field-textarea" v-model="form.remark" placeholder="备注信息(选填)" placeholder-style="color:#B5C2C2" />
</view>
</view>
<!-- 操作行 -->
<view class="action-row">
<view class="btn-primary" @tap="onConfirmInbound">
<text class="btn-primary-text">确认入库</text>
</view>
<view class="btn-ghost" @tap="Form">
<text class="btn-ghost-text">重置</text>
</view>
</view>
<!-- 高级设置(可折叠) -->
<view class="section-card">
<view class="section-header" @tap="toggleAdvanced">
<text class="section-title">高级设置</text>
<text class="section-arrow">{{ showAdvanced ? '收起 ▲' : '展开 ▼' }}</text>
</view>
<view v-if="showAdvanced" class="advanced-body">
<!-- 扫描线带宽 -->
<view class="sub-section">
<view class="sub-header">
<text class="sub-label">扫描线带宽</text>
<text class="sub-value">{{ (ocrLineBandRatio * 100).toFixed(0) }}%</text>
</view>
<slider class="sub-slider" :min="1" :max="100" :value="ocrLineBandRatio * 100" @change="onBandRatioChange" />
</view>
<!-- 扫描框排版(宽/高/竖直位置,位置即 setScanFramePosition) -->
<view class="sub-section">
<view class="sub-header">
<text class="sub-label">扫描框排版</text>
<text class="sub-value">{{ scanFrameWidthPx }} × {{ scanFrameHeightPx }} px / 位置{{ (scanFramePosition * 100).toFixed(0) }}%</text>
</view>
<view class="frame-row">
<text class="frame-axis">宽</text>
<slider class="sub-slider" :min="80" :max="maxFrameWidth" :value="scanFrameWidthPx" @change="onFrameWidthChange" />
</view>
<view class="frame-row">
<text class="frame-axis">高</text>
<slider class="sub-slider" :min="80" :max="maxFrameHeight" :value="scanFrameHeightPx" @change="onFrameHeightChange" />
</view>
<view class="frame-row">
<text class="frame-axis">位置</text>
<slider class="sub-slider" :min="0" :max="100" :value="scanFramePosition * 100" @change="onFramePositionChange" />
</view>
</view>
<!-- 缩放 -->
<view class="sub-section">
<view class="sub-header">
<text class="sub-label">缩放</text>
<text class="sub-value">{{ currentZoom.toFixed(1) }}x</text>
</view>
<slider class="sub-slider" :min="10" :max="maxZoom * 10" :value="currentZoom * 10" @change="onZoomSliderChange" />
</view>
<!-- 音效 -->
<view class="sound-row">
<text class="sub-label">扫描音效</text>
<switch :checked="playSound == true" @change="togglePlaySound" />
</view>
<!-- 日志 -->
<view class="log-section">
<view class="log-header">
<text class="sub-label">日志</text>
<view class="log-clear-btn" @tap="clearLogs"><text class="log-clear-text">清空</text></view>
</view>
<scroll-view scroll-y="true" class="log-scroll">
<text v-for="(log, index) in logs" :key="index" class="log-item">{{ log }}</text>
</scroll-view>
</view>
</view>
</view>
<!-- 已入库包裹 -->
<view class="section-card">
<view class="section-header">
<text class="section-title">今日入库 ({{ inboundRecords.length }})</text>
</view>
<view v-if="inboundRecords.length === 0" class="empty-hint">
<text class="empty-text">暂无入库记录</text>
</view>
<view v-for="(rec, index) in inboundRecords" :key="index" class="record-item">
<view class="record-top">
<text class="record-waybill">{{ rec.waybillNo }}</text>
<text class="record-time">{{ rec.inboundTime }}</text>
</view>
<view class="record-meta">
<text class="record-recipient">{{ rec.recipient }}</text>
<text class="record-courier">{{ rec.courierCompany }}</text>
<view v-if="rec.packageSize" class="record-size-tag">
<text class="record-size-text">{{ rec.packageSize }}</text>
</view>
</view>
<view v-if="index < inboundRecords.length - 1" class="record-divider"></view>
</view>
</view>
<view class="scroll-bottom-space"></view>
</scroll-view>
</view>
</template>
<script>
// 直接导入 UTS 原生模块(绕过 .vue 组件层,避免 nvue 下组件不实例化的问题)
import { NativeScanner } from "@/uni_modules/yuange-scanneruniapp";
export default {
data() {
return {
// 扫描器预览尺寸(device px;铺底模式下 onReady 直接按全屏赋值,无需测量占位区)
scannerWidth: 0,
scannerHeight: 0,
torchOn: false,
currentZoom: 1.0,
maxZoom: 10.0,
isScanning: false,
scanFrameWidthRatio: 0.7,
scanFrameHeightRatio: 0.5,
// 扫描框绝对像素尺寸(>0 时优先于比例);运单条码为横向一维码,默认宽扁框
scanFrameWidthPx: 1006,
scanFrameHeightPx: 341,
maxFrameWidth: 1080,
maxFrameHeight: 1000,
scanLineColor: '#13C2C2',
frameBorderColor: '#FFFFFF',
hintText: '将运单条码对准扫描框',
hintColor: '#FFFFFF',
playSound: true,
showScanLine: true,
ocrLineBandRatio: 0.01,
// 扫描框竖直排版位置(框中心相对预览高度比例:0=顶部,0.5=居中,1=底部;识别带宽自动跟随)
// 默认 0.35:标题栏之下、表单区之上的预览窗中部
scanFramePosition: 0.20,
lineColors: ['#FFFFFF', '#13C2C2', '#FF0000', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'],
logs: [],
// NativeScanner 实例(直接管理,不经过组件层)
nativeScanner: null,
isInitialized: false,
initError: '',
isPageReady: false,
initFailed: false,
// ---- 包裹入库表单 ----
form: {
waybillNo: '',
recipient: '',
phone: '',
courierIndex: -1,
courierCompany: '',
sizeIndex: -1,
packageSize: '',
shelfNo: '',
arrivalTime: '',
remark: ''
},
courierOptions: ['顺丰速运', '中通快递', '圆通速递', '韵达快递', '申通快递', '京东物流', '极兔速递', '其他'],
sizeOptions: ['小件', '中件', '大件'],
inboundRecords: [],
showAdvanced: false,
statusBarHeight: 0,
pixelRatio: 1
}
},
onLoad() {
try {
const sys = uni.getSystemInfoSync();
this.statusBarHeight = sys.statusBarHeight || 0;
this.pixelRatio = sys.pixelRatio || 1;
} catch (e) {
this.statusBarHeight = 0;
this.pixelRatio = 1;
}
// 标题栏为青绿色,状态栏文字用浅色
try { plus.navigator.setStatusBarStyle('light'); } catch (e) {}
this.refreshArrivalTime();
this.addLog('页面加载完成');
},
onReady() {
this.isPageReady = true;
// 铺底模式:预览铺满全屏,按屏幕尺寸(设备物理像素)初始化,无需测量占位区
try {
const sys = uni.getSystemInfoSync();
this.pixelRatio = sys.pixelRatio || 1;
this.scannerWidth = Math.floor(sys.windowWidth * this.pixelRatio);
this.scannerHeight = Math.floor(sys.windowHeight * this.pixelRatio);
} catch (e) {
this.pixelRatio = 1;
}
this.addLog('页面就绪,开始初始化扫描器... 全屏预览 ' + this.scannerWidth + 'x' + this.scannerHeight + ' (dpr=' + this.pixelRatio + ')');
this.initScanner();
},
onUnload() {
this.stopScanner();
// 清理帧图片缓存,避免 cacheDir 下 scan_frame_*.jpg 堆积
if (this.nativeScanner) {
try {
this.nativeScanner.clearFrameImageCache();
} catch (err) {}
}
this.destroyScanner();
this.addLog('扫描器已销毁');
},
onHide() {
this.stopScanner();
},
methods: {
// 把任意错误对象转成可读字符串(兼容 UTS HashMap)
toMsg(err) {
if (err == null) return '未知错误';
if (typeof err === 'string') return err;
try {
if (typeof err.get === 'function') {
return err.get('msg') || err.get('message') || String(err);
}
} catch (e) {}
return err.message || err.msg || String(err);
},
// 直接创建并初始化 NativeScanner 实例
initScanner() {
this.addLog('开始创建 NativeScanner...');
// 检查 UTS 模块是否可用
if (typeof NativeScanner !== 'function') {
const msg = 'NativeScanner 未定义 (typeof=' + typeof NativeScanner + ')。请确认已制作自定义基座且 UTS 编译无错误。';
this.initError = msg;
this.initFailed = true;
this.isInitialized = true;
this.addLog(msg);
uni.showModal({ title: '初始化失败', content: msg, showCancel: false });
return;
}
try {
this.nativeScanner = new NativeScanner(this.scannerWidth, this.scannerHeight);
this.addLog('NativeScanner 已创建');
} catch (err) {
this.initError = '创建NativeScanner失败: ' + this.toMsg(err);
this.initFailed = true;
this.isInitialized = true;
this.addLog(this.initError);
uni.showModal({ title: '初始化失败', content: this.initError, showCancel: false });
return;
}
// 确保尺寸有效
let w = this.scannerWidth;
let h = this.scannerHeight;
if (w <= 0 || h <= 0) {
w = w > 0 ? w : 300;
h = h > 0 ? h : 400;
}
// 扫描框默认尺寸:取预览区较短边的 70% 作为正方形边长,dispatchDraw 会自动居中显示
if (this.scanFrameWidthPx <= 0 || this.scanFrameHeightPx <= 0) {
const frameSize = this.computeDefaultFrameSize(w, h);
this.scanFrameWidthPx = frameSize;
this.scanFrameHeightPx = frameSize;
}
this.maxFrameWidth = w;
this.maxFrameHeight = h;
// 扫描框像素尺寸不超过预览区(设备像素),避免 slider value > max
if (this.scanFrameWidthPx > w) this.scanFrameWidthPx = w;
if (this.scanFrameHeightPx > h) this.scanFrameHeightPx = h;
// 铺底模式绑定:预览自动插入到页面内容之下、根容器背景之上,铺满全屏
// (尺寸为 0 时插件内部按 MATCH_PARENT 处理,页面控件绘制在预览上层作蒙层)
try {
this.nativeScanner.bindViewBelowPage();
this.addLog('原生视图已绑定(铺底模式)');
} catch (err) {
this.initError = '绑定视图失败: ' + this.toMsg(err);
this.initFailed = true;
this.isInitialized = true;
this.addLog(this.initError);
uni.showModal({ title: '初始化失败', content: this.initError, showCancel: false });
return;
}
// 初始化扫描视图配置
this.applyScanViewOptions();
// 设置扫描线带宽(OCR/条码按线条位置过滤)
try {
this.nativeScanner.setOcrLineBandRatio(this.ocrLineBandRatio);
} catch (err) {
this.addLog('设置带宽失败(非致命): ' + this.toMsg(err));
}
// 设置扫描框竖直排版位置(0=顶部,0.5=居中,1=底部;识别带宽自动跟随框中心)
try {
this.nativeScanner.setScanFramePosition(this.scanFramePosition);
} catch (err) {
this.addLog('设置扫描框位置失败(非致命): ' + this.toMsg(err));
}
// 初始化反馈配置
try {
this.nativeScanner.setScanFeedbackOptions({
playSound: this.playSound,
autoStop: false
});
} catch (err) {
this.addLog('设置反馈配置失败(非致命): ' + this.toMsg(err));
}
// 入库页不使用帧图片,关闭回调避免 cacheDir 堆积 scan_frame_*.jpg
try {
this.nativeScanner.setEnableFrameImageCallback(false);
} catch (err) {
this.addLog('关闭帧图片回调失败(非致命): ' + this.toMsg(err));
}
// 设置扫描结果回调
this.setupScanResultCallback();
this.isInitialized = true;
this.initFailed = false;
this.addLog('扫描器初始化完成');
// 获取最大缩放
this.loadMaxZoom();
// 自动启动相机预览(入库页默认进入扫描状态)
setTimeout(() => { this.startScanner(); }, 120);
},
// 统一构造扫描视图配置对象(init 与运行时复用)
applyScanViewOptions() {
if (!this.nativeScanner) return;
try {
this.nativeScanner.setScanViewOptions({
scanFrameWidth: this.scanFrameWidthRatio,
scanFrameHeight: this.scanFrameHeightRatio,
scanFrameWidthPx: this.scanFrameWidthPx,
scanFrameHeightPx: this.scanFrameHeightPx,
frameBorderColor: this.frameBorderColor,
frameBorderWidth: 2,
scanLineStyle: {
color: this.scanLineColor,
width: 2,
height: 4
},
hintText: this.hintText,
hintColor: this.hintColor,
hintTextSize: 14,
maskOpacity: 0,
maskColor: '#000000'
});
} catch (err) {
this.addLog('设置扫描视图配置失败(非致命): ' + this.toMsg(err));
}
},
// 设置扫描结果回调
setupScanResultCallback() {
if (!this.nativeScanner) return;
try {
this.nativeScanner.setScanResultCallback({
success: (res) => {
let rawValue = '';
let format = '';
let ocrText = '';
try {
if (typeof res.get === 'function') {
rawValue = res.get("rawValue") || '';
format = res.get("format") || '';
ocrText = res.get("ocrText") || '';
} else {
rawValue = res["rawValue"] || res.rawValue || '';
format = res["format"] || res.format || '';
ocrText = res["ocrText"] || res.ocrText || '';
}
} catch (e) {
rawValue = '';
format = '';
}
this.addLog('扫描成功: ' + format);
this.addLog('内容: ' + rawValue);
if (ocrText != '') {
this.addLog('原始OCR: ' + ocrText);
}
// 扫描结果回填运单号
if (rawValue != '') {
this.form.waybillNo = rawValue;
}
// 遍历 results 数组(同时扫描的所有结果)
let jsResults = [];
try {
const results = res.get("results");
if (results != null) {
const rSize = typeof results.size === 'function' ? results.size() : 0;
if (rSize > 1) {
this.addLog('共识别到 ' + rSize + ' 个结果:');
}
for (let ri = 0; ri < rSize; ri++) {
const item = results.get(ri);
const rType = item.get("type");
const rFmt = item.get("format") || '';
const rVal = item.get("rawValue") || '';
const rItem = { type: rType, format: rFmt, rawValue: rVal };
if (rType === 'barcode') {
this.addLog(' [条码] ' + rFmt + ': ' + rVal);
rItem.valueType = item.get("valueType");
} else if (rType === 'text') {
this.addLog(' [文字]: ' + rVal);
}
jsResults.push(rItem);
}
}
} catch (e3) {}
// 回调出去:通过全局事件通知其他页面或组件
uni.$emit('scannerResult', {
rawValue: rawValue,
format: format,
ocrText: ocrText,
results: jsResults
});
uni.showToast({ title: '扫描成功', icon: 'success', duration: 1500 });
},
fail: (err) => {
let msg = '';
try {
if (typeof err.get === 'function') {
msg = err.get("msg") || '';
} else {
msg = err["msg"] || err.msg || '';
}
} catch (e) {
msg = '未知错误';
}
this.addLog('错误: ' + msg);
}
});
this.addLog('扫描结果回调已设置');
} catch (err) {
this.addLog('设置回调失败: ' + this.toMsg(err));
}
},
// 获取最大缩放倍数
loadMaxZoom() {
if (!this.nativeScanner) return;
try {
this.nativeScanner.getMaxZoom({
success: (res) => {
let zoom = 10;
try {
if (typeof res.get === 'function') {
zoom = res.get("maxZoom") || 10;
} else {
zoom = res["maxZoom"] || res.maxZoom || 10;
}
} catch (e) {}
this.maxZoom = zoom;
this.addLog('最大缩放: ' + zoom.toFixed(0) + 'x');
},
fail: () => {
this.addLog('获取最大缩放失败,使用默认值');
}
});
} catch (err) {
this.addLog('获取最大缩放异常: ' + this.toMsg(err));
}
},
addLog(msg) {
const now = new Date();
const time = now.getHours() + ':' + String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getSeconds()).padStart(2, '0');
const line = '[' + time + '] ' + msg;
this.logs.unshift(line);
if (this.logs.length > 50) {
this.logs.pop();
}
// 同步输出到控制台
console.log(line);
},
clearLogs() {
this.logs = [];
},
startScanner() {
if (this.isScanning) return;
if (!this.nativeScanner) {
this.addLog('扫描器不可用');
return;
}
if (this.initFailed) {
const reason = this.initError || '初始化失败,请重新进入页面';
this.addLog('启动失败: ' + reason);
uni.showModal({ title: '无法启动', content: reason, showCancel: false });
return;
}
if (!this.isInitialized) {
this.addLog('扫描器未初始化完成');
return;
}
this.addLog('启动相机...');
try {
this.nativeScanner.start({
cameraId: 0,
torch: this.torchOn,
requestPermission: true
}, {
success: (res) => {
this.addLog('相机启动成功');
},
fail: (err) => {
let msg = '';
try {
if (typeof err.get === 'function') {
msg = err.get("msg") || '';
} else {
msg = err["msg"] || err.msg || '';
}
} catch (e) {
msg = '未知错误';
}
this.addLog('启动失败: ' + msg);
}
});
} catch (err) {
this.addLog('启动异常: ' + this.toMsg(err));
}
this.isScanning = true;
this.addLog('启动扫描...');
},
stopScanner() {
if (!this.isScanning) return;
if (this.nativeScanner) {
try {
this.nativeScanner.stop();
} catch (err) {
this.addLog('停止异常: ' + this.toMsg(err));
}
}
this.isScanning = false;
this.addLog('停止扫描');
},
destroyScanner() {
if (this.nativeScanner) {
try {
this.nativeScanner.destroy();
} catch (err) {
this.addLog('销毁异常: ' + this.toMsg(err));
}
this.nativeScanner = null;
}
},
toggleTorch() {
this.torchOn = !this.torchOn;
if (this.nativeScanner) {
try {
this.nativeScanner.setTorch(this.torchOn, {
success: function(res) {},
fail: (err) => {
this.addLog('设置闪光灯失败: ' + this.toMsg(err));
}
});
} catch (err) {
this.addLog('设置闪光灯异常: ' + this.toMsg(err));
}
}
this.addLog('闪光灯: ' + (this.torchOn ? '开启' : '关闭'));
},
onZoomSliderChange(e) {
const rawValue = this.parseSliderValue(e);
if (rawValue == null) {
this.addLog('缩放值无效,已忽略');
return;
}
const zoom = rawValue / 10.0;
this.currentZoom = zoom;
if (this.nativeScanner) {
try {
this.nativeScanner.setZoom(zoom, {
success: function(res) {},
fail: (err) => {
this.addLog('设置缩放失败: ' + this.toMsg(err));
}
});
} catch (err) {
this.addLog('设置缩放异常: ' + this.toMsg(err));
}
}
this.addLog('设置缩放: ' + zoom.toFixed(1) + 'x');
},
togglePlaySound() {
this.playSound = !this.playSound;
if (this.nativeScanner) {
try {
this.nativeScanner.setScanFeedbackOptions({
playSound: this.playSound,
autoStop: false
});
} catch (err) {
this.addLog('设置反馈异常: ' + this.toMsg(err));
}
}
this.addLog('提示音: ' + (this.playSound ? '开启' : '关闭'));
},
toggleScanLine() {
this.showScanLine = !this.showScanLine;
this.addLog('扫描线: ' + (this.showScanLine ? '显示' : '隐藏'));
},
setScanLineColor(color) {
this.scanLineColor = color;
this.applyScanViewOptions();
this.addLog('扫描线颜色: ' + color);
},
onBandRatioChange(e) {
const rawValue = this.parseSliderValue(e);
if (rawValue == null) {
this.addLog('带宽值无效,已忽略');
return;
}
const ratio = rawValue / 100.0;
this.ocrLineBandRatio = ratio;
if (this.nativeScanner) {
try {
this.nativeScanner.setOcrLineBandRatio(ratio);
} catch (err) {
this.addLog('设置带宽异常: ' + this.toMsg(err));
}
}
this.addLog('扫描线带宽: ' + (ratio * 100).toFixed(0) + '%');
},
// 调整扫描框竖直排版位置(0=预览顶部,1=预览底部),识别带宽跟随框中心
onFramePositionChange(e) {
const v = this.parseSliderValue(e);
if (v == null) {
this.addLog('扫描框位置值无效,已忽略');
return;
}
const ratio = v / 100.0;
this.scanFramePosition = ratio;
if (this.nativeScanner) {
try {
this.nativeScanner.setScanFramePosition(ratio);
} catch (err) {
this.addLog('设置扫描框位置异常: ' + this.toMsg(err));
}
}
this.addLog('扫描框位置: ' + (ratio * 100).toFixed(0) + '%');
},
// 通用 slider @change 值解析(兼容 nvue 各版本事件结构)
parseSliderValue(e) {
const num = (v) => {
if (v == null) return null;
if (typeof v === 'number') return (isNaN(v) || !isFinite(v)) ? null : v;
const n = Number(v);
return (isNaN(n) || !isFinite(n)) ? null : n;
};
// 直接是值(number 或字符串数字)
let n = num(e);
if (n != null) return n;
if (e != null && typeof e === 'object') {
n = num(e.value); if (n != null) return n;
// detail 可能直接是值(number/string),也可能是 {value:...}
n = num(e.detail); if (n != null) return n;
if (e.detail != null && typeof e.detail === 'object') {
n = num(e.detail.value); if (n != null) return n;
}
n = num(e.currentValue); if (n != null) return n;
// weex 风格:值可能在 target.attr.value
const t = (e.target != null) ? e.target : e.currentTarget;
if (t != null) {
n = num(t.value); if (n != null) return n;
if (t.attr != null) { n = num(t.attr.value); if (n != null) return n; }
}
}
// 解析失败:输出原始事件结构便于定位
let dump = '';
try {
const keys = (typeof e === 'object' && e != null) ? Object.keys(e).join(',') : '';
dump = typeof e + ' [' + keys + '] ' + JSON.stringify(e);
} catch (ex) {
dump = typeof e + ' ' + String(e);
}
this.addLog('slider原始事件: ' + dump);
return null;
},
onFrameWidthChange(e) {
const v = this.parseSliderValue(e);
if (v == null) {
this.addLog('扫描框宽度值无效,已忽略');
return;
}
this.scanFrameWidthPx = Math.floor(v);
this.applyScanViewOptions();
this.addLog('扫描框宽度: ' + this.scanFrameWidthPx + 'px');
},
onFrameHeightChange(e) {
const v = this.parseSliderValue(e);
if (v == null) {
this.addLog('扫描框高度值无效,已忽略');
return;
}
this.scanFrameHeightPx = Math.floor(v);
this.applyScanViewOptions();
this.addLog('扫描框高度: ' + this.scanFrameHeightPx + 'px');
},
// 默认扫描框尺寸:取预览区较短边的 70% 作为正方形边长(dispatchDraw 自动居中)
computeDefaultFrameSize(w, h) {
const ww = w > 0 ? w : 300;
const hh = h > 0 ? h : 400;
return Math.floor(Math.min(ww, hh) * 0.7);
},
resetFrameSize() {
const frameSize = this.computeDefaultFrameSize(this.scannerWidth, this.scannerHeight);
this.scanFrameWidthPx = frameSize;
this.scanFrameHeightPx = frameSize;
this.applyScanViewOptions();
this.addLog('扫描框已重置: ' + this.scanFrameWidthPx + 'x' + this.scanFrameHeightPx);
},
// ---- 包裹入库表单方法 ----
onCourierChange(e) {
let idx = -1;
try {
if (e && e.detail && e.detail.value != null) idx = Number(e.detail.value);
else if (typeof e === 'number') idx = e;
} catch (_) {}
if (isNaN(idx) || idx < 0) return;
this.form.courierIndex = idx;
this.form.courierCompany = this.courierOptions[idx] || '';
this.addLog('快递公司: ' + this.form.courierCompany);
},
onSizeChange(e) {
let idx = -1;
try {
if (e && e.detail && e.detail.value != null) idx = Number(e.detail.value);
else if (typeof e === 'number') idx = e;
} catch (_) {}
if (isNaN(idx) || idx < 0) return;
this.form.sizeIndex = idx;
this.form.packageSize = this.sizeOptions[idx] || '';
this.addLog('包裹规格: ' + this.form.packageSize);
},
formatDateTime(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return y + '-' + m + '-' + day + ' ' + hh + ':' + mm;
},
refreshArrivalTime() {
this.form.arrivalTime = this.formatDateTime(new Date());
},
onConfirmInbound() {
const f = this.form;
if (!f.waybillNo || !String(f.waybillNo).trim()) {
uni.showToast({ title: '请输入或扫描运单号', icon: 'none' });
return;
}
if (!f.recipient || !String(f.recipient).trim()) {
uni.showToast({ title: '请输入收件人', icon: 'none' });
return;
}
const phoneStr = String(f.phone == null ? '' : f.phone).trim();
if (!/^\d{11}$/.test(phoneStr)) {
uni.showToast({ title: '请输入正确的11位手机号', icon: 'none' });
return;
}
const record = {
waybillNo: String(f.waybillNo).trim(),
recipient: String(f.recipient).trim(),
phone: phoneStr,
courierCompany: f.courierCompany || '未选择',
packageSize: f.packageSize || '',
shelfNo: f.shelfNo || '',
arrivalTime: f.arrivalTime || '',
remark: f.remark || '',
inboundTime: this.formatDateTime(new Date())
};
this.inboundRecords.unshift(record);
this.addLog('入库成功: ' + record.waybillNo + ' / ' + record.recipient);
uni.showToast({ title: '入库成功', icon: 'success', duration: 1500 });
this.onResetForm();
},
Form() {
this.form.waybillNo = '';
this.form.recipient = '';
this.form.phone = '';
this.form.courierIndex = -1;
this.form.courierCompany = '';
this.form.sizeIndex = -1;
this.form.packageSize = '';
this.form.shelfNo = '';
this.form.remark = '';
this.refreshArrivalTime();
},
toggleAdvanced() {
this.showAdvanced = !this.showAdvanced;
}
}
}
</script>
<style>
/* 容器必须透明,铺底的相机预览才能透出 */
.container { background-color: rgba(0,0,0,0); flex: 1; flex-direction: column; }
/* 表单滚动区:不透明浅色底,覆盖预览下半部分,保证表单可读性 */
.form-scroll { height: 1000rpx; background-color: #F5F7F7; }
.scroll-bottom-space { height: 40rpx; }
/* 自定义标题栏(不透明,悬浮于铺底预览之上) */
.page-header { background-color: #13C2C2; padding-left: 32rpx; padding-right: 32rpx; padding-bottom: 24rpx; }
.header-inner { flex-direction: column; }
.header-title { color: #FFFFFF; font-size: 40rpx; font-weight: bold; }
.header-subtitle { color: #D6F5F5; font-size: 24rpx; margin-top: 6rpx; }
/* 扫描预览区:透明露出铺底的全屏预览,控制条悬浮在其底部 */
.scan-stage { flex: 1; background-color: rgba(0,0,0,0); justify-content: flex-end; }
.scan-controls { flex-direction: row; padding: 16rpx 20rpx; background-color: rgba(0,0,0,0.45); }
.ctrl-btn { flex: 1; height: 76rpx; border-radius: 12rpx; justify-content: center; align-items: center; margin-left: 6rpx; margin-right: 6rpx; }
.ctrl-teal { background-color: #13C2C2; }
.ctrl-danger { background-color: #F5222D; }
.ctrl-ghost { background-color: #E6F9F9; }
.ctrl-disabled { background-color: #D6DEDE; }
.ctrl-btn-text-w { color: #FFFFFF; font-size: 28rpx; }
.ctrl-btn-text-d { color: #0E9E9E; font-size: 28rpx; }
/* 运单号行 */
.waybill-row { flex-direction: row; align-items: center; margin: 0 20rpx; }
.input-wrap { flex: 1; flex-direction: row; align-items: center; background-color: #FFFFFF; border-radius: 16rpx; padding: 0 24rpx; height: 92rpx; margin-right: 16rpx; }
.input-label { color: #0E9E9E; font-size: 28rpx; font-weight: bold; width: 120rpx; }
.form-input { flex: 1; font-size: 30rpx; color: #1A2B2B; height: 92rpx; }
.scan-btn { width: 140rpx; height: 92rpx; background-color: #13C2C2; border-radius: 16rpx; justify-content: center; align-items: center; }
.scan-btn-text { color: #FFFFFF; font-size: 30rpx; font-weight: bold; }
/* 表单卡片 */
.form-card { background-color: #FFFFFF; margin: 20rpx; padding: 8rpx 24rpx; border-radius: 20rpx; }
.field-row { flex-direction: row; align-items: center; height: 100rpx; }
.field-label { color: #5A6A6A; font-size: 28rpx; width: 160rpx; }
.field-input { flex: 1; font-size: 30rpx; color: #1A2B2B; height: 100rpx; }
.field-picker { flex: 1; }
.field-picker-text { font-size: 30rpx; }
.field-value { color: #1A2B2B; font-size: 30rpx; }
.field-textarea { font-size: 28rpx; color: #1A2B2B; width: 658rpx; height: 120rpx; margin-top: 12rpx; }
.field-divider { height: 1rpx; background-color: #EAEFEF; }
.remark-block { padding: 20rpx 0; }
.ph { color: #B5C2C2; }
/* 操作行 */
.action-row { flex-direction: row; margin: 0 20rpx; }
.btn-primary { flex: 1; height: 96rpx; background-color: #13C2C2; border-radius: 16rpx; justify-content: center; align-items: center; margin-right: 16rpx; }
.btn-primary-text { color: #FFFFFF; font-size: 32rpx; font-weight: bold; }
.btn-ghost { width: 160rpx; height: 96rpx; background-color: #FFFFFF; border-radius: 16rpx; justify-content: center; align-items: center; border-width: 2rpx; border-style: solid; border-color: #13C2C2; }
.btn-ghost-text { color: #13C2C2; font-size: 30rpx; }
/* 通用 section 卡片 */
.section-card { background-color: #FFFFFF; margin: 20rpx; padding: 24rpx; border-radius: 20rpx; }
.section-header { flex-direction: row; justify-content: space-between; align-items: center; }
.section-title { color: #1A2B2B; font-size: 30rpx; font-weight: bold; }
.section-arrow { color: #13C2C2; font-size: 24rpx; }
/* 高级设置子项 */
.advanced-body { margin-top: 20rpx; }
.sub-section { margin-top: 8rpx; }
.sub-header { flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.sub-label { color: #5A6A6A; font-size: 26rpx; }
.sub-value { color: #13C2C2; font-size: 26rpx; }
.sub-slider { flex: 1; }
.frame-row { flex-direction: row; align-items: center; margin-top: 8rpx; }
.frame-axis { color: #5A6A6A; font-size: 26rpx; width: 40rpx; }
.sound-row { flex-direction: row; justify-content: space-between; align-items: center; margin-top: 20rpx; }
.log-section { margin-top: 20rpx; background-color: #F5F7F7; border-radius: 12rpx; padding: 16rpx; }
.log-header { flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.log-clear-btn { padding: 4rpx 16rpx; background-color: #E6F9F9; border-radius: 8rpx; }
.log-clear-text { color: #0E9E9E; font-size: 22rpx; }
.log-scroll { height: 200rpx; }
.log-item { font-size: 20rpx; color: #8A9A9A; line-height: 36rpx; }
/* 入库记录 */
.empty-hint { padding: 40rpx 0; justify-content: center; align-items: center; }
.empty-text { color: #B5C2C2; font-size: 26rpx; }
.record-item { padding-top: 20rpx; }
.record-top { flex-direction: row; justify-content: space-between; align-items: center; }
.record-waybill { color: #1A2B2B; font-size: 28rpx; font-weight: bold; }
.record-time { color: #8A9A9A; font-size: 22rpx; }
.record-meta { flex-direction: row; align-items: center; margin-top: 12rpx; }
.record-recipient { color: #5A6A6A; font-size: 24rpx; margin-right: 20rpx; }
.record-courier { color: #13C2C2; font-size: 24rpx; margin-right: 20rpx; }
.record-size-tag { background-color: #E6F9F9; border-radius: 8rpx; padding: 4rpx 14rpx; }
.record-size-text { color: #0E9E9E; font-size: 22rpx; }
.record-divider { height: 1rpx; background-color: #EAEFEF; margin-top: 20rpx; }
</style>

收藏人数:
购买普通授权版(
试用
赞赏(0)
下载 675
赞赏 0
下载 12650762
赞赏 1953
赞赏
京公网安备:11010802035340号