在Windows系统上构建

This commit is contained in:
cheney 2026-07-09 20:03:35 +08:00
parent 22ae3ac755
commit 0652323b85
11 changed files with 642 additions and 19 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ node_modules/
dist/
src-tauri/target/
src-tauri/gen/
tests/fixtures/sample-qr.png

View File

@ -69,6 +69,7 @@ npm run tauri android build
产物路径:
```
src-tauri/gen/android/app/build/outputs/apk/
```
## 6. 生成签名密钥release 必需)
@ -111,3 +112,38 @@ adb install -r <上面生成的 apk 路径>
- 图标缺失导致 `generate_context!` 失败:确认 `src-tauri/icons/icon.png` 存在,可用 `npm run tauri icon <源图>` 生成全套。
- NDK 找不到:检查 `NDK_HOME` 是否指向真实存在的版本目录。
## 9. Windows 本地工具链(通过 mise 安装)
本仓库根目录的 `mise.toml` 固定了 Android 构建所需的工具链,均通过 [mise](https://mise.jdx.dev) 管理。
安装步骤:
```powershell
mise install # 安装 java(temurin-17) + rust(1.89)
mise trust # 首次需信任本目录的 mise.toml 以启用环境变量
```
工具链清单:
- JDK 17`java = "temurin-17"`Android/Gradle 需要 17+。
- Rust 1.89`rust = "1.89"`),并已 `rustup target add` 四个 Android 目标
aarch64/armv7/i686/x86_64-linux-android
- Android SDK 安装在 `D:\android-sdk`(平台 android-34、build-tools 34.0.0、
platform-tools、NDK 27.2.12479018、emulator + system-images;android-34;google_apis;x86_64
通过 `mise.toml``[env]` 注入 `ANDROID_HOME` / `NDK_HOME` 及 PATH。
- 模拟器 AVD`tauri_test`Pixel 6Android 14。启动`emulator -avd tauri_test`。
> 说明Windows 上 mise 的 vfox android-sdk 插件校验会失败,且 C 盘空间紧张,
> 因此 SDK 手动安装到 D 盘并用环境变量接入JDK 因 GitHub 发布 CDN 在本网络不可达,
> 改用清华镜像下载后由 mise 离线安装(`MISE_PREFER_OFFLINE=1`)。
### 已知问题360 安全卫士拦截构建
本机安装了 **360 安全卫士**`ZhuDongFangYu` 主动防御)。它会拦截 cargo 新编译出的
build-script 可执行文件的运行,导致 `cargo build``拒绝访问 (os error 5)` 或卡死。
构建 Android 前需临时退出 360 主动防御,或将本仓库与 `~/.cargo`、`~/.rustup` 加入信任白名单。
### 前端测试
```powershell
npm test # 含 autofill 与 qrscan二维码解码用例
node tests/fixtures/generate-qr.mjs # 生成一张测试二维码图片 tests/fixtures/sample-qr.png
```

16
mise.toml Normal file
View File

@ -0,0 +1,16 @@
# Tauri Android 工具链(通过 mise 管理)
[tools]
java = "temurin-17"
rust = "1.89"
# Android SDK 手动安装到 D:\android-sdkWindows 上 vfox 插件校验失败,
# 且 C 盘空间紧张),此处通过环境变量接入 mise。
[env]
ANDROID_HOME = "D:\\android-sdk"
ANDROID_SDK_ROOT = "D:\\android-sdk"
NDK_HOME = "D:\\android-sdk\\ndk\\27.2.12479018"
_.path = [
"D:\\android-sdk\\platform-tools",
"D:\\android-sdk\\emulator",
"D:\\android-sdk\\cmdline-tools\\latest\\bin",
]

