index.vue 9.98 KB
<template>
  <view class="nfc-page">
    <view class="header">
      <text class="title">NFC 功能测试</text>
      <text class="subtitle">请确保手机 NFC 功能已开启</text>
    </view>

    <view class="action-area">
      <nut-button
        type="primary"
        size="large"
        @click="startScan"
        :disabled="isScanning"
        :loading="isScanning"
      >
        {{ isScanning ? '正在扫描...' : '开始 NFC 扫描' }}
      </nut-button>

      <nut-button
        v-if="isScanning"
        type="danger"
        size="large"
        @click="stopScan"
        style="margin-top: 40rpx"
      >
        停止扫描
      </nut-button>
    </view>

    <view class="status-area">
      <text class="status-label">当前状态:</text>
      <text class="status-text" :class="{ scanning: isScanning, error: !!error }">{{
        status
      }}</text>
    </view>

    <view class="result-area" v-if="result">
      <view class="result-header">扫描结果</view>
      <view class="result-content">
        <text>{{ result }}</text>
      </view>
    </view>

    <view class="error-area" v-if="error">
      <text class="error-text">{{ error }}</text>
    </view>

    <view class="result-area" v-if="debugInfo">
      <view class="result-header">调试信息</view>
      <view class="result-content">
        <text>{{ debugInfo }}</text>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, onUnmounted } from 'vue'
import Taro from '@tarojs/taro'

const isScanning = ref(false)
const status = ref('等待操作')
const result = ref('')
const error = ref('')
const debugInfo = ref('')

let nfcAdapter = null

const safe_stringify = data => {
  try {
    return JSON.stringify(
      data,
      (key, value) => {
        if (value instanceof ArrayBuffer) {
          return {
            __type: 'ArrayBuffer',
            hex: bufferToHex(value)
          }
        }
        if (value && value.buffer instanceof ArrayBuffer) {
          return {
            __type: 'TypedArray',
            hex: bufferToHex(value.buffer)
          }
        }
        return value
      },
      2
    )
  } catch (e) {
    return String(data)
  }
}

const startScan = async () => {
  error.value = ''
  result.value = ''
  debugInfo.value = ''

  let systemInfo = null
  try {
    systemInfo = Taro.getSystemInfoSync()
  } catch (e) {}
  const envType = Taro.getEnv && Taro.getEnv()
  const platform = systemInfo && systemInfo.platform ? systemInfo.platform : ''
  const system = systemInfo && systemInfo.system ? systemInfo.system : ''
  const model = systemInfo && systemInfo.model ? systemInfo.model : ''
  const SDKVersion = systemInfo && systemInfo.SDKVersion ? systemInfo.SDKVersion : ''
  const version = systemInfo && systemInfo.version ? systemInfo.version : ''
  debugInfo.value = `env: ${envType}\nplatform: ${platform}\nsystem: ${system}\nmodel: ${model}\nSDKVersion: ${SDKVersion}\nversion: ${version}`

  if (platform === 'ios') {
    error.value = 'iOS 端微信小程序通常不支持 NFC(该能力主要在 Android 可用)'
    status.value = '启动失败'
    return
  }

  if (!Taro.getNFCAdapter) {
    error.value = '当前环境不支持 NFC 接口'
    status.value = '启动失败'
    return
  }

  if (envType && Taro.ENV_TYPE && envType !== Taro.ENV_TYPE.WEAPP) {
    error.value = '当前不是微信小程序环境,无法使用 NFC'
    status.value = '启动失败'
    return
  }

  try {
    nfcAdapter = Taro.getNFCAdapter()

    status.value = '正在初始化 NFC...'

    await nfcAdapter.startDiscovery({
      techs: [
        'NFC-A',
        'NFC-B',
        'NFC-F',
        'NFC-V',
        'ISO-DEP',
        'MIFARE-CLASSIC',
        'MIFARE-ULTRALIGHT',
        'NDEF'
      ],
      success: () => {
        status.value = '请将手机背面靠近 NFC 标签'
        isScanning.value = true
      },
      fail: err => {
        console.error('NFC start error:', err)
        // 错误码参考微信文档
        debugInfo.value = `${debugInfo.value}\n\nstartDiscovery fail:\n${err && (err.errMsg || JSON.stringify(err))}`
        if (err.errCode === 13000) {
          error.value = '设备不支持 NFC'
        } else if (err.errCode === 13001) {
          error.value = '系统 NFC 开关未开启'
        } else if (err.errMsg && err.errMsg.includes('platform is not supported')) {
          error.value = '开发者工具不支持 NFC,请使用真机调试'
        } else {
          error.value = `NFC 启动失败: ${err.errMsg || JSON.stringify(err)}`
        }
        status.value = '启动失败'
      }
    })

    nfcAdapter.onDiscovered(res => {
      console.log('NFC Discovered:', res)
      status.value = '发现标签,正在读取...'
      Taro.vibrateShort()

      handleNfcMessage(res)

      // 扫描成功后,通常可以选择是否停止。这里我们保持扫描状态,或者提供停止按钮。
      // 用户需求是“扫描成功后显示信息内容”,为了防止重复读取造成刷屏,可以考虑读取成功后自动停止。
      // 但如果是测试页,可能想连续测多个。我选择不自动停止,但更新结果。
    })
  } catch (e) {
    console.error('NFC Adapter error:', e)
    debugInfo.value = `${debugInfo.value}\n\ngetNFCAdapter error:\n${e && (e.errMsg || e.message || JSON.stringify(e))}`
    error.value = 'NFC 初始化失败(可能是设备/系统不支持,或不在可用环境)'
    status.value = '错误'
  }
}

