更新记录

1.0.4(2026-08-06)

  • 添加支持设置扫描框的宽高scanFrameWidthPxscanFrameHeightPx

1.0.3(2026-08-04)

  • 添加支持根据扫描线条带宽范围精准扫描
  • 移除手机号提取、身份证号提取、家庭地址提取功能,不再返回 phoneNumbers/idCardNumbers/addresses/textType 字段
  • OCR 识别仍返回完整原始文本(ocrText 字段),业务侧可自行解析

1.0.2(2026-07-25)

  • 支持中文、英文OCR
  • 新增家庭地址提取:从 OCR 文本中提取家庭地址并通过 addresses 字段回调
  • 支持返回OCR识别的原始结果
查看更多

平台兼容性

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 文本识别,并返回原始识别文本
  • 支持条码/二维码和文字同时出现在扫描区域时同时识别并返回
  • 支持闪光灯控制
  • 支持相机缩放(放大/缩小)
  • 支持自定义扫描线颜色、扫描框、提示文案
  • 支持扫描完成提示音控制
  • 支持根据扫描线带宽范围精准扫描

适用范围

适用于 uni-app 非 x 项目的 nvue 页面,支持 vue2vue3。若是uni-app x项目请使用 yuange-scanner插件

本插件需制作自定义基座后运行。

快速开始

1. 导入 NativeScanner

nvue 页面直接导入 UTS 原生模块,无需组件层:

import { NativeScanner } from "@/uni_modules/yuange-scanneruniapp";

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,
      nativeScanner: null,
      isInitialized: false
    };
  },

  onReady() {
    this.measureAndInit();
  },

  onUnload() {
    this.stopScanner();
    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) {
              this.scannerWidth = Math.floor(res.size.width);
              this.scannerHeight = Math.floor(res.size.height);
              this.scannerLeft = Math.floor(res.size.left || 0);
              this.scannerTop = Math.floor(res.size.top || 0);
              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);

      // 设置扫描反馈
      this.nativeScanner.setScanFeedbackOptions({ playSound: true, autoStop: false });

      // 设置扫描结果回调
      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 识别文本

      console.log('内容:', rawValue);
      console.log('格式:', format);
      if (ocrText != '') {
        console.log('原始OCR:', ocrText);
      }

      // 遍历 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 之后调用。

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/条码结果(仅命中扫描线附近带宽内的内容)。运行时可随时调用,下一帧即生效。

// ratio: 0.01~1.0,默认 0.12。0.12 = 扫描线中线 ±12%,1.0 = 不限制(整帧命中)
nativeScanner.setOcrLineBandRatio(0.12);

setScanFeedbackOptions(options)

nativeScanner.setScanFeedbackOptions({
  playSound: true,   // 扫描成功后是否播放提示音,默认 true
  autoStop: false    // 扫描成功后是否自动停止,默认 false
});

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 结果,不受条码影响)
}

注意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
}

OCR 文本识别结果

当仅识别到文字时,results 数组包含 text 项,顶层兼容字段取该文本的值:

{
  code: 200,
  msg: "识别成功",
  results: [
    { type: "text", format: "TEXT", rawValue: "原始识别文本" }
  ],
  rawValue: "原始识别文本",
  format: "TEXT",
  valueType: 0,
  ocrText: "原始识别文本"        // 原始 OCR 识别文本(完整识别结果)
}

支持的条码格式

格式 说明
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-areaflex:1 会覆盖显式 height,如需固定尺寸请去掉 flex:1
  4. 数值类型:UTS 在 nvue 下把 JS number 装成 BigDecimal/DoublesetScanViewOptions 等方法已内部转换,JS 侧直接传 number 即可。
  5. maskOpacity0 = 完全透明(无遮罩),0.5 = 半透明黑(默认),1 = 完全不透明。
  6. 页面生命周期onHide 时停止扫描,onUnload 时销毁扫描器,避免后台占用相机。
  7. 仅支持后置摄像头startcameraId 参数当前保留,插件始终使用后置摄像头(setCamera 已禁用)。

完整 Demo 使用示例(scanner-demo.nvue)

<template>
  <view class="container">
    <!-- 扫描区:ScannerView overlay 会按测量结果精确覆盖此区域 -->
    <view class="scan-container">
    <view ref="scanArea" class="scan-area">
      <text class="scan-hint">扫描预览区</text>
    </view>
    </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>

    <!-- 底部控制面板 -->
    <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>

      <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>
  </view>
</template>

