hookehuyr

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

实现NFC扫描功能页面,包括:
- 创建NFC测试页面及路由配置
- 实现NFC扫描启动、停止和模拟功能
- 添加NFC标签解析和结果显示
- 支持不同设备和环境的兼容性处理
......@@ -26,6 +26,7 @@ export default {
'pages/verificationResult/index',
'pages/weakNetwork/index',
'pages/offlineBookingCode/index',
'pages/nfcTest/index',
],
subpackages: [ // 配置在tabBar中的页面不能分包写到subpackages中去
{
......
export default {
navigationBarTitleText: 'NFC测试',
}
<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>
<nut-button v-if="showMockButton" type="info" size="large" @click="mockScan" 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 showMockButton = ref(false);
const debugInfo = ref('');
let nfcAdapter = null;
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 === 'devtools') {
showMockButton.value = true;
} else {
showMockButton.value = false;
}
if (platform === 'ios') {
error.value = 'iOS 端微信小程序通常不支持 NFC(该能力主要在 Android 可用)';
status.value = '启动失败';
showMockButton.value = true;
return;
}
if (!Taro.getNFCAdapter) {
error.value = '当前环境不支持 NFC 接口';
status.value = '启动失败';
showMockButton.value = true;
return;
}
if (envType && Taro.ENV_TYPE && envType !== Taro.ENV_TYPE.WEAPP) {
error.value = '当前不是微信小程序环境,无法使用 NFC';
status.value = '启动失败';
showMockButton.value = true;
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,请使用真机调试或点击下方模拟按钮';
showMockButton.value = true;
} else {
error.value = 'NFC 启动失败: ' + (err.errMsg || JSON.stringify(err));
showMockButton.value = true;
}
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 = '错误';
showMockButton.value = true;
}
};
const mockScan = () => {
status.value = '模拟扫描成功';
error.value = '';
// 构造模拟数据
// 模拟一个包含 Text Record 的 NDEF 消息
const text = 'Hello NFC!';
const langCode = 'en';
const payload = new Uint8Array(1 + langCode.length + text.length);
payload[0] = langCode.length; // Status byte (simplified)
// 写入 lang code
for (let i = 0; i < langCode.length; i++) {
payload[1 + i] = langCode.charCodeAt(i);
}
// 写入 text
for (let i = 0; i < text.length; i++) {
payload[1 + langCode.length + i] = text.charCodeAt(i);
}
// 模拟 type 'T'
const type = new Uint8Array([84]); // 'T'
const mockRes = {
id: new Uint8Array([0x04, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0]).buffer,
techs: ['NFC-A', 'NDEF'],
messages: [
{
records: [
{
type: type.buffer,
payload: payload.buffer,
tnf: 1
}
]
}
]
};
handleNfcMessage(mockRes);
};
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 = 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;
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>