feat: enhance BottomNav component and update project configuration
This commit is contained in:
61
AGENTS.md
61
AGENTS.md
@@ -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
|
||||
@@ -104,14 +58,6 @@ if (isXHS()) {
|
||||
|
||||
应用入口组件,包含 `useLaunch` 生命周期钩子,用于应用初始化。在微信环境下会自动调用 `Taro.login`。
|
||||
|
||||
### project.xhs.json
|
||||
|
||||
小红书 IDE 特定配置(appid、编译设置等)。
|
||||
|
||||
### tsconfig.json
|
||||
|
||||
TypeScript 编译器选项,包括路径别名配置(`@/*` → `./src/*`)。
|
||||
|
||||
## AI 代理注意事项
|
||||
|
||||
1. 修改平台特定代码时,使用环境检测确保跨平台兼容性
|
||||
@@ -119,3 +65,8 @@ TypeScript 编译器选项,包括路径别名配置(`@/*` → `./src/*`)
|
||||
3. 遵循现有的代码风格和目录结构
|
||||
4. 添加新依赖时,确保与所有目标平台兼容
|
||||
5. 项目使用 pnpm 作为包管理器
|
||||
|
||||
## 避免
|
||||
1. 不能使用 `?.` 和 `??` 操作符, 因为不支持
|
||||
2. 不能使用 TextDecoder 和 TextEncoder, 因为不支持
|
||||
3. 不能使用 Buffer, 因为不支持
|
||||
@@ -6,5 +6,9 @@ export default {
|
||||
stats: true
|
||||
},
|
||||
mini: {},
|
||||
h5: {}
|
||||
h5: {
|
||||
devServer: {
|
||||
host: '0.0.0.0'
|
||||
}
|
||||
}
|
||||
} satisfies UserConfigExport<'webpack5'>
|
||||
|
||||
@@ -7,8 +7,8 @@ import prodConfig from "./prod";
|
||||
// @ts-ignore
|
||||
export default defineConfig<"webpack5">(async (merge, { command, mode }) => {
|
||||
const baseConfig: UserConfigExport<"webpack5"> = {
|
||||
projectName: "2025-09-14-webpack-demo",
|
||||
date: "2025-9-14",
|
||||
projectName: "taro-template",
|
||||
date: "2026-03-12",
|
||||
designWidth: 750,
|
||||
deviceRatio: {
|
||||
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
|
||||
},
|
||||
mini: {
|
||||
compiler: {
|
||||
type: 'webpack5',
|
||||
prebundle: {
|
||||
enable: false // Webpack 持久化缓存配置,建议开启。默认配置请参考:https://docs.taro.zone/docs/config-detail#cache
|
||||
},
|
||||
},
|
||||
postcss: {
|
||||
pxtransform: {
|
||||
enable: true,
|
||||
@@ -51,6 +57,7 @@ export default defineConfig<"webpack5">(async (merge, { command, mode }) => {
|
||||
h5: {
|
||||
publicPath: "/",
|
||||
staticDirectory: "static",
|
||||
esnextModules: ["@stencil/core"],
|
||||
output: {
|
||||
filename: "js/[name].[hash:8].js",
|
||||
chunkFilename: "js/[name].[chunkhash:8].js",
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"author": "",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@tarojs/components": "4.1.11",
|
||||
"@tarojs/helper": "4.1.11",
|
||||
|
||||
145
pnpm-lock.yaml
generated
145
pnpm-lock.yaml
generated
@@ -11,6 +11,12 @@ importers:
|
||||
'@babel/runtime':
|
||||
specifier: ^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':
|
||||
specifier: 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':
|
||||
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':
|
||||
resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==}
|
||||
|
||||
'@napi-rs/triples@1.2.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1178,6 +1203,10 @@ packages:
|
||||
'@nutui/touch-emulator@1.0.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -2218,6 +2247,9 @@ packages:
|
||||
big.js@5.2.2:
|
||||
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2869,6 +2901,9 @@ packages:
|
||||
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
|
||||
hasBin: true
|
||||
|
||||
error-causes@3.0.2:
|
||||
resolution: {integrity: sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==}
|
||||
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
@@ -2897,6 +2932,9 @@ packages:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.45.1:
|
||||
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
|
||||
|
||||
esbuild-loader@4.4.2:
|
||||
resolution: {integrity: sha512-8LdoT9sC7fzfvhxhsIAiWhzLJr9yT3ggmckXxsgvM07wgrRxhuT98XhLn3E7VczU5W5AFsPKv9DdWcZIubbWkQ==}
|
||||
peerDependencies:
|
||||
@@ -2976,6 +3014,9 @@ packages:
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
@@ -3159,6 +3200,10 @@ packages:
|
||||
function-bind@1.1.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -4058,6 +4103,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
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:
|
||||
resolution: {integrity: sha512-/etjwrK0J4Ebbcnt35VMWnfiUX/B04uwGJxyJInagxDqf2z5drSt/lsOvEMWGYunz1kaLZAFrV4NDAbOoDKvAQ==}
|
||||
|
||||
@@ -4251,6 +4301,9 @@ packages:
|
||||
pascal-case@3.1.2:
|
||||
resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==}
|
||||
|
||||
path-browserify-esm@1.0.6:
|
||||
resolution: {integrity: sha512-9nUwYvvu/yq1PYrUyYCihNWmpzacaRYF6gGbjLWErrZ4MRDWyfPN7RpE8E7tsw8eqBU/rr7mcoTXbS+Vih8uUA==}
|
||||
|
||||
path-browserify@1.0.1:
|
||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||
|
||||
@@ -5019,6 +5072,12 @@ packages:
|
||||
solid-js@1.9.11:
|
||||
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:
|
||||
resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5053,6 +5112,9 @@ packages:
|
||||
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
spark-md5@3.0.2:
|
||||
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
|
||||
|
||||
spdy-transport@3.0.0:
|
||||
resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==}
|
||||
|
||||
@@ -5622,6 +5684,24 @@ packages:
|
||||
yup@1.7.1:
|
||||
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:
|
||||
|
||||
'@adobe/css-tools@4.3.3': {}
|
||||
@@ -6713,10 +6793,43 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@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': {}
|
||||
|
||||
'@napi-rs/triples@1.2.0': {}
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -6754,6 +6867,12 @@ snapshots:
|
||||
|
||||
'@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':
|
||||
optional: true
|
||||
|
||||
@@ -7954,6 +8073,8 @@ snapshots:
|
||||
|
||||
big.js@5.2.2: {}
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bl@1.2.3:
|
||||
@@ -8690,6 +8811,8 @@ snapshots:
|
||||
prr: 1.0.1
|
||||
optional: true
|
||||
|
||||
error-causes@3.0.2: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
@@ -8717,6 +8840,8 @@ snapshots:
|
||||
has-tostringtag: 1.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)):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
@@ -8868,6 +8993,8 @@ snapshots:
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
execa@5.1.1:
|
||||
@@ -9083,6 +9210,8 @@ snapshots:
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
fuse.js@7.1.0: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
@@ -9974,6 +10103,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.6: {}
|
||||
|
||||
native-request@1.1.2:
|
||||
optional: true
|
||||
|
||||
@@ -10171,6 +10302,8 @@ snapshots:
|
||||
no-case: 3.0.4
|
||||
tslib: 2.8.1
|
||||
|
||||
path-browserify-esm@1.0.6: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
path-case@3.0.4:
|
||||
@@ -10933,6 +11066,11 @@ snapshots:
|
||||
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:
|
||||
dependencies:
|
||||
sort-keys: 1.1.2
|
||||
@@ -10960,6 +11098,8 @@ snapshots:
|
||||
|
||||
source-map@0.7.6: {}
|
||||
|
||||
spark-md5@3.0.2: {}
|
||||
|
||||
spdy-transport@3.0.0:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -11548,3 +11688,8 @@ snapshots:
|
||||
tiny-case: 1.0.3
|
||||
toposort: 2.0.2
|
||||
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
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
{
|
||||
"miniprogramRoot": "./dist",
|
||||
"projectname": "2026-03-12-taro-template",
|
||||
"miniprogramRoot": "dist/",
|
||||
"projectname": "taro-template",
|
||||
"description": "taro-template",
|
||||
"appid": "touristappid",
|
||||
"appid": "wx5464d820d8c2e4ad",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"compileHotReLoad": 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
0
public/update.json
Normal file
@@ -1,6 +1,7 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/nfc-read/index',
|
||||
'pages/mine/index'
|
||||
],
|
||||
window: {
|
||||
|
||||
@@ -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 {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-shrink: 0;
|
||||
height: 100px;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
@@ -17,12 +28,12 @@
|
||||
|
||||
.bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { View, Text } from "@tarojs/components";
|
||||
import Taro from "@tarojs/taro";
|
||||
import type { ReactNode } from "react";
|
||||
import type { NavItem, NavKey } from "../../config";
|
||||
import "./index.css";
|
||||
|
||||
@@ -10,33 +11,35 @@ const ICON_SIZE = 24;
|
||||
interface BottomNavProps {
|
||||
active: NavKey;
|
||||
navItems: NavItem[];
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function BottomNav({ active, navItems }: BottomNavProps) {
|
||||
export default function BottomNav({ active, navItems, children }: BottomNavProps) {
|
||||
const handleNavigate = (item: NavItem) => {
|
||||
if (item.key === active) return;
|
||||
Taro.redirectTo({ url: item.path });
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="bottom-nav">
|
||||
<View className="bottom-nav-wrapper">
|
||||
<View className="bottom-nav-content">{children}</View>
|
||||
<View className="bottom-nav">
|
||||
{navItems.map((item) => {
|
||||
const isActive = active === item.key;
|
||||
const IconComp = item.icon;
|
||||
const iconColor = isActive ? ACTIVE_COLOR : DEFAULT_COLOR;
|
||||
return (
|
||||
<View
|
||||
key={item.key}
|
||||
className={`bottom-nav-item${isActive ? " active" : ""}`}
|
||||
onClick={() => handleNavigate(item)}
|
||||
>
|
||||
<IconComp
|
||||
size={ICON_SIZE}
|
||||
color={isActive ? ACTIVE_COLOR : DEFAULT_COLOR}
|
||||
/>
|
||||
<IconComp size={ICON_SIZE} color={iconColor} />
|
||||
<Text className="bottom-nav-label">{item.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
74
src/components/Icons/index.tsx
Normal file
74
src/components/Icons/index.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, User } from "@nutui/icons-react-taro";
|
||||
import { IconHome, IconUser, IconNfc } from "./components/Icons";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export type NavKey = string;
|
||||
@@ -7,7 +7,7 @@ export interface NavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
path: string;
|
||||
icon: ComponentType<{ size?: string | number; color?: string }>;
|
||||
icon: ComponentType<{ size?: number; color?: string }>;
|
||||
}
|
||||
|
||||
export const navItems: NavItem[] = [
|
||||
@@ -15,12 +15,18 @@ export const navItems: NavItem[] = [
|
||||
key: "index",
|
||||
label: "主页",
|
||||
path: "/pages/index/index",
|
||||
icon: Home,
|
||||
icon: IconHome,
|
||||
},
|
||||
{
|
||||
key: "nfc-read",
|
||||
label: "NFC",
|
||||
path: "/pages/nfc-read/index",
|
||||
icon: IconNfc,
|
||||
},
|
||||
{
|
||||
key: "mine",
|
||||
label: "我的",
|
||||
path: "/pages/mine/index",
|
||||
icon: User,
|
||||
icon: IconUser,
|
||||
},
|
||||
];
|
||||
|
||||
10
src/modules/taro-request.ts
Normal file
10
src/modules/taro-request.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 80px;
|
||||
padding-bottom: 100px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Index() {
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="index">
|
||||
<BottomNav active="index" navItems={navItems}>
|
||||
<View className="index-content">
|
||||
<Text>Hello world!</Text>
|
||||
<Button
|
||||
@@ -22,7 +22,6 @@ export default function Index() {
|
||||
User Info
|
||||
</Button>
|
||||
</View>
|
||||
<BottomNav active="index" navItems={navItems} />
|
||||
</View>
|
||||
</BottomNav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: "我的",
|
||||
navigationStyle: "custom",
|
||||
});
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
.mine-page {
|
||||
height: 100vh;
|
||||
background: #f5f5f5;
|
||||
.mine-content {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 30px 32px;
|
||||
padding-top: 180px;
|
||||
}
|
||||
|
||||
.mine-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
.mine-profile-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-top: 80px;
|
||||
padding-bottom: 100px;
|
||||
gap: 20px;
|
||||
gap: 24px;
|
||||
background: #fff;
|
||||
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 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 60px;
|
||||
background: #1976d2;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -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 { navItems } from "../../config";
|
||||
import "./index.css";
|
||||
|
||||
const STORAGE_KEY_AVATAR = "mine_avatarUrl";
|
||||
const STORAGE_KEY_NICKNAME = "mine_nickName";
|
||||
|
||||
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 (
|
||||
<View className="mine-page">
|
||||
<BottomNav active="mine" navItems={navItems}>
|
||||
<View className="mine-content">
|
||||
<View className="mine-avatar" />
|
||||
<Text className="mine-name">用户昵称</Text>
|
||||
<View className="mine-profile-row">
|
||||
{/* 头像选择按钮 */}
|
||||
<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>
|
||||
<BottomNav active="mine" navItems={navItems} />
|
||||
</View>
|
||||
</BottomNav>
|
||||
);
|
||||
}
|
||||
|
||||
5
src/pages/nfc-read/index.config.ts
Normal file
5
src/pages/nfc-read/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: "NFC 读取",
|
||||
enablePullDownRefresh: false,
|
||||
navigationStyle: "custom",
|
||||
});
|
||||
56
src/pages/nfc-read/index.css
Normal file
56
src/pages/nfc-read/index.css
Normal 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;
|
||||
}
|
||||
155
src/pages/nfc-read/index.tsx
Normal file
155
src/pages/nfc-read/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/pages/nfc-read/lib/app.ts
Normal file
94
src/pages/nfc-read/lib/app.ts
Normal 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
|
||||
// 视频/专栏/课程/番剧/影视
|
||||
331
src/pages/nfc-read/lib/nfc.ts
Normal file
331
src/pages/nfc-read/lib/nfc.ts
Normal 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 || [],
|
||||
};
|
||||
};
|
||||
3
src/pages/nfc-read/readme.md
Normal file
3
src/pages/nfc-read/readme.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 注意事项
|
||||
|
||||
必须是 type:55 的标签才是网页跳转,如果是55,buffer里面为00的时候
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"module": "commonjs",
|
||||
"module": "esnext",
|
||||
"removeComments": false,
|
||||
"preserveConstEnums": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"noImplicitAny": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
Reference in New Issue
Block a user