342
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-barcode-scanner": "^2.0.0",
"jsqr": "^1.4.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
@ -17,6 +18,7 @@
"@tauri-apps/cli": "^2.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"jsdom": "^24.0.0",
"qrcode": "^1.5.4",
"vite": "^5.2.0",
"vitest": "^1.6.0"
}
@ -1460,6 +1462,16 @@
"node": ">= 14"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
@ -1514,6 +1526,16 @@
"node": ">= 0.4"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chai": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
@ -1546,6 +1568,38 @@
"node": "*"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -1640,6 +1694,16 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@ -1680,6 +1744,13 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1695,6 +1766,13 @@
"node": ">= 0.4"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@ -1830,6 +1908,20 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@ -1872,6 +1964,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-func-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@ -2053,6 +2155,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@ -2128,6 +2240,12 @@
}
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/local-pkg": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
@ -2145,6 +2263,19 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/loupe": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
@ -2337,6 +2468,45 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-locate/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@ -2350,6 +2520,16 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -2402,6 +2582,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@ -2468,6 +2658,24 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@ -2482,6 +2690,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@ -2561,6 +2786,13 @@
"node": ">=v12.22.7"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -2627,6 +2859,34 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
@ -3016,6 +3276,13 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -3033,6 +3300,37 @@
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
@ -3072,6 +3370,50 @@
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",

View File

@ -13,6 +13,7 @@
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-barcode-scanner": "^2.0.0",
"jsqr": "^1.4.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
@ -20,6 +21,7 @@
"@tauri-apps/cli": "^2.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"jsdom": "^24.0.0",
"qrcode": "^1.5.4",
"vite": "^5.2.0",
"vitest": "^1.6.0"
}

View File

@ -0,0 +1,2 @@
[target.x86_64-pc-windows-msvc]
linker = "C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/VC/Tools/MSVC/14.44.35207/bin/Hostx64/x64/link.exe"

76
src/qrscan.js Normal file
View File

@ -0,0 +1,76 @@
// 二维码解码核心逻辑:与 UI、相机、Tauri 解耦,便于单元测试。
// 相机扫码走原生插件,图片扫码走纯 JS 的 jsQR桌面端也可用。
import jsQR from "jsqr";
/**
* 校验扫码内容是否为有效网址仅接受 http/https
* @param {unknown} content 扫码得到的原始内容
* @returns {string} 合法网址去除首尾空白非法时返回空字符串
*/
export function extractUrl(content) {
if (typeof content !== "string") return "";
const text = content.trim();
return /^https?:\/\//i.test(text) ? text : "";
}
/**
* 从像素数据中解码二维码
* @param {{data: Uint8ClampedArray, width: number, height: number}} imageData
* 画布像素数据RGBA
* @returns {string|null} 解码到的文本未识别到返回 null
*/
export function decodeQrFromImageData(imageData) {
if (!imageData || !imageData.data || !imageData.width || !imageData.height) {
return null;
}
// attemptBoth 允许识别反色(浅底深码或深底浅码)二维码,提升清晰码的识别率。
const result = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "attemptBoth",
});
return result ? result.data : null;
}
/**
* 将图片元素绘制到离屏画布并取出像素数据仅浏览器环境可用
* @param {CanvasImageSource & {width:number,height:number}} image 已加载的图片源
* @param {number} [maxSize=1600] 长边最大像素过大图片等比缩小以兼顾速度与精度
* @returns {ImageData} 画布像素数据
*/
export function imageToImageData(image, maxSize = 1600) {
const sw = image.naturalWidth || image.width;
const sh = image.naturalHeight || image.height;
const scale = Math.min(1, maxSize / Math.max(sw, sh));
const w = Math.max(1, Math.round(sw * scale));
const h = Math.max(1, Math.round(sh * scale));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, w, h);
return ctx.getImageData(0, 0, w, h);
}
/**
* 从文件用户选择的图片解码二维码仅浏览器环境可用
* @param {Blob} file 图片文件
* @returns {Promise<string|null>} 解码文本未识别到返回 null
*/
export function decodeQrFromFile(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
try {
resolve(decodeQrFromImageData(imageToImageData(img)));
} catch (e) {
reject(e);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("图片加载失败"));
};
img.src = url;
});
}

View File

