更新记录

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 是 Java ArrayList<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 条形码

注意事项

  1. 必须制作自定义基座:UTS 插件涉及原生 Kotlin 代码,标准基座无法运行。
  2. nvue 专用:本插件通过原生 overlay 实现,不适用于 vue 页面。onReady 时 flexbox 布局可能未完成,需延迟测量占位区。
  3. overlay 机制:扫描器原生视图浮在 nvue 页面之上,靠 setViewFrame 精确对齐 <view ref="scanArea"> 的位置。scan-area 的 flex:1 会覆盖显式 height,如需固定尺寸请去掉 flex:1。
  4. dpr(设备像素比):getComponentRect 返回的是 CSS px(逻辑像素),而 overlay 的 setViewFrame 需要的是 device px(物理像素),两者必须通过 uni.getSystemInfoSync().pixelRatio 换算。若不乘 dpr,在高分屏(pixelRatio=2/3)上 overlay 尺寸会比实际占位区小一半,导致相机预览只覆盖左上角。建议统一在测量时 Math.floor(value * dpr)。
  5. 数值类型:UTS 在 nvue 下把 JS number 装成 BigDecimal/Double,setScanViewOptions 已用 UTSJSONObject 接收并内部转换,JS 侧直接传 number 即可。
  6. maskOpacity:0 = 完全透明(无遮罩),0.5 = 半透明黑(默认),1 = 完全不透明。
  7. 页面生命周期:onHide 时停止扫描,onUnload 时销毁扫描器,避免后台占用相机。
  8. 仅支持后置摄像头:start 的 cameraId 参数当前保留,插件始终使用后置摄像头(setCamera 已禁用)。
  9. 帧图片缓存清理:开启 setEnableFrameImageCallback(true) 后,每次扫描成功都会在 cacheDir 下生成一个 scan_frame_*.jpg 且不会自动删除。建议在 onUnload 或切换"帧图"开关关闭时调用 clearFrameImageCache(),避免缓存无限堆积。
  10. 震动反馈已禁用:ScanFeedbackOptions 的 vibrate 字段接口保留,但当前版本震动功能已禁用,传入不生效,仅 playSound / autoStop 生效。
  11. 扫描线带宽钳制:setOcrLineBandRatio 传入值低于 0.01 会被钳制为 0.01,有效范围 0.01~1.0。
  12. 铺底模式免测量:使用 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>

隐私、权限声明

1. 本插件需要申请的系统权限列表:

android.permission.CAMERA

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件不采集任何数据

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

无

暂无用户评论。