diff --git a/package.json b/package.json index e243176..e9f978a 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@tarojs/shared": "4.2.0", "@tarojs/taro": "4.2.0", "crypto-js": "^4.2.0", + "qrcode": "^1.5.4", "react": "^18.0.0", "react-dom": "^18.0.0" }, @@ -76,6 +77,7 @@ "@tarojs/vite-runner": "4.2.0", "@types/crypto-js": "^4.2.2", "@types/minimatch": "^5", + "@types/qrcode": "^1.5.6", "@types/react": "^18.0.0", "@vitejs/plugin-react": "^4.3.0", "babel-preset-taro": "4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b248af7..e691922 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: crypto-js: specifier: ^4.2.0 version: 4.2.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^18.0.0 version: 18.3.1 @@ -96,6 +99,9 @@ importers: '@types/minimatch': specifier: ^5 version: 5.1.2 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^18.0.0 version: 18.3.31 @@ -2126,6 +2132,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} @@ -2552,6 +2561,10 @@ packages: camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001797: resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} @@ -2628,6 +2641,9 @@ packages: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -2802,6 +2818,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} @@ -2875,6 +2895,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dingtalk-jsapi@2.15.6: resolution: {integrity: sha512-804mFz2AFV/H9ysmo7dLqMjSGOQgREsgQIuep+Xg+yNQeQtnUOYntElEzlB798Sj/691e4mMKz9mtQ7v9qdjuA==} @@ -3221,6 +3244,10 @@ packages: resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} engines: {node: '>=14.16'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3899,6 +3926,10 @@ packages: resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} engines: {node: '>= 12.13.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4206,6 +4237,10 @@ packages: resolution: {integrity: sha512-zL7VE4JVS2IFSkR2GQKDSPEVxkoH43/p7oEnwpdCndKYJO0HVeRB7fA8TJwuLOTBREtK0ea8eHaxdwcpob5dmg==} engines: {node: '>=4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -4214,6 +4249,10 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -4226,6 +4265,10 @@ packages: resolution: {integrity: sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==} engines: {node: '>=4'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json@6.5.0: resolution: {integrity: sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==} engines: {node: '>=8'} @@ -4336,6 +4379,10 @@ packages: platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4475,6 +4522,11 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + query-string@5.1.1: resolution: {integrity: sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==} engines: {node: '>=0.10.0'} @@ -4589,6 +4641,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==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4721,6 +4776,9 @@ packages: resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} + 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'} @@ -5303,6 +5361,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.22: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} @@ -5355,6 +5416,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + 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'} @@ -5367,6 +5431,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + 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'} @@ -5375,6 +5443,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + 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'} @@ -7480,6 +7552,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.9.2 + '@types/react@18.3.31': dependencies: '@types/prop-types': 15.7.15 @@ -8038,6 +8114,8 @@ snapshots: pascal-case: 3.1.2 tslib: 2.8.1 + camelcase@5.3.1: {} + caniuse-lite@1.0.30001797: {} capital-case@1.0.4: @@ -8140,6 +8218,12 @@ snapshots: cli-width@3.0.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 @@ -8304,6 +8388,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-uri-component@0.2.2: {} decode-uri-component@0.4.1: {} @@ -8381,6 +8467,8 @@ snapshots: detect-libc@2.1.2: optional: true + dijkstrajs@1.0.3: {} + dingtalk-jsapi@2.15.6: dependencies: promise-polyfill: 7.1.2 @@ -8919,6 +9007,11 @@ snapshots: filter-obj@5.1.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -9633,6 +9726,10 @@ snapshots: loader-utils@3.3.1: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -9916,6 +10013,10 @@ snapshots: p-is-promise@1.1.0: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -9924,6 +10025,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -9936,6 +10041,8 @@ snapshots: dependencies: p-finally: 1.0.0 + p-try@2.2.0: {} + package-json@6.5.0: dependencies: got: 9.6.0 @@ -10028,6 +10135,8 @@ snapshots: platform@1.3.6: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-css-variables@0.19.0(postcss@8.5.15): @@ -10149,6 +10258,12 @@ snapshots: dependencies: hookified: 2.2.0 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + query-string@5.1.1: dependencies: decode-uri-component: 0.2.2 @@ -10281,6 +10396,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -10457,6 +10574,8 @@ snapshots: seroval@1.5.4: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -11106,6 +11225,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 @@ -11166,16 +11287,37 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} yaml@2.9.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@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/contact.test.ts b/src/lib/contact.test.ts index a4bcac8..3bfdefa 100644 --- a/src/lib/contact.test.ts +++ b/src/lib/contact.test.ts @@ -1,5 +1,28 @@ import { describe, expect, it } from 'vitest' -import { hasServiceContact, pickServiceContact } from './contact' +import { getWeixinContactAction, hasServiceContact, pickServiceContact, selectServiceWeixinLink } from './contact' + +describe('selectServiceWeixinLink', () => { + it('returns a single configured link unchanged', () => { + expect(selectServiceWeixinLink('https://u.wechat.com/example')).toBe('https://u.wechat.com/example') + }) + + it('trims candidates and ignores empty entries', () => { + expect(selectServiceWeixinLink( + ' https://work.weixin.qq.com/example-a, , https://u.wechat.com/example-b, ', + () => 0.5, + )).toBe('https://u.wechat.com/example-b') + }) +}) + +describe('getWeixinContactAction', () => { + it('shows a QR code for personal wechat u.wechat.com links', () => { + expect(getWeixinContactAction('https://u.wechat.com/ELkCkT_hVVAz8wNghBA3hMc?s=2')).toBe('qrcode') + }) + + it('copies the link for work weixin customer-service urls', () => { + expect(getWeixinContactAction('https://work.weixin.qq.com/ca/cawcde9d9bc540aebe')).toBe('copy') + }) +}) describe('pickServiceContact', () => { it('reads mobile online phone and weixin from client config', () => { diff --git a/src/lib/contact.ts b/src/lib/contact.ts index 6aa2863..7b88ec4 100644 --- a/src/lib/contact.ts +++ b/src/lib/contact.ts @@ -16,6 +16,32 @@ export function pickServiceContact (config?: Record | null): Servic } } +export function selectServiceWeixinLink (value: unknown, random: () => number = Math.random) { + if (typeof value !== 'string') return '' + + const links = value + .split(',') + .map((link) => link.trim()) + .filter(Boolean) + + if (links.length === 0) return '' + return links[Math.floor(random() * links.length)] || '' +} + +export function getWeixinContactAction (url?: string): 'qrcode' | 'copy' { + const value = String(url || '').trim() + if (!value) return 'copy' + + try { + const host = new URL(value).hostname.toLowerCase() + if (host === 'u.wechat.com' || host.endsWith('.u.wechat.com')) return 'qrcode' + } catch { + if (/u\.wechat\.com/i.test(value)) return 'qrcode' + } + + return 'copy' +} + export function hasServiceContact (config?: Record | null) { const contact = pickServiceContact(config) return Boolean(contact.online || contact.phone || contact.weixin) diff --git a/src/lib/qrImage.test.ts b/src/lib/qrImage.test.ts new file mode 100644 index 0000000..3cefab7 --- /dev/null +++ b/src/lib/qrImage.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { buildPublicQrImageUrl } from './qrImage' + +describe('buildPublicQrImageUrl', () => { + it('builds a domestic public qr image url with the wechat link encoded', () => { + expect(buildPublicQrImageUrl('https://u.wechat.com/ELkCkT_hVVAz8wNghBA3hMc?s=2')).toBe( + 'https://api.qrtool.cn/?text=https%3A%2F%2Fu.wechat.com%2FELkCkT_hVVAz8wNghBA3hMc%3Fs%3D2&size=400&margin=16&level=H', + ) + }) + + it('returns empty string for blank text', () => { + expect(buildPublicQrImageUrl('')).toBe('') + expect(buildPublicQrImageUrl(' ')).toBe('') + }) +}) diff --git a/src/lib/qrImage.ts b/src/lib/qrImage.ts new file mode 100644 index 0000000..4570afe --- /dev/null +++ b/src/lib/qrImage.ts @@ -0,0 +1,5 @@ +export function buildPublicQrImageUrl (text: string) { + const value = String(text || '').trim() + if (!value) return '' + return `https://api.qrtool.cn/?text=${encodeURIComponent(value)}&size=400&margin=16&level=H` +} diff --git a/src/lib/qrPng.test.ts b/src/lib/qrPng.test.ts new file mode 100644 index 0000000..2c5ab25 --- /dev/null +++ b/src/lib/qrPng.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { bytesToBase64, encodeQrPng } from './qrPng' + +describe('encodeQrPng', () => { + it('encodes a wechat personal link into a png buffer', () => { + const png = encodeQrPng('https://u.wechat.com/ELkCkT_hVVAz8wNghBA3hMc?s=2') + expect(Array.from(png.slice(0, 8))).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + expect(bytesToBase64(png).startsWith('iVBOR')).toBe(true) + }) +}) diff --git a/src/lib/qrPng.ts b/src/lib/qrPng.ts new file mode 100644 index 0000000..5f17400 --- /dev/null +++ b/src/lib/qrPng.ts @@ -0,0 +1,129 @@ +import QRCode from 'qrcode' + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] +const CRC_TABLE = makeCrcTable() +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +export function encodeQrPng (text: string, scale = 6, margin = 4) { + const qr = QRCode.create(text, { errorCorrectionLevel: 'M' }) + const moduleCount = qr.modules.size + const dim = (moduleCount + margin * 2) * scale + const raw = new Uint8Array((dim * 3 + 1) * dim) + let offset = 0 + + for (let y = 0; y < dim; y += 1) { + raw[offset] = 0 + offset += 1 + const moduleY = Math.floor(y / scale) - margin + for (let x = 0; x < dim; x += 1) { + const moduleX = Math.floor(x / scale) - margin + const dark = moduleX >= 0 && moduleX < moduleCount && moduleY >= 0 && moduleY < moduleCount + ? Boolean(qr.modules.get(moduleX, moduleY)) + : false + const value = dark ? 0 : 255 + raw[offset] = value + raw[offset + 1] = value + raw[offset + 2] = value + offset += 3 + } + } + + const ihdr = [ + ...u32(dim), + ...u32(dim), + 8, + 2, + 0, + 0, + 0, + ] + const idat = zlibStore(raw) + const bytes = new Uint8Array([ + ...PNG_SIGNATURE, + ...chunk('IHDR', ihdr), + ...chunk('IDAT', idat), + ...chunk('IEND', []), + ]) + return bytes +} + +export function bytesToBase64 (bytes: Uint8Array) { + let result = '' + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i] + const b = i + 1 < bytes.length ? bytes[i + 1] : 0 + const c = i + 2 < bytes.length ? bytes[i + 2] : 0 + const triple = (a << 16) | (b << 8) | c + result += BASE64_CHARS[(triple >> 18) & 63] + result += BASE64_CHARS[(triple >> 12) & 63] + result += i + 1 < bytes.length ? BASE64_CHARS[(triple >> 6) & 63] : '=' + result += i + 2 < bytes.length ? BASE64_CHARS[triple & 63] : '=' + } + return result +} + +function zlibStore (raw: Uint8Array) { + const blocks: number[] = [0x78, 0x01] + const max = 65535 + for (let i = 0; i < raw.length; i += max) { + const end = Math.min(i + max, raw.length) + const chunkData = raw.subarray(i, end) + const last = end === raw.length ? 1 : 0 + const len = chunkData.length + blocks.push(last, len & 0xff, (len >> 8) & 0xff, (~len) & 0xff, ((~len) >> 8) & 0xff) + for (let j = 0; j < chunkData.length; j += 1) { + blocks.push(chunkData[j]) + } + } + blocks.push(...u32(adler32(raw))) + return blocks +} + +function chunk (type: string, data: number[]) { + const typeBytes = [type.charCodeAt(0), type.charCodeAt(1), type.charCodeAt(2), type.charCodeAt(3)] + return [ + ...u32(data.length), + ...typeBytes, + ...data, + ...u32(crc32([...typeBytes, ...data])), + ] +} + +function u32 (value: number) { + return [ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ] +} + +function crc32 (bytes: number[]) { + let crc = 0xffffffff + for (let i = 0; i < bytes.length; i += 1) { + crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +function adler32 (bytes: Uint8Array) { + let a = 1 + let b = 0 + for (let i = 0; i < bytes.length; i += 1) { + a = (a + bytes[i]) % 65521 + b = (b + a) % 65521 + } + return ((b << 16) | a) >>> 0 +} + +function makeCrcTable () { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n += 1) { + let c = n + for (let k = 0; k < 8; k += 1) { + c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1) + } + table[n] = c >>> 0 + } + return table +} diff --git a/src/pages/index/contact.tsx b/src/pages/index/contact.tsx index ec9618a..d441391 100644 --- a/src/pages/index/contact.tsx +++ b/src/pages/index/contact.tsx @@ -1,7 +1,9 @@ import { Image, Text, View } from '@tarojs/components' import Taro from '@tarojs/taro' -import { CORP_ASSET_BASE } from '@/lib/cdn' -import type { ServiceContact } from '@/lib/contact' +import { useEffect, useState } from 'react' +import { CORP_ASSET_BASE, MAP_ASSET_BASE } from '@/lib/cdn' +import { getWeixinContactAction, selectServiceWeixinLink, type ServiceContact } from '@/lib/contact' +import WeixinQrImage from './weixin-qr' type Props = { open: boolean @@ -32,11 +34,31 @@ function ContactItem ({ } export default function ContactModal ({ open, contact, onCancel }: Props) { + const [weixin, setWeixin] = useState('') + const [showQr, setShowQr] = useState(false) + + useEffect(() => { + if (!open) { + setShowQr(false) + return + } + setWeixin(selectServiceWeixinLink(contact.weixin)) + }, [open, contact.weixin]) + if (!open) return null - const copyLink = async (url: string, title: string) => { + const copyLink = async (url: string) => { await Taro.setClipboardData({ data: url }) - Taro.showToast({ title: `${title}链接已复制`, icon: 'none' }) + Taro.showToast({ title: '微信客服链接已复制', icon: 'none' }) + } + + const handleWeixin = () => { + if (!weixin) return + if (getWeixinContactAction(weixin) === 'qrcode') { + setShowQr((current) => !current) + return + } + void copyLink(weixin) } const handlePhone = () => { @@ -44,48 +66,52 @@ export default function ContactModal ({ open, contact, onCancel }: Props) { Taro.makePhoneCall({ phoneNumber: contact.phone.replace(/-/g, '') }) } - const hasAny = Boolean(contact.online || contact.phone || contact.weixin) + const hasAny = Boolean(weixin || contact.phone) return ( - event.stopPropagation()}> - 联系客服 - 工作时间段:法定工作日 9:00-18:00 + event.stopPropagation()}> + + + 联系客服 + + + 工作时间段:法定工作日 9:00-18:00 - {contact.online ? ( - void copyLink(contact.online, '在线客服')} - /> - ) : null} + {weixin ? ( + <> + + {showQr && getWeixinContactAction(weixin) === 'qrcode' ? ( + + ) : null} + + ) : null} - {contact.weixin ? ( - void copyLink(contact.weixin, '微信客服')} - /> - ) : null} + {contact.phone ? ( + + ) : null} - {contact.phone ? ( - - ) : null} - - {!hasAny ? ( - 暂无客服信息,请稍后再试 - ) : null} - - - 关闭 + {!hasAny ? ( + 暂无客服信息,请稍后再试 + ) : null} + + + ) diff --git a/src/pages/index/index.scss b/src/pages/index/index.scss index e801bd6..30abd71 100644 --- a/src/pages/index/index.scss +++ b/src/pages/index/index.scss @@ -60,6 +60,13 @@ color: #c9cdd4; } +.home-search__clear { + width: 36px; + height: 36px; + margin: 0 12px; + flex-shrink: 0; +} + .home-search__button { flex-shrink: 0; display: flex; @@ -498,29 +505,53 @@ display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.65); + background: rgba(0, 0, 0, 0.8); +} + +.contact-shell { + display: flex; + flex-direction: column; + align-items: center; } .contact-dialog { box-sizing: border-box; width: 576px; - padding: 32px 28px 28px; - border-radius: 32px; - background: #fff; + overflow: hidden; + border-radius: 40px; + background: #f9fbff; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18); +} + +.contact-dialog__header { + display: flex; + align-items: center; + justify-content: center; + height: 120px; + padding: 0 28px; + background: + linear-gradient(180deg, rgba(249, 251, 255, 0) 0%, #f9fbff 100%), + linear-gradient(90deg, #c1ebff 0%, #bdf3ff 100%); } .contact-dialog__title { - display: block; color: #222; font-size: 34px; - font-weight: 600; - line-height: 48px; + font-weight: 700; + line-height: 44px; text-align: center; } +.contact-dialog__body { + display: flex; + flex-direction: column; + gap: 28px; + padding: 0 28px 28px; +} + .contact-dialog__hours { display: block; - margin: 16px 0 28px; + margin-bottom: 12px; color: #999; font-size: 24px; line-height: 32px; @@ -538,12 +569,13 @@ .contact-item { display: flex; align-items: center; - height: 124px; - margin-bottom: 20px; + box-sizing: border-box; + width: 520px; + min-height: 124px; padding: 0 24px; border-radius: 16px; background: #fff; - box-shadow: 0 0 20px rgba(147, 153, 161, 0.12); + box-shadow: 0 0 20px rgba(147, 153, 161, 0.1); } .contact-item__icon { @@ -566,19 +598,40 @@ } .contact-item__desc { - margin-top: 4px; color: #999; font-size: 24px; + font-weight: 400; line-height: 32px; } -.contact-dialog__close { - margin-top: 12px; - height: 80px; +.contact-close { + width: 60px; + height: 60px; + margin-top: 40px; +} + +.contact-qrcode { + display: flex; + box-sizing: border-box; + flex-direction: column; + align-items: center; + width: 520px; + padding: 24px 24px 28px; border-radius: 16px; - background: #f5f6f8; - color: #666; - font-size: 30px; - line-height: 80px; + background: #fff; + box-shadow: 0 0 20px rgba(147, 153, 161, 0.1); +} + +.contact-qrcode__image { + width: 320px; + height: 320px; + background: #fff; +} + +.contact-qrcode__hint { + margin-top: 16px; + color: #999; + font-size: 20px; + line-height: 28px; text-align: center; } diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index d399dc2..ff568e5 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -111,6 +111,13 @@ export default function Index () { value={inputPhone} onInput={(event) => setInputPhone(event.detail.value.replace(/\D/g, ''))} /> + {inputPhone ? ( + setInputPhone('')} + /> + ) : null}