<script>
  // 直接导入 UTS 原生模块(绕过 .vue 组件层,避免 nvue 下组件不实例化的问题)
  import { NativeScanner } from "@/uni_modules/yuange-scanneruniapp";

  export default {
    data() {
      return {
        // 扫描器 overlay 的位置和尺寸(相对屏幕像素,由 onReady 测量 scanArea 得到)
        scannerWidth: 0,
        scannerHeight: 0,
        scannerLeft: 0,
        scannerTop: 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: true,
        showScanLine: true,
        ocrLineBandRatio: 0.01,
        lineColors: ['#FFFFFF', '#00FF00', '#FF0000', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'],
        logs: [],
        // NativeScanner 实例(直接管理,不经过组件层)
        nativeScanner: null,
        isInitialized: false,
        initError: '',
        isPageReady: false,
        initFailed: false
      }
    },

    onLoad() {
      this.addLog('页面加载完成');
    },

    onReady() {
      this.isPageReady = true;
      this.addLog('页面就绪,开始初始化扫描器...');
      this.measureAndInit();
    },

    onUnload() {
      this.stopScanner();
      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);
      },

      // 测量 scan-area 的实际位置和尺寸,再初始化扫描器。
      // nvue 的 onReady 触发时 flexbox 布局可能尚未完成(尤其 flex:1 的 view),
      // getComponentRect 会返回 0×0。故先延迟一帧再测,并支持失败重试。
      measureAndInit(retryCount) {
        const retry = (retryCount == null) ? 0 : retryCount;
        const dom = uni.requireNativePlugin('dom');
        const doMeasure = () => {
          if (dom && typeof dom.getComponentRect === 'function' && this.$refs.scanArea) {
            dom.getComponentRect(this.$refs.scanArea, (res) => {
              if (res && res.size && res.size.width > 0 && res.size.height > 0) {
                this.scannerWidth = Math.floor(res.size.width);
                this.scannerHeight = Math.floor(res.size.height);
                this.scannerLeft = Math.floor(res.size.left || 0);
                this.scannerTop = Math.floor(res.size.top || 0);
                this.addLog('扫描区: ' + this.scannerWidth + 'x' + this.scannerHeight + ' @(' + this.scannerLeft + ',' + this.scannerTop + ')');
                this.initScanner();
              } else {
                // 布局未就绪,最多重试 5 次(累计约 250ms)
                if (retry < 5) {
                  setTimeout(() => this.measureAndInit(retry + 1), 50);
                } else {
                  this.addLog('多次测量仍为 0,使用回退值');
                  this.fallbackSize();
                  this.initScanner();
                }
              }
            });
          } else {
            this.fallbackSize();
            this.initScanner();
          }
        };
        // 首次延迟一帧,让 flexbox 布局 pass 跑完;重试时无需再延迟
        if (retry === 0) {
          setTimeout(doMeasure, 50);
        } else {
          doMeasure();
        }
      },

      // 测量失败时的回退尺寸(屏幕宽 × 屏幕高 55%,垂直居中)
      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);
        this.addLog('测量失败,回退值: ' + this.scannerWidth + 'x' + this.scannerHeight);
      },

      // 直接创建并初始化 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;

        // 设置 overlay 精确位置和尺寸(让相机预览覆盖 scan-area,而非贴在左上角)
        try {
          this.nativeScanner.setViewFrame(this.scannerLeft, this.scannerTop, w, h);
          this.addLog('视图位置已设置: ' + w + 'x' + h + ' @(' + this.scannerLeft + ',' + this.scannerTop + ')');
        } catch (err) {
          this.addLog('设置视图位置失败(非致命): ' + this.toMsg(err));
        }

        try {
          this.nativeScanner.bindView();
          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));
        }

        // 初始化反馈配置
        try {
          this.nativeScanner.setScanFeedbackOptions({
            playSound: this.playSound,
            autoStop: false
          });
        } 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 = '';

              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);
              }

              // 遍历 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 });
              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 ? '开启' : '关闭'));
      },

      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) + '%');
      },

      // 通用 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: #1a1a1a; flex: 1; }
  .top-bar { flex-direction: row; align-items: center; padding: 16rpx 20rpx; background-color: #2c2c2c; }
  .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; }
  .scan-container { flex: 1;}
  .scan-area { justify-content: center; align-items: center; margin-top: 200px; width: 1080px; height: 800px;}
  .scan-hint { color: #666666; font-size: 28rpx; }
  .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-panel { background-color: #1e1e1e; padding: 16rpx 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: 200rpx; }
  .log-item { font-size: 20rpx; color: #aaaaaa; line-height: 36rpx; }
</style>

隐私、权限声明

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

android.permission.CAMERA

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

插件不采集任何数据

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

暂无用户评论。