feat: enhance BottomNav component and update project configuration

This commit is contained in:
2026-03-12 22:53:57 +08:00
parent 02dd31e85c
commit 8d2180940b
25 changed files with 1083 additions and 135 deletions

View File

@@ -2,7 +2,7 @@
## 项目概述 ## 项目概述
这是一个基于 **Taro 框架的多端小程序开发模板**项目。它提供了一个统一的开发框架用于构建跨平台小程序可以编译到微信、小红书、支付宝、百度、字节跳动、H5、React Native、QQ、京东等多个平台 微信小程序开发模板基于Taro框架
## 技术栈 ## 技术栈
@@ -48,52 +48,6 @@ taro-template/
## 开发指南 ## 开发指南
### 创建新页面
使用 Taro CLI 创建新页面:
```bash
npm run new -- [pageName]
```
### 添加平台特定代码
使用 `Taro.getEnv()` 检测当前平台:
```typescript
import Taro from "@tarojs/taro";
const env = Taro.getEnv();
if (env === Taro.ENV_TYPE.WEAPP) {
// 微信小程序特定代码
} else if (env === "xhs") {
// 小红书特定代码
}
```
或使用提供的工具函数:
```typescript
import { isXHS } from './pages/xhs/utils/is-xhs';
if (isXHS()) {
// 小红书特定代码
}
```
### 样式
项目使用标准 CSS页面样式与组件放在一起
- 全局样式: `src/app.css`
- 页面样式: `src/pages/{page}/{page}.css`
### 环境变量
- `.env.development` - 开发环境
- `.env.test` - 测试环境
- `.env.production` - 生产环境
## 配置文件说明 ## 配置文件说明
### app.config.ts ### app.config.ts
@@ -104,14 +58,6 @@ if (isXHS()) {
应用入口组件,包含 `useLaunch` 生命周期钩子,用于应用初始化。在微信环境下会自动调用 `Taro.login` 应用入口组件,包含 `useLaunch` 生命周期钩子,用于应用初始化。在微信环境下会自动调用 `Taro.login`
### project.xhs.json
小红书 IDE 特定配置appid、编译设置等
### tsconfig.json
TypeScript 编译器选项,包括路径别名配置(`@/*``./src/*`)。
## AI 代理注意事项 ## AI 代理注意事项
1. 修改平台特定代码时,使用环境检测确保跨平台兼容性 1. 修改平台特定代码时,使用环境检测确保跨平台兼容性
@@ -119,3 +65,8 @@ TypeScript 编译器选项,包括路径别名配置(`@/*` → `./src/*`
3. 遵循现有的代码风格和目录结构 3. 遵循现有的代码风格和目录结构
4. 添加新依赖时,确保与所有目标平台兼容 4. 添加新依赖时,确保与所有目标平台兼容
5. 项目使用 pnpm 作为包管理器 5. 项目使用 pnpm 作为包管理器
## 避免
1. 不能使用 `?.``??` 操作符, 因为不支持
2. 不能使用 TextDecoder 和 TextEncoder, 因为不支持
3. 不能使用 Buffer, 因为不支持

View File

@@ -6,5 +6,9 @@ export default {
stats: true stats: true
}, },
mini: {}, mini: {},
h5: {} h5: {
devServer: {
host: '0.0.0.0'
}
}
} satisfies UserConfigExport<'webpack5'> } satisfies UserConfigExport<'webpack5'>

View File

