From 8d194f8fa2a782285dd6ade4ef0db55c33c16f86 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 25 Dec 2025 18:13:28 +0800 Subject: [PATCH 01/12] feat(scanner): implement high-performance QR scanning with Web Worker - Add QRScanner class with Web Worker for background decoding - Add MockFrameSource supporting images, videos, and image sequences - Add comprehensive reliability tests with QR transformation - Update Scanner page to use new async QRScanner - Enhance camera mock with frame source support - Add Storybook stories for testing and demo --- package.json | 2 + pnpm-lock.yaml | 143 ++++++- .../qr-scanner/__tests__/qr-scanner.test.ts | 108 ++++++ .../qr-scanner/__tests__/reliability.test.ts | 276 +++++++++++++ src/lib/qr-scanner/index.ts | 244 ++++++++++++ src/lib/qr-scanner/mock-frame-source.ts | 361 ++++++++++++++++++ src/lib/qr-scanner/qr-scanner.stories.tsx | 310 +++++++++++++++ src/lib/qr-scanner/test-utils.ts | 301 +++++++++++++++ src/lib/qr-scanner/types.ts | 80 ++++ src/lib/qr-scanner/worker.ts | 83 ++++ src/pages/scanner/index.tsx | 77 +++- src/services/camera/mock.ts | 90 ++++- 12 files changed, 2054 insertions(+), 21 deletions(-) create mode 100644 src/lib/qr-scanner/__tests__/qr-scanner.test.ts create mode 100644 src/lib/qr-scanner/__tests__/reliability.test.ts create mode 100644 src/lib/qr-scanner/index.ts create mode 100644 src/lib/qr-scanner/mock-frame-source.ts create mode 100644 src/lib/qr-scanner/qr-scanner.stories.tsx create mode 100644 src/lib/qr-scanner/test-utils.ts create mode 100644 src/lib/qr-scanner/types.ts create mode 100644 src/lib/qr-scanner/worker.ts diff --git a/package.json b/package.json index 9313cf16..49809412 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "@testing-library/user-event": "^14.6.1", "@types/big.js": "^6.2.2", "@types/node": "^24.10.1", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/semver": "^7.7.1", @@ -132,6 +133,7 @@ "playwright": "^1.57.0", "prettier": "^3.7.4", "prettier-plugin-tailwindcss": "^0.7.2", + "qrcode": "^1.5.4", "rollup-plugin-visualizer": "^6.0.5", "semver": "^7.7.3", "shadcn": "^3.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f03e2f9..be9b83ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@types/node': specifier: ^24.10.1 version: 24.10.4 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.0.0 version: 19.2.7 @@ -246,6 +249,9 @@ importers: prettier-plugin-tailwindcss: specifier: ^0.7.2 version: 0.7.2(prettier@3.7.4) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 rollup-plugin-visualizer: specifier: ^6.0.5 version: 6.0.5(rollup@4.54.0) @@ -275,7 +281,7 @@ importers: version: 0.10.4 vitepress: specifier: ^1.6.4 - version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@24.10.4)(@types/react@19.2.7)(axios@1.12.2)(lightningcss@1.30.2)(postcss@8.5.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.46.2)(@types/node@24.10.4)(@types/react@19.2.7)(axios@1.12.2)(lightningcss@1.30.2)(postcss@8.5.6)(qrcode@1.5.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(search-insights@2.17.3)(typescript@5.9.3) vitest: specifier: ^4.0.15 version: 4.0.16(@types/node@24.10.4)(@vitest/browser-playwright@4.0.16)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(msw@2.12.4(@types/node@24.10.4)(typescript@5.9.3)) @@ -2522,6 +2528,9 @@ packages: '@types/pbkdf2@3.1.2': resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3047,6 +3056,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001761: resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} @@ -3126,6 +3139,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -3323,6 +3339,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -3391,6 +3411,9 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -3677,6 +3700,10 @@ packages: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -4324,6 +4351,10 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -4648,6 +4679,10 @@ packages: resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} engines: {node: '>=6'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -4702,6 +4737,10 @@ packages: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4780,6 +4819,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -4900,6 +4943,11 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -5066,6 +5114,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requireindex@1.1.0: resolution: {integrity: sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==} engines: {node: '>=0.10.5'} @@ -5201,6 +5252,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -6026,6 +6080,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -6135,6 +6192,9 @@ packages: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6145,6 +6205,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -6157,6 +6221,10 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -8698,6 +8766,10 @@ snapshots: dependencies: '@types/node': 24.10.4 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.10.4 + '@types/react-dom@19.2.3(@types/react@19.2.7)': dependencies: '@types/react': 19.2.7 @@ -9030,7 +9102,7 @@ snapshots: transitivePeerDependencies: - typescript - '@vueuse/integrations@12.8.2(axios@1.12.2)(focus-trap@7.7.0)(typescript@5.9.3)': + '@vueuse/integrations@12.8.2(axios@1.12.2)(focus-trap@7.7.0)(qrcode@1.5.4)(typescript@5.9.3)': dependencies: '@vueuse/core': 12.8.2(typescript@5.9.3) '@vueuse/shared': 12.8.2(typescript@5.9.3) @@ -9038,6 +9110,7 @@ snapshots: optionalDependencies: axios: 1.12.2 focus-trap: 7.7.0 + qrcode: 1.5.4 transitivePeerDependencies: - typescript @@ -9322,6 +9395,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001761: {} caseless@0.12.0: {} @@ -9409,6 +9484,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -9593,6 +9674,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} dedent@1.7.1: {} @@ -9636,6 +9719,8 @@ snapshots: diff@8.0.2: {} + dijkstrajs@1.0.3: {} + doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -10085,6 +10170,11 @@ snapshots: dependencies: locate-path: 3.0.0 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + flatted@3.3.3: {} focus-trap@7.7.0: @@ -10676,6 +10766,10 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash@4.17.21: {} log-symbols@6.0.0: @@ -10997,6 +11091,10 @@ snapshots: dependencies: p-limit: 2.3.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -11040,6 +11138,8 @@ snapshots: path-exists@3.0.0: {} + path-exists@4.0.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -11103,6 +11203,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + pngjs@7.0.0: {} possible-typed-array-names@1.1.0: {} @@ -11164,6 +11266,12 @@ snapshots: dependencies: react: 19.2.3 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -11353,6 +11461,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requireindex@1.1.0: {} requires-port@1.0.0: {} @@ -11509,6 +11619,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -12162,7 +12274,7 @@ snapshots: jiti: 2.6.1 lightningcss: 1.30.2 - vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@24.10.4)(@types/react@19.2.7)(axios@1.12.2)(lightningcss@1.30.2)(postcss@8.5.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(search-insights@2.17.3)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.46.2)(@types/node@24.10.4)(@types/react@19.2.7)(axios@1.12.2)(lightningcss@1.30.2)(postcss@8.5.6)(qrcode@1.5.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(search-insights@2.17.3)(typescript@5.9.3): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.46.2)(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(search-insights@2.17.3) @@ -12175,7 +12287,7 @@ snapshots: '@vue/devtools-api': 7.7.9 '@vue/shared': 3.5.26 '@vueuse/core': 12.8.2(typescript@5.9.3) - '@vueuse/integrations': 12.8.2(axios@1.12.2)(focus-trap@7.7.0)(typescript@5.9.3) + '@vueuse/integrations': 12.8.2(axios@1.12.2)(focus-trap@7.7.0)(qrcode@1.5.4)(typescript@5.9.3) focus-trap: 7.7.0 mark.js: 8.11.1 minisearch: 7.2.0 @@ -12501,6 +12613,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -12575,18 +12689,39 @@ snapshots: xmlhttprequest-ssl@2.1.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} yallist@4.0.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@16.2.0: dependencies: cliui: 7.0.4 diff --git a/src/lib/qr-scanner/__tests__/qr-scanner.test.ts b/src/lib/qr-scanner/__tests__/qr-scanner.test.ts new file mode 100644 index 00000000..df8504d9 --- /dev/null +++ b/src/lib/qr-scanner/__tests__/qr-scanner.test.ts @@ -0,0 +1,108 @@ +/** + * QR Scanner 单元测试 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { QRScanner, createQRScanner } from '../index' + +/** 创建模拟的 ImageData(jsdom 中不可用) */ +function createMockImageData(width: number, height: number): ImageData { + const data = new Uint8ClampedArray(width * height * 4) + return { + data, + width, + height, + colorSpace: 'srgb', + } as ImageData +} + +describe('QRScanner', () => { + let scanner: QRScanner + + beforeEach(() => { + // 使用主线程模式(Worker 在 jsdom 中不可用) + scanner = createQRScanner({ useWorker: false }) + }) + + afterEach(() => { + scanner.destroy() + }) + + describe('initialization', () => { + it('should create scanner instance', () => { + expect(scanner).toBeInstanceOf(QRScanner) + }) + + it('should be ready immediately in non-worker mode', async () => { + await expect(scanner.waitReady()).resolves.toBeUndefined() + }) + + it('should expose isReady getter', () => { + expect(scanner.isReady).toBe(true) + }) + }) + + describe('scan', () => { + it('should return null for empty ImageData', async () => { + const imageData = createMockImageData(100, 100) + const result = await scanner.scan(imageData) + expect(result).toBeNull() + }) + + it('should return null for random noise image', async () => { + const imageData = createMockImageData(100, 100) + // 填充随机数据 + for (let i = 0; i < imageData.data.length; i += 4) { + imageData.data[i] = Math.random() * 255 + imageData.data[i + 1] = Math.random() * 255 + imageData.data[i + 2] = Math.random() * 255 + imageData.data[i + 3] = 255 + } + const result = await scanner.scan(imageData) + expect(result).toBeNull() + }) + }) + + describe('scanBatch', () => { + it('should handle empty batch', async () => { + const results = await scanner.scanBatch([]) + expect(results).toEqual([]) + }) + + it('should return results for each frame', async () => { + const frames = [ + createMockImageData(100, 100), + createMockImageData(100, 100), + createMockImageData(100, 100), + ] + const results = await scanner.scanBatch(frames) + expect(results).toHaveLength(3) + expect(results.every(r => r === null)).toBe(true) + }) + }) + + describe('destroy', () => { + it('should clean up resources', () => { + scanner.destroy() + // 应该能够多次调用 destroy + expect(() => scanner.destroy()).not.toThrow() + }) + }) +}) + +describe('createQRScanner', () => { + it('should create scanner with default config', () => { + const scanner = createQRScanner() + expect(scanner).toBeInstanceOf(QRScanner) + scanner.destroy() + }) + + it('should create scanner with custom config', () => { + const scanner = createQRScanner({ + scanInterval: 200, + useWorker: false, + }) + expect(scanner).toBeInstanceOf(QRScanner) + scanner.destroy() + }) +}) diff --git a/src/lib/qr-scanner/__tests__/reliability.test.ts b/src/lib/qr-scanner/__tests__/reliability.test.ts new file mode 100644 index 00000000..29641928 --- /dev/null +++ b/src/lib/qr-scanner/__tests__/reliability.test.ts @@ -0,0 +1,276 @@ +/** + * QR Scanner 可靠性测试 + * + * 这些测试需要在浏览器环境中运行(通过 Storybook/Playwright) + * 因为需要真实的 Canvas 支持来生成和变换 QR 码 + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { QRScanner, createQRScanner } from '../index' +import { + generateQRImageData, + generateTransformedQR, + getCanvasImageData, + runReliabilityTest, + STANDARD_TEST_CASES, +} from '../test-utils' + +// 跳过这些测试在 jsdom 环境中(Canvas 功能有限) +// 这些测试会在 Storybook 测试中运行 +const isJsdom = typeof navigator !== 'undefined' && navigator.userAgent.includes('jsdom') + +describe.skipIf(isJsdom)('QR Scanner Reliability', () => { + let scanner: QRScanner + + beforeAll(async () => { + scanner = createQRScanner({ useWorker: false }) + await scanner.waitReady() + }) + + afterAll(() => { + scanner.destroy() + }) + + describe('Basic QR Detection', () => { + it('should detect simple text QR', async () => { + const imageData = await generateQRImageData('Hello World') + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe('Hello World') + expect(result!.duration).toBeGreaterThan(0) + }) + + it('should detect URL QR', async () => { + const url = 'https://example.com/path?query=value' + const imageData = await generateQRImageData(url) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe(url) + }) + + it('should detect Ethereum address QR', async () => { + const address = 'ethereum:0x1234567890123456789012345678901234567890' + const imageData = await generateQRImageData(address) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe(address) + }) + + it('should detect Bitcoin address QR', async () => { + const address = 'bitcoin:1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2' + const imageData = await generateQRImageData(address) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe(address) + }) + }) + + describe('Error Correction Levels', () => { + const levels = ['L', 'M', 'Q', 'H'] as const + + for (const level of levels) { + it(`should detect QR with ECC level ${level}`, async () => { + const imageData = await generateQRImageData(`ECC Level ${level}`, { + errorCorrectionLevel: level, + }) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe(`ECC Level ${level}`) + }) + } + }) + + describe('Different Sizes', () => { + const sizes = [100, 150, 200, 300, 400] + + for (const size of sizes) { + it(`should detect QR at size ${size}px`, async () => { + const imageData = await generateQRImageData(`Size ${size}`, { width: size }) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe(`Size ${size}`) + }) + } + }) + + describe('Transformations', () => { + describe('Scale', () => { + const scales = [0.5, 0.75, 1.0, 1.25, 1.5] + + for (const scale of scales) { + it(`should detect QR at scale ${scale}`, async () => { + const canvas = await generateTransformedQR( + `Scale ${scale}`, + { width: 200 }, + { scale } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe(`Scale ${scale}`) + }) + } + }) + + describe('Rotation', () => { + const angles = [0, 15, 30, 45, 90, 180, 270] + + for (const rotate of angles) { + it(`should detect QR rotated ${rotate}°`, async () => { + const canvas = await generateTransformedQR( + `Rotate ${rotate}`, + { width: 200, errorCorrectionLevel: 'H' }, + { rotate } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe(`Rotate ${rotate}`) + }) + } + }) + + describe('Noise', () => { + it('should detect QR with low noise (10)', async () => { + const canvas = await generateTransformedQR( + 'Low Noise', + { width: 200, errorCorrectionLevel: 'H' }, + { noise: 10 } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe('Low Noise') + }) + + it('should detect QR with medium noise (20)', async () => { + const canvas = await generateTransformedQR( + 'Medium Noise', + { width: 200, errorCorrectionLevel: 'H' }, + { noise: 20 } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe('Medium Noise') + }) + }) + + describe('Blur', () => { + it('should detect QR with slight blur (1)', async () => { + const canvas = await generateTransformedQR( + 'Slight Blur', + { width: 300, errorCorrectionLevel: 'H' }, + { blur: 1 } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe('Slight Blur') + }) + }) + + describe('Brightness and Contrast', () => { + it('should detect dark QR (brightness -30)', async () => { + const canvas = await generateTransformedQR( + 'Dark Image', + { width: 200 }, + { brightness: -30 } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe('Dark Image') + }) + + it('should detect bright QR (brightness +30)', async () => { + const canvas = await generateTransformedQR( + 'Bright Image', + { width: 200 }, + { brightness: 30 } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe('Bright Image') + }) + + it('should detect low contrast QR', async () => { + const canvas = await generateTransformedQR( + 'Low Contrast', + { width: 200, errorCorrectionLevel: 'H' }, + { contrast: 0.7 } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe('Low Contrast') + }) + }) + }) + + describe('Combined Transformations', () => { + it('should detect QR with easy combined transforms', async () => { + const canvas = await generateTransformedQR( + 'Easy Combined', + { width: 200, errorCorrectionLevel: 'H' }, + { scale: 0.8, rotate: 5, noise: 5 } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe('Easy Combined') + }) + + it('should detect QR with medium combined transforms', async () => { + const canvas = await generateTransformedQR( + 'Medium Combined', + { width: 300, errorCorrectionLevel: 'H' }, + { scale: 0.7, rotate: 10, noise: 10 } + ) + const imageData = getCanvasImageData(canvas) + const result = await scanner.scan(imageData) + + expect(result).not.toBeNull() + expect(result!.content).toBe('Medium Combined') + }) + }) + + describe('Full Reliability Report', () => { + it('should pass at least 80% of standard test cases', async () => { + const report = await runReliabilityTest(scanner, STANDARD_TEST_CASES) + + console.log('=== QR Scanner Reliability Report ===') + console.log(`Pass Rate: ${(report.passRate * 100).toFixed(1)}%`) + console.log(`Passed: ${report.passed}/${report.totalCases}`) + console.log(`Average Scan Time: ${report.avgScanTime.toFixed(2)}ms`) + console.log('') + + // 打印失败的用例 + const failed = report.results.filter(r => !r.passed) + if (failed.length > 0) { + console.log('Failed cases:') + for (const f of failed) { + console.log(` - ${f.name}: expected "${f.expectedContent}", got "${f.actualContent}"`) + } + } + + expect(report.passRate).toBeGreaterThanOrEqual(0.8) + }) + }) +}) diff --git a/src/lib/qr-scanner/index.ts b/src/lib/qr-scanner/index.ts new file mode 100644 index 00000000..49bbf79b --- /dev/null +++ b/src/lib/qr-scanner/index.ts @@ -0,0 +1,244 @@ +/** + * QR Scanner - 高性能二维码扫描器 + * + * 特性: + * - Web Worker 后台解码,不阻塞 UI + * - 支持单帧和批量扫描 + * - 自动管理 Worker 生命周期 + */ + +import type { ScanResult, ScannerConfig, WorkerMessage, WorkerResponse } from './types' + +export type { ScanResult, ScannerConfig, FrameSource, MockFrameSourceConfig } from './types' + +/** 默认配置 */ +const DEFAULT_CONFIG: Required = { + scanInterval: 100, + useWorker: true, + workerCount: 1, +} + +/** 请求 ID 计数器 */ +let requestId = 0 + +/** 待处理的请求回调 */ +type PendingCallback = { + resolve: (result: ScanResult | null) => void + reject: (error: Error) => void +} + +type BatchPendingCallback = { + resolve: (results: (ScanResult | null)[]) => void + reject: (error: Error) => void +} + +/** + * QR Scanner 类 + */ +export class QRScanner { + private worker: Worker | null = null + private config: Required + private pendingRequests = new Map() + private batchPendingRequests = new Map() + private _ready = false + private readyPromise: Promise + private readyResolve: (() => void) | null = null + + constructor(config: ScannerConfig = {}) { + this.config = { ...DEFAULT_CONFIG, ...config } + + this.readyPromise = new Promise((resolve) => { + this.readyResolve = resolve + }) + + if (this.config.useWorker && typeof Worker !== 'undefined') { + this.initWorker() + } else { + this._ready = true + this.readyResolve?.() + } + } + + /** 初始化 Worker */ + private initWorker() { + this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) + + this.worker.onmessage = (event: MessageEvent) => { + const response = event.data + + switch (response.type) { + case 'ready': + this._ready = true + this.readyResolve?.() + break + + case 'result': { + const callback = this.pendingRequests.get(response.id) + if (callback) { + this.pendingRequests.delete(response.id) + if (response.error) { + callback.reject(new Error(response.error)) + } else { + callback.resolve(response.result) + } + } + break + } + + case 'batchResult': { + const callback = this.batchPendingRequests.get(response.id) + if (callback) { + this.batchPendingRequests.delete(response.id) + if (response.error) { + callback.reject(new Error(response.error)) + } else { + callback.resolve(response.results) + } + } + break + } + } + } + + this.worker.onerror = (error) => { + console.error('[QRScanner] Worker error:', error) + // 回退到主线程模式 + this.worker?.terminate() + this.worker = null + this._ready = true + this.readyResolve?.() + } + } + + /** 检查 Scanner 是否就绪 */ + get isReady(): boolean { + return this._ready + } + + /** 等待 Scanner 就绪 */ + async waitReady(): Promise { + return this.readyPromise + } + + /** 扫描单帧 ImageData */ + async scan(imageData: ImageData): Promise { + await this.readyPromise + + if (this.worker) { + return this.scanWithWorker(imageData) + } + return this.scanMainThread(imageData) + } + + /** 通过 Worker 扫描 */ + private scanWithWorker(imageData: ImageData): Promise { + return new Promise((resolve, reject) => { + const id = ++requestId + this.pendingRequests.set(id, { resolve, reject }) + + const message: WorkerMessage = { type: 'scan', id, imageData } + this.worker!.postMessage(message, [imageData.data.buffer]) + }) + } + + /** 主线程扫描(降级方案) */ + private async scanMainThread(imageData: ImageData): Promise { + const { default: jsQR } = await import('jsqr') + const start = performance.now() + + const result = jsQR(imageData.data, imageData.width, imageData.height, { + inversionAttempts: 'dontInvert', + }) + + if (!result) return null + + return { + content: result.data, + duration: performance.now() - start, + location: { + topLeftCorner: result.location.topLeftCorner, + topRightCorner: result.location.topRightCorner, + bottomLeftCorner: result.location.bottomLeftCorner, + bottomRightCorner: result.location.bottomRightCorner, + }, + } + } + + /** 批量扫描多帧 */ + async scanBatch(frames: ImageData[]): Promise<(ScanResult | null)[]> { + await this.readyPromise + + if (this.worker && frames.length > 0) { + return this.scanBatchWithWorker(frames) + } + return Promise.all(frames.map(f => this.scanMainThread(f))) + } + + /** 通过 Worker 批量扫描 */ + private scanBatchWithWorker(frames: ImageData[]): Promise<(ScanResult | null)[]> { + return new Promise((resolve, reject) => { + const id = ++requestId + this.batchPendingRequests.set(id, { resolve, reject }) + + const message: WorkerMessage = { type: 'scanBatch', id, frames } + const transfers = frames.map(f => f.data.buffer) + this.worker!.postMessage(message, transfers) + }) + } + + /** 从 Video 元素扫描 */ + async scanFromVideo(video: HTMLVideoElement, canvas?: HTMLCanvasElement): Promise { + const width = video.videoWidth + const height = video.videoHeight + + if (width === 0 || height === 0) return null + + const cvs = canvas ?? document.createElement('canvas') + cvs.width = width + cvs.height = height + + const ctx = cvs.getContext('2d', { willReadFrequently: true }) + if (!ctx) return null + + ctx.drawImage(video, 0, 0, width, height) + const imageData = ctx.getImageData(0, 0, width, height) + + return this.scan(imageData) + } + + /** 从 Canvas 扫描 */ + async scanFromCanvas(canvas: HTMLCanvasElement): Promise { + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) return null + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) + return this.scan(imageData) + } + + /** 销毁 Scanner,释放资源 */ + destroy() { + if (this.worker) { + const message: WorkerMessage = { type: 'terminate' } + this.worker.postMessage(message) + this.worker.terminate() + this.worker = null + } + this.pendingRequests.clear() + this.batchPendingRequests.clear() + } +} + +/** 创建 Scanner 实例 */ +export function createQRScanner(config?: ScannerConfig): QRScanner { + return new QRScanner(config) +} + +// 导出默认单例(按需使用) +let defaultScanner: QRScanner | null = null + +export function getDefaultScanner(): QRScanner { + if (!defaultScanner) { + defaultScanner = new QRScanner() + } + return defaultScanner +} diff --git a/src/lib/qr-scanner/mock-frame-source.ts b/src/lib/qr-scanner/mock-frame-source.ts new file mode 100644 index 00000000..79e2f803 --- /dev/null +++ b/src/lib/qr-scanner/mock-frame-source.ts @@ -0,0 +1,361 @@ +/** + * Mock Frame Source - 模拟相机帧输入 + * + * 支持三种输入模式: + * 1. 单张图片 - 持续返回同一帧 + * 2. 多张图片序列 - 按顺序返回帧 + * 3. 视频 - 从视频中采样帧 + */ + +import type { FrameSource, MockFrameSourceConfig } from './types' + +/** 加载图片 */ +async function loadImage(source: string | Blob): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + const url = source instanceof Blob ? URL.createObjectURL(source) : source + + img.onload = () => { + if (source instanceof Blob) { + URL.revokeObjectURL(url) + } + resolve(img) + } + + img.onerror = () => { + if (source instanceof Blob) { + URL.revokeObjectURL(url) + } + reject(new Error('Failed to load image')) + } + + img.crossOrigin = 'anonymous' + img.src = url + }) +} + +/** 加载视频 */ +async function loadVideo(source: string | Blob): Promise { + return new Promise((resolve, reject) => { + const video = document.createElement('video') + const url = source instanceof Blob ? URL.createObjectURL(source) : source + + video.onloadedmetadata = () => { + if (source instanceof Blob) { + URL.revokeObjectURL(url) + } + resolve(video) + } + + video.onerror = () => { + if (source instanceof Blob) { + URL.revokeObjectURL(url) + } + reject(new Error('Failed to load video')) + } + + video.crossOrigin = 'anonymous' + video.muted = true + video.playsInline = true + video.src = url + video.load() + }) +} + +/** + * 单图片帧源 + */ +export class ImageFrameSource implements FrameSource { + private canvas: HTMLCanvasElement + private ctx: CanvasRenderingContext2D + private imageData: ImageData | null = null + + readonly width: number + readonly height: number + + constructor(image: HTMLImageElement) { + this.width = image.naturalWidth + this.height = image.naturalHeight + + this.canvas = document.createElement('canvas') + this.canvas.width = this.width + this.canvas.height = this.height + + this.ctx = this.canvas.getContext('2d', { willReadFrequently: true })! + this.ctx.drawImage(image, 0, 0) + this.imageData = this.ctx.getImageData(0, 0, this.width, this.height) + } + + static async create(source: string | Blob): Promise { + const image = await loadImage(source) + return new ImageFrameSource(image) + } + + getFrame(): ImageData | null { + return this.imageData + } + + hasNextFrame(): boolean { + return false // 单图片没有下一帧 + } + + async nextFrame(): Promise { + return false + } + + reset(): void { + // 单图片不需要重置 + } + + destroy(): void { + this.imageData = null + } +} + +/** + * 图片序列帧源 + */ +export class ImageSequenceFrameSource implements FrameSource { + private images: HTMLImageElement[] + private currentIndex = 0 + private canvas: HTMLCanvasElement + private ctx: CanvasRenderingContext2D + + readonly width: number + readonly height: number + + constructor(images: HTMLImageElement[], private frameInterval: number = 100) { + if (images.length === 0) { + throw new Error('At least one image required') + } + + this.images = images + const firstImage = images[0]! + this.width = firstImage.naturalWidth + this.height = firstImage.naturalHeight + + this.canvas = document.createElement('canvas') + this.canvas.width = this.width + this.canvas.height = this.height + + this.ctx = this.canvas.getContext('2d', { willReadFrequently: true })! + } + + static async create(sources: (string | Blob)[], frameInterval?: number): Promise { + const images = await Promise.all(sources.map(loadImage)) + return new ImageSequenceFrameSource(images, frameInterval) + } + + getFrame(): ImageData | null { + const image = this.images[this.currentIndex] + if (!image) return null + + this.ctx.clearRect(0, 0, this.width, this.height) + this.ctx.drawImage(image, 0, 0) + return this.ctx.getImageData(0, 0, this.width, this.height) + } + + hasNextFrame(): boolean { + return this.currentIndex < this.images.length - 1 + } + + async nextFrame(): Promise { + if (!this.hasNextFrame()) return false + + await new Promise(resolve => setTimeout(resolve, this.frameInterval)) + this.currentIndex++ + return true + } + + reset(): void { + this.currentIndex = 0 + } + + destroy(): void { + this.images = [] + } +} + +/** + * 视频帧源 + */ +export class VideoFrameSource implements FrameSource { + private canvas: HTMLCanvasElement + private ctx: CanvasRenderingContext2D + private frameDuration: number + + readonly width: number + readonly height: number + + constructor(private video: HTMLVideoElement, frameRate: number = 10) { + this.width = video.videoWidth + this.height = video.videoHeight + this.frameDuration = 1000 / frameRate + + this.canvas = document.createElement('canvas') + this.canvas.width = this.width + this.canvas.height = this.height + + this.ctx = this.canvas.getContext('2d', { willReadFrequently: true })! + } + + static async create(source: string | Blob, frameRate?: number): Promise { + const video = await loadVideo(source) + return new VideoFrameSource(video, frameRate) + } + + getFrame(): ImageData | null { + if (this.video.readyState < 2) return null + + this.ctx.drawImage(this.video, 0, 0) + return this.ctx.getImageData(0, 0, this.width, this.height) + } + + hasNextFrame(): boolean { + return !this.video.ended && this.video.currentTime < this.video.duration + } + + async nextFrame(): Promise { + if (!this.hasNextFrame()) return false + + return new Promise((resolve) => { + const targetTime = this.video.currentTime + this.frameDuration / 1000 + + if (targetTime >= this.video.duration) { + resolve(false) + return + } + + const onSeeked = () => { + this.video.removeEventListener('seeked', onSeeked) + resolve(true) + } + + this.video.addEventListener('seeked', onSeeked) + this.video.currentTime = targetTime + }) + } + + reset(): void { + this.video.currentTime = 0 + } + + destroy(): void { + this.video.pause() + this.video.src = '' + } + + /** 开始播放(实时模式) */ + async play(): Promise { + await this.video.play() + } + + /** 暂停播放 */ + pause(): void { + this.video.pause() + } +} + +/** + * 根据配置创建帧源 + */ +export async function createFrameSource(config: MockFrameSourceConfig): Promise { + if (config.video) { + return VideoFrameSource.create(config.video, config.frameRate) + } + + if (config.images && config.images.length > 0) { + return ImageSequenceFrameSource.create(config.images, config.frameInterval) + } + + if (config.image) { + return ImageFrameSource.create(config.image) + } + + throw new Error('No valid source provided in config') +} + +/** + * Mock Camera Component - 渲染帧源到 Canvas + * 提供类似真实相机的接口 + */ +export class MockCameraView { + private canvas: HTMLCanvasElement + private ctx: CanvasRenderingContext2D + private animationFrameId: number | null = null + private isRunning = false + + constructor( + private frameSource: FrameSource, + targetCanvas?: HTMLCanvasElement + ) { + this.canvas = targetCanvas ?? document.createElement('canvas') + this.canvas.width = frameSource.width + this.canvas.height = frameSource.height + this.ctx = this.canvas.getContext('2d', { willReadFrequently: true })! + } + + /** 获取 Canvas 元素 */ + getCanvas(): HTMLCanvasElement { + return this.canvas + } + + /** 获取当前帧数据 */ + getFrameData(): ImageData | null { + return this.frameSource.getFrame() + } + + /** 开始渲染循环 */ + start(onFrame?: (imageData: ImageData | null) => void): void { + if (this.isRunning) return + this.isRunning = true + + const render = () => { + if (!this.isRunning) return + + const frame = this.frameSource.getFrame() + if (frame) { + this.ctx.putImageData(frame, 0, 0) + onFrame?.(frame) + } + + this.animationFrameId = requestAnimationFrame(render) + } + + render() + } + + /** 停止渲染循环 */ + stop(): void { + this.isRunning = false + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId) + this.animationFrameId = null + } + } + + /** 单步渲染(手动控制) */ + renderFrame(): ImageData | null { + const frame = this.frameSource.getFrame() + if (frame) { + this.ctx.putImageData(frame, 0, 0) + } + return frame + } + + /** 前进到下一帧 */ + async nextFrame(): Promise { + return this.frameSource.nextFrame() + } + + /** 重置 */ + reset(): void { + this.frameSource.reset() + } + + /** 销毁 */ + destroy(): void { + this.stop() + this.frameSource.destroy() + } +} diff --git a/src/lib/qr-scanner/qr-scanner.stories.tsx b/src/lib/qr-scanner/qr-scanner.stories.tsx new file mode 100644 index 00000000..6ebe6b5d --- /dev/null +++ b/src/lib/qr-scanner/qr-scanner.stories.tsx @@ -0,0 +1,310 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState, useEffect, useRef } from 'react' +import { QRScanner, createQRScanner } from './index' +import { generateQRCanvas, generateTransformedQR, getCanvasImageData, runReliabilityTest, STANDARD_TEST_CASES } from './test-utils' +import type { ScanResult } from './types' + +const meta: Meta = { + title: 'Lib/QRScanner', + parameters: { + layout: 'centered', + }, +} + +export default meta + +/** 基础扫描演示 */ +export const BasicScan: StoryObj = { + render: () => { + const [content, setContent] = useState('Hello World') + const [scanResult, setScanResult] = useState(null) + const [scanning, setScanning] = useState(false) + const [qrCanvas, setQrCanvas] = useState(null) + const canvasContainerRef = useRef(null) + const scannerRef = useRef(null) + + useEffect(() => { + scannerRef.current = createQRScanner({ useWorker: true }) + return () => { + scannerRef.current?.destroy() + } + }, []) + + useEffect(() => { + generateQRCanvas(content, { width: 200 }).then(setQrCanvas) + }, [content]) + + useEffect(() => { + if (qrCanvas && canvasContainerRef.current) { + canvasContainerRef.current.innerHTML = '' + canvasContainerRef.current.appendChild(qrCanvas) + } + }, [qrCanvas]) + + const handleScan = async () => { + if (!qrCanvas || !scannerRef.current) return + setScanning(true) + setScanResult(null) + + const imageData = getCanvasImageData(qrCanvas) + const result = await scannerRef.current.scan(imageData) + + setScanResult(result) + setScanning(false) + } + + return ( +
+

