fix(pda): 国内海外最新 APK 下载二维码 #33

Merged
admin merged 1 commits from hotfix-gray-bugfix into main 2026-11-06 14:49:14 +08:00
4 changed files with 490 additions and 30 deletions
Showing only changes of commit c78fbd2d45 - Show all commits
+2
View File
@@ -20,6 +20,7 @@
"echarts": "^6.1.0", "echarts": "^6.1.0",
"element-plus": "^2.14.3", "element-plus": "^2.14.3",
"pinia": "^4.0.2", "pinia": "^4.0.2",
"qrcode": "^1.5.4",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"vue": "^3.5.40", "vue": "^3.5.40",
"vue-echarts": "^8.3.2", "vue-echarts": "^8.3.2",
@@ -27,6 +28,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/qrcode": "^1.5.6",
"@types/sortablejs": "^1.15.9", "@types/sortablejs": "^1.15.9",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11", "@vue/test-utils": "^2.4.11",
+170
View File
@@ -29,6 +29,9 @@ importers:
pinia: pinia:
specifier: ^4.0.2 specifier: ^4.0.2
version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)) version: 4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3))
qrcode:
specifier: ^1.5.4
version: 1.5.4
sortablejs: sortablejs:
specifier: ^1.15.7 specifier: ^1.15.7
version: 1.15.7 version: 1.15.7
@@ -45,6 +48,9 @@ importers:
'@types/node': '@types/node':
specifier: ^24.10.0 specifier: ^24.10.0
version: 24.13.3 version: 24.13.3
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/sortablejs': '@types/sortablejs':
specifier: ^1.15.9 specifier: ^1.15.9
version: 1.15.9 version: 1.15.9
@@ -376,6 +382,9 @@ packages:
'@types/node@24.13.3': '@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/sortablejs@1.15.9': '@types/sortablejs@1.15.9':
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
@@ -567,6 +576,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
chai@6.2.2: chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -575,6 +588,9 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -629,6 +645,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0: decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -640,6 +660,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -719,6 +742,10 @@ packages:
picomatch: picomatch:
optional: true optional: true
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
follow-redirects@1.16.0: follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@@ -744,6 +771,10 @@ packages:
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -908,6 +939,10 @@ packages:
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
engines: {node: '>=14'} engines: {node: '>=14'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash-es@4.18.1: lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
@@ -990,6 +1025,18 @@ packages:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
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'}
package-json-from-dist@1.0.1: package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -999,6 +1046,10 @@ packages:
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-key@3.1.1: path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1036,6 +1087,10 @@ packages:
pkg-types@2.3.1: pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss@8.5.23: postcss@8.5.23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -1051,6 +1106,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
quansync@0.2.11: quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -1058,10 +1118,17 @@ packages:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2: require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
rolldown@1.1.5: rolldown@1.1.5:
resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1079,6 +1146,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1394,6 +1464,9 @@ packages:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1404,6 +1477,10 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
hasBin: true hasBin: true
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1419,11 +1496,22 @@ packages:
xmlchars@2.2.0: xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yaml@2.9.0: yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
zrender@6.1.0: zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
@@ -1674,6 +1762,10 @@ snapshots:
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 24.13.3
'@types/sortablejs@1.15.9': {} '@types/sortablejs@1.15.9': {}
'@types/web-bluetooth@0.0.21': {} '@types/web-bluetooth@0.0.21': {}
@@ -1912,12 +2004,20 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
function-bind: 1.1.2 function-bind: 1.1.2
camelcase@5.3.1: {}
chai@6.2.2: {} chai@6.2.2: {}
chokidar@5.0.0: chokidar@5.0.0:
dependencies: dependencies:
readdirp: 5.0.0 readdirp: 5.0.0
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -1967,12 +2067,16 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decamelize@1.2.0: {}
decimal.js@10.6.0: {} decimal.js@10.6.0: {}
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dijkstrajs@1.0.3: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -2053,6 +2157,11 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.5 picomatch: 4.0.5
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
follow-redirects@1.16.0(debug@4.4.3): follow-redirects@1.16.0(debug@4.4.3):
optionalDependencies: optionalDependencies:
debug: 4.4.3 debug: 4.4.3
@@ -2075,6 +2184,8 @@ snapshots:
function-bind@1.1.2: {} function-bind@1.1.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -2240,6 +2351,10 @@ snapshots:
pkg-types: 2.3.1 pkg-types: 2.3.1
quansync: 0.2.11 quansync: 0.2.11
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash-es@4.18.1: {} lodash-es@4.18.1: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1):
@@ -2303,6 +2418,16 @@ snapshots:
obug@2.1.4: {} obug@2.1.4: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {} package-json-from-dist@1.0.1: {}
parse5@8.0.1: parse5@8.0.1:
@@ -2311,6 +2436,8 @@ snapshots:
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
path-scurry@1.11.1: path-scurry@1.11.1:
@@ -2346,6 +2473,8 @@ snapshots:
exsolve: 1.1.0 exsolve: 1.1.0
pathe: 2.0.3 pathe: 2.0.3
pngjs@5.0.0: {}
postcss@8.5.23: postcss@8.5.23:
dependencies: dependencies:
nanoid: 3.3.16 nanoid: 3.3.16
@@ -2358,12 +2487,22 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
quansync@0.2.11: {} quansync@0.2.11: {}
readdirp@5.0.0: {} readdirp@5.0.0: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
rolldown@1.1.5: rolldown@1.1.5:
dependencies: dependencies:
'@oxc-project/types': 0.139.0 '@oxc-project/types': 0.139.0
@@ -2393,6 +2532,8 @@ snapshots:
semver@7.8.5: {} semver@7.8.5: {}
set-blocking@2.0.0: {}
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0
@@ -2665,6 +2806,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@noble/hashes' - '@noble/hashes'
which-module@2.0.1: {}
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
@@ -2674,6 +2817,12 @@ snapshots:
siginfo: 2.0.0 siginfo: 2.0.0
stackback: 0.0.2 stackback: 0.0.2
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -2690,8 +2839,29 @@ snapshots:
xmlchars@2.2.0: {} xmlchars@2.2.0: {}
y18n@4.0.3: {}
yaml@2.9.0: {} yaml@2.9.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.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
zrender@6.1.0: zrender@6.1.0:
dependencies: dependencies:
tslib: 2.3.0 tslib: 2.3.0
@@ -1,8 +1,26 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, type PropType } from 'vue'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessageBox, ElRadioGroup } from 'element-plus'
import QRCode from 'qrcode'
import { pdaReleaseRoutes } from '../routes' import { pdaReleaseRoutes } from '../routes'
import { adminNavigation } from '@/core/navigation/adminNavigation' import { adminNavigation } from '@/core/navigation/adminNavigation'
import CopyButton from '@/shared/components/CopyButton.vue'
import { copyText } from '@/shared/utils/copyText'
import { listPdaReleases, publishPdaRelease, withdrawPdaRelease, type PdaRelease } from '../api/pdaReleases'
import PdaReleasesView from './PdaReleasesView.vue'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/pdaReleases', () => ({
listPdaReleases: vi.fn(), publishPdaRelease: vi.fn(), uploadPdaRelease: vi.fn(), withdrawPdaRelease: vi.fn(),
}))
vi.mock('@/shared/utils/copyText', () => ({ copyText: vi.fn() }))
enableAutoUnmount(afterEach)
afterEach(() => vi.restoreAllMocks())
const page = readFileSync(resolve(process.cwd(), 'src/modules/pda-releases/pages/PdaReleasesView.vue'), 'utf8') const page = readFileSync(resolve(process.cwd(), 'src/modules/pda-releases/pages/PdaReleasesView.vue'), 'utf8')
@@ -18,7 +36,8 @@ describe('PDA release workbench', () => {
it('provides separate apps, an APK download and manage-only upload/publish/withdraw controls', () => { it('provides separate apps, an APK download and manage-only upload/publish/withdraw controls', () => {
expect(page).toContain('value="domestic"') expect(page).toContain('value="domestic"')
expect(page).toContain('value="overseas"') expect(page).toContain('value="overseas"')
expect(page).toContain('row.packageType === \'apk\' && row.downloadUrl') expect(page).toContain('aria-label="最新 APK 下载"')
expect(page).not.toContain('@click="download(asRelease(row))"')
expect(page).toContain('v-if="canManage"') expect(page).toContain('v-if="canManage"')
expect(page).toContain('row.status === \'DRAFT\'') expect(page).toContain('row.status === \'DRAFT\'')
expect(page).toContain('row.current') expect(page).toContain('row.current')
@@ -35,10 +54,207 @@ describe('PDA release workbench', () => {
const toolbar = page.match(/<template #toolbar-start>([\s\S]*?)<\/template>/)?.[1] const toolbar = page.match(/<template #toolbar-start>([\s\S]*?)<\/template>/)?.[1]
expect(toolbar).toContain('class="pda-release-context query-toolbar workbench-list-tabs"') expect(toolbar).toContain('class="pda-release-context query-toolbar workbench-list-tabs"')
expect(toolbar).toContain('<el-radio-group v-model="app" size="small" aria-label="选择PDA应用">') expect(toolbar).toContain('<el-radio-group v-model="app" size="small" aria-label="选择PDA应用">')
expect(toolbar).toContain('当前 APK')
expect(toolbar).toContain('当前 WGT') expect(toolbar).toContain('当前 WGT')
expect(page).not.toContain('<template #filters>') expect(page).not.toContain('<template #filters>')
expect(page).not.toContain('<h1>PDA版本</h1>') expect(page).not.toContain('<h1>PDA版本</h1>')
expect(pdaReleaseRoutes[0]?.meta?.showPageHeading).toBe(false) expect(pdaReleaseRoutes[0]?.meta?.showPageHeading).toBe(false)
}) })
}) })
const apk: PdaRelease = {
id: 'apk-current', app: 'domestic', appid: 'domestic-pda', packageType: 'apk',
versionName: '0.1.0', versionCode: 10, minAppVersionCode: 10, mandatory: false,
releaseNotes: '更新扫码', status: 'PUBLISHED', current: true, sha256: 'checksum', sizeBytes: 1024,
downloadUrl: '/gateway-api/pda/domestic/current.apk', createdAt: '2026-11-06T14:00:00', publishedAt: '2026-11-06T14:10:00',
}
const apkUrl = new URL(apk.downloadUrl!, window.location.href).href
const overseasApk: PdaRelease = { ...apk, id: 'overseas-current', app: 'overseas', appid: 'overseas-pda',
downloadUrl: 'https://downloads.example.com/pda/overseas/current.apk' }
const TableStub = defineComponent({
props: { data: { type: Array as PropType<PdaRelease[]>, default: () => [] } },
template: `<div><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="notices" />
<div v-for="row in data" :key="row.id"><slot name="actions" :row="row" /></div></div>`,
})
function renderPage() {
return mount(PdaReleasesView, {
global: { plugins: [ElementPlus], stubs: { DataTable: TableStub, AppDialog: true } },
})
}
describe('latest APK download', () => {
beforeEach(() => {
can.mockReturnValue(true)
vi.mocked(listPdaReleases).mockReset().mockImplementation(async (targetApp) => targetApp === 'domestic' ? [apk] : [overseasApk])
vi.mocked(publishPdaRelease).mockReset().mockResolvedValue(apk)
vi.mocked(withdrawPdaRelease).mockReset().mockResolvedValue({ ...apk, current: false, status: 'WITHDRAWN' })
vi.spyOn(QRCode, 'toString')
})
it('encodes the current published APK as an absolute URL and shares it with read-only users', async () => {
can.mockReturnValue(false)
vi.mocked(listPdaReleases).mockResolvedValue([
{ ...apk, id: 'apk-history', current: false, downloadUrl: '/old.apk' },
{ ...apk, id: 'apk-draft', current: false, status: 'DRAFT', versionCode: 20, downloadUrl: '/draft.apk' },
{ ...apk, id: 'wgt-current', packageType: 'wgt', versionCode: 30, downloadUrl: '/current.wgt' },
overseasApk, apk,
])
const downloads: HTMLAnchorElement[] = []
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this) })
const wrapper = renderPage()
await flushPromises()
expect(QRCode.toString).toHaveBeenCalledTimes(2)
expect(QRCode.toString).toHaveBeenCalledWith(apkUrl, expect.objectContaining({ type: 'svg', margin: 4, width: 160 }))
expect(QRCode.toString).toHaveBeenCalledWith(overseasApk.downloadUrl, expect.objectContaining({ type: 'svg' }))
const domestic = wrapper.get('[data-app="domestic"]')
const overseas = wrapper.get('[data-app="overseas"]')
expect(domestic.findAll('img')).toHaveLength(1)
expect(overseas.findAll('img')).toHaveLength(1)
expect(wrapper.get('.list-page-layout__toolbar .pda-apk-downloads').exists()).toBe(true)
expect(wrapper.get('.workbench-table').find('.pda-apk-downloads').exists()).toBe(false)
const image = domestic.get('.pda-apk-qr img')
expect(image.attributes('alt')).toBe('国内 PDA最新APK下载二维码')
expect(decodeURIComponent(image.attributes('src').split(',')[1]!)).toContain('<svg')
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
expect(domestic.findComponent(CopyButton).props('value')).toBe(apkUrl)
await domestic.get('[aria-label="复制国内 PDA APK下载链接"]').trigger('click')
await flushPromises()
expect(copyText).toHaveBeenCalledWith(apkUrl)
await domestic.findAll('button').find((button) => button.text() === '下载 APK')!.trigger('click')
expect(downloads[0]?.href).toBe(apkUrl)
expect(downloads[0]?.download).toBe('domestic-pda-0.1.0.apk')
wrapper.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'overseas')
await flushPromises()
await overseas.findAll('button').find((button) => button.text() === '下载 APK')!.trigger('click')
expect(downloads[1]?.href).toBe(overseasApk.downloadUrl)
expect(downloads[1]?.download).toBe('overseas-pda-0.1.0.apk')
expect(wrapper.text()).not.toContain('上传版本')
})
it('offers no download for drafts, withdrawn APKs or WGT-only releases', async () => {
vi.mocked(listPdaReleases).mockResolvedValue([
{ ...apk, status: 'DRAFT', current: true },
{ ...apk, id: 'withdrawn', status: 'WITHDRAWN', current: true },
{ ...apk, id: 'wgt', packageType: 'wgt' },
])
const wrapper = renderPage()
await flushPromises()
expect(wrapper.text()).toContain('尚未发布 APK')
expect(wrapper.find('.pda-apk-qr img').exists()).toBe(false)
expect(wrapper.find('.pda-apk-qr').exists()).toBe(false)
expect(wrapper.find('.pda-apk-download-url').exists()).toBe(false)
expect(QRCode.toString).not.toHaveBeenCalled()
})
it('shows an unavailable state when a published APK has no download URL', async () => {
vi.mocked(listPdaReleases).mockResolvedValue([{ ...apk, downloadUrl: null }])
const wrapper = renderPage()
await flushPromises()
expect(wrapper.text()).toContain('当前 APK 下载地址暂不可用')
expect(wrapper.find('.pda-apk-download-url').exists()).toBe(false)
expect(QRCode.toString).not.toHaveBeenCalled()
})
it('keeps both download cards independent of the selected list tab, including pending QR generation', async () => {
let finishDomestic!: (svg: string) => void
vi.mocked(QRCode.toString).mockImplementationOnce(() => new Promise<string>((resolve) => { finishDomestic = resolve }))
const wrapper = renderPage()
await flushPromises()
const domestic = wrapper.get('[data-app="domestic"]')
const overseas = wrapper.get('[data-app="overseas"]')
const overseasImage = overseas.get('.pda-apk-qr img').attributes('src')
wrapper.findComponent(ElRadioGroup).vm.$emit('update:modelValue', 'overseas')
await flushPromises()
finishDomestic('<svg>domestic QR</svg>')
await flushPromises()
expect(overseas.get('.pda-apk-qr img').attributes('src')).toBe(overseasImage)
expect(domestic.get('.pda-apk-qr img').attributes('alt')).toBe('国内 PDA最新APK下载二维码')
expect(overseas.get('.pda-apk-qr img').attributes('alt')).toBe('海外 PDA最新APK下载二维码')
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
expect(overseas.get('.pda-apk-download-url').attributes('href')).toBe(overseasApk.downloadUrl)
expect(wrapper.findComponent(TableStub).props('data')).toEqual([overseasApk])
expect(listPdaReleases).toHaveBeenCalledTimes(2)
})
it('ignores a previous QR generation after refreshing the latest APK', async () => {
let finishOld!: (svg: string) => void
vi.mocked(QRCode.toString).mockImplementationOnce(() => new Promise<string>((resolve) => { finishOld = resolve }))
const newerApk = { ...apk, id: 'newer-apk', downloadUrl: '/newer.apk' }
const wrapper = renderPage()
await flushPromises()
vi.mocked(listPdaReleases).mockImplementation(async (targetApp) => targetApp === 'domestic' ? [newerApk] : [overseasApk])
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
await flushPromises()
const domestic = wrapper.get('[data-app="domestic"]')
const newerImage = domestic.get('.pda-apk-qr img').attributes('src')
finishOld('<svg>old QR</svg>')
await flushPromises()
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(new URL('/newer.apk', window.location.href).href)
expect(domestic.get('.pda-apk-qr img').attributes('src')).toBe(newerImage)
expect(listPdaReleases).toHaveBeenCalledTimes(4)
})
it('shows the QR only after publishing and removes it on withdrawal without selecting history', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm')
const domesticResponses: PdaRelease[][] = [
[{ ...apk, status: 'DRAFT', current: false }], [apk],
[{ ...apk, status: 'WITHDRAWN', current: false }, { ...apk, id: 'history', current: false }],
]
vi.mocked(listPdaReleases).mockImplementation(async (targetApp) => targetApp === 'domestic' ? domesticResponses.shift()! : [overseasApk])
const wrapper = renderPage()
await flushPromises()
const domestic = wrapper.get('[data-app="domestic"]')
const overseasImage = wrapper.get('[data-app="overseas"] img').attributes('src')
expect(domestic.find('.pda-apk-qr img').exists()).toBe(false)
await wrapper.findAll('button').find((button) => button.text() === '发布')!.trigger('click')
await flushPromises()
expect(publishPdaRelease).toHaveBeenCalledWith(apk.id)
expect(domestic.find('.pda-apk-qr img').exists()).toBe(true)
await wrapper.findAll('button').find((button) => button.text() === '停发')!.trigger('click')
await flushPromises()
expect(withdrawPdaRelease).toHaveBeenCalledWith(apk.id)
expect(domestic.find('.pda-apk-qr').exists()).toBe(false)
expect(domestic.find('.pda-apk-download-url').exists()).toBe(false)
expect(wrapper.get('[data-app="overseas"] img').attributes('src')).toBe(overseasImage)
expect(listPdaReleases).toHaveBeenCalledTimes(4)
})
it('clears the previous download when refresh fails and recovers on retry', async () => {
const wrapper = renderPage()
await flushPromises()
const domestic = wrapper.get('[data-app="domestic"]')
vi.mocked(listPdaReleases).mockRejectedValueOnce(new Error('network error'))
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
expect(domestic.find('.pda-apk-qr img').exists()).toBe(false)
await flushPromises()
expect(domestic.find('.pda-apk-download-url').exists()).toBe(false)
expect(domestic.get('[role="alert"]').text()).toBeTruthy()
expect(wrapper.get('[data-app="overseas"] .pda-apk-download-url').attributes('href')).toBe(overseasApk.downloadUrl)
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
await flushPromises()
expect(domestic.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
expect(domestic.find('.pda-apk-qr img').exists()).toBe(true)
})
it('reports QR generation failure while preserving the APK link and allows refresh to retry', async () => {
vi.mocked(QRCode.toString).mockImplementationOnce(() => Promise.reject(new Error('QR unavailable')))
const wrapper = renderPage()
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('下载二维码生成失败,请刷新后重试')
expect(wrapper.get('.pda-apk-download-url').attributes('href')).toBe(apkUrl)
await wrapper.findAll('button').find((button) => button.text() === '刷新')!.trigger('click')
await flushPromises()
expect(wrapper.find('.pda-apk-qr img').exists()).toBe(true)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
})
@@ -4,8 +4,10 @@ import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { FileUp, Plus, RefreshCw } from '@lucide/vue' import { FileUp, Plus, RefreshCw } from '@lucide/vue'
import QRCode from 'qrcode'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue' import AppDialog from '@/shared/components/AppDialog.vue'
import CopyButton from '@/shared/components/CopyButton.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -18,9 +20,14 @@ import {
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const canManage = computed(() => access.can('platform:pda-release:manage')) const canManage = computed(() => access.can('platform:pda-release:manage'))
const app = ref<PdaApp>('domestic') const app = ref<PdaApp>('domestic')
const rows = ref<PdaRelease[]>([]) const apps: PdaApp[] = ['domestic', 'overseas']
const loading = ref(false) const releaseLists = reactive<Record<PdaApp, { rows: PdaRelease[]; loading: boolean; error: string; request: number }>>({
const error = ref('') domestic: { rows: [], loading: false, error: '', request: 0 },
overseas: { rows: [], loading: false, error: '', request: 0 },
})
const rows = computed(() => releaseLists[app.value].rows)
const loading = computed(() => releaseLists[app.value].loading)
const refreshing = computed(() => apps.some((targetApp) => releaseLists[targetApp].loading))
const actionId = ref('') const actionId = ref('')
const uploadOpen = ref(false) const uploadOpen = ref(false)
const uploading = ref(false) const uploading = ref(false)
@@ -42,27 +49,65 @@ const columns: DataTableColumn[] = [
{ key: 'publishedAt', label: '发布时间', width: 170, slotName: 'publishedAt' }, { key: 'publishedAt', label: '发布时间', width: 170, slotName: 'publishedAt' },
] ]
const currentApk = computed(() => rows.value.find((row) => row.current && row.packageType === 'apk')) const apkDownloads = apps.map((targetApp) => {
const list = releaseLists[targetApp]
const release = computed(() => list.rows.find((row) => row.app === targetApp
&& row.current && row.status === 'PUBLISHED' && row.packageType === 'apk'))
const url = computed(() => {
if (list.loading || list.error || !release.value?.downloadUrl) return ''
return new URL(release.value.downloadUrl, window.location.href).href
})
const qrImage = ref('')
const qrLoading = ref(false)
const qrError = ref('')
watch(url, async (downloadUrl, _previous, onCleanup) => {
let cancelled = false
onCleanup(() => { cancelled = true })
qrImage.value = ''
qrError.value = ''
qrLoading.value = Boolean(downloadUrl)
if (!downloadUrl) return
try {
const svg = await QRCode.toString(downloadUrl, { type: 'svg', margin: 4, width: 160 })
if (!cancelled) qrImage.value = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
} catch {
if (!cancelled) qrError.value = '下载二维码生成失败,请刷新后重试'
} finally {
if (!cancelled) qrLoading.value = false
}
}, { immediate: true, flush: 'sync' })
return reactive({ app: targetApp, label: targetApp === 'domestic' ? '国内 PDA' : '海外 PDA',
release, url, qrImage, qrLoading, qrError, loading: computed(() => list.loading), error: computed(() => list.error) })
})
const currentApk = computed(() => apkDownloads.find((card) => card.app === app.value)?.release)
const currentWgt = computed(() => rows.value.find((row) => row.current && row.packageType === 'wgt')) const currentWgt = computed(() => rows.value.find((row) => row.current && row.packageType === 'wgt'))
function asRelease(row: unknown) { return row as PdaRelease } function asRelease(row: unknown) { return row as PdaRelease }
async function load() { async function load(requestedApp: PdaApp) {
loading.value = true const list = releaseLists[requestedApp]
error.value = '' const request = ++list.request
const requestedApp = app.value list.loading = true
list.error = ''
try { try {
const result = await listPdaReleases(requestedApp) const result = await listPdaReleases(requestedApp)
if (app.value === requestedApp) rows.value = result if (request === list.request) list.rows = result
} catch (cause) { } catch (cause) {
if (app.value === requestedApp) error.value = getApiErrorMessage(cause, '版本列表加载失败') if (request === list.request) list.error = getApiErrorMessage(cause, '版本列表加载失败')
} finally { } finally {
if (app.value === requestedApp) loading.value = false if (request === list.request) list.loading = false
} }
} }
watch(app, () => { rows.value = []; void load() }) async function refreshAll() {
await Promise.all(apps.map(load))
}
watch(() => form.packageType, () => { form.file = null }) watch(() => form.packageType, () => { form.file = null })
onMounted(() => { void load() }) onMounted(() => { void refreshAll() })
function openUpload() { function openUpload() {
Object.assign(form, { packageType: 'wgt', versionName: '', versionCode: currentApk.value?.versionCode ?? 1, Object.assign(form, { packageType: 'wgt', versionName: '', versionCode: currentApk.value?.versionCode ?? 1,
@@ -83,11 +128,12 @@ async function upload() {
return return
} }
uploading.value = true uploading.value = true
const uploadedApp = app.value
try { try {
await uploadPdaRelease(app.value, { ...form, file: form.file }) await uploadPdaRelease(uploadedApp, { ...form, file: form.file })
uploadOpen.value = false uploadOpen.value = false
ElMessage.success('版本草稿已上传,请核对后发布') ElMessage.success('版本草稿已上传,请核对后发布')
await load() await load(uploadedApp)
} catch (cause) { } catch (cause) {
ElMessage.error(getApiErrorMessage(cause, '更新包上传失败')) ElMessage.error(getApiErrorMessage(cause, '更新包上传失败'))
} finally { uploading.value = false } } finally { uploading.value = false }
@@ -104,18 +150,19 @@ async function changeStatus(row: PdaRelease, action: 'publish' | 'withdraw') {
if (action === 'publish') await publishPdaRelease(row.id) if (action === 'publish') await publishPdaRelease(row.id)
else await withdrawPdaRelease(row.id) else await withdrawPdaRelease(row.id)
ElMessage.success(`版本已${label}`) ElMessage.success(`版本已${label}`)
await load() await load(row.app)
} catch (cause) { } catch (cause) {
ElMessage.error(getApiErrorMessage(cause, `${label}失败,请刷新后重试`)) ElMessage.error(getApiErrorMessage(cause, `${label}失败,请刷新后重试`))
} finally { actionId.value = '' } } finally { actionId.value = '' }
} }
function download(row: PdaRelease) { function downloadApk(targetApp: PdaApp) {
if (!row.downloadUrl) return const card = apkDownloads.find((item) => item.app === targetApp)
if (!card?.url || !card.release) return
const link = document.createElement('a') const link = document.createElement('a')
link.href = row.downloadUrl link.href = card.url
link.rel = 'noopener noreferrer' link.rel = 'noopener noreferrer'
link.download = `${row.app}-pda-${row.versionName}.${row.packageType}` link.download = `${targetApp}-pda-${card.release.versionName}.apk`
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
link.remove() link.remove()
@@ -125,6 +172,29 @@ function download(row: PdaRelease) {
<template> <template>
<AdminPage class="admin-density pda-releases-page admin-workbench"> <AdminPage class="admin-density pda-releases-page admin-workbench">
<ListPageLayout aria-label="PDA版本"> <ListPageLayout aria-label="PDA版本">
<template #toolbar>
<div class="pda-apk-downloads" aria-label="最新 APK 下载">
<section v-for="card in apkDownloads" :key="card.app" v-loading="card.loading || card.qrLoading" class="pda-apk-download" :data-app="card.app" :aria-label="`${card.label}下载`" :aria-busy="card.loading || card.qrLoading">
<div v-if="card.release?.downloadUrl" class="pda-apk-qr">
<img v-if="card.qrImage" :src="card.qrImage" :alt="`${card.label}最新APK下载二维码`" width="160" height="160" />
</div>
<div class="pda-apk-download-content">
<h2>{{ card.label }} · 扫码下载 APK</h2>
<template v-if="card.url">
<p>{{ card.release?.versionName }} ({{ card.release?.versionCode }})</p>
<a class="pda-apk-download-url" :href="card.url" rel="noopener noreferrer">{{ card.url }}</a>
<div class="density-actions">
<el-button @click="downloadApk(card.app)">下载 APK</el-button>
<CopyButton :value="card.url" :label="`${card.label} APK下载链接`" button-text="复制链接" />
</div>
</template>
<p v-else-if="card.error" role="alert">{{ card.error }}</p>
<p v-else-if="!card.loading">{{ card.release ? '当前 APK 下载地址暂不可用' : '尚未发布 APK' }}</p>
<p v-if="card.qrError" role="alert">{{ card.qrError }}</p>
</div>
</section>
</div>
</template>
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" preference-key="pda-releases-table" :data="rows" :columns="columns" :loading="loading" :aria-busy="loading" row-key="id" border empty-text="暂无版本记录"> <div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" preference-key="pda-releases-table" :data="rows" :columns="columns" :loading="loading" :aria-busy="loading" row-key="id" border empty-text="暂无版本记录">
<template #toolbar-start> <template #toolbar-start>
<div class="pda-release-context query-toolbar workbench-list-tabs"> <div class="pda-release-context query-toolbar workbench-list-tabs">
@@ -133,21 +203,16 @@ function download(row: PdaRelease) {
<el-radio-button value="overseas">海外 PDA</el-radio-button> <el-radio-button value="overseas">海外 PDA</el-radio-button>
</el-radio-group> </el-radio-group>
<dl class="pda-release-current"> <dl class="pda-release-current">
<div><dt>当前 APK</dt><dd>{{ currentApk ? `${currentApk.versionName} (${currentApk.versionCode})` : '尚未发布' }}</dd></div>
<div><dt>当前 WGT</dt><dd>{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</dd></div> <div><dt>当前 WGT</dt><dd>{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</dd></div>
</dl> </dl>
</div> </div>
</template> </template>
<template #notices><el-alert v-if="error" class="pda-release-error" type="error" :title="error" show-icon :closable="false" /></template>
<template #toolbar-actions> <template #toolbar-actions>
<el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button> <el-button :loading="refreshing" @click="refreshAll"><RefreshCw :size="16" />刷新</el-button>
<el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button> <el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button>
</template> </template>
<template #actions="{ row }"> <template #actions="{ row }">
<div class="pda-release-actions density-actions row-actions admin-text-actions"> <div class="pda-release-actions density-actions row-actions admin-text-actions">
<el-button v-if="row.packageType === 'apk' && row.downloadUrl" class="density-action" data-tone="neutral" @click="download(asRelease(row))">下载 APK</el-button>
<el-button v-if="canManage && row.status === 'DRAFT'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button> <el-button v-if="canManage && row.status === 'DRAFT'" class="density-action" data-tone="primary" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button>
<el-button v-if="canManage && row.current" class="density-action" data-tone="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button> <el-button v-if="canManage && row.current" class="density-action" data-tone="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button>
</div> </div>
@@ -190,12 +255,19 @@ function download(row: PdaRelease) {
<style scoped> <style scoped>
.pda-release-panel { min-width: 0; overflow: hidden; } .pda-release-panel { min-width: 0; overflow: hidden; }
.pda-release-error { border: 0; border-bottom: 1px solid var(--el-color-danger-light-7); border-radius: 0; }
.pda-release-context { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); } .pda-release-context { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); }
.pda-release-current { display: flex; min-width: 0; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); margin: 0; } .pda-release-current { display: flex; min-width: 0; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); margin: 0; }
.pda-release-current > div { display: flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2); } .pda-release-current > div { display: flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2); }
.pda-release-current dt { color: var(--workbench-muted); font-size: var(--admin-font-caption); } .pda-release-current dt { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.pda-release-current dd { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body); font-variant-numeric: tabular-nums; } .pda-release-current dd { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body); font-variant-numeric: tabular-nums; }
.pda-apk-downloads { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: var(--admin-space-5); }
.pda-apk-download { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: 4px; background: var(--cc-surface); }
.pda-apk-qr { display: grid; width: 160px; height: 160px; flex-shrink: 0; place-items: center; background: #fff; }
.pda-apk-qr img { display: block; }
.pda-apk-download-content { display: grid; min-width: 0; gap: var(--admin-space-5); }
.pda-apk-download-content h2 { margin: 0; color: var(--cc-text); font-size: var(--admin-font-section); }
.pda-apk-download-content p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-body); }
.pda-apk-download-url { color: var(--el-color-primary); font-size: var(--admin-font-body); overflow-wrap: anywhere; }
.pda-release-actions { display: flex; align-items: center; gap: var(--admin-space-2); } .pda-release-actions { display: flex; align-items: center; gap: var(--admin-space-2); }
.pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; } .pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; }
.pda-release-options { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 0 var(--admin-space-6); } .pda-release-options { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 0 var(--admin-space-6); }