@@ -7,8 +7,8 @@ import prodConfig from "./prod";
// @ts-ignore // @ts-ignore
export default defineConfig<"webpack5">(async (merge, { command, mode }) => { export default defineConfig<"webpack5">(async (merge, { command, mode }) => {
const baseConfig: UserConfigExport<"webpack5"> = { const baseConfig: UserConfigExport<"webpack5"> = {
projectName: "2025-09-14-webpack-demo", projectName: "taro-template",
date: "2025-9-14", date: "2026-03-12",
designWidth: 750, designWidth: 750,
deviceRatio: { deviceRatio: {
640: 2.34 / 2, 640: 2.34 / 2,
@@ -31,6 +31,12 @@ export default defineConfig<"webpack5">(async (merge, { command, mode }) => {
enable: false, // Webpack 持久化缓存配置建议开启。默认配置请参考https://docs.taro.zone/docs/config-detail#cache enable: false, // Webpack 持久化缓存配置建议开启。默认配置请参考https://docs.taro.zone/docs/config-detail#cache
}, },
mini: { mini: {
compiler: {
type: 'webpack5',
prebundle: {
enable: false // Webpack 持久化缓存配置建议开启。默认配置请参考https://docs.taro.zone/docs/config-detail#cache
},
},
postcss: { postcss: {
pxtransform: { pxtransform: {
enable: true, enable: true,
@@ -51,6 +57,7 @@ export default defineConfig<"webpack5">(async (merge, { command, mode }) => {
h5: { h5: {
publicPath: "/", publicPath: "/",
staticDirectory: "static", staticDirectory: "static",
esnextModules: ["@stencil/core"],
output: { output: {
filename: "js/[name].[hash:8].js", filename: "js/[name].[hash:8].js",
chunkFilename: "js/[name].[chunkhash:8].js", chunkFilename: "js/[name].[chunkhash:8].js",

View File

@@ -40,7 +40,8 @@
"author": "", "author": "",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.6", "@babel/runtime": "^7.28.6",
"@nutui/icons-react-taro": "3.0.2-cpp.3.beta.5", "@kevisual/api": "^0.0.64",
"@kevisual/query": "^0.0.53",
"@nutui/nutui-react-taro": "^2.7.15", "@nutui/nutui-react-taro": "^2.7.15",
"@tarojs/components": "4.1.11", "@tarojs/components": "4.1.11",
"@tarojs/helper": "4.1.11", "@tarojs/helper": "4.1.11",

145
pnpm-lock.yaml generated
View File

@@ -11,6 +11,12 @@ importers:
'@babel/runtime': '@babel/runtime':
specifier: ^7.28.6 specifier: ^7.28.6
version: 7.28.6 version: 7.28.6
'@kevisual/api':
specifier: ^0.0.64
version: 0.0.64(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@kevisual/query':
specifier: ^0.0.53
version: 0.0.53
'@nutui/icons-react-taro': '@nutui/icons-react-taro':
specifier: 3.0.2-cpp.3.beta.5 specifier: 3.0.2-cpp.3.beta.5
version: 3.0.2-cpp.3.beta.5 version: 3.0.2-cpp.3.beta.5
@@ -1143,12 +1149,31 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kevisual/api@0.0.64':
resolution: {integrity: sha512-y7wP8ucvi/rflVGd6uJpvuEUTwI7wMef8+ITQzv4flg7a2pwWZYe/DT0TOyaqDAqKOTlXaVIdBeI15jXuUxIIg==}
'@kevisual/context@0.0.8':
resolution: {integrity: sha512-DTJpyHI34NE76B7g6f+QlIqiCCyqI2qkBMQE736dzeRDGxOjnbe2iQY9W+Rt2PE6kmymM3qyOmSfNovyWyWrkA==}
'@kevisual/js-filter@0.0.6':
resolution: {integrity: sha512-FcbOsmS1inhwrfgXMM/XLFTGTHUxBCss32JEMYdEFWQDYCar5rN8cxD1W8FuKDTVRlpA+zBpQ/BE6XT4UaeljA==}
'@kevisual/load@0.0.6':
resolution: {integrity: sha512-+3YTFehRcZ1haGel5DKYMUwmi5i6f2psyaPZlfkKU/cOXgkpwoG9/BEqPCnPjicKqqnksEpixVRkyHJ+5bjLVA==}
'@kevisual/query@0.0.53':
resolution: {integrity: sha512-PAhpCLBr0emz0lGNlTVHMbJiC5wrtGLbInPddRzgKE35fiyNt+SWSsUWABiD0DeNrLN/OxWyAFobt880Z/e5MQ==}
'@leichtgewicht/ip-codec@2.0.5': '@leichtgewicht/ip-codec@2.0.5':
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
'@napi-rs/triples@1.2.0': '@napi-rs/triples@1.2.0':
resolution: {integrity: sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA==} resolution: {integrity: sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA==}
'@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1178,6 +1203,10 @@ packages:
'@nutui/touch-emulator@1.0.0': '@nutui/touch-emulator@1.0.0':
resolution: {integrity: sha512-k2hvI/9LlRA7Ph1Chni27pTuvPmKPt+/I10sWWd2sWzqiCOYRerD79eIwCMRGUF/q6WVDEKVnv00t9CEUL4sPA==} resolution: {integrity: sha512-k2hvI/9LlRA7Ph1Chni27pTuvPmKPt+/I10sWWd2sWzqiCOYRerD79eIwCMRGUF/q6WVDEKVnv00t9CEUL4sPA==}
'@paralleldrive/cuid2@3.3.0':
resolution: {integrity: sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw==}
hasBin: true
'@parcel/watcher-android-arm64@2.5.6': '@parcel/watcher-android-arm64@2.5.6':
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
@@ -2218,6 +2247,9 @@ packages:
big.js@5.2.2: big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
binary-extensions@2.3.0: binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2869,6 +2901,9 @@ packages:
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
hasBin: true hasBin: true
error-causes@3.0.2:
resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==}
error-ex@1.3.4: error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -2897,6 +2932,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-toolkit@1.45.1:
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
esbuild-loader@4.4.2: esbuild-loader@4.4.2:
resolution: {integrity: sha512-8LdoT9sC7fzfvhxhsIAiWhzLJr9yT3ggmckXxsgvM07wgrRxhuT98XhLn3E7VczU5W5AFsPKv9DdWcZIubbWkQ==} resolution: {integrity: sha512-8LdoT9sC7fzfvhxhsIAiWhzLJr9yT3ggmckXxsgvM07wgrRxhuT98XhLn3E7VczU5W5AFsPKv9DdWcZIubbWkQ==}
peerDependencies: peerDependencies:
@@ -2976,6 +3014,9 @@ packages:
eventemitter3@4.0.7: eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events@3.3.0: events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} engines: {node: '>=0.8.x'}
@@ -3159,6 +3200,10 @@ packages:
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
fuse.js@7.1.0:
resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==}
engines: {node: '>=10'}
gensync@1.0.0-beta.2: gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -4058,6 +4103,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
native-request@1.1.2: native-request@1.1.2:
resolution: {integrity: sha512-/etjwrK0J4Ebbcnt35VMWnfiUX/B04uwGJxyJInagxDqf2z5drSt/lsOvEMWGYunz1kaLZAFrV4NDAbOoDKvAQ==} resolution: {integrity: sha512-/etjwrK0J4Ebbcnt35VMWnfiUX/B04uwGJxyJInagxDqf2z5drSt/lsOvEMWGYunz1kaLZAFrV4NDAbOoDKvAQ==}
@@ -4251,6 +4301,9 @@ packages:
pascal-case@3.1.2: pascal-case@3.1.2:
resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==}
path-browserify-esm@1.0.6:
resolution: {integrity: sha512-9nUwYvvu/yq1PYrUyYCihNWmpzacaRYF6gGbjLWErrZ4MRDWyfPN7RpE8E7tsw8eqBU/rr7mcoTXbS+Vih8uUA==}
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@@ -5019,6 +5072,12 @@ packages:
solid-js@1.9.11: solid-js@1.9.11:
resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
sort-keys-length@1.0.1: sort-keys-length@1.0.1:
resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==} resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -5053,6 +5112,9 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
spdy-transport@3.0.0: spdy-transport@3.0.0:
resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==}
@@ -5622,6 +5684,24 @@ packages:
yup@1.7.1: yup@1.7.1:
resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==}
zustand@5.0.11:
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots: snapshots:
'@adobe/css-tools@4.3.3': {} '@adobe/css-tools@4.3.3': {}
@@ -6713,10 +6793,43 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@kevisual/api@0.0.64(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@kevisual/context': 0.0.8
'@kevisual/js-filter': 0.0.6
'@kevisual/load': 0.0.6
'@paralleldrive/cuid2': 3.3.0
es-toolkit: 1.45.1
eventemitter3: 5.0.4
fuse.js: 7.1.0
nanoid: 5.1.6
path-browserify-esm: 1.0.6
sonner: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
spark-md5: 3.0.2
zustand: 5.0.11(@types/react@18.3.28)(react@18.3.1)
transitivePeerDependencies:
- '@types/react'
- immer
- react
- react-dom
- use-sync-external-store
'@kevisual/context@0.0.8': {}
'@kevisual/js-filter@0.0.6': {}
'@kevisual/load@0.0.6':
dependencies:
eventemitter3: 5.0.4
'@kevisual/query@0.0.53': {}
'@leichtgewicht/ip-codec@2.0.5': {} '@leichtgewicht/ip-codec@2.0.5': {}
'@napi-rs/triples@1.2.0': {} '@napi-rs/triples@1.2.0': {}
'@noble/hashes@2.0.1': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -6754,6 +6867,12 @@ snapshots:
'@nutui/touch-emulator@1.0.0': {} '@nutui/touch-emulator@1.0.0': {}
'@paralleldrive/cuid2@3.3.0':
dependencies:
'@noble/hashes': 2.0.1
bignumber.js: 9.3.1
error-causes: 3.0.2
'@parcel/watcher-android-arm64@2.5.6': '@parcel/watcher-android-arm64@2.5.6':
optional: true optional: true
@@ -7954,6 +8073,8 @@ snapshots:
big.js@5.2.2: {} big.js@5.2.2: {}
bignumber.js@9.3.1: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
bl@1.2.3: bl@1.2.3:
@@ -8690,6 +8811,8 @@ snapshots:
prr: 1.0.1 prr: 1.0.1
optional: true optional: true
error-causes@3.0.2: {}
error-ex@1.3.4: error-ex@1.3.4:
dependencies: dependencies:
is-arrayish: 0.2.1 is-arrayish: 0.2.1
@@ -8717,6 +8840,8 @@ snapshots:
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
hasown: 2.0.2 hasown: 2.0.2
es-toolkit@1.45.1: {}
esbuild-loader@4.4.2(webpack@5.105.4(@swc/core@1.3.96)): esbuild-loader@4.4.2(webpack@5.105.4(@swc/core@1.3.96)):
dependencies: dependencies:
esbuild: 0.27.3 esbuild: 0.27.3
@@ -8868,6 +8993,8 @@ snapshots:
eventemitter3@4.0.7: {} eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
events@3.3.0: {} events@3.3.0: {}
execa@5.1.1: execa@5.1.1:
@@ -9083,6 +9210,8 @@ snapshots:
function-bind@1.1.2: {} function-bind@1.1.2: {}
fuse.js@7.1.0: {}
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
@@ -9974,6 +10103,8 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
nanoid@5.1.6: {}
native-request@1.1.2: native-request@1.1.2:
optional: true optional: true
@@ -10171,6 +10302,8 @@ snapshots:
no-case: 3.0.4 no-case: 3.0.4
tslib: 2.8.1 tslib: 2.8.1
path-browserify-esm@1.0.6: {}
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
path-case@3.0.4: path-case@3.0.4:
@@ -10933,6 +11066,11 @@ snapshots:
seroval: 1.5.1 seroval: 1.5.1
seroval-plugins: 1.5.1(seroval@1.5.1) seroval-plugins: 1.5.1(seroval@1.5.1)
sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
sort-keys-length@1.0.1: sort-keys-length@1.0.1:
dependencies: dependencies:
sort-keys: 1.1.2 sort-keys: 1.1.2
@@ -10960,6 +11098,8 @@ snapshots:
source-map@0.7.6: {} source-map@0.7.6: {}
spark-md5@3.0.2: {}
spdy-transport@3.0.0: spdy-transport@3.0.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -11548,3 +11688,8 @@ snapshots:
tiny-case: 1.0.3 tiny-case: 1.0.3
toposort: 2.0.2 toposort: 2.0.2
type-fest: 2.19.0 type-fest: 2.19.0
zustand@5.0.11(@types/react@18.3.28)(react@18.3.1):
optionalDependencies:
'@types/react': 18.3.28
react: 18.3.1

View File

@@ -1,15 +1,39 @@
{ {
"miniprogramRoot": "./dist", "miniprogramRoot": "dist/",
"projectname": "2026-03-12-taro-template", "projectname": "taro-template",
"description": "taro-template", "description": "taro-template",
"appid": "touristappid", "appid": "wx5464d820d8c2e4ad",
"setting": { "setting": {
"urlCheck": true, "urlCheck": true,
"es6": false, "es6": false,
"enhance": false, "enhance": false,
"compileHotReLoad": false, "compileHotReLoad": false,
"postcss": false, "postcss": false,
"minified": false "minified": false,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": false,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
}, },
"compileType": "miniprogram" "compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"editorSetting": {}
} }

0
public/update.json Normal file
View File

View File

@@ -1,6 +1,7 @@
export default defineAppConfig({ export default defineAppConfig({
pages: [ pages: [
'pages/index/index', 'pages/index/index',
'pages/nfc-read/index',
'pages/mine/index' 'pages/mine/index'
], ],
window: { window: {

View File

@@ -1,8 +1,19 @@
.bottom-nav-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
overflow: hidden;
}
.bottom-nav-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.bottom-nav { .bottom-nav {
position: fixed; flex-shrink: 0;
bottom: 0;
left: 0;
right: 0;
height: 100px; height: 100px;
background: #ffffff; background: #ffffff;
border-top: 1px solid #e8e8e8; border-top: 1px solid #e8e8e8;
@@ -17,12 +28,12 @@
.bottom-nav-item { .bottom-nav-item {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 1; flex: 1;
height: 100%; height: 100%;
gap: 4px; gap: 6px;
} }

View File

@@ -1,5 +1,6 @@
import { View, Text } from "@tarojs/components"; import { View, Text } from "@tarojs/components";
import Taro from "@tarojs/taro"; import Taro from "@tarojs/taro";
import type { ReactNode } from "react";
import type { NavItem, NavKey } from "../../config"; import type { NavItem, NavKey } from "../../config";
import "./index.css"; import "./index.css";
@@ -10,33 +11,35 @@ const ICON_SIZE = 24;
interface BottomNavProps { interface BottomNavProps {
active: NavKey; active: NavKey;
navItems: NavItem[]; navItems: NavItem[];
children?: ReactNode;
} }
export default function BottomNav({ active, navItems }: BottomNavProps) { export default function BottomNav({ active, navItems, children }: BottomNavProps) {
const handleNavigate = (item: NavItem) => { const handleNavigate = (item: NavItem) => {
if (item.key === active) return; if (item.key === active) return;
Taro.redirectTo({ url: item.path }); Taro.redirectTo({ url: item.path });
}; };
return ( return (
<View className="bottom-nav"> <View className="bottom-nav-wrapper">
<View className="bottom-nav-content">{children}</View>
<View className="bottom-nav">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = active === item.key; const isActive = active === item.key;
const IconComp = item.icon; const IconComp = item.icon;
const iconColor = isActive ? ACTIVE_COLOR : DEFAULT_COLOR;
return ( return (
<View <View
key={item.key} key={item.key}
className={`bottom-nav-item${isActive ? " active" : ""}`} className={`bottom-nav-item${isActive ? " active" : ""}`}
onClick={() => handleNavigate(item)} onClick={() => handleNavigate(item)}
> >
<IconComp <IconComp size={ICON_SIZE} color={iconColor} />
size={ICON_SIZE}
color={isActive ? ACTIVE_COLOR : DEFAULT_COLOR}
/>
<Text className="bottom-nav-label">{item.label}</Text> <Text className="bottom-nav-label">{item.label}</Text>
</View> </View>
); );
})} })}
</View>
</View> </View>
); );
} }

View File

@@ -0,0 +1,74 @@
import { Image } from "@tarojs/components";
import type { CSSProperties } from "react";
// SVG path 与颜色的映射
const svgPaths: Record<string, string> = {
home: "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z",
user: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z",
nfc: "M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 18H4V4h16v16zM6 6h12v2H6zm0 4h12v2H6zm0 4h8v2H6z",
};
// 手动将字符串转为 base64不依赖 Buffer
function toBase64(str: string): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let result = "";
let i = 0;
while (i < str.length) {
const a = str.charCodeAt(i++);
const b = i < str.length ? str.charCodeAt(i++) : NaN;
const c = i < str.length ? str.charCodeAt(i++) : NaN;
const triplet = (a << 16) | (b << 8) | c;
result += chars[(triplet >> 18) & 0x3f];
result += chars[(triplet >> 12) & 0x3f];
result += isNaN(b) ? "=" : chars[(triplet >> 6) & 0x3f];
result += isNaN(c) ? "=" : chars[triplet & 0x3f];
}
return result;
}
interface IconProps {
name: "home" | "user" | "nfc";
size?: number;
color?: string;
style?: CSSProperties;
}
export default function SvgIcon({ name, size = 24, color, style }: IconProps) {
const path = svgPaths[name];
// 将 SVG 转为 data URL
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color || "currentColor"}"><path d="${path}"/></svg>`;
const dataUrl = `data:image/svg+xml;base64,${toBase64(svgString)}`;
return (
<Image
src={dataUrl}
mode="aspectFit"
style={{
width: `${size}px`,
height: `${size}px`,
...style,
}}
/>
);
}
// Home 图标
export function IconHome(props: { size?: number; color?: string; style?: CSSProperties }) {
return <SvgIcon name="home" {...props} />;
}
// User 图标
export function IconUser(props: { size?: number; color?: string; style?: CSSProperties }) {
return <SvgIcon name="user" {...props} />;
}
// NFC 图标
export function IconNfc(props: { size?: number; color?: string; style?: CSSProperties }) {
return <SvgIcon name="nfc" {...props} />;
}

View File

@@ -1,4 +1,4 @@
import { Home, User } from "@nutui/icons-react-taro"; import { IconHome, IconUser, IconNfc } from "./components/Icons";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
export type NavKey = string; export type NavKey = string;
@@ -7,7 +7,7 @@ export interface NavItem {
key: NavKey; key: NavKey;
label: string; label: string;
path: string; path: string;
icon: ComponentType<{ size?: string | number; color?: string }>; icon: ComponentType<{ size?: number; color?: string }>;
} }
export const navItems: NavItem[] = [ export const navItems: NavItem[] = [
@@ -15,12 +15,18 @@ export const navItems: NavItem[] = [
key: "index", key: "index",
label: "主页", label: "主页",
path: "/pages/index/index", path: "/pages/index/index",
icon: Home, icon: IconHome,
},
{
key: "nfc-read",
label: "NFC",
path: "/pages/nfc-read/index",
icon: IconNfc,
}, },
{ {
key: "mine", key: "mine",
label: "我的", label: "我的",
path: "/pages/mine/index", path: "/pages/mine/index",
icon: User, icon: IconUser,
}, },
]; ];

View File

@@ -0,0 +1,10 @@
import Taro from "@tarojs/taro";
import { Query } from '@kevisual/query'
export const query = new Query({
adapter: (config) => {
//
console.log("Request config:", config);
return Taro.request(config as any);
}
});

View File

@@ -1,27 +1,10 @@
.nutui-react-demo {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.index {
height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
overflow: hidden;
}
.index-content { .index-content {
flex: 1; height: 100%;
overflow-y: auto; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding-top: 80px; padding-top: 80px;
padding-bottom: 100px;
gap: 20px; gap: 20px;
} }

View File

@@ -10,7 +10,7 @@ export default function Index() {
}); });
return ( return (
<View className="index"> <BottomNav active="index" navItems={navItems}>
<View className="index-content"> <View className="index-content">
<Text>Hello world!</Text> <Text>Hello world!</Text>
<Button <Button
@@ -22,7 +22,6 @@ export default function Index() {
User Info User Info
</Button> </Button>
</View> </View>
<BottomNav active="index" navItems={navItems} /> </BottomNav>
</View>
); );
} }

View File

@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: "我的", navigationBarTitleText: "我的",
navigationStyle: "custom",
}); });

View File

@@ -1,27 +1,59 @@
.mine-page { .mine-content {
height: 100vh; height: 100%;
background: #f5f5f5; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; padding: 30px 32px;
padding-top: 180px;
} }
.mine-content { .mine-profile-row {
flex: 1;
overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
padding-top: 80px; gap: 24px;
padding-bottom: 100px; background: #fff;
gap: 20px; border-radius: 16px;
padding: 24px 28px;
}
.mine-avatar-btn {
padding: 0;
background: transparent;
border: none;
border-radius: 50px;
width: 100px;
height: 100px;
overflow: hidden;
line-height: 1;
flex-shrink: 0;
}
.mine-avatar-btn::after {
border: none;
} }
.mine-avatar { .mine-avatar {
width: 120px; width: 100px;
height: 120px; height: 100px;
border-radius: 60px; border-radius: 50px;
background: #1976d2; }
.mine-avatar-placeholder {
background: #e0e0e0;
display: block;
width: 100px;
height: 100px;
border-radius: 50px;
}
.mine-nickname-input {
flex: 1;
font-size: 34px;
font-weight: 500;
color: #333;
border-bottom: 1px solid #e0e0e0;
padding: 8px 0;
} }
.mine-name { .mine-name {

View File

@@ -1,16 +1,68 @@
import { View, Text } from "@tarojs/components"; import { View, Button, Image, Input } from "@tarojs/components";
import { useState, useEffect } from "react";
import Taro from "@tarojs/taro";
import BottomNav from "../../components/BottomNav"; import BottomNav from "../../components/BottomNav";
import { navItems } from "../../config"; import { navItems } from "../../config";
import "./index.css"; import "./index.css";
const STORAGE_KEY_AVATAR = "mine_avatarUrl";
const STORAGE_KEY_NICKNAME = "mine_nickName";
export default function Mine() { export default function Mine() {
const [avatarUrl, setAvatarUrl] = useState("");
const [nickName, setNickName] = useState("");
// 初始化时从缓存读取
useEffect(() => {
try {
const cachedAvatar = Taro.getStorageSync(STORAGE_KEY_AVATAR);
const cachedNick = Taro.getStorageSync(STORAGE_KEY_NICKNAME);
if (cachedAvatar) setAvatarUrl(cachedAvatar);
if (cachedNick) setNickName(cachedNick);
} catch (e) {}
}, []);
const handleChooseAvatar = (e: any) => {
const url = e && e.detail && e.detail.avatarUrl;
if (url) {
setAvatarUrl(url);
try { Taro.setStorageSync(STORAGE_KEY_AVATAR, url); } catch (e) {}
}
};
const handleNickNameBlur = (e: any) => {
const val = (e && e.detail && e.detail.value) || "";
if (val) {
setNickName(val);
try { Taro.setStorageSync(STORAGE_KEY_NICKNAME, val); } catch (e) {}
}
};
return ( return (
<View className="mine-page"> <BottomNav active="mine" navItems={navItems}>
<View className="mine-content"> <View className="mine-content">
<View className="mine-avatar" /> <View className="mine-profile-row">
<Text className="mine-name"></Text> {/* 头像选择按钮 */}
<Button
className="mine-avatar-btn"
openType="chooseAvatar"
onChooseAvatar={handleChooseAvatar}
>
{avatarUrl ? (
<Image className="mine-avatar" src={avatarUrl} />
) : (
<View className="mine-avatar mine-avatar-placeholder" />
)}
</Button>
{/* 昵称输入框type="nickname" 可唤起微信昵称键盘,用 onBlur 接收昵称选择结果 */}
<Input
className="mine-nickname-input"
type="nickname"
placeholder={nickName || "点击设置昵称"}
onBlur={handleNickNameBlur}
/>
</View>
</View> </View>
<BottomNav active="mine" navItems={navItems} /> </BottomNav>
</View>
); );
} }

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: "NFC 读取",
enablePullDownRefresh: false,
navigationStyle: "custom",
});

View File

@@ -0,0 +1,56 @@
.nfc-container {
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 80px;
padding-left: 32px;
padding-right: 32px;
padding-bottom: 32px;
background-color: #f5f5f5;
overflow: hidden;
}
.nfc-icon {
width: 120px;
height: 120px;
margin-bottom: 32px;
}
.nfc-status {
font-size: 18px;
color: #333;
margin-bottom: 16px;
text-align: center;
}
.nfc-result {
width: 100%;
padding: 24px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.nfc-result-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.nfc-result-content {
font-size: 14px;
color: #333;
word-break: break-all;
line-height: 1.6;
white-space: pre-wrap;
font-family: monospace;
}
.nfc-placeholder {
font-size: 14px;
color: #999;
text-align: center;
}

View File

@@ -0,0 +1,155 @@
import { View, Text } from "@tarojs/components";
import { useDidShow, useDidHide } from "@tarojs/taro";
import { useState, useRef, useEffect } from "react";
import BottomNav from "../../components/BottomNav";
import { navItems } from "../../config";
import "./index.css";
import Taro from "@tarojs/taro";
import { parseNfcReadResult } from "./lib/nfc";
export default function NfcRead() {
const [nfcData, setNfcData] = useState<string>("");
const [status, setStatus] = useState<string>("等待靠近NFC标签...");
const [isSupported, setIsSupported] = useState<boolean>(true);
const nfcAdapterRef = useRef<Taro.NFCAdapter | null>(null);
const isReadingRef = useRef<boolean>(false);
// 初始化 NFC 适配器并开始读取
const startNfcRead = async () => {
try {
// #ifdef MP-WEIXIN
const wx = (window as any).wx;
if (!wx) {
setStatus("当前环境不支持 NFC");
return;
}
// 获取 NFC 适配器
const adapter = Taro.getNFCAdapter();
nfcAdapterRef.current = adapter;
if (!adapter) {
setStatus("当前设备不支持 NFC");
return;
}
// 监听 NDEF 消息
adapter.onDiscovered((res: any) => {
console.log("NFC discovered:", res);
const result = parseNfcReadResult(res);
console.log("Parsed NFC result:", result);
if (result) {
setNfcData(JSON.stringify(result, null, 2));
setStatus("读取成功!");
} else {
setStatus("未读取到有效数据");
}
});
// 开始监听
adapter.startDiscovery({
success: () => {
isReadingRef.current = true;
setStatus("请将设备靠近 NFC 标签");
},
fail: (err: any) => {
console.error("NFC discovery failed:", err);
// 检查是否是平台不支持的错误
if (err.errMsg && err.errMsg.includes("current platform is not supported")) {
setIsSupported(false);
setStatus("当前平台不支持 NFC");
} else {
setStatus("NFC 启动失败: " + (err.errMsg || "未知错误"));
}
},
});
// #endif
// #ifndef MP-WEIXIN
setStatus("请在微信小程序中使用 NFC 功能");
// #endif
} catch (error) {
console.error("NFC error:", error);
setStatus("NFC 初始化失败");
}
};
// 停止 NFC 读取
const stopNfcRead = () => {
try {
// #ifdef MP-WEIXIN
if (nfcAdapterRef.current && isReadingRef.current) {
nfcAdapterRef.current.stopDiscovery({
success: () => {
console.log("NFC discovery stopped");
isReadingRef.current = false;
},
fail: (err: any) => {
console.error("Stop NFC failed:", err);
},
});
}
// #endif
} catch (error) {
console.error("Stop NFC error:", error);
}
};
// 页面显示时启动 NFC
useDidShow(() => {
setNfcData("");
setStatus("等待靠近NFC标签...");
setIsSupported(true);
startNfcRead();
});
// 页面隐藏时停止 NFC
useDidHide(() => {
stopNfcRead();
});
// 组件卸载时清理
useEffect(() => {
return () => {
stopNfcRead();
};
}, []);
return (
<BottomNav active="nfc-read" navItems={navItems}>
<View className="nfc-container">
{/* NFC 图标 */}
<View className="nfc-icon">
<View
style={{
backgroundColor: isSupported ? "#1890ff" : "#999",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ fontSize: "48px", color: "#fff" }}>N</Text>
</View>
</View>
{/* 状态提示 */}
<Text className="nfc-status">{status}</Text>
{/* NFC 读取结果 */}
{!isSupported ? null : (
<View className="nfc-result">
<Text className="nfc-result-label">NFC </Text>
{nfcData ? (
<Text className="nfc-result-content">{nfcData}</Text>
) : (
<Text className="nfc-placeholder">
NFC
</Text>
)}
</View>
)}
</View>
</BottomNav>
);
}

View File

@@ -0,0 +1,94 @@
export const nfcAppSchema = [
]
// 微博
// 主页/帖子/超话
// 小红书
// 主页/笔记/视频
// 搜索
// 抖音
// 主页/视频/直播
// 搜索
// 哔哩哔哩
// 主页/视频/专栏/课程/番剧/影视
// 快手
// 视频/主页
// 网易云音乐
// 音乐/歌手/歌单/专辑/播客/播客合集/视频/笔记
// 漫游模式
// QQ
// 加好友
// QQ音乐
// 歌曲/歌单/专辑/歌手/视频
// 微信
// 公众号/小程序/个人号/朋友圈/微信支付
// 酷狗
// 歌曲/歌单/专辑/歌手/视频
// 汽水音乐
// 歌曲/歌单/专辑/歌手/视频
// 全民K歌
// 作品
// 波点音乐
// 歌曲/歌单/专辑/歌手/视频
// Apple Music
// 歌曲
// 华为音乐
// 歌曲
// 番茄畅听
// 书籍/专栏/课程
// 美团
// 美团外卖红包
// 扫一扫
// 淘宝闪购
// 支付宝红包
// 扫一扫
// 高德地图
// 位置
// KEEP
// 原神
// 自动打开 com.MiHoYo.YuanShen
// 王者荣耀
// 自动打开 com.tencent.tmgp.sgame
// 和平精英
// 自动打开 com.tencent.ig
// 米游社
// 自动打开 com.mihoyo.hyperion
// 王者营地
// 签到 smobagamehelper://web?url=
// 腾讯视频
// 视频/专栏/课程/番剧/影视
// 爱奇艺
// 视频/专栏/课程/番剧/影视
// 优酷
// 视频/专栏/课程/番剧/影视
// 芒果TV
// 视频/专栏/课程/番剧/影视

View File

@@ -0,0 +1,331 @@
// WiFi 认证类型
const WIFI_AUTH_TYPES: Record<number, string> = {
0x0000: "Open",
0x0001: "WPA-Personal",
0x0002: "Shared",
0x0003: "WPA-Auto-Personal",
0x0004: "WPA2-Personal",
0x0005: "WPA2-Auto-Personal",
0x0006: "WPA-Enterprise",
0x0007: "WPA2-Enterprise",
0x0008: "WPA-Enterprise-V2",
0x0009: "WPA2-Enterprise-V2",
0x0010: "WPA3-Personal",
0x0011: "WPA3-Enterprise",
0x0012: "WPA3-Transition",
};
// WiFi 加密类型
const WIFI_ENCRYPTION_TYPES: Record<number, string> = {
0x0000: "None",
0x0001: "WEP",
0x0002: "TKIP",
0x0003: "AES",
0x0004: "TKIP+AES",
0x0005: "AES-CCM",
0x0006: "WPA3-SAE",
0x0007: "WPA3-Enterprise-192bit",
};
// WiFi TLV 类型
const WIFI_TLV_TYPES: Record<number, string> = {
0x100E: "SSID",
0x1027: "Password",
0x1023: "AuthType",
0x1025: "EncryptionType",
0x100F: "MACAddress",
0x1010: "VendorExtension",
0x1011: "VendorSpecific",
0x1020: "NetworkKeyIndex",
0x1021: "Channel",
};
const parseWifiTlv = (buffer: Uint8Array): { type: number; length: number; value: Uint8Array } | null => {
if (buffer.length < 3) return null;
const type = (buffer[0] << 8) | buffer[1];
const length = buffer[2];
if (buffer.length < 3 + length) return null;
const value = buffer.slice(3, 3 + length);
return { type, length, value };
};
const parseWifiAuthType = (value: Uint8Array): string => {
if (value.length < 2) return "Unknown";
const authType = (value[0] << 8) | value[1];
return WIFI_AUTH_TYPES[authType] || `Unknown (0x${authType.toString(16)})`;
};
const parseWifiEncryptionType = (value: Uint8Array): string => {
if (value.length < 2) return "Unknown";
const encType = (value[0] << 8) | value[1];
return WIFI_ENCRYPTION_TYPES[encType] || `Unknown (0x${encType.toString(16)})`;
};
const parseWifiPayload = (payload: Uint8Array): Record<string, string> => {
const result: Record<string, string> = {};
let offset = 0;
// WiFi payload 前面可能有 2 字节的前缀 (00 00 或 10 00 等)
if (payload.length > 2 && payload[0] === 0x10 && payload[1] === 0x00) {
offset = 2;
}
while (offset < payload.length) {
const remaining = payload.slice(offset);
const tlv = parseWifiTlv(remaining);
if (!tlv) break;
const typeName = WIFI_TLV_TYPES[tlv.type] || `Unknown(0x${tlv.type.toString(16)})`;
switch (tlv.type) {
case 0x100E: // SSID
result.ssid = textDecode(tlv.value);
break;
case 0x1027: // Password
result.password = textDecode(tlv.value);
break;
case 0x1023: // AuthType
result.authType = parseWifiAuthType(tlv.value);
break;
case 0x1025: // EncryptionType
result.encryptionType = parseWifiEncryptionType(tlv.value);
break;
case 0x1021: // Channel
result.channel = tlv.value[0].toString();
break;
case 0x100F: // MAC Address
result.macAddress = Array.from(tlv.value)
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join(":");
break;
default:
result[typeName] = arrayBufferToHex(tlv.value.buffer);
break;
}
offset += 3 + tlv.length;
}
return result;
};
// ArrayBuffer 转十六进制字符串
export const arrayBufferToHex = (buffer: ArrayBufferLike): string => {
if (!buffer) return "";
const bytes = new Uint8Array(buffer);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join(" ");
};
// URL 协议前缀映射 (NDEF RTD 规范)
const URL_PREFIXES = [
"", "http://www.", "https://www.", "http://", "https://",
"tel:", "mailto:", "ftp://anonymous:anonymous@", "ftp://ftp.",
"file://", "news:", "telnet://", "imap:", "rtsp://",
"urn:", "pop:", "sip:", "sips:", "tftp:", "btspp://",
"btl2cap://", "btgoep://", "tcpobex://", "irdaobex://", "file://"
];
const urlDecode = (buffer: Uint8Array): string => {
if (!buffer || buffer.length === 0) return "";
const prefixCode = buffer[0];
const prefix = URL_PREFIXES[prefixCode] || "";
const urlBytes = buffer.slice(1);
const urlPart = textDecode(urlBytes);
return prefix + urlPart;
};
const textDecode = (buffer: Uint8Array): string => {
const encoded = Array.from(buffer).reduce((s, b) => s + "%" + b.toString(16).padStart(2, "0"), "");
const text = decodeURIComponent(encoded);
return text;
}
// 将 ArrayBuffer 解析为字符串
export const arrayBufferToString = (buffer: ArrayBufferLike): string => {
if (!buffer) return "";
console.log("Parsing ArrayBuffer to string:", arrayBufferToHex(buffer));
const bytes = new Uint8Array(buffer);
// 检查是否是 02 65 6E 开头
if (bytes.length >= 3 && bytes[0] === 0x02 && bytes[1] === 0x65 && bytes[2] === 0x6E) {
// 去掉前3个字节解析后面的内容
const textBytes = bytes.slice(3);
const text = textDecode(textBytes);
console.log("Parsed text (with 02 65 6E prefix):", text);
return text;
}
console.log("No 02 65 6E prefix found, parsing entire buffer as UTF-8 string.");
// 其他情况直接按 UTF-8 解析
const text = textDecode(bytes);
console.log("Parsed text:", text);
return text;
};
export type NfcReadMessageRecordBuffer = {
id: ArrayBuffer;
payload: ArrayBuffer;
tnf: number;
type: ArrayBuffer;
};
export type NfcReadResultBuffer = {
id: ArrayBuffer;
messages: { records: NfcReadMessageRecordBuffer[] }[];
techs: string[];
};
export type NfcText = {
id: string;
messages: {
records: {
id: string;
payload: string;
tnf: number;
type: string;
}[];
}[];
techs: string[];
};
/**
* 解析NFC记录
* @param record NFC记录数据
* @returns 解析后的记录对象
*/
export const parseNfcRecord = (record: NfcReadMessageRecordBuffer) => {
// 将type字节转为十六进制字符串用于比较
const typeHex = arrayBufferToHex(record.type);
// 解析payload为Uint8Array
const payloadBytes = new Uint8Array(record.payload);
let payloadText = "";
switch (typeHex) {
case "00":
// 空记录 - 无payload
payloadText = "[空记录]";
break;
case "01":
// NFC论坛外部类型 - 通常是自定义格式
payloadText = `[外部类型]: ${arrayBufferToHex(record.payload)}`;
break;
case "53":
// 智能海报 - 包含URL、文本等嵌套记录
payloadText = `[智能海报]: ${urlDecode(payloadBytes)}`;
break;
case "54":
// 文本记录 - 常见类型
payloadText = arrayBufferToString(record.payload);
break;
case "55":
// URL记录 - 网页链接
payloadText = urlDecode(payloadBytes);
break;
case "56":
// VCard记录 - 联系人信息
payloadText = parseVCard(payloadBytes);
break;
case "57":
// WiFi配置记录 - 网络连接信息
const wifiData = parseWifiPayload(payloadBytes);
payloadText = Object.entries(wifiData)
.map(([key, value]) => `${key}: ${value}`)
.join("\n");
break;
case "61":
// Android应用记录 - 包名或URL
payloadText = textDecode(payloadBytes);
break;
case "4D":
// MIME媒体类型 - 可能包含图片、音频等
const mimeType = textDecode(payloadBytes.slice(0, Math.min(50, payloadBytes.length)));
payloadText = `[MIME]: ${mimeType}\n[数据]: ${arrayBufferToHex(record.payload)}`;
break;
case "44":
// 设备信息 - 设备厂商、型号等
payloadText = parseDeviceInfo(payloadBytes);
break;
default:
// 未知类型 - 转为十六进制显示
payloadText = `[未知类型 ${typeHex}]: ${arrayBufferToHex(record.payload)}`;
break;
}
return {
id: arrayBufferToHex(record.id),
payload: payloadText,
tnf: record.tnf,
type: typeHex,
};
};
/**
* 解析VCard联系人信息
* @param buffer VCard数据
* @returns 格式化的联系人信息
*/
const parseVCard = (buffer: Uint8Array): string => {
const text = textDecode(buffer);
// 提取常用字段
const lines = text.split(/\r?\n/);
const result: string[] = [];
for (const line of lines) {
if (line.startsWith("FN:") || line.startsWith("fn:")) {
result.push(`姓名: ${line.substring(3)}`);
} else if (line.startsWith("TEL:") || line.startsWith("tel:")) {
result.push(`电话: ${line.substring(4)}`);
} else if (line.startsWith("EMAIL:") || line.startsWith("email:")) {
result.push(`邮箱: ${line.substring(6)}`);
} else if (line.startsWith("ORG:") || line.startsWith("org:")) {
result.push(`公司: ${line.substring(4)}`);
} else if (line.startsWith("TITLE:") || line.startsWith("title:")) {
result.push(`职位: ${line.substring(6)}`);
} else if (line.startsWith("URL:") || line.startsWith("url:")) {
result.push(`网站: ${line.substring(4)}`);
}
}
return result.length > 0 ? result.join("\n") : text;
};
/**
* 解析设备信息
* @param buffer 设备信息数据
* @returns 格式化的设备信息
*/
const parseDeviceInfo = (buffer: Uint8Array): string => {
const text = textDecode(buffer);
const lines = text.split(/\r?\n/);
const result: string[] = [];
for (const line of lines) {
const parts = line.split(":");
if (line.startsWith("manufacturer:") || line.startsWith("Manufacturer:")) {
result.push(`厂商: ${parts[1] ? parts[1].trim() : ""}`);
} else if (line.startsWith("model:") || line.startsWith("Model:")) {
result.push(`型号: ${parts[1] ? parts[1].trim() : ""}`);
} else if (line.startsWith("serial:") || line.startsWith("Serial:")) {
result.push(`序列号: ${parts[1] ? parts[1].trim() : ""}`);
}
}
return result.length > 0 ? result.join("\n") : `[设备信息]: ${text}`;
};
export const parseNfcReadResult = (result: NfcReadResultBuffer): NfcText => {
return {
id: arrayBufferToHex(result.id),
messages: result.messages.map((msg) => ({
records: msg.records.map((rec) => parseNfcRecord(rec)),
})),
techs: result.techs || [],
};
};

View File

@@ -0,0 +1,3 @@
# 注意事项
必须是 type:55 的标签才是网页跳转如果是55buffer里面为00的时候

View File

@@ -1,10 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2017", "target": "es2017",
"module": "commonjs", "module": "esnext",
"removeComments": false, "removeComments": false,
"preserveConstEnums": true, "preserveConstEnums": true,
"moduleResolution": "node", "moduleResolution": "bundler",
"experimentalDecorators": true, "experimentalDecorators": true,
"noImplicitAny": false, "noImplicitAny": false,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,