QR Scanner 基础演示

+ +
+ setContent(e.target.value)} + className="rounded border px-3 py-2" + placeholder="输入 QR 内容" + /> +
+ +
+ + + + {scanResult && ( +
+

扫描成功!

+

内容: {scanResult.content}

+

耗时: {scanResult.duration.toFixed(2)}ms

+
+ )} +
+ ) + }, +} + +/** 变换测试 */ +export const TransformTest: StoryObj = { + render: () => { + const [scale, setScale] = useState(1) + const [rotate, setRotate] = useState(0) + const [noise, setNoise] = useState(0) + const [blur, setBlur] = useState(0) + const [scanResult, setScanResult] = useState(null) + const [qrCanvas, setQrCanvas] = useState(null) + const canvasContainerRef = useRef(null) + const scannerRef = useRef(null) + + const content = 'Transform Test QR' + + useEffect(() => { + scannerRef.current = createQRScanner({ useWorker: true }) + return () => { + scannerRef.current?.destroy() + } + }, []) + + useEffect(() => { + generateTransformedQR( + content, + { width: 200, errorCorrectionLevel: 'H' }, + { scale, rotate, noise, blur } + ).then(setQrCanvas) + }, [scale, rotate, noise, blur]) + + useEffect(() => { + if (qrCanvas && canvasContainerRef.current) { + canvasContainerRef.current.innerHTML = '' + canvasContainerRef.current.appendChild(qrCanvas) + } + }, [qrCanvas]) + + const handleScan = async () => { + if (!qrCanvas || !scannerRef.current) return + setScanResult(null) + + const imageData = getCanvasImageData(qrCanvas) + const result = await scannerRef.current.scan(imageData) + setScanResult(result) + } + + return ( +
+

QR 变换测试

+ +
+ + + + +
+ +
+ + + + {scanResult ? ( +
+

✓ 识别成功

+

耗时: {scanResult.duration.toFixed(2)}ms

+
+ ) : ( +
+

点击扫描按钮测试

+
+ )} +
+ ) + }, +} + +/** 可靠性测试报告 */ +export const ReliabilityReport: StoryObj = { + render: () => { + const [running, setRunning] = useState(false) + const [report, setReport] = useState> | null>(null) + const scannerRef = useRef(null) + + useEffect(() => { + scannerRef.current = createQRScanner({ useWorker: true }) + return () => { + scannerRef.current?.destroy() + } + }, []) + + const handleRunTest = async () => { + if (!scannerRef.current) return + setRunning(true) + setReport(null) + + await scannerRef.current.waitReady() + const result = await runReliabilityTest(scannerRef.current, STANDARD_TEST_CASES) + setReport(result) + setRunning(false) + } + + return ( +
+

QR Scanner 可靠性测试

+ + + + {report && ( +
+
+
+

{report.totalCases}

+

总用例

+
+
+

{report.passed}

+

通过

+
+
+

{report.failed}

+

失败

+
+
= 0.8 ? 'bg-green-100' : 'bg-yellow-100'}`}> +

{(report.passRate * 100).toFixed(1)}%

+

通过率

+
+
+ +
+
测试结果详情
+
+ + + + + + + + + + {report.results.map((r, i) => ( + + + + + + ))} + +
用例状态耗时
{r.name} + {r.passed ? ( + + ) : ( + + )} + + {r.scanTime ? `${r.scanTime.toFixed(2)}ms` : '-'} +
+
+
+ +
+ 平均扫描时间: {report.avgScanTime.toFixed(2)}ms +
+
+ )} +
+ ) + }, +} diff --git a/src/lib/qr-scanner/test-utils.ts b/src/lib/qr-scanner/test-utils.ts new file mode 100644 index 00000000..2d73f0d0 --- /dev/null +++ b/src/lib/qr-scanner/test-utils.ts @@ -0,0 +1,301 @@ +/** + * QR Scanner 测试工具 + * 用于生成和变换 QR 码图片进行可靠性测试 + */ + +import QRCode from 'qrcode' + +/** QR 码生成选项 */ +export interface QRGenerateOptions { + /** 容错级别 */ + errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H' + /** 图片尺寸 */ + width?: number + /** 边距 */ + margin?: number + /** 前景色 */ + color?: { dark: string; light: string } +} + +/** 图像变换选项 */ +export interface TransformOptions { + /** 缩放比例 (0.1 - 2.0) */ + scale?: number + /** 旋转角度 (度) */ + rotate?: number + /** 高斯噪声强度 (0 - 50) */ + noise?: number + /** 模糊半径 (0 - 10) */ + blur?: number + /** 亮度调整 (-100 to 100) */ + brightness?: number + /** 对比度调整 (0 - 2) */ + contrast?: number +} + +/** + * 生成 QR 码为 Canvas + */ +export async function generateQRCanvas( + content: string, + options: QRGenerateOptions = {} +): Promise { + const canvas = document.createElement('canvas') + + await QRCode.toCanvas(canvas, content, { + errorCorrectionLevel: options.errorCorrectionLevel ?? 'M', + width: options.width ?? 200, + margin: options.margin ?? 2, + color: options.color ?? { dark: '#000000', light: '#ffffff' }, + }) + + return canvas +} + +/** + * 生成 QR 码为 ImageData + */ +export async function generateQRImageData( + content: string, + options: QRGenerateOptions = {} +): Promise { + const canvas = await generateQRCanvas(content, options) + const ctx = canvas.getContext('2d')! + return ctx.getImageData(0, 0, canvas.width, canvas.height) +} + +/** + * 生成 QR 码为 Data URL + */ +export async function generateQRDataURL( + content: string, + options: QRGenerateOptions = {} +): Promise { + return QRCode.toDataURL(content, { + errorCorrectionLevel: options.errorCorrectionLevel ?? 'M', + width: options.width ?? 200, + margin: options.margin ?? 2, + color: options.color ?? { dark: '#000000', light: '#ffffff' }, + }) +} + +/** + * 对 Canvas 应用变换 + */ +export function transformCanvas( + sourceCanvas: HTMLCanvasElement, + options: TransformOptions +): HTMLCanvasElement { + const { scale = 1, rotate = 0, noise = 0, blur = 0, brightness = 0, contrast = 1 } = options + + // 计算变换后的尺寸 + const radians = (rotate * Math.PI) / 180 + const cos = Math.abs(Math.cos(radians)) + const sin = Math.abs(Math.sin(radians)) + + const originalWidth = sourceCanvas.width + const originalHeight = sourceCanvas.height + + const rotatedWidth = originalWidth * cos + originalHeight * sin + const rotatedHeight = originalWidth * sin + originalHeight * cos + + const finalWidth = Math.ceil(rotatedWidth * scale) + const finalHeight = Math.ceil(rotatedHeight * scale) + + // 创建目标 Canvas + const targetCanvas = document.createElement('canvas') + targetCanvas.width = finalWidth + targetCanvas.height = finalHeight + + const ctx = targetCanvas.getContext('2d')! + + // 应用滤镜 + const filters: string[] = [] + if (blur > 0) filters.push(`blur(${blur}px)`) + if (brightness !== 0) filters.push(`brightness(${100 + brightness}%)`) + if (contrast !== 1) filters.push(`contrast(${contrast * 100}%)`) + if (filters.length > 0) ctx.filter = filters.join(' ') + + // 变换矩阵 + ctx.translate(finalWidth / 2, finalHeight / 2) + ctx.rotate(radians) + ctx.scale(scale, scale) + ctx.translate(-originalWidth / 2, -originalHeight / 2) + + // 绘制原图 + ctx.drawImage(sourceCanvas, 0, 0) + + // 添加噪声 + if (noise > 0) { + addNoise(ctx, finalWidth, finalHeight, noise) + } + + return targetCanvas +} + +/** + * 添加高斯噪声 + */ +function addNoise( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + intensity: number +): void { + const imageData = ctx.getImageData(0, 0, width, height) + const data = imageData.data + + for (let i = 0; i < data.length; i += 4) { + const noise = (Math.random() - 0.5) * intensity * 2 + const r = data[i] ?? 0 + const g = data[i + 1] ?? 0 + const b = data[i + 2] ?? 0 + data[i] = Math.max(0, Math.min(255, r + noise)) // R + data[i + 1] = Math.max(0, Math.min(255, g + noise)) // G + data[i + 2] = Math.max(0, Math.min(255, b + noise)) // B + } + + ctx.putImageData(imageData, 0, 0) +} + +/** + * 生成并变换 QR 码 + */ +export async function generateTransformedQR( + content: string, + qrOptions: QRGenerateOptions = {}, + transformOptions: TransformOptions = {} +): Promise { + const originalCanvas = await generateQRCanvas(content, qrOptions) + return transformCanvas(originalCanvas, transformOptions) +} + +/** + * 获取 Canvas 的 ImageData + */ +export function getCanvasImageData(canvas: HTMLCanvasElement): ImageData { + const ctx = canvas.getContext('2d')! + return ctx.getImageData(0, 0, canvas.width, canvas.height) +} + +/** + * 批量生成测试用例 + */ +export interface TestCase { + name: string + content: string + qrOptions?: QRGenerateOptions + transformOptions?: TransformOptions +} + +export const STANDARD_TEST_CASES: TestCase[] = [ + // 基础测试 + { name: 'simple-text', content: 'Hello World' }, + { name: 'url', content: 'https://example.com/path?query=value' }, + { name: 'ethereum-address', content: 'ethereum:0x1234567890123456789012345678901234567890' }, + { name: 'bitcoin-address', content: 'bitcoin:1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2' }, + + // 不同容错级别 + { name: 'ecc-L', content: 'Error Correction L', qrOptions: { errorCorrectionLevel: 'L' } }, + { name: 'ecc-M', content: 'Error Correction M', qrOptions: { errorCorrectionLevel: 'M' } }, + { name: 'ecc-Q', content: 'Error Correction Q', qrOptions: { errorCorrectionLevel: 'Q' } }, + { name: 'ecc-H', content: 'Error Correction H', qrOptions: { errorCorrectionLevel: 'H' } }, + + // 不同尺寸 + { name: 'size-100', content: 'Small QR', qrOptions: { width: 100 } }, + { name: 'size-200', content: 'Medium QR', qrOptions: { width: 200 } }, + { name: 'size-400', content: 'Large QR', qrOptions: { width: 400 } }, + + // 变换测试 + { name: 'scale-0.5', content: 'Scaled Down', transformOptions: { scale: 0.5 } }, + { name: 'scale-1.5', content: 'Scaled Up', transformOptions: { scale: 1.5 } }, + { name: 'rotate-15', content: 'Rotated 15deg', transformOptions: { rotate: 15 } }, + { name: 'rotate-45', content: 'Rotated 45deg', transformOptions: { rotate: 45 } }, + { name: 'rotate-90', content: 'Rotated 90deg', transformOptions: { rotate: 90 } }, + { name: 'noise-10', content: 'Low Noise', transformOptions: { noise: 10 } }, + { name: 'noise-30', content: 'High Noise', transformOptions: { noise: 30 } }, + { name: 'blur-1', content: 'Slight Blur', transformOptions: { blur: 1 } }, + { name: 'blur-2', content: 'Medium Blur', transformOptions: { blur: 2 } }, + { name: 'brightness-low', content: 'Dark Image', transformOptions: { brightness: -30 } }, + { name: 'brightness-high', content: 'Bright Image', transformOptions: { brightness: 30 } }, + { name: 'contrast-low', content: 'Low Contrast', transformOptions: { contrast: 0.7 } }, + { name: 'contrast-high', content: 'High Contrast', transformOptions: { contrast: 1.3 } }, + + // 组合变换 + { name: 'combined-easy', content: 'Easy Combined', transformOptions: { scale: 0.8, rotate: 5, noise: 5 } }, + { name: 'combined-medium', content: 'Medium Combined', transformOptions: { scale: 0.6, rotate: 15, noise: 15, blur: 1 } }, + { name: 'combined-hard', content: 'Hard Combined', transformOptions: { scale: 0.5, rotate: 30, noise: 25, blur: 1.5 } }, +] + +/** + * 运行可靠性测试并生成报告 + */ +export interface ReliabilityReport { + totalCases: number + passed: number + failed: number + passRate: number + avgScanTime: number + results: Array<{ + name: string + passed: boolean + expectedContent: string + actualContent: string | null + scanTime: number | null + error?: string + }> +} + +export async function runReliabilityTest( + scanner: { scan: (imageData: ImageData) => Promise<{ content: string; duration: number } | null> }, + testCases: TestCase[] = STANDARD_TEST_CASES +): Promise { + const results: ReliabilityReport['results'] = [] + let totalScanTime = 0 + let passedCount = 0 + + for (const testCase of testCases) { + try { + const canvas = await generateTransformedQR( + testCase.content, + testCase.qrOptions, + testCase.transformOptions + ) + const imageData = getCanvasImageData(canvas) + + const result = await scanner.scan(imageData) + + const passed = result?.content === testCase.content + if (passed) { + passedCount++ + totalScanTime += result!.duration + } + + results.push({ + name: testCase.name, + passed, + expectedContent: testCase.content, + actualContent: result?.content ?? null, + scanTime: result?.duration ?? null, + }) + } catch (error) { + results.push({ + name: testCase.name, + passed: false, + expectedContent: testCase.content, + actualContent: null, + scanTime: null, + error: error instanceof Error ? error.message : 'Unknown error', + }) + } + } + + return { + totalCases: testCases.length, + passed: passedCount, + failed: testCases.length - passedCount, + passRate: passedCount / testCases.length, + avgScanTime: passedCount > 0 ? totalScanTime / passedCount : 0, + results, + } +} diff --git a/src/lib/qr-scanner/types.ts b/src/lib/qr-scanner/types.ts new file mode 100644 index 00000000..0254483c --- /dev/null +++ b/src/lib/qr-scanner/types.ts @@ -0,0 +1,80 @@ +/** + * QR Scanner 类型定义 + */ + +/** 扫描结果 */ +export interface ScanResult { + /** 解码内容 */ + content: string + /** 扫描耗时 (ms) */ + duration: number + /** 检测位置(可选) */ + location?: QRLocation +} + +/** QR 码位置信息 */ +export interface QRLocation { + topLeftCorner: Point + topRightCorner: Point + bottomLeftCorner: Point + bottomRightCorner: Point +} + +export interface Point { + x: number + y: number +} + +/** Worker 消息类型 */ +export type WorkerMessage = + | { type: 'scan'; id: number; imageData: ImageData } + | { type: 'scanBatch'; id: number; frames: ImageData[] } + | { type: 'terminate' } + +/** Worker 响应类型 */ +export type WorkerResponse = + | { type: 'result'; id: number; result: ScanResult | null; error?: string } + | { type: 'batchResult'; id: number; results: (ScanResult | null)[]; error?: string } + | { type: 'ready' } + +/** 帧源接口 - 统一不同输入源 */ +export interface FrameSource { + /** 获取当前帧的 ImageData */ + getFrame(): ImageData | null + /** 是否有下一帧 */ + hasNextFrame(): boolean + /** 前进到下一帧(视频/序列图片) */ + nextFrame(): Promise + /** 重置到第一帧 */ + reset(): void + /** 销毁资源 */ + destroy(): void + /** 帧宽度 */ + readonly width: number + /** 帧高度 */ + readonly height: number +} + +/** Mock 帧源配置 */ +export interface MockFrameSourceConfig { + /** 单张图片 URL 或 Blob */ + image?: string | Blob + /** 多张图片 URLs 或 Blobs */ + images?: (string | Blob)[] + /** 视频 URL 或 Blob */ + video?: string | Blob + /** 帧率(用于视频采样,默认 10fps) */ + frameRate?: number + /** 图片序列帧间隔(ms,默认 100) */ + frameInterval?: number +} + +/** Scanner 配置 */ +export interface ScannerConfig { + /** 扫描间隔(ms,默认 100) */ + scanInterval?: number + /** 是否启用多线程(默认 true) */ + useWorker?: boolean + /** Worker 数量(默认 1) */ + workerCount?: number +} diff --git a/src/lib/qr-scanner/worker.ts b/src/lib/qr-scanner/worker.ts new file mode 100644 index 00000000..ba4d51c9 --- /dev/null +++ b/src/lib/qr-scanner/worker.ts @@ -0,0 +1,83 @@ +/** + * QR Scanner Web Worker + * 在后台线程执行 QR 码解码,避免阻塞主线程 + */ + +import jsQR from 'jsqr' +import type { WorkerMessage, WorkerResponse, ScanResult, QRLocation } from './types' + +/** 执行单帧扫描 */ +function scanFrame(imageData: ImageData): ScanResult | null { + const start = performance.now() + + const result = jsQR(imageData.data, imageData.width, imageData.height, { + inversionAttempts: 'dontInvert', // 性能优化:只尝试正常模式 + }) + + if (!result) return null + + const duration = performance.now() - start + + const location: QRLocation = { + topLeftCorner: result.location.topLeftCorner, + topRightCorner: result.location.topRightCorner, + bottomLeftCorner: result.location.bottomLeftCorner, + bottomRightCorner: result.location.bottomRightCorner, + } + + return { + content: result.data, + duration, + location, + } +} + +/** 处理消息 */ +self.onmessage = (event: MessageEvent) => { + const message = event.data + + switch (message.type) { + case 'scan': { + try { + const result = scanFrame(message.imageData) + const response: WorkerResponse = { type: 'result', id: message.id, result } + self.postMessage(response) + } catch (error) { + const response: WorkerResponse = { + type: 'result', + id: message.id, + result: null, + error: error instanceof Error ? error.message : 'Unknown error', + } + self.postMessage(response) + } + break + } + + case 'scanBatch': { + try { + const results = message.frames.map(scanFrame) + const response: WorkerResponse = { type: 'batchResult', id: message.id, results } + self.postMessage(response) + } catch (error) { + const response: WorkerResponse = { + type: 'batchResult', + id: message.id, + results: [], + error: error instanceof Error ? error.message : 'Unknown error', + } + self.postMessage(response) + } + break + } + + case 'terminate': { + self.close() + break + } + } +} + +// 通知主线程 Worker 已就绪 +const readyResponse: WorkerResponse = { type: 'ready' } +self.postMessage(readyResponse) diff --git a/src/pages/scanner/index.tsx b/src/pages/scanner/index.tsx index 05ca1aec..3de7caae 100644 --- a/src/pages/scanner/index.tsx +++ b/src/pages/scanner/index.tsx @@ -10,7 +10,8 @@ import { } from '@tabler/icons-react'; import { cn } from '@/lib/utils'; import { useCamera } from '@/services/hooks'; -import { scanQRFromVideo, scanQRFromFile, parseQRContent, type ParsedQRContent } from '@/lib/qr-parser'; +import { parseQRContent, type ParsedQRContent } from '@/lib/qr-parser'; +import { QRScanner, createQRScanner } from '@/lib/qr-scanner'; type ScannerState = 'idle' | 'requesting' | 'scanning' | 'denied' | 'error' | 'success'; @@ -32,6 +33,7 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { const videoRef = useRef(null); const canvasRef = useRef(null); const streamRef = useRef(null); + const scannerRef = useRef(null); const scanningRef = useRef(false); const [state, setState] = useState('idle'); @@ -39,6 +41,15 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { const [flashEnabled, setFlashEnabled] = useState(false); const [lastScanned, setLastScanned] = useState(null); + // 初始化 QR Scanner(使用 Web Worker) + useEffect(() => { + scannerRef.current = createQRScanner({ useWorker: true }); + return () => { + scannerRef.current?.destroy(); + scannerRef.current = null; + }; + }, []); + // Handle successful scan const handleScanSuccess = useCallback( (content: string) => { @@ -75,7 +86,7 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { [lastScanned, onScan, navigate], ); - // Scan loop - 帧扫描循环 + // Scan loop - 帧扫描循环(使用 Web Worker 异步扫描) const startScanLoop = useCallback(() => { if (scanningRef.current) return; scanningRef.current = true; @@ -85,17 +96,23 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { canvasRef.current = document.createElement('canvas'); } - const scan = () => { - if (!scanningRef.current || !videoRef.current) return; + const scan = async () => { + if (!scanningRef.current || !videoRef.current || !scannerRef.current) return; - const result = scanQRFromVideo(videoRef.current, canvasRef.current ?? undefined); - if (result) { - handleScanSuccess(result); - return; // 停止扫描 + try { + const result = await scannerRef.current.scanFromVideo(videoRef.current, canvasRef.current ?? undefined); + if (result) { + handleScanSuccess(result.content); + return; // 停止扫描 + } + } catch (err) { + console.error('[Scanner] Scan error:', err); } // 继续下一帧 - setTimeout(scan, SCAN_INTERVAL); + if (scanningRef.current) { + setTimeout(scan, SCAN_INTERVAL); + } }; scan(); @@ -169,11 +186,43 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; - const result = await scanQRFromFile(file); - if (result) { - handleScanSuccess(result); - } else { - // 无法识别 QR 码 + try { + // 加载图片到 Canvas + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = async () => { + URL.revokeObjectURL(url); + + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + if (!ctx || !scannerRef.current) { + setError(t('noQrFound')); + setState('error'); + return; + } + + ctx.drawImage(img, 0, 0); + const result = await scannerRef.current.scanFromCanvas(canvas); + + if (result) { + handleScanSuccess(result.content); + } else { + setError(t('noQrFound')); + setState('error'); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + setError(t('noQrFound')); + setState('error'); + }; + + img.src = url; + } catch { setError(t('noQrFound')); setState('error'); } diff --git a/src/services/camera/mock.ts b/src/services/camera/mock.ts index 3f2ecc73..1a70bbe7 100644 --- a/src/services/camera/mock.ts +++ b/src/services/camera/mock.ts @@ -1,14 +1,48 @@ /** * 相机服务 - Mock 实现 + * + * 支持多种输入源模拟: + * - 静态结果(默认) + * - 单张图片 + * - 多张图片序列 + * - 视频 */ import { cameraServiceMeta, type ScanResult } from './types' +import type { FrameSource, MockFrameSourceConfig } from '@/lib/qr-scanner/types' +import { createFrameSource, MockCameraView } from '@/lib/qr-scanner/mock-frame-source' +import { createQRScanner, type QRScanner } from '@/lib/qr-scanner' let mockScanResult: ScanResult = { content: 'mock-qr-content', format: 'QR_CODE' } let mockPermission = true +let mockFrameSource: FrameSource | null = null +let mockCameraView: MockCameraView | null = null +let mockScanner: QRScanner | null = null export const cameraService = cameraServiceMeta.impl({ async scanQRCode() { + // 如果有帧源,从帧源扫描 + if (mockFrameSource && mockScanner) { + const frame = mockFrameSource.getFrame() + if (frame) { + const result = await mockScanner.scan(frame) + if (result) { + return { content: result.content, format: 'QR_CODE' } + } + } + // 尝试下一帧 + const hasNext = await mockFrameSource.nextFrame() + if (hasNext) { + const nextFrame = mockFrameSource.getFrame() + if (nextFrame) { + const result = await mockScanner.scan(nextFrame) + if (result) { + return { content: result.content, format: 'QR_CODE' } + } + } + } + } + // 回退到静态结果 return mockScanResult }, @@ -21,12 +55,62 @@ export const cameraService = cameraServiceMeta.impl({ }, }) -/** Mock 控制器 */ +/** Mock 控制器 - 增强版 */ export const cameraMockController = { - setScanResult: (result: ScanResult) => { mockScanResult = result }, - setPermission: (permission: boolean) => { mockPermission = permission }, + /** 设置静态扫描结果 */ + setScanResult: (result: ScanResult) => { + mockScanResult = result + }, + + /** 设置权限状态 */ + setPermission: (permission: boolean) => { + mockPermission = permission + }, + + /** 设置帧源(单图片/多图片/视频) */ + setFrameSource: async (config: MockFrameSourceConfig) => { + // 清理旧资源 + mockCameraView?.destroy() + mockFrameSource?.destroy() + + // 创建新帧源 + mockFrameSource = await createFrameSource(config) + mockCameraView = new MockCameraView(mockFrameSource) + + // 确保有 scanner + if (!mockScanner) { + mockScanner = createQRScanner({ useWorker: false }) + } + }, + + /** 获取 MockCameraView(可用于在 UI 中显示) */ + getCameraView: () => mockCameraView, + + /** 获取当前帧的 Canvas */ + getCanvas: () => mockCameraView?.getCanvas() ?? null, + + /** 前进到下一帧 */ + nextFrame: async () => { + if (mockFrameSource) { + return mockFrameSource.nextFrame() + } + return false + }, + + /** 重置帧源到起始位置 */ + resetFrameSource: () => { + mockFrameSource?.reset() + }, + + /** 重置所有 Mock 状态 */ reset: () => { mockScanResult = { content: 'mock-qr-content', format: 'QR_CODE' } mockPermission = true + mockCameraView?.destroy() + mockFrameSource?.destroy() + mockScanner?.destroy() + mockCameraView = null + mockFrameSource = null + mockScanner = null }, } From ed34240416c1ff0f838e1b7f96e13fd21fab8ae6 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 25 Dec 2025 18:19:05 +0800 Subject: [PATCH 02/12] fix(scanner): prevent infinite re-render loop in camera initialization - Use refs for lastScanned and onScan to avoid dependency chain - Initialize camera only once on mount with empty deps array - Stop existing stream before starting new one --- src/pages/scanner/index.tsx | 59 ++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/pages/scanner/index.tsx b/src/pages/scanner/index.tsx index 3de7caae..c1377734 100644 --- a/src/pages/scanner/index.tsx +++ b/src/pages/scanner/index.tsx @@ -35,28 +35,25 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { const streamRef = useRef(null); const scannerRef = useRef(null); const scanningRef = useRef(false); + const lastScannedRef = useRef(null); + const onScanRef = useRef(onScan); const [state, setState] = useState('idle'); const [error, setError] = useState(null); const [flashEnabled, setFlashEnabled] = useState(false); - const [lastScanned, setLastScanned] = useState(null); - // 初始化 QR Scanner(使用 Web Worker) + // 保持 onScan 回调的最新引用 useEffect(() => { - scannerRef.current = createQRScanner({ useWorker: true }); - return () => { - scannerRef.current?.destroy(); - scannerRef.current = null; - }; - }, []); + onScanRef.current = onScan; + }, [onScan]); - // Handle successful scan + // Handle successful scan(使用 ref 避免依赖变化) const handleScanSuccess = useCallback( (content: string) => { // 防止重复触发 - if (content === lastScanned) return; + if (content === lastScannedRef.current) return; - setLastScanned(content); + lastScannedRef.current = content; setState('success'); scanningRef.current = false; @@ -67,8 +64,8 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { const parsed = parseQRContent(content); - if (onScan) { - onScan(content, parsed); + if (onScanRef.current) { + onScanRef.current(content, parsed); } else { // 默认行为:根据解析结果导航 if (parsed.type === 'address' || parsed.type === 'payment') { @@ -83,7 +80,7 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { } } }, - [lastScanned, onScan, navigate], + [navigate], ); // Scan loop - 帧扫描循环(使用 Web Worker 异步扫描) @@ -118,10 +115,22 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { scan(); }, [handleScanSuccess]); + // Stop camera stream + const stopCamera = useCallback(() => { + scanningRef.current = false; + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + } + }, []); + // Check and request camera permission const initCamera = useCallback(async () => { + // 先停止现有的流 + stopCamera(); + setState('requesting'); - setLastScanned(null); + lastScannedRef.current = null; try { const hasPermission = await cameraService.checkPermission(); @@ -152,24 +161,20 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { setState('error'); setError(err instanceof Error ? err.message : 'Camera initialization failed'); } - }, [cameraService, startScanLoop]); - - // Stop camera stream - const stopCamera = useCallback(() => { - scanningRef.current = false; - if (streamRef.current) { - streamRef.current.getTracks().forEach((track) => track.stop()); - streamRef.current = null; - } - }, []); + }, [cameraService, startScanLoop, stopCamera]); - // Initialize on mount + // 初始化 QR Scanner 和相机(只在挂载时执行一次) useEffect(() => { + scannerRef.current = createQRScanner({ useWorker: true }); initCamera(); + return () => { stopCamera(); + scannerRef.current?.destroy(); + scannerRef.current = null; }; - }, [initCamera, stopCamera]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- 只在挂载时执行 + }, []); // Handle back navigation const handleBack = useCallback(() => { From a437b0cfb934d9d58c35835cd067b62c9c9653d8 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 25 Dec 2025 18:22:36 +0800 Subject: [PATCH 03/12] feat(scanner): add deep link support for authorize routes - Add ParsedDeepLink type for hash-based routes - Parse #/authorize/address and #/authorize/signature URLs - Handle deeplink, address, payment, and unknown content types - Navigate to appropriate routes based on QR content --- src/lib/qr-parser.ts | 64 +++++++++++++++++++++++++++++++++++-- src/pages/scanner/index.tsx | 37 +++++++++++++++------ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/lib/qr-parser.ts b/src/lib/qr-parser.ts index ceb548ab..b8436cbd 100644 --- a/src/lib/qr-parser.ts +++ b/src/lib/qr-parser.ts @@ -1,7 +1,7 @@ import jsQR from 'jsqr' /** QR 内容类型 */ -export type QRContentType = 'address' | 'payment' | 'unknown' +export type QRContentType = 'address' | 'payment' | 'deeplink' | 'unknown' /** 解析后的地址信息 */ export interface ParsedAddress { @@ -29,13 +29,24 @@ export interface ParsedPayment { chainId?: number | undefined } +/** 解析后的深度链接 */ +export interface ParsedDeepLink { + type: 'deeplink' + /** 路由路径 */ + path: string + /** 查询参数 */ + params: Record + /** 原始内容 */ + raw: string +} + /** 未知内容 */ export interface ParsedUnknown { type: 'unknown' content: string } -export type ParsedQRContent = ParsedAddress | ParsedPayment | ParsedUnknown +export type ParsedQRContent = ParsedAddress | ParsedPayment | ParsedDeepLink | ParsedUnknown /** 以太坊地址正则 (0x + 40 hex chars) */ const ETH_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/ @@ -160,6 +171,49 @@ function parseTronURI(uri: string): ParsedAddress | ParsedPayment { return { type: 'address', chain: 'tron', address } } +/** + * 解析深度链接(hash 路由格式) + * 支持格式: + * - #/authorize/address?eventId=...&type=... + * - #/authorize/signature?eventId=... + * - #/send?address=... + * - 完整 URL 带 hash: https://app.example.com/#/authorize/address?... + */ +function parseDeepLink(content: string): ParsedDeepLink | null { + let hashPart = content + + // 如果是完整 URL,提取 hash 部分 + if (content.startsWith('http://') || content.startsWith('https://')) { + const hashIndex = content.indexOf('#') + if (hashIndex === -1) return null + hashPart = content.slice(hashIndex) + } + + // 必须以 #/ 开头 + if (!hashPart.startsWith('#/')) return null + + const inner = hashPart.slice(1) // 去掉 # + const queryIndex = inner.indexOf('?') + const path = queryIndex >= 0 ? inner.slice(0, queryIndex) : inner + const queryString = queryIndex >= 0 ? inner.slice(queryIndex + 1) : '' + + // 解析查询参数 + const params: Record = {} + if (queryString) { + const searchParams = new URLSearchParams(queryString) + for (const [key, value] of searchParams) { + params[key] = value + } + } + + return { + type: 'deeplink', + path, + params, + raw: content, + } +} + /** * 解析 QR 码内容 */ @@ -179,6 +233,12 @@ export function parseQRContent(content: string): ParsedQRContent { return parseTronURI(trimmed) } + // 深度链接(hash 路由格式) + if (trimmed.startsWith('#/') || (trimmed.startsWith('http') && trimmed.includes('#/'))) { + const deepLink = parseDeepLink(trimmed) + if (deepLink) return deepLink + } + // 纯地址字符串 const chain = detectAddressChain(trimmed) if (chain !== 'unknown') { diff --git a/src/pages/scanner/index.tsx b/src/pages/scanner/index.tsx index c1377734..da37751b 100644 --- a/src/pages/scanner/index.tsx +++ b/src/pages/scanner/index.tsx @@ -68,15 +68,34 @@ export function ScannerPage({ onScan, className }: ScannerPageProps) { onScanRef.current(content, parsed); } else { // 默认行为:根据解析结果导航 - if (parsed.type === 'address' || parsed.type === 'payment') { - navigate({ - to: '/send', - search: { - address: parsed.address, - chain: parsed.chain, - amount: parsed.type === 'payment' ? parsed.amount : undefined, - }, - }); + switch (parsed.type) { + case 'address': + case 'payment': + navigate({ + to: '/send', + search: { + address: parsed.address, + chain: parsed.chain, + amount: parsed.type === 'payment' ? parsed.amount : undefined, + }, + }); + break; + + case 'deeplink': + // 深度链接:直接导航到目标路径 + navigate({ + to: parsed.path, + search: parsed.params, + }); + break; + + case 'unknown': + // 未知内容:尝试作为地址处理,跳转到发送页面 + navigate({ + to: '/send', + search: { address: parsed.content }, + }); + break; } } }, From aca38d65cbeca4e8b439f7e576c5ab133e59a7a8 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 25 Dec 2025 18:38:23 +0800 Subject: [PATCH 04/12] feat(scanner): implement ScannerJob using Stackflow pattern - Create ScannerJob as Stackflow BottomSheet activity - Add chainType-based validators for address filtering - Use callback pattern (setScannerResultCallback) for results - Integrate with SendPage for address scanning - Register ScannerJob in stackflow router --- src/pages/send/index.tsx | 45 +- .../activities/sheets/ScannerJob.tsx | 410 ++++++++++++++++++ src/stackflow/activities/sheets/index.ts | 1 + src/stackflow/stackflow.ts | 4 +- 4 files changed, 435 insertions(+), 25 deletions(-) create mode 100644 src/stackflow/activities/sheets/ScannerJob.tsx diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index 014edd47..50352cdc 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigation, useActivityParams, useFlow } from '@/stackflow'; -import { setTransferConfirmCallback, setTransferWalletLockCallback } from '@/stackflow/activities/sheets'; +import { setTransferConfirmCallback, setTransferWalletLockCallback, setScannerResultCallback } from '@/stackflow/activities/sheets'; import type { Contact, ContactAddress } from '@/stores'; import { PageHeader } from '@/components/layout/page-header'; import { AddressInput } from '@/components/transfer/address-input'; @@ -10,7 +10,7 @@ import { GradientButton } from '@/components/common/gradient-button'; import { Alert } from '@/components/common/alert'; import { ChainIcon } from '@/components/wallet/chain-icon'; import { SendResult } from '@/components/transfer/send-result'; -import { useCamera, useToast, useHaptics } from '@/services'; +import { useToast, useHaptics } from '@/services'; import { useSend } from '@/hooks/use-send'; import { Amount } from '@/types/amount'; import { IconChevronRight as ArrowRight } from '@tabler/icons-react'; @@ -42,7 +42,6 @@ export function SendPage() { const { t } = useTranslation(['transaction', 'common', 'security']); const { goBack: navGoBack } = useNavigation(); const { push } = useFlow(); - const camera = useCamera(); const toast = useToast(); const haptics = useHaptics(); const isWalletLockSheetOpen = useRef(false); @@ -117,27 +116,25 @@ export function SendPage() { const balance = state.asset?.amount ?? null; const symbol = state.asset?.assetType ?? 'TOKEN'; - const handleScan = async () => { - try { - const hasPermission = await camera.checkPermission(); - if (!hasPermission) { - const granted = await camera.requestPermission(); - if (!granted) { - toast.show({ message: t('sendPage.cameraPermissionRequired'), position: 'center' }); - return; - } - } - - const result = await camera.scanQRCode(); - if (result.content) { - setToAddress(result.content); - await haptics.impact('success'); - toast.show(t('sendPage.scanSuccess')); + const handleOpenScanner = useCallback(() => { + // 设置扫描结果回调 + setScannerResultCallback(({ content, parsed }) => { + let address = content; + if (parsed.type === 'address' || parsed.type === 'payment') { + address = parsed.address; } - } catch { - toast.show({ message: t('sendPage.scanFailed'), position: 'center' }); - } - }; + setToAddress(address); + haptics.impact('success'); + toast.show(t('sendPage.scanSuccess')); + }); + + // 打开扫描器 + push('ScannerJob', { + chainType: selectedChain, + title: t('sendPage.scanAddressTitle'), + hint: t('sendPage.scanAddressHint', { chain: selectedChainName }), + }); + }, [push, selectedChain, selectedChainName, setToAddress, haptics, toast, t]); const handleProceed = () => { if (!goToConfirm()) return; @@ -279,7 +276,7 @@ export function SendPage() { value={state.toAddress} onChange={setToAddress} placeholder={t('sendPage.toAddressPlaceholder', { chain: selectedChainName })} - onScan={handleScan} + onScan={handleOpenScanner} onContactPicker={handleContactPicker} chainType={selectedChain} error={state.addressError ?? undefined} diff --git a/src/stackflow/activities/sheets/ScannerJob.tsx b/src/stackflow/activities/sheets/ScannerJob.tsx new file mode 100644 index 00000000..43414e8d --- /dev/null +++ b/src/stackflow/activities/sheets/ScannerJob.tsx @@ -0,0 +1,410 @@ +/** + * ScannerJob - 扫码 BottomSheet + * + * 使用 Stackflow Job 模式实现,支持: + * - 持续扫描模式(直到验证通过或手动关闭) + * - 可配置验证器(支持地址类型过滤) + * - 通过事件回调返回结果 + */ + +import { useState, useEffect, useCallback, useRef } from 'react' +import type { ActivityComponentType } from '@stackflow/react' +import { BottomSheet } from '@/components/layout/bottom-sheet' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import { + IconX as X, + IconAperture as ImageIcon, + IconBulb as Flashlight, +} from '@tabler/icons-react' +import { cn } from '@/lib/utils' +import { useCamera } from '@/services/hooks' +import { parseQRContent, type ParsedQRContent } from '@/lib/qr-parser' +import { QRScanner, createQRScanner } from '@/lib/qr-scanner' +import { useFlow } from '../../stackflow' +import { ActivityParamsProvider, useActivityParams } from '../../hooks' + +/** 验证器类型 */ +export type ScanValidator = (content: string, parsed: ParsedQRContent) => true | string + +/** 预设验证器 */ +export const scanValidators = { + ethereumAddress: (_content: string, parsed: ParsedQRContent): true | string => { + if (parsed.type === 'address' && parsed.chain === 'ethereum') return true + if (parsed.type === 'payment' && parsed.chain === 'ethereum') return true + if (parsed.type === 'unknown' && /^0x[a-fA-F0-9]{40}$/.test(parsed.content)) return true + return 'invalidEthereumAddress' + }, + + bitcoinAddress: (_content: string, parsed: ParsedQRContent): true | string => { + if (parsed.type === 'address' && parsed.chain === 'bitcoin') return true + if (parsed.type === 'payment' && parsed.chain === 'bitcoin') return true + return 'invalidBitcoinAddress' + }, + + tronAddress: (_content: string, parsed: ParsedQRContent): true | string => { + if (parsed.type === 'address' && parsed.chain === 'tron') return true + if (parsed.type === 'payment' && parsed.chain === 'tron') return true + if (parsed.type === 'unknown' && /^T[a-zA-HJ-NP-Z1-9]{33}$/.test(parsed.content)) return true + return 'invalidTronAddress' + }, + + anyAddress: (_content: string, parsed: ParsedQRContent): true | string => { + if (parsed.type === 'address') return true + if (parsed.type === 'payment') return true + if (parsed.type === 'unknown' && parsed.content.length >= 26 && parsed.content.length <= 64) return true + return 'invalidAddress' + }, + + any: (): true => true, +} + +/** 根据链类型获取验证器 */ +export function getValidatorForChain(chainType?: string): ScanValidator { + switch (chainType?.toLowerCase()) { + case 'ethereum': + case 'eth': + return scanValidators.ethereumAddress + case 'bitcoin': + case 'btc': + return scanValidators.bitcoinAddress + case 'tron': + case 'trx': + return scanValidators.tronAddress + default: + return scanValidators.anyAddress + } +} + +/** 扫描结果事件 */ +export interface ScannerResultEvent { + content: string + parsed: ParsedQRContent +} + +/** 设置扫描结果回调 */ +let scannerResultCallback: ((result: ScannerResultEvent) => void) | null = null + +export function setScannerResultCallback(callback: ((result: ScannerResultEvent) => void) | null) { + scannerResultCallback = callback +} + +/** Job 参数 */ +export type ScannerJobParams = { + /** 链类型(用于验证) */ + chainType?: string + /** 标题 */ + title?: string + /** 提示文字 */ + hint?: string +} + +const SCAN_INTERVAL = 150 + +function ScannerJobContent() { + const { t } = useTranslation('scanner') + const { pop } = useFlow() + const cameraService = useCamera() + const { chainType, title, hint } = useActivityParams() + + const videoRef = useRef(null) + const canvasRef = useRef(null) + const streamRef = useRef(null) + const scannerRef = useRef(null) + const scanningRef = useRef(false) + + const [cameraReady, setCameraReady] = useState(false) + const [flashEnabled, setFlashEnabled] = useState(false) + const [message, setMessage] = useState<{ type: 'error' | 'success' | 'info'; text: string } | null>(null) + const [lastScanned, setLastScanned] = useState(null) + + const validator = getValidatorForChain(chainType) + + // 处理扫描结果 + const handleScanResult = useCallback((content: string) => { + if (content === lastScanned) return + setLastScanned(content) + + const parsed = parseQRContent(content) + const result = validator(content, parsed) + + if (result === true) { + setMessage({ type: 'success', text: t('scanSuccess') }) + + if ('vibrate' in navigator) { + navigator.vibrate(100) + } + + // 触发回调 + scannerResultCallback?.({ content, parsed }) + + // 关闭 + setTimeout(() => pop(), 300) + } else { + setMessage({ type: 'error', text: t(result, { defaultValue: result }) }) + + setTimeout(() => { + setMessage(null) + setLastScanned(null) + }, 3000) + } + }, [lastScanned, validator, t, pop]) + + // 扫描循环 + const startScanLoop = useCallback(() => { + if (scanningRef.current) return + scanningRef.current = true + + if (!canvasRef.current) { + canvasRef.current = document.createElement('canvas') + } + + const scan = async () => { + if (!scanningRef.current || !videoRef.current || !scannerRef.current) return + + try { + const result = await scannerRef.current.scanFromVideo(videoRef.current, canvasRef.current ?? undefined) + if (result) { + handleScanResult(result.content) + } + } catch (err) { + console.error('[ScannerJob] Scan error:', err) + } + + if (scanningRef.current) { + setTimeout(scan, SCAN_INTERVAL) + } + } + + scan() + }, [handleScanResult]) + + // 停止相机 + const stopCamera = useCallback(() => { + scanningRef.current = false + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()) + streamRef.current = null + } + setCameraReady(false) + }, []) + + // 初始化相机 + const initCamera = useCallback(async () => { + try { + const hasPermission = await cameraService.checkPermission() + if (!hasPermission) { + const granted = await cameraService.requestPermission() + if (!granted) { + setMessage({ type: 'error', text: t('permissionDenied') }) + return + } + } + + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment' }, + }) + streamRef.current = stream + + if (videoRef.current) { + videoRef.current.srcObject = stream + await videoRef.current.play() + setCameraReady(true) + startScanLoop() + } + } catch (err) { + console.error('[ScannerJob] Camera error:', err) + setMessage({ type: 'error', text: t('error') }) + } + }, [cameraService, startScanLoop, t]) + + // 初始化 + useEffect(() => { + scannerRef.current = createQRScanner({ useWorker: true }) + initCamera() + + return () => { + stopCamera() + scannerRef.current?.destroy() + scannerRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // 从相册导入 + const handleGalleryImport = useCallback(() => { + const input = document.createElement('input') + input.type = 'file' + input.accept = 'image/*' + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (!file || !scannerRef.current) return + + try { + const img = new Image() + const url = URL.createObjectURL(file) + + img.onload = async () => { + URL.revokeObjectURL(url) + + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const ctx = canvas.getContext('2d') + if (!ctx || !scannerRef.current) { + setMessage({ type: 'error', text: t('noQrFound') }) + return + } + + ctx.drawImage(img, 0, 0) + const result = await scannerRef.current.scanFromCanvas(canvas) + + if (result) { + handleScanResult(result.content) + } else { + setMessage({ type: 'error', text: t('noQrFound') }) + } + } + + img.onerror = () => { + URL.revokeObjectURL(url) + setMessage({ type: 'error', text: t('noQrFound') }) + } + + img.src = url + } catch { + setMessage({ type: 'error', text: t('noQrFound') }) + } + } + input.click() + }, [handleScanResult, t]) + + // 切换闪光灯 + const toggleFlash = useCallback(async () => { + if (streamRef.current) { + const track = streamRef.current.getVideoTracks()[0] + if (track) { + try { + await track.applyConstraints({ + // @ts-expect-error - torch is not in standard types + advanced: [{ torch: !flashEnabled }], + }) + setFlashEnabled(!flashEnabled) + } catch { + // Flash not supported + } + } + } + }, [flashEnabled]) + + const handleClose = useCallback(() => { + stopCamera() + pop() + }, [stopCamera, pop]) + + return ( + +
+ {/* Header */} +
+ +

+ {title ?? t('title')} +

+ +
+ + {/* Camera View */} +
+ {cameraReady && ( +