@ -1,4 +1,4 @@
<script setup>
<script setup>
import { ref, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import {
@ -9,16 +9,19 @@ import {
openAppSettings,
Format,
} from "@tauri-apps/plugin-barcode-scanner";
import { extractUrl, decodeQrFromFile } from "../qrscan.js";
// URL h5
// Android WebView
// JS
// Android WebView
const router = useRouter();
const error = ref("");
const denied = ref(false);
const scanning = ref(false);
const decoding = ref(false);
const fileInput = ref(null);
/**
* 将任意异常转为可读字符串避免出现 [object Object]
* 将任意异常转为可读字符串避免出现 [object Object]
* @param {unknown} e 捕获到的异常
* @returns {string} 可读的错误描述
*/
@ -34,6 +37,7 @@ function toMessage(e) {
/**
* 切换扫码透明态透明时可看到 WebView 下层的相机预览
* 同时作用于 html/body #app 挂载点避免残留不透明背景挡住画面
* @param {boolean} on 是否进入透明态
* @returns {void}
*/
@ -41,6 +45,23 @@ function setTransparent(on) {
const val = on ? "transparent" : "";
document.documentElement.style.background = val;
document.body.style.background = val;
const app = document.getElementById("app");
if (app) app.style.background = val;
}
/**
* 命中网址后跳转到 WebForm 非网址则提示
* @param {string} content 扫码/解码得到的原始文本
* @returns {boolean} 是否为有效网址并已跳转
*/
function handleContent(content) {
const url = extractUrl(content);
if (url) {
router.push({ path: "/web", query: { url } });
return true;
}
error.value = "扫描内容不是有效网址:" + content;
return false;
}
/**
@ -56,7 +77,7 @@ async function ensurePermission() {
return state === "granted";
}
/** 启动扫码,成功后携带 URL 跳转到 WebForm 页。@returns {Promise<void>} */
/** 启动相机扫码,成功后携带 URL 跳转到 WebForm 页。@returns {Promise<void>} */
async function startScan() {
error.value = "";
if (scanning.value) return;
@ -68,13 +89,12 @@ async function startScan() {
}
scanning.value = true;
setTransparent(true);
const res = await scan({ windowed: true, formats: [Format.QRCode] });
const url = res.content;
if (/^https?:\/\//.test(url)) {
router.push({ path: "/web", query: { url } });
} else {
error.value = "扫描内容不是有效网址:" + url;
}
const res = await scan({
windowed: true,
formats: [Format.QRCode],
cameraDirection: "back",
});
handleContent(res.content);
} catch (e) {
error.value = "扫码失败或已取消:" + toMessage(e);
} finally {
@ -83,7 +103,7 @@ async function startScan() {
}
}
/** 取消正在进行的扫码。@returns {Promise<void>} */
/** 取消正在进行的相机扫码。@returns {Promise<void>} */
async function stopScan() {
try {
await cancel();
@ -94,7 +114,38 @@ async function stopScan() {
setTransparent(false);
}
//
/** 触发图片选择框。@returns {void} */
function pickImage() {
error.value = "";
if (fileInput.value) fileInput.value.click();
}
/**
* 从用户选择的图片解码二维码
* @param {Event} evt input change 事件
* @returns {Promise<void>}
*/
async function onFileChange(evt) {
const file = evt.target.files && evt.target.files[0];
evt.target.value = ""; //
if (!file) return;
error.value = "";
decoding.value = true;
try {
const content = await decodeQrFromFile(file);
if (content == null) {
error.value = "未在图片中识别到二维码,请换一张更清晰的图片。";
return;
}
handleContent(content);
} catch (e) {
error.value = "图片识别失败:" + toMessage(e);
} finally {
decoding.value = false;
}
}
//
onUnmounted(() => {
if (scanning.value) stopScan();
setTransparent(false);
@ -105,8 +156,18 @@ onUnmounted(() => {
<div>
<!-- 非扫码态说明与入口 -->
<div v-if="!scanning" class="card">
<p>点击下方按钮将小票二维码对准取景框即可自动识别</p>
<p>点击下方按钮将小票二维码对准取景框即可自动识别也可从相册选择二维码图片</p>
<button @click="startScan">开始扫码</button>
<button class="plain" style="margin-left:8px" :disabled="decoding" @click="pickImage">
{{ decoding ? "识别中..." : "从图片扫描" }}
</button>
<input
ref="fileInput"
type="file"
accept="image/*"
style="display:none"
@change="onFileChange"
/>
<p v-if="error" style="color:#d32f2f;margin-top:12px">{{ error }}</p>
<button v-if="denied" class="plain" style="margin-top:12px" @click="openAppSettings()">
去系统设置开启相机权限

16
tests/fixtures/generate-qr.mjs vendored Normal file
View File

@ -0,0 +1,16 @@
// 生成测试用二维码图片PNG便于用「从图片扫描」功能验证。
// 用法node tests/fixtures/generate-qr.mjs [网址] [输出路径]
import QRCode from "qrcode";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const url = process.argv[2] || "https://fapiao.example.com/open?id=20240701";
const out = resolve(__dirname, process.argv[3] || "sample-qr.png");
await QRCode.toFile(out, url, {
errorCorrectionLevel: "M",
margin: 4,
scale: 8,
});
console.log(`已生成二维码图片: ${out}\n内容: ${url}`);

71
tests/qrscan.test.js Normal file
View File

@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import QRCode from "qrcode";
import {
extractUrl,
decodeQrFromImageData,
} from "../src/qrscan.js";
/**
* qrcode 库把文本渲染成 RGBA 像素数据放大每个模块以贴近真实拍摄清晰度
* @param {string} text 待编码文本
* @param {number} scale 每个二维码模块放大的像素倍数
* @param {number} quiet 静区模块数
* @returns {{data:Uint8ClampedArray,width:number,height:number}} 画布像素数据
*/
function renderQrImageData(text, scale = 8, quiet = 4) {
const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
const size = qr.modules.size;
const modules = qr.modules.data;
const dim = (size + quiet * 2) * scale;
const data = new Uint8ClampedArray(dim * dim * 4);
// 先整体填白(含静区)。
data.fill(255);
for (let row = 0; row < size; row += 1) {
for (let col = 0; col < size; col += 1) {
if (!modules[row * size + col]) continue; // 0 = 浅色,跳过
const x0 = (col + quiet) * scale;
const y0 = (row + quiet) * scale;
for (let dy = 0; dy < scale; dy += 1) {
for (let dx = 0; dx < scale; dx += 1) {
const idx = ((y0 + dy) * dim + (x0 + dx)) * 4;
data[idx] = 0;
data[idx + 1] = 0;
data[idx + 2] = 0;
data[idx + 3] = 255;
}
}
}
}
return { data, width: dim, height: dim };
}
describe("qrscan", () => {
// 4-1 网址校验:接受 http/https拒绝其它内容。
it("4-1 extractUrl 仅接受 http/https 网址", () => {
expect(extractUrl("https://example.com/inv?id=1")).toBe(
"https://example.com/inv?id=1"
);
expect(extractUrl(" http://a.b/c ")).toBe("http://a.b/c");
expect(extractUrl("ftp://x")).toBe("");
expect(extractUrl("hello")).toBe("");
expect(extractUrl(null)).toBe("");
});
// 4-2 从生成的清晰二维码像素中解码,验证扫描核心逻辑正确。
it("4-2 decodeQrFromImageData 能识别清晰二维码", () => {
const url = "https://fapiao.example.com/open?id=20240701";
const imageData = renderQrImageData(url);
expect(decodeQrFromImageData(imageData)).toBe(url);
});
// 4-3 无法识别时返回 null而不是抛错。
it("4-3 无二维码时返回 null", () => {
const blank = {
data: new Uint8ClampedArray(64 * 64 * 4).fill(255),
width: 64,
height: 64,
};
expect(decodeQrFromImageData(blank)).toBeNull();
expect(decodeQrFromImageData(null)).toBeNull();
});
});