feat(nfc): 添加NFC测试页面及功能实现
实现NFC扫描功能页面,包括: - 创建NFC测试页面及路由配置 - 实现NFC扫描启动、停止和模拟功能 - 添加NFC标签解析和结果显示 - 支持不同设备和环境的兼容性处理
Showing
3 changed files
with
408 additions
and
0 deletions
| ... | @@ -26,6 +26,7 @@ export default { | ... | @@ -26,6 +26,7 @@ export default { |
| 26 | 'pages/verificationResult/index', | 26 | 'pages/verificationResult/index', |
| 27 | 'pages/weakNetwork/index', | 27 | 'pages/weakNetwork/index', |
| 28 | 'pages/offlineBookingCode/index', | 28 | 'pages/offlineBookingCode/index', |
| 29 | + 'pages/nfcTest/index', | ||
| 29 | ], | 30 | ], |
| 30 | subpackages: [ // 配置在tabBar中的页面不能分包写到subpackages中去 | 31 | subpackages: [ // 配置在tabBar中的页面不能分包写到subpackages中去 |
| 31 | { | 32 | { | ... | ... |
src/pages/nfcTest/index.config.js
0 → 100644
src/pages/nfcTest/index.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <view class="nfc-page"> | ||
| 3 | + <view class="header"> | ||
| 4 | + <text class="title">NFC 功能测试</text> | ||
| 5 | + <text class="subtitle">请确保手机 NFC 功能已开启</text> | ||
| 6 | + </view> | ||
| 7 | + | ||
| 8 | + <view class="action-area"> | ||
| 9 | + <nut-button type="primary" size="large" @click="startScan" :disabled="isScanning" :loading="isScanning"> | ||
| 10 | + {{ isScanning ? '正在扫描...' : '开始 NFC 扫描' }} | ||
| 11 | + </nut-button> | ||
| 12 | + | ||
| 13 | + <nut-button v-if="isScanning" type="danger" size="large" @click="stopScan" style="margin-top: 40rpx;"> | ||
| 14 | + 停止扫描 | ||
| 15 | + </nut-button> | ||
| 16 | + | ||
| 17 | + <nut-button v-if="showMockButton" type="info" size="large" @click="mockScan" style="margin-top: 40rpx;"> | ||
| 18 | + 模拟扫描 (开发调试) | ||
| 19 | + </nut-button> | ||
| 20 | + </view> | ||
| 21 | + | ||
| 22 | + <view class="status-area"> | ||
| 23 | + <text class="status-label">当前状态:</text> | ||
| 24 | + <text class="status-text" :class="{ 'scanning': isScanning, 'error': !!error }">{{ status }}</text> | ||
| 25 | + </view> | ||
| 26 | + | ||
| 27 | + <view class="result-area" v-if="result"> | ||
| 28 | + <view class="result-header">扫描结果</view> | ||
| 29 | + <view class="result-content"> | ||
| 30 | + <text>{{ result }}</text> | ||
| 31 | + </view> | ||
| 32 | + </view> | ||
| 33 | + | ||
| 34 | + <view class="error-area" v-if="error"> | ||
| 35 | + <text class="error-text">{{ error }}</text> | ||
| 36 | + </view> | ||
| 37 | + | ||
| 38 | + <view class="result-area" v-if="debugInfo"> | ||
| 39 | + <view class="result-header">调试信息</view> | ||
| 40 | + <view class="result-content"> | ||
| 41 | + <text>{{ debugInfo }}</text> | ||
| 42 | + </view> | ||
| 43 | + </view> | ||
| 44 | + </view> | ||
| 45 | +</template> | ||
| 46 | + | ||
| 47 | +<script setup> | ||
| 48 | +import { ref, onUnmounted } from 'vue'; | ||
| 49 | +import Taro from '@tarojs/taro'; | ||
| 50 | + | ||
| 51 | +const isScanning = ref(false); | ||
| 52 | +const status = ref('等待操作'); | ||
| 53 | +const result = ref(''); | ||
| 54 | +const error = ref(''); | ||
| 55 | +const showMockButton = ref(false); | ||
| 56 | +const debugInfo = ref(''); | ||
| 57 | + | ||
| 58 | +let nfcAdapter = null; | ||
| 59 | + | ||
| 60 | +const startScan = async () => { | ||
| 61 | + error.value = ''; | ||
| 62 | + result.value = ''; | ||
| 63 | + debugInfo.value = ''; | ||
| 64 | + | ||
| 65 | + let systemInfo = null; | ||
| 66 | + try { | ||
| 67 | + systemInfo = Taro.getSystemInfoSync(); | ||
| 68 | + } catch (e) {} | ||
| 69 | + const envType = Taro.getEnv && Taro.getEnv(); | ||
| 70 | + const platform = systemInfo && systemInfo.platform ? systemInfo.platform : ''; | ||
| 71 | + const system = systemInfo && systemInfo.system ? systemInfo.system : ''; | ||
| 72 | + const model = systemInfo && systemInfo.model ? systemInfo.model : ''; | ||
| 73 | + const SDKVersion = systemInfo && systemInfo.SDKVersion ? systemInfo.SDKVersion : ''; | ||
| 74 | + const version = systemInfo && systemInfo.version ? systemInfo.version : ''; | ||
| 75 | + debugInfo.value = `env: ${envType}\nplatform: ${platform}\nsystem: ${system}\nmodel: ${model}\nSDKVersion: ${SDKVersion}\nversion: ${version}`; | ||
| 76 | + | ||
| 77 | + if (platform === 'devtools') { | ||
| 78 | + showMockButton.value = true; | ||
| 79 | + } else { | ||
| 80 | + showMockButton.value = false; | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + if (platform === 'ios') { | ||
| 84 | + error.value = 'iOS 端微信小程序通常不支持 NFC(该能力主要在 Android 可用)'; | ||
| 85 | + status.value = '启动失败'; | ||
| 86 | + showMockButton.value = true; | ||
| 87 | + return; | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + if (!Taro.getNFCAdapter) { | ||
| 91 | + error.value = '当前环境不支持 NFC 接口'; | ||
| 92 | + status.value = '启动失败'; | ||
| 93 | + showMockButton.value = true; | ||
| 94 | + return; | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + if (envType && Taro.ENV_TYPE && envType !== Taro.ENV_TYPE.WEAPP) { | ||
| 98 | + error.value = '当前不是微信小程序环境,无法使用 NFC'; | ||
| 99 | + status.value = '启动失败'; | ||
| 100 | + showMockButton.value = true; | ||
| 101 | + return; | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + try { | ||
| 105 | + nfcAdapter = Taro.getNFCAdapter(); | ||
| 106 | + | ||
| 107 | + status.value = '正在初始化 NFC...'; | ||
| 108 | + | ||
| 109 | + await nfcAdapter.startDiscovery({ | ||
| 110 | + techs: [ | ||
| 111 | + 'NFC-A', | ||
| 112 | + 'NFC-B', | ||
| 113 | + 'NFC-F', | ||
| 114 | + 'NFC-V', | ||
| 115 | + 'ISO-DEP', | ||
| 116 | + 'MIFARE-CLASSIC', | ||
| 117 | + 'MIFARE-ULTRALIGHT', | ||
| 118 | + 'NDEF', | ||
| 119 | + ], | ||
| 120 | + success: () => { | ||
| 121 | + status.value = '请将手机背面靠近 NFC 标签'; | ||
| 122 | + isScanning.value = true; | ||
| 123 | + }, | ||
| 124 | + fail: (err) => { | ||
| 125 | + console.error('NFC start error:', err); | ||
| 126 | + // 错误码参考微信文档 | ||
| 127 | + debugInfo.value = `${debugInfo.value}\n\nstartDiscovery fail:\n${err && (err.errMsg || JSON.stringify(err))}`; | ||
| 128 | + if (err.errCode === 13000) { | ||
| 129 | + error.value = '设备不支持 NFC'; | ||
| 130 | + } else if (err.errCode === 13001) { | ||
| 131 | + error.value = '系统 NFC 开关未开启'; | ||
| 132 | + } else if (err.errMsg && err.errMsg.includes('platform is not supported')) { | ||
| 133 | + error.value = '开发者工具不支持 NFC,请使用真机调试或点击下方模拟按钮'; | ||
| 134 | + showMockButton.value = true; | ||
| 135 | + } else { | ||
| 136 | + error.value = 'NFC 启动失败: ' + (err.errMsg || JSON.stringify(err)); | ||
| 137 | + showMockButton.value = true; | ||
| 138 | + } | ||
| 139 | + status.value = '启动失败'; | ||
| 140 | + } | ||
| 141 | + }); | ||
| 142 | + | ||
| 143 | + nfcAdapter.onDiscovered((res) => { | ||
| 144 | + console.log('NFC Discovered:', res); | ||
| 145 | + status.value = '发现标签,正在读取...'; | ||
| 146 | + Taro.vibrateShort(); | ||
| 147 | + | ||
| 148 | + handleNfcMessage(res); | ||
| 149 | + | ||
| 150 | + // 扫描成功后,通常可以选择是否停止。这里我们保持扫描状态,或者提供停止按钮。 | ||
| 151 | + // 用户需求是“扫描成功后显示信息内容”,为了防止重复读取造成刷屏,可以考虑读取成功后自动停止。 | ||
| 152 | + // 但如果是测试页,可能想连续测多个。我选择不自动停止,但更新结果。 | ||
| 153 | + }); | ||
| 154 | + | ||
| 155 | + } catch (e) { | ||
| 156 | + console.error('NFC Adapter error:', e); | ||
| 157 | + debugInfo.value = `${debugInfo.value}\n\ngetNFCAdapter error:\n${e && (e.errMsg || e.message || JSON.stringify(e))}`; | ||
| 158 | + error.value = 'NFC 初始化失败(可能是设备/系统不支持,或不在可用环境)'; | ||
| 159 | + status.value = '错误'; | ||
| 160 | + showMockButton.value = true; | ||
| 161 | + } | ||
| 162 | +}; | ||
| 163 | + | ||
| 164 | +const mockScan = () => { | ||
| 165 | + status.value = '模拟扫描成功'; | ||
| 166 | + error.value = ''; | ||
| 167 | + | ||
| 168 | + // 构造模拟数据 | ||
| 169 | + // 模拟一个包含 Text Record 的 NDEF 消息 | ||
| 170 | + const text = 'Hello NFC!'; | ||
| 171 | + const langCode = 'en'; | ||
| 172 | + const payload = new Uint8Array(1 + langCode.length + text.length); | ||
| 173 | + payload[0] = langCode.length; // Status byte (simplified) | ||
| 174 | + // 写入 lang code | ||
| 175 | + for (let i = 0; i < langCode.length; i++) { | ||
| 176 | + payload[1 + i] = langCode.charCodeAt(i); | ||
| 177 | + } | ||
| 178 | + // 写入 text | ||
| 179 | + for (let i = 0; i < text.length; i++) { | ||
| 180 | + payload[1 + langCode.length + i] = text.charCodeAt(i); | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + // 模拟 type 'T' | ||
| 184 | + const type = new Uint8Array([84]); // 'T' | ||
| 185 | + | ||
| 186 | + const mockRes = { | ||
| 187 | + id: new Uint8Array([0x04, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0]).buffer, | ||
| 188 | + techs: ['NFC-A', 'NDEF'], | ||
| 189 | + messages: [ | ||
| 190 | + { | ||
| 191 | + records: [ | ||
| 192 | + { | ||
| 193 | + type: type.buffer, | ||
| 194 | + payload: payload.buffer, | ||
| 195 | + tnf: 1 | ||
| 196 | + } | ||
| 197 | + ] | ||
| 198 | + } | ||
| 199 | + ] | ||
| 200 | + }; | ||
| 201 | + | ||
| 202 | + handleNfcMessage(mockRes); | ||
| 203 | +}; | ||
| 204 | + | ||
| 205 | +const stopScan = () => { | ||
| 206 | + if (nfcAdapter) { | ||
| 207 | + nfcAdapter.stopDiscovery({ | ||
| 208 | + success: () => { | ||
| 209 | + status.value = '已停止扫描'; | ||
| 210 | + isScanning.value = false; | ||
| 211 | + }, | ||
| 212 | + fail: (err) => { | ||
| 213 | + console.error('Stop NFC fail', err); | ||
| 214 | + }, | ||
| 215 | + complete: () => { | ||
| 216 | + // 确保状态更新 | ||
| 217 | + isScanning.value = false; | ||
| 218 | + if (nfcAdapter && nfcAdapter.offDiscovered) { | ||
| 219 | + nfcAdapter.offDiscovered(); | ||
| 220 | + } | ||
| 221 | + } | ||
| 222 | + }); | ||
| 223 | + } else { | ||
| 224 | + isScanning.value = false; | ||
| 225 | + } | ||
| 226 | +}; | ||
| 227 | + | ||
| 228 | +// 辅助函数:将 ArrayBuffer 转为 16 进制字符串 | ||
| 229 | +const bufferToHex = (buffer) => { | ||
| 230 | + return Array.from(new Uint8Array(buffer)) | ||
| 231 | + .map(b => b.toString(16).padStart(2, '0')) | ||
| 232 | + .join(':') | ||
| 233 | + .toUpperCase(); | ||
| 234 | +}; | ||
| 235 | + | ||
| 236 | +const handleNfcMessage = (res) => { | ||
| 237 | + let content = ''; | ||
| 238 | + | ||
| 239 | + // 1. 获取 UID | ||
| 240 | + if (res.id) { | ||
| 241 | + content += `UID: ${bufferToHex(res.id)}\n`; | ||
| 242 | + } | ||
| 243 | + | ||
| 244 | + // 2. 获取 Tech Types | ||
| 245 | + if (res.techs && res.techs.length) { | ||
| 246 | + content += `Techs: ${res.techs.join(', ')}\n`; | ||
| 247 | + } | ||
| 248 | + | ||
| 249 | + // 3. 解析 NDEF 消息 | ||
| 250 | + if (res.messages && res.messages.length > 0) { | ||
| 251 | + content += '\n--- NDEF Records ---\n'; | ||
| 252 | + try { | ||
| 253 | + // res.messages 是一个数组,通常取第一个 NDEF Message | ||
| 254 | + const records = res.messages[0].records || []; | ||
| 255 | + | ||
| 256 | + records.forEach((record, index) => { | ||
| 257 | + content += `[Record ${index + 1}]\n`; | ||
| 258 | + | ||
| 259 | + // Type Name Format (TNF) - bits 0-2 of first byte (not directly exposed in simple objects usually, | ||
| 260 | + // but we might need to parse payload if it's raw. | ||
| 261 | + // However, WeChat returns parsed objects: { type, payload, id, tnf } | ||
| 262 | + | ||
| 263 | + if (record.type) { | ||
| 264 | + // record.type is ArrayBuffer | ||
| 265 | + const typeStr = new TextDecoder().decode(record.type); | ||
| 266 | + content += `Type: ${typeStr}\n`; | ||
| 267 | + | ||
| 268 | + // Text Record Parsing (Type = 'T') | ||
| 269 | + if (typeStr === 'T') { | ||
| 270 | + const payload = new Uint8Array(record.payload); | ||
| 271 | + if (payload.length > 0) { | ||
| 272 | + const statusByte = payload[0]; | ||
| 273 | + const langCodeLen = statusByte & 0x3F; | ||
| 274 | + // const isUtf16 = (statusByte & 0x80) !== 0; // bit 7 | ||
| 275 | + | ||
| 276 | + // 提取文本内容 | ||
| 277 | + const textBytes = payload.slice(1 + langCodeLen); | ||
| 278 | + const text = new TextDecoder().decode(textBytes); | ||
| 279 | + content += `Content: ${text}\n`; | ||
| 280 | + } | ||
| 281 | + } else { | ||
| 282 | + // 其他类型,尝试直接转码显示,或者显示 HEX | ||
| 283 | + const text = new TextDecoder().decode(record.payload); | ||
| 284 | + // 简单的过滤,如果看起来像乱码则显示 Hex | ||
| 285 | + if (/[\x00-\x08\x0E-\x1F]/.test(text)) { | ||
| 286 | + content += `Payload (Hex): ${bufferToHex(record.payload)}\n`; | ||
| 287 | + } else { | ||
| 288 | + content += `Payload: ${text}\n`; | ||
| 289 | + } | ||
| 290 | + } | ||
| 291 | + } | ||
| 292 | + }); | ||
| 293 | + | ||
| 294 | + } catch (parseErr) { | ||
| 295 | + console.error(parseErr); | ||
| 296 | + content += '解析 NDEF 数据出错\n'; | ||
| 297 | + } | ||
| 298 | + } else { | ||
| 299 | + content += '\n(无 NDEF 消息)\n'; | ||
| 300 | + } | ||
| 301 | + | ||
| 302 | + result.value = content; | ||
| 303 | +}; | ||
| 304 | + | ||
| 305 | +onUnmounted(() => { | ||
| 306 | + stopScan(); | ||
| 307 | +}); | ||
| 308 | +</script> | ||
| 309 | + | ||
| 310 | +<style lang="less"> | ||
| 311 | +.nfc-page { | ||
| 312 | + min-height: 100vh; | ||
| 313 | + background-color: #f7f8fa; | ||
| 314 | + padding: 60rpx 40rpx; | ||
| 315 | + box-sizing: border-box; | ||
| 316 | + display: flex; | ||
| 317 | + flex-direction: column; | ||
| 318 | + align-items: center; | ||
| 319 | + | ||
| 320 | + .header { | ||
| 321 | + text-align: center; | ||
| 322 | + margin-bottom: 80rpx; | ||
| 323 | + | ||
| 324 | + .title { | ||
| 325 | + font-size: 48rpx; | ||
| 326 | + font-weight: bold; | ||
| 327 | + color: #333; | ||
| 328 | + display: block; | ||
| 329 | + margin-bottom: 20rpx; | ||
| 330 | + } | ||
| 331 | + | ||
| 332 | + .subtitle { | ||
| 333 | + font-size: 28rpx; | ||
| 334 | + color: #999; | ||
| 335 | + } | ||
| 336 | + } | ||
| 337 | + | ||
| 338 | + .action-area { | ||
| 339 | + width: 100%; | ||
| 340 | + margin-bottom: 60rpx; | ||
| 341 | + } | ||
| 342 | + | ||
| 343 | + .status-area { | ||
| 344 | + margin-bottom: 40rpx; | ||
| 345 | + font-size: 32rpx; | ||
| 346 | + | ||
| 347 | + .status-label { | ||
| 348 | + color: #666; | ||
| 349 | + margin-right: 20rpx; | ||
| 350 | + } | ||
| 351 | + | ||
| 352 | + .status-text { | ||
| 353 | + color: #333; | ||
| 354 | + font-weight: 500; | ||
| 355 | + | ||
| 356 | + &.scanning { | ||
| 357 | + color: #1989fa; | ||
| 358 | + } | ||
| 359 | + | ||
| 360 | + &.error { | ||
| 361 | + color: #fa2c19; | ||
| 362 | + } | ||
| 363 | + } | ||
| 364 | + } | ||
| 365 | + | ||
| 366 | + .result-area { | ||
| 367 | + width: 100%; | ||
| 368 | + background: #fff; | ||
| 369 | + border-radius: 24rpx; | ||
| 370 | + padding: 40rpx; | ||
| 371 | + box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.05); | ||
| 372 | + | ||
| 373 | + .result-header { | ||
| 374 | + font-size: 36rpx; | ||
| 375 | + font-weight: bold; | ||
| 376 | + margin-bottom: 30rpx; | ||
| 377 | + border-bottom: 2rpx solid #eee; | ||
| 378 | + padding-bottom: 20rpx; | ||
| 379 | + } | ||
| 380 | + | ||
| 381 | + .result-content { | ||
| 382 | + font-size: 28rpx; | ||
| 383 | + color: #333; | ||
| 384 | + line-height: 1.6; | ||
| 385 | + word-break: break-all; | ||
| 386 | + white-space: pre-wrap; | ||
| 387 | + } | ||
| 388 | + } | ||
| 389 | + | ||
| 390 | + .error-area { | ||
| 391 | + margin-top: 40rpx; | ||
| 392 | + padding: 20rpx; | ||
| 393 | + background-color: #ffeaea; | ||
| 394 | + border-radius: 16rpx; | ||
| 395 | + width: 100%; | ||
| 396 | + text-align: center; | ||
| 397 | + | ||
| 398 | + .error-text { | ||
| 399 | + color: #fa2c19; | ||
| 400 | + font-size: 28rpx; | ||
| 401 | + } | ||
| 402 | + } | ||
| 403 | +} | ||
| 404 | +</style> |
-
Please register or login to post a comment