hookehuyr

feat(nfc): 添加NFC测试页面及功能实现

实现NFC扫描功能页面,包括:
- 创建NFC测试页面及路由配置
- 实现NFC扫描启动、停止和模拟功能
- 添加NFC标签解析和结果显示
- 支持不同设备和环境的兼容性处理
...@@ -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 {
......
1 +export default {
2 + navigationBarTitleText: 'NFC测试',
3 +}
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>