const stopScan = () => {
  if (nfcAdapter) {
    nfcAdapter.stopDiscovery({
      success: () => {
        status.value = '已停止扫描'
        isScanning.value = false
      },
      fail: err => {
        console.error('Stop NFC fail', err)
      },
      complete: () => {
        // 确保状态更新
        isScanning.value = false
        if (nfcAdapter && nfcAdapter.offDiscovered) {
          nfcAdapter.offDiscovered()
        }
      }
    })
  } else {
    isScanning.value = false
  }
}

// 辅助函数:将 ArrayBuffer 转为 16 进制字符串
const bufferToHex = buffer => {
  return Array.from(new Uint8Array(buffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join(':')
    .toUpperCase()
}

const handleNfcMessage = res => {
  let content = ''

  // 1. 获取 UID
  if (res.id) {
    content += `UID: ${bufferToHex(res.id)}\n`
  }

  // 2. 获取 Tech Types
  if (res.techs && res.techs.length) {
    content += `Techs: ${res.techs.join(', ')}\n`
  }

  // 3. 解析 NDEF 消息
  if (res.messages && res.messages.length > 0) {
    content += '\n--- NDEF Records ---\n'
    try {
      // res.messages 是一个数组,通常取第一个 NDEF Message
      const records = res.messages[0].records || []

      records.forEach((record, index) => {
        content += `[Record ${index + 1}]\n`

        // Type Name Format (TNF) - bits 0-2 of first byte (not directly exposed in simple objects usually,
        // but we might need to parse payload if it's raw.
        // However, WeChat returns parsed objects: { type, payload, id, tnf }

        if (record.type) {
          // record.type is ArrayBuffer
          const typeStr = new TextDecoder().decode(record.type)
          content += `Type: ${typeStr}\n`

          // Text Record Parsing (Type = 'T')
          if (typeStr === 'T') {
            const payload = new Uint8Array(record.payload)
            if (payload.length > 0) {
              const statusByte = payload[0]
              const langCodeLen = statusByte & 0x3f
              // const isUtf16 = (statusByte & 0x80) !== 0; // bit 7

              // 提取文本内容
              const textBytes = payload.slice(1 + langCodeLen)
              const text = new TextDecoder().decode(textBytes)
              content += `Content: ${text}\n`
            }
          } else {
            // 其他类型,尝试直接转码显示,或者显示 HEX
            const text = new TextDecoder().decode(record.payload)
            // 简单的过滤,如果看起来像乱码则显示 Hex
            if (/[\x00-\x08\x0E-\x1F]/.test(text)) {
              content += `Payload (Hex): ${bufferToHex(record.payload)}\n`
            } else {
              content += `Payload: ${text}\n`
            }
          }
        }
      })
    } catch (parseErr) {
      console.error(parseErr)
      content += '解析 NDEF 数据出错\n'
    }
  } else {
    content += '\n(无 NDEF 消息)\n'
  }

  result.value = `--- 原始数据 ---\n${safe_stringify(res)}\n\n--- 解析结果 ---\n${content}`
}

onUnmounted(() => {
  stopScan()
})
</script>

<style lang="less">
.nfc-page {
  min-height: 100vh;
  background-color: #f7f8fa;
  padding: 60rpx 40rpx;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;

  .header {
    text-align: center;
    margin-bottom: 80rpx;

    .title {
      font-size: 48rpx;
      font-weight: bold;
      color: #333;
      display: block;
      margin-bottom: 20rpx;
    }

    .subtitle {
      font-size: 28rpx;
      color: #999;
    }
  }

  .action-area {
    width: 100%;
    margin-bottom: 60rpx;
  }

  .status-area {
    margin-bottom: 40rpx;
    font-size: 32rpx;

    .status-label {
      color: #666;
      margin-right: 20rpx;
    }

    .status-text {
      color: #333;
      font-weight: 500;

      &.scanning {
        color: #1989fa;
      }

      &.error {
        color: #fa2c19;
      }
    }
  }

  .result-area {
    width: 100%;
    background: #fff;
    border-radius: 24rpx;
    padding: 40rpx;
    margin: 10rpx auto;
    box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.05);

    .result-header {
      font-size: 36rpx;
      font-weight: bold;
      margin-bottom: 30rpx;
      border-bottom: 2rpx solid #eee;
      padding-bottom: 20rpx;
    }

    .result-content {
      font-size: 28rpx;
      color: #333;
      line-height: 1.6;
      word-break: break-all;
      white-space: pre-wrap;
    }
  }

  .error-area {
    margin-top: 40rpx;
    padding: 20rpx;
    background-color: #ffeaea;
    border-radius: 16rpx;
    width: 100%;
    text-align: center;

    .error-text {
      color: #fa2c19;
      font-size: 28rpx;
    }
  }
}
</style>