11
This commit is contained in:
1 parent
7571180b55
commit
26e8a38893
78 files changed
+8035
-457
No files matched your search
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.zax.htys">
|
||||
<!-- Camera 模块会自动声明硬件特征,在主清单覆盖可选性,避免 permissions 数组生成重复节点。 -->
|
||||
<uses-feature android:name="android.hardware.camera"
|
||||
android:required="false" tools:replace="android:required" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus"
|
||||
android:required="false" tools:replace="android:required" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array><string>remote-notification</string></array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"JPUSH_APPKEY": "447fcdbce3b2dcac3091c0de",
|
||||
"JPUSH_CHANNEL": "developer-default"
|
||||
}
|
||||
@@ -71,6 +71,7 @@
|
||||
"@dcloudio/uni-automator": "3.0.0-5010520260709002",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-5010520260709002",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-5010520260709002",
|
||||
"@dcloudio/uni-uts-v1": "3.0.0-5010520260709002",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-5010520260709002",
|
||||
"@types/node": "^24.9.1",
|
||||
"@uni-helper/uni-app-types": "1.0.0-alpha.6",
|
||||
|
||||
Generated
+168
@@ -111,6 +111,9 @@ importers:
|
||||
'@dcloudio/uni-stacktracey':
|
||||
specifier: 3.0.0-5010520260709002
|
||||
version: 3.0.0-5010520260709002
|
||||
'@dcloudio/uni-uts-v1':
|
||||
specifier: 3.0.0-5010520260709002
|
||||
version: 3.0.0-5010520260709002(rollup@4.14.3)
|
||||
'@dcloudio/vite-plugin-uni':
|
||||
specifier: 3.0.0-5010520260709002
|
||||
version: 3.0.0-5010520260709002(postcss@8.5.8)(rollup@4.14.3)(vite@5.2.8(@types/node@24.12.0)(sass@1.78.0)(terser@5.46.1))(vue@3.5.30(typescript@4.9.5))
|
||||
@@ -820,6 +823,9 @@ packages:
|
||||
'@dcloudio/uni-app-vue@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-3Qo327MnpffW+NqidysJi/1hOuyETLV0Iu4X3MaKnNYo8BFl0ihHlO7q9g1X3WkUQyHNnfwLgNGTrQ9kLM6L0w==}
|
||||
|
||||
'@dcloudio/uni-app-x@0.7.114':
|
||||
resolution: {integrity: sha512-Kh6RVYmzx1mW9PSXXZmYJt2kanG7mRwKy6dQ0wH/D0EjHgWHsNbXuRQ6++xwc7KOChkoqnRWr68AiQTP1CcT0Q==}
|
||||
|
||||
'@dcloudio/uni-app@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-yMfuGjj52VIgdG1VJ/YpQus3TyVnlc1JYt7rcqGfsSG+pPI7M/kTKSEIsXZ2acEPKH6hHYX/DC3k4wPdwf18Ag==}
|
||||
peerDependencies:
|
||||
@@ -916,6 +922,50 @@ packages:
|
||||
'@dcloudio/uni-ui@1.5.11':
|
||||
resolution: {integrity: sha512-DBtk046ofmeFd82zRI7d89SoEwrAxYzUN3WVPm1DIBkpLPG5F5QDNkHMnZGu2wNrMEmGBjBpUh3vqEY1L3jaMw==}
|
||||
|
||||
'@dcloudio/uni-uts-v1@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-QvSWV1uiIyh3Cus6Bs3diWB5HDAC1QLwZokkHrh9NVMVANRFJNd+6MVjqUI7IvLy2S45/5F96HhWNlyhaivtbw==}
|
||||
|
||||
'@dcloudio/uts-darwin-arm64@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-Ac8QnGmBJCHgrvopyXtHwruRk3+FB8JkU9amsDcnkdprANGbnjLNf6lpNPIBsG4M3Tu6pPeygOT/DINe9pJ1Cg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@dcloudio/uts-darwin-x64@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-7AVpGOrkiz7lvTO1y4qsZJMUjSmOQR9Ou1u99PdPBuLqcRshhI0jj3Rf9BZmYW6c9Gk+3kXEUJ3PB6CpieFIJg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@dcloudio/uts-linux-x64-gnu@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-3cXg/ALLzHM5GOCi7PnPEB8prXxHg9vmw8rG734HJ37H8BC6jBzXoEMbVVt7y5jyCnYSVdRYN6kx8Vht/Iezdw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [gnu]
|
||||
|
||||
'@dcloudio/uts-linux-x64-musl@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-W7ds3jXo9VBLFkOR4Dye5Q4YFh72OOhTbXsFBt98RyzyN0legCryXI29LY3xelHHOuQv5cmNp6hhZQnpz6wTaQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@dcloudio/uts-win32-ia32-msvc@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-3u3DsY6KqDNCw4Zpv15ySXewSaaJDMhLNqvTfzTx+Pq1iKqxsofxUn4hKrh8TXYA6+Qncgondrrp34casBljWQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@dcloudio/uts-win32-x64-msvc@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-BrI3+3yFc+3nCVHnbXxXi0PqDXfpgZgyNLSCM4fzY/TU59TqPeOH84gg/YVu6u1JkHmnsRj2kPoQyJtMU3he+w==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@dcloudio/uts@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-K4KgP2vwYj3mQybgtlA+bqrfCDEa/Hn0gTJLk4i1vzL/nUdB/MnGauiXu3BUuipOlWROww14YqY3dnCw89iyLw==}
|
||||
|
||||
'@dcloudio/vite-plugin-uni@3.0.0-5010520260709002':
|
||||
resolution: {integrity: sha512-HynSNsicIj8KJ9OztI3hVcU7inFw/vzQGauqfzlLV+yBNRhAGqxx9WKN/NZiN6HVpOc8xTyICr0x4XlI7r7f7A==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
@@ -1796,6 +1846,9 @@ packages:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
android-versions@1.9.0:
|
||||
resolution: {integrity: sha512-13O2B6PQMEM4ej9n13ePRQeckrCoKbZrvuzlLvK+9s2QmncpHDbYzZxhgapN32sJNoifN6VAHexLnd/6CYrs7Q==}
|
||||
|
||||
ansi-escapes@4.3.2:
|
||||
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2038,6 +2091,10 @@ packages:
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
colors@1.4.0:
|
||||
resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
|
||||
engines: {node: '>=0.1.90'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2045,6 +2102,9 @@ packages:
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
commondir@1.0.1:
|
||||
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
|
||||
|
||||
compare-versions@3.6.0:
|
||||
resolution: {integrity: sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==}
|
||||
|
||||
@@ -2409,6 +2469,10 @@ packages:
|
||||
resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
find-cache-dir@3.3.2:
|
||||
resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2504,6 +2568,9 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
graphlib@2.1.8:
|
||||
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
|
||||
|
||||
gzip-size@6.0.0:
|
||||
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2950,6 +3017,10 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
make-dir@3.1.0:
|
||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2961,6 +3032,11 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
md5-file@5.0.0:
|
||||
resolution: {integrity: sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
mdn-data@2.0.30:
|
||||
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
|
||||
|
||||
@@ -3090,6 +3166,10 @@ packages:
|
||||
nwsapi@2.2.23:
|
||||
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
|
||||
|
||||
object-hash@3.0.0:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3514,6 +3594,11 @@ packages:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
semver@7.6.3:
|
||||
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
semver@7.7.4:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5072,6 +5157,8 @@ snapshots:
|
||||
|
||||
'@dcloudio/uni-app-vue@3.0.0-5010520260709002': {}
|
||||
|
||||
'@dcloudio/uni-app-x@0.7.114': {}
|
||||
|
||||
'@dcloudio/uni-app@3.0.0-5010520260709002(@dcloudio/types@3.4.30)(postcss@8.5.8)(rollup@4.14.3)(vite@5.2.8(@types/node@24.12.0)(sass@1.78.0)(terser@5.46.1))(vue@3.5.30(typescript@4.9.5))':
|
||||
dependencies:
|
||||
'@dcloudio/types': 3.4.30
|
||||
@@ -5587,6 +5674,59 @@ snapshots:
|
||||
|
||||
'@dcloudio/uni-ui@1.5.11': {}
|
||||
|
||||
'@dcloudio/uni-uts-v1@3.0.0-5010520260709002(rollup@4.14.3)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.24.7
|
||||
'@dcloudio/uni-app-x': 0.7.114
|
||||
'@dcloudio/uts': 3.0.0-5010520260709002
|
||||
'@rollup/pluginutils': 5.1.0(rollup@4.14.3)
|
||||
'@vue/shared': 3.4.21
|
||||
adm-zip: 0.5.16
|
||||
android-versions: 1.9.0
|
||||
colors: 1.4.0
|
||||
debug: 4.3.7
|
||||
fast-glob: 3.3.3
|
||||
find-cache-dir: 3.3.2
|
||||
fs-extra: 10.1.0
|
||||
graphlib: 2.1.8
|
||||
jsonc-parser: 3.3.1
|
||||
lodash: 4.17.23
|
||||
md5-file: 5.0.0
|
||||
object-hash: 3.0.0
|
||||
semver: 7.6.3
|
||||
source-map: 0.7.6
|
||||
source-map-js: 1.2.1
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
'@dcloudio/uts-darwin-arm64@3.0.0-5010520260709002':
|
||||
optional: true
|
||||
|
||||
'@dcloudio/uts-darwin-x64@3.0.0-5010520260709002':
|
||||
optional: true
|
||||
|
||||
'@dcloudio/uts-linux-x64-gnu@3.0.0-5010520260709002':
|
||||
optional: true
|
||||
|
||||
'@dcloudio/uts-linux-x64-musl@3.0.0-5010520260709002':
|
||||
optional: true
|
||||
|
||||
'@dcloudio/uts-win32-ia32-msvc@3.0.0-5010520260709002':
|
||||
optional: true
|
||||
|
||||
'@dcloudio/uts-win32-x64-msvc@3.0.0-5010520260709002':
|
||||
optional: true
|
||||
|
||||
'@dcloudio/uts@3.0.0-5010520260709002':
|
||||
optionalDependencies:
|
||||
'@dcloudio/uts-darwin-arm64': 3.0.0-5010520260709002
|
||||
'@dcloudio/uts-darwin-x64': 3.0.0-5010520260709002
|
||||
'@dcloudio/uts-linux-x64-gnu': 3.0.0-5010520260709002
|
||||
'@dcloudio/uts-linux-x64-musl': 3.0.0-5010520260709002
|
||||
'@dcloudio/uts-win32-ia32-msvc': 3.0.0-5010520260709002
|
||||
'@dcloudio/uts-win32-x64-msvc': 3.0.0-5010520260709002
|
||||
|
||||
'@dcloudio/vite-plugin-uni@3.0.0-5010520260709002(postcss@8.5.8)(rollup@4.14.3)(vite@5.2.8(@types/node@24.12.0)(sass@1.78.0)(terser@5.46.1))(vue@3.5.30(typescript@4.9.5))':
|
||||
dependencies:
|
||||
'@babel/core': 7.25.2
|
||||
@@ -6746,6 +6886,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
android-versions@1.9.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
|
||||
ansi-escapes@4.3.2:
|
||||
dependencies:
|
||||
type-fest: 0.21.3
|
||||
@@ -7029,12 +7173,16 @@ snapshots:
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
colors@1.4.0: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commondir@1.0.1: {}
|
||||
|
||||
compare-versions@3.6.0: {}
|
||||
|
||||
computeds@0.0.1: {}
|
||||
@@ -7389,6 +7537,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
find-cache-dir@3.3.2:
|
||||
dependencies:
|
||||
commondir: 1.0.1
|
||||
make-dir: 3.1.0
|
||||
pkg-dir: 4.2.0
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
@@ -7479,6 +7633,10 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
graphlib@2.1.8:
|
||||
dependencies:
|
||||
lodash: 4.17.23
|
||||
|
||||
gzip-size@6.0.0:
|
||||
dependencies:
|
||||
duplexer: 0.1.2
|
||||
@@ -8150,6 +8308,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
make-dir@3.1.0:
|
||||
dependencies:
|
||||
semver: 6.3.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
@@ -8160,6 +8322,8 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
md5-file@5.0.0: {}
|
||||
|
||||
mdn-data@2.0.30: {}
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
@@ -8252,6 +8416,8 @@ snapshots:
|
||||
|
||||
nwsapi@2.2.23: {}
|
||||
|
||||
object-hash@3.0.0: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
object-is@1.1.6:
|
||||
@@ -8674,6 +8840,8 @@ snapshots:
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.6.3: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
send@0.18.0:
|
||||
|
||||
+24
-5
@@ -8,6 +8,11 @@ import * as wsApi from './common/wssocket';
|
||||
import chatTokenManager from './common/chat-token-manager';
|
||||
import CHAT_ENV from '@/chat-env.js';
|
||||
import AuthUtil from '@/sheep/api/member/auth';
|
||||
import {
|
||||
initializePush, setPushForeground, consumePendingPushRoute, unbindPushUser, syncPushBadge,
|
||||
} from '@/common/jpush';
|
||||
import AppVersionApi from '@/sheep/api/member/app-version';
|
||||
import appUpdates from '@/sheep/platform/app-update';
|
||||
|
||||
import useChatStore from '@/chat-store/chatStore.js';
|
||||
|
||||
@@ -24,6 +29,7 @@ const reissueChatToken = async () => {
|
||||
|
||||
// 页面可能早于异步 onLaunch 完成加载,需在模块初始化时就注册重签能力。
|
||||
chatTokenManager.setReissueToken(reissueChatToken);
|
||||
appUpdates.configure(AppVersionApi);
|
||||
|
||||
export default {
|
||||
/** ↓↓↓ 以下为app启动聊天相关模块初始化,WebSocket链接,加载聊天消息,加载离线消息,加载群组消息,初始化聊天相关的全局状态仓库 */
|
||||
@@ -76,6 +82,7 @@ export default {
|
||||
complete: () => { },
|
||||
});
|
||||
}
|
||||
syncPushBadge(count);
|
||||
},
|
||||
// 初始化聊天
|
||||
async init() {
|
||||
@@ -186,6 +193,7 @@ export default {
|
||||
this.chatStore.setLoading(false);
|
||||
// 刷新会话
|
||||
this.chatStore.refreshChats();
|
||||
void consumePendingPushRoute();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
@@ -450,6 +458,7 @@ export default {
|
||||
},
|
||||
exit() {
|
||||
console.log('exit');
|
||||
void unbindPushUser({ clearPendingRoute: true });
|
||||
this.isExit = true;
|
||||
chatTokenManager.stop();
|
||||
wsApi.destroy(3099);
|
||||
@@ -521,6 +530,7 @@ export default {
|
||||
this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId);
|
||||
this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId);
|
||||
this.configStore.setAppInit(true);
|
||||
void consumePendingPushRoute();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
@@ -539,6 +549,7 @@ export default {
|
||||
// #endif
|
||||
|
||||
async onLaunch() {
|
||||
initializePush();
|
||||
uni.$on('chat-account-unavailable', (message) => {
|
||||
if (!this.isExit) {
|
||||
this.exit();
|
||||
@@ -546,7 +557,13 @@ export default {
|
||||
}
|
||||
});
|
||||
// 加载Shopro底层依赖
|
||||
await ShoproInit();
|
||||
try {
|
||||
await ShoproInit();
|
||||
} finally {
|
||||
// #ifdef APP-PLUS || APP-HARMONY
|
||||
appUpdates.start();
|
||||
// #endif
|
||||
}
|
||||
// #ifdef H5
|
||||
let lastTouchEnd = 0;
|
||||
document.addEventListener(
|
||||
@@ -587,6 +604,11 @@ export default {
|
||||
// console.log('onError', error);
|
||||
// },
|
||||
onShow() {
|
||||
setPushForeground(true);
|
||||
this.refreshUnreadBadge?.();
|
||||
// #ifdef APP-PLUS || APP-HARMONY
|
||||
void appUpdates.foreground();
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
// H5 页面重新可见或 App 回到前台时,立即检查并重新建立轮询。
|
||||
this.startChatTokenCheck();
|
||||
@@ -596,13 +618,10 @@ export default {
|
||||
const args = plus.runtime.arguments;
|
||||
if (args) {
|
||||
}
|
||||
// 获取剪贴板
|
||||
uni.getClipboardData({
|
||||
success: (res) => { },
|
||||
});
|
||||
// #endif
|
||||
},
|
||||
onHide() {
|
||||
setPushForeground(false);
|
||||
// #ifndef MP-WEIXIN
|
||||
// 进入后台后停止轮询,避免无效请求和重复定时器。
|
||||
chatTokenManager.stop();
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/** 保留用户资料请求,让通知可以在令牌替换与资料返回之间到达。 */
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((complete) => { resolve = complete; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** 同一 VM 执行实际 user 与 jpush 模块,覆盖公开登录入口的联动行为。 */
|
||||
async function fixture(authenticated = true) {
|
||||
const storage = new Map(authenticated ? [['token', 'account-A-token']] : []);
|
||||
const profile = deferred();
|
||||
const routes = [];
|
||||
const unbindings = [];
|
||||
const bindings = [];
|
||||
const logins = [];
|
||||
let route = 'pages/tabbar/home';
|
||||
const navigate = async ({ url }) => {
|
||||
routes.push(url);
|
||||
route = url.replace(/^\//, '').split('?')[0];
|
||||
};
|
||||
class NotificationChannel {
|
||||
static IMPORTANCE_HIGH = 4;
|
||||
setDescription() {}
|
||||
enableVibration() {}
|
||||
}
|
||||
const context = vm.createContext({
|
||||
console: { warn() {} }, setTimeout, clearTimeout,
|
||||
JPUSH_APP_KEY: 'test-app-key', JPUSH_APNS_PRODUCTION: true,
|
||||
plus: { android: {
|
||||
importClass: (value) => typeof value === 'string' ? NotificationChannel : value,
|
||||
runtimeMainActivity: () => ({ getSystemService: () => ({ createNotificationChannel() {} }) }),
|
||||
} },
|
||||
nativePush: {
|
||||
setEventCallBack() {}, setDebug() {}, init() {}, requestRequiredPermission() {},
|
||||
getRegistrationId: () => 'test-registration', setAlias() {}, deleteAlias() {}, clearNotificationAll() {},
|
||||
},
|
||||
PushApi: {
|
||||
bind: async (data, token) => { bindings.push({ data, token }); return { code: 0 }; },
|
||||
unbind: async (registrationId, token) => { unbindings.push({ registrationId, token }); return { code: 0 }; },
|
||||
},
|
||||
UserApi: { getUserInfo: () => profile.promise },
|
||||
PayWalletApi: { getPayWallet: async () => ({ code: 0, data: { balance: 0 } }) },
|
||||
checkPushNotificationPermission: async () => null,
|
||||
wsApi: { setPushState() {}, destroy() {} },
|
||||
chatTokenManager: { stop() {} },
|
||||
appUpdates: { identityChanged() {}, authenticated() {} },
|
||||
$share: { getShareInfo() {}, bindBrokerageUser() {} },
|
||||
app: () => ({ platform: { bind_mobile: 0 } }),
|
||||
cart: () => ({ emptyList() {} }),
|
||||
clone: (value) => JSON.parse(JSON.stringify(value)),
|
||||
cloneDeep: (value) => JSON.parse(JSON.stringify(value)),
|
||||
defineStore: (definition) => {
|
||||
let store;
|
||||
return () => {
|
||||
if (store) return store;
|
||||
store = definition.state();
|
||||
for (const [name, action] of Object.entries(definition.actions)) store[name] = action.bind(store);
|
||||
return store;
|
||||
};
|
||||
},
|
||||
uni: {
|
||||
getSystemInfoSync: () => ({ platform: 'android' }),
|
||||
getStorageSync: (key) => storage.get(key),
|
||||
setStorageSync: (key, value) => storage.set(key, value),
|
||||
removeStorageSync: (key) => storage.delete(key),
|
||||
navigateTo: navigate, redirectTo: navigate, switchTab: navigate,
|
||||
},
|
||||
getCurrentPages: () => [{ route }],
|
||||
getApp: () => ({ $vm: { configStore: { appInit: true }, chatStore: { loading: false } } }),
|
||||
});
|
||||
const pushSource = (await readFile(new URL('../jpush.js', import.meta.url), 'utf8'))
|
||||
.replace(/^import .+;\n/gm, '').replace(/^export const /gm, 'const ');
|
||||
const userSource = (await readFile(new URL('../../sheep/store/user.js', import.meta.url), 'utf8'))
|
||||
.replace(/^import .+;\n/gm, '').replace('export default user;', '');
|
||||
vm.runInContext(`${pushSource}\n${userSource}\nglobalThis.service = {
|
||||
initializePush, bindPushUser, handlePushNotificationClick, getPushAccountId, user,
|
||||
};`, context);
|
||||
const service = context.service;
|
||||
if (authenticated) await service.bindPushUser(42);
|
||||
else service.initializePush();
|
||||
const store = service.user();
|
||||
if (authenticated) store.userInfo = { id: 42, sex: 1 };
|
||||
const loginAfter = store.loginAfter;
|
||||
store.loginAfter = () => {
|
||||
const task = loginAfter();
|
||||
logins.push(task);
|
||||
return task;
|
||||
};
|
||||
const finishProfile = async (id) => {
|
||||
profile.resolve({ code: 0, data: { id, sex: 1 } });
|
||||
await Promise.all(logins);
|
||||
await settle();
|
||||
};
|
||||
return { service, store, storage, routes, bindings, unbindings, finishProfile };
|
||||
}
|
||||
|
||||
test('account transition rejects old-account navigation and unbinds using the old token', async () => {
|
||||
const { service, store, storage, routes, bindings, unbindings, finishProfile } = await fixture();
|
||||
store.setToken('account-B-token', 'account-B-refresh');
|
||||
assert.equal(storage.get('token'), 'account-B-token');
|
||||
assert.equal(service.getPushAccountId(), '');
|
||||
service.handlePushNotificationClick({ extras: {
|
||||
type: 'private_message', taskId: 'late-A', receiverId: 42, targetId: 99,
|
||||
} });
|
||||
await settle();
|
||||
assert.equal(routes.length, 0);
|
||||
assert.equal(unbindings.length, 1);
|
||||
assert.equal(unbindings[0].token, 'account-A-token');
|
||||
assert.equal(unbindings[0].registrationId, 'test-registration');
|
||||
|
||||
await finishProfile(43);
|
||||
assert.equal(service.getPushAccountId(), '43');
|
||||
assert.equal(storage.has('chat-jpush-pending-route'), false);
|
||||
assert.equal(bindings.at(-1).token, 'account-B-token');
|
||||
service.handlePushNotificationClick({ extras: {
|
||||
type: 'private_message', taskId: 'current-B', receiverId: 43, targetId: 100,
|
||||
} });
|
||||
await settle();
|
||||
assert.equal(routes.length, 1);
|
||||
assert.match(routes[0], /targetId=100&receiverId=43/);
|
||||
});
|
||||
|
||||
test('refreshing a token keeps the current push account and notification handling', async () => {
|
||||
const { service, store, unbindings, routes, finishProfile } = await fixture();
|
||||
store.setToken('account-A-refreshed', 'account-A-refresh', true);
|
||||
assert.equal(service.getPushAccountId(), '42');
|
||||
await finishProfile(42);
|
||||
assert.equal(unbindings.length, 0);
|
||||
service.handlePushNotificationClick({ extras: {
|
||||
type: 'private_message', taskId: 'after-refresh', receiverId: 42, targetId: 99,
|
||||
} });
|
||||
await settle();
|
||||
assert.match(routes.at(-1), /targetId=99&receiverId=42/);
|
||||
});
|
||||
|
||||
test('first login preserves an anonymous owned cold click until its user profile is ready', async () => {
|
||||
const { service, store, storage, routes, unbindings, finishProfile } = await fixture(false);
|
||||
service.handlePushNotificationClick({ extras: {
|
||||
type: 'private_message', taskId: 'cold-A', receiverId: 42, targetId: 99,
|
||||
} });
|
||||
await settle();
|
||||
store.setToken('account-A-token', 'account-A-refresh');
|
||||
assert.equal(storage.get('chat-jpush-pending-route').clickId, 'private_message:cold-A');
|
||||
assert.equal(unbindings.length, 0);
|
||||
await finishProfile(42);
|
||||
assert.equal(service.getPushAccountId(), '42');
|
||||
assert.match(routes.at(-1), /targetId=99&receiverId=42/);
|
||||
assert.equal(storage.has('chat-jpush-pending-route'), false);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const GUIDE_SHOWN_KEY = 'chat-jpush-notification-guide-shown';
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/** 与现有推送测试相同,使用隔离 VM 注入 uni 和原生接口。 */
|
||||
async function fixture(options = {}) {
|
||||
const storage = options.storage || new Map([['token', 'test-token']]);
|
||||
const state = {
|
||||
status: options.status ?? 0,
|
||||
checks: 0,
|
||||
settings: 0,
|
||||
modals: [],
|
||||
};
|
||||
const nativePush = {
|
||||
isNotificationEnabled() {
|
||||
state.checks++;
|
||||
if (options.checkFails) throw new Error('Native unavailable');
|
||||
return state.status;
|
||||
},
|
||||
goToAppNotificationSettings() {
|
||||
state.settings++;
|
||||
if (options.settingsFail) throw new Error('Settings unavailable');
|
||||
},
|
||||
};
|
||||
const context = vm.createContext({
|
||||
...(options.native === false ? {} : { plus: { runtime: {} } }),
|
||||
uni: {
|
||||
getDeviceInfo: () => ({ platform: options.platform || 'android' }),
|
||||
getStorageSync: (key) => storage.get(key),
|
||||
setStorageSync: (key, value) => storage.set(key, value),
|
||||
showModal(modal) {
|
||||
state.modals.push(modal);
|
||||
if (options.manualModal) return;
|
||||
if (options.modalFails) modal.fail();
|
||||
else modal.success({ confirm: options.confirm ?? false });
|
||||
},
|
||||
},
|
||||
});
|
||||
let source = await readFile(new URL('../jpush-permission.js', import.meta.url), 'utf8');
|
||||
if (options.h5Build) {
|
||||
source = source.replace(/\/\/ #ifdef APP-PLUS[\s\S]*?\/\/ #endif/g, '');
|
||||
}
|
||||
source = source.replace(/^export const /gm, 'const ');
|
||||
vm.runInContext(`${source}\nglobalThis.check = checkPushNotificationPermission;`, context);
|
||||
return { check: () => context.check(nativePush), nativePush, state, storage };
|
||||
}
|
||||
|
||||
test('enabled notifications continue without a guide or installation marker', async () => {
|
||||
const { check, state, storage } = await fixture({ status: 1 });
|
||||
assert.equal(await check(), true);
|
||||
assert.equal(state.checks, 1);
|
||||
assert.equal(state.modals.length, 0);
|
||||
assert.equal(storage.has(GUIDE_SHOWN_KEY), false);
|
||||
});
|
||||
|
||||
test('confirmation opens official notification settings once and foreground return rechecks', async () => {
|
||||
const { check, state, storage } = await fixture({ confirm: true });
|
||||
assert.equal(await check(), false);
|
||||
assert.equal(state.modals.length, 1);
|
||||
assert.equal(state.modals[0].title, '开启消息通知');
|
||||
assert.equal(state.modals[0].confirmText, '去开启');
|
||||
assert.equal(state.modals[0].cancelText, '暂不开启');
|
||||
assert.equal(state.settings, 1);
|
||||
assert.equal(storage.get(GUIDE_SHOWN_KEY), true);
|
||||
state.status = 1;
|
||||
assert.equal(await check(), true);
|
||||
state.status = 0;
|
||||
assert.equal(await check(), false);
|
||||
assert.equal(state.checks, 3);
|
||||
assert.equal(state.modals.length, 1);
|
||||
assert.equal(state.settings, 1);
|
||||
});
|
||||
|
||||
test('declining the guide persists across reload and account changes', async () => {
|
||||
const first = await fixture();
|
||||
assert.equal(await first.check(), false);
|
||||
first.storage.set('token', 'second-account-token');
|
||||
assert.equal(await first.check(), false);
|
||||
assert.equal(first.state.modals.length, 1);
|
||||
assert.equal(first.state.settings, 0);
|
||||
const reloaded = await fixture({ storage: first.storage });
|
||||
assert.equal(await reloaded.check(), false);
|
||||
assert.equal(reloaded.state.modals.length, 0);
|
||||
});
|
||||
|
||||
test('overlapping login and foreground checks share a single modal', async () => {
|
||||
const { check, state } = await fixture({ manualModal: true });
|
||||
const first = check();
|
||||
const second = check();
|
||||
assert.equal(first, second);
|
||||
await settle();
|
||||
assert.equal(state.checks, 1);
|
||||
assert.equal(state.modals.length, 1);
|
||||
state.modals[0].success({ confirm: false });
|
||||
assert.equal(await first, false);
|
||||
});
|
||||
|
||||
test('login completion can continue while the user has not answered', async () => {
|
||||
const { check, state } = await fixture({ manualModal: true });
|
||||
void check();
|
||||
await settle();
|
||||
assert.equal(state.modals.length, 1);
|
||||
state.modals[0].success({ confirm: false });
|
||||
await settle();
|
||||
});
|
||||
|
||||
test('logged-out applications and iOS do not query Android notification APIs', async () => {
|
||||
for (const options of [{ storage: new Map() }, { platform: 'ios' }]) {
|
||||
const { check, state } = await fixture(options);
|
||||
assert.equal(await check(), null);
|
||||
assert.equal(state.checks, 0);
|
||||
assert.equal(state.modals.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('Android browsers and preprocessed H5 builds do not access the native module', async () => {
|
||||
for (const options of [{ native: false }, { h5Build: true }]) {
|
||||
const { check, state } = await fixture(options);
|
||||
assert.equal(await check(), null);
|
||||
assert.equal(state.checks, 0);
|
||||
assert.equal(state.modals.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown native status and query errors do not consume the one-time guide', async () => {
|
||||
for (const options of [{ status: -1 }, { checkFails: true }]) {
|
||||
const { check, state, storage } = await fixture(options);
|
||||
assert.equal(await check(), null);
|
||||
assert.equal(state.modals.length, 0);
|
||||
assert.equal(storage.has(GUIDE_SHOWN_KEY), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('modal and settings failures resolve safely without repeating the guide', async () => {
|
||||
for (const options of [{ modalFails: true }, { confirm: true, settingsFail: true }]) {
|
||||
const { check, state } = await fixture(options);
|
||||
await check();
|
||||
await check();
|
||||
assert.equal(state.modals.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('logout while the guide is open prevents a later settings launch', async () => {
|
||||
const { check, state, storage } = await fixture({ manualModal: true });
|
||||
const pending = check();
|
||||
await settle();
|
||||
storage.delete('token');
|
||||
state.modals[0].success({ confirm: true });
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(state.settings, 0);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
|
||||
/** 控制一次页面跳转完成时机,重现两个通知点击交错到达。 */
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((complete) => { resolve = complete; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** 等待通知路由内部的异步消费和 finally 全部完成。 */
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/** 通过服务公开入口初始化隔离账户和原生替身,复用项目既有 Node/VM 测试方式。 */
|
||||
async function fixture(navigate = async () => {}, authenticated = true) {
|
||||
const storage = new Map(authenticated ? [['token', 'test-token']] : []);
|
||||
const routes = [];
|
||||
const bindings = [];
|
||||
class NotificationChannel {
|
||||
static IMPORTANCE_HIGH = 4;
|
||||
setDescription() {}
|
||||
enableVibration() {}
|
||||
}
|
||||
const manager = { createNotificationChannel() {} };
|
||||
const context = vm.createContext({
|
||||
console: { warn() {} },
|
||||
setTimeout, clearTimeout,
|
||||
JPUSH_APP_KEY: 'test-app-key', JPUSH_APNS_PRODUCTION: true,
|
||||
plus: {
|
||||
android: {
|
||||
importClass: (value) => typeof value === 'string' ? NotificationChannel : value,
|
||||
runtimeMainActivity: () => ({ getSystemService: () => manager }),
|
||||
},
|
||||
},
|
||||
nativePush: {
|
||||
setEventCallBack() {}, setDebug() {}, init() {}, requestRequiredPermission() {},
|
||||
getRegistrationId: () => 'test-registration', setAlias() {}, deleteAlias() {}, clearNotificationAll() {},
|
||||
},
|
||||
PushApi: {
|
||||
bind: async (data) => { bindings.push(data); return { code: 0 }; },
|
||||
unbind: async () => ({ code: 0 }),
|
||||
},
|
||||
checkPushNotificationPermission: async () => null,
|
||||
wsApi: { setPushState() {} },
|
||||
uni: {
|
||||
getSystemInfoSync: () => ({ platform: 'android' }),
|
||||
getStorageSync: (key) => storage.get(key),
|
||||
setStorageSync: (key, value) => storage.set(key, value),
|
||||
removeStorageSync: (key) => storage.delete(key),
|
||||
navigateTo: async ({ url }) => { routes.push(url); await navigate(url); },
|
||||
redirectTo: async ({ url }) => { routes.push(url); await navigate(url); },
|
||||
switchTab: async ({ url }) => { routes.push(url); await navigate(url); },
|
||||
},
|
||||
getCurrentPages: () => [{ route: 'pages/tabbar/home' }],
|
||||
getApp: () => ({ $vm: { configStore: { appInit: true }, chatStore: { loading: false } } }),
|
||||
});
|
||||
const source = (await readFile(new URL('../jpush.js', import.meta.url), 'utf8'))
|
||||
.replace(/^import .+;\n/gm, '')
|
||||
.replace(/^export const /gm, 'const ');
|
||||
vm.runInContext(`${source}\nglobalThis.service = { bindPushUser, unbindPushUser, handlePushNotificationClick };`, context);
|
||||
if (authenticated) await context.service.bindPushUser(42);
|
||||
return { service: context.service, storage, routes, bindings };
|
||||
}
|
||||
|
||||
test('device binding reports the configured push application', async () => {
|
||||
const { bindings } = await fixture();
|
||||
assert.equal(bindings[0].appKey, 'test-app-key');
|
||||
assert.equal(bindings[0].registrationId, 'test-registration');
|
||||
});
|
||||
|
||||
test('a second notification click is consumed after the first navigation completes', async () => {
|
||||
const first = deferred();
|
||||
const { service, routes, storage } = await fixture((url) => url.includes('targetId=101') ? first.promise : undefined);
|
||||
service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'A', receiverId: 42, targetId: 101 } });
|
||||
service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'B', receiverId: 42, targetId: 102 } });
|
||||
assert.equal(routes.length, 1);
|
||||
first.resolve();
|
||||
await settle();
|
||||
assert.equal(routes.length, 2);
|
||||
assert.match(routes[1], /targetId=102/);
|
||||
assert.equal(storage.has('chat-jpush-pending-route'), false);
|
||||
});
|
||||
|
||||
test('a failed notification navigation waits for another lifecycle event', async () => {
|
||||
const { service, routes, storage } = await fixture(async () => { throw new Error('Page not ready'); });
|
||||
service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'A', receiverId: 42, targetId: 101 } });
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(routes.length, 1);
|
||||
assert.equal(storage.get('chat-jpush-pending-route').clickId, 'private_message:A');
|
||||
});
|
||||
|
||||
test('legacy queued notifications use senderId and native msgId for routing and deduplication', async () => {
|
||||
const { service, routes } = await fixture();
|
||||
const notificationExtras = JSON.stringify({ type: 'private_message', senderId: 99, receiverId: 42, messageType: 0 });
|
||||
service.handlePushNotificationClick({ msgId: 'native-A', notificationExtras });
|
||||
await settle();
|
||||
assert.match(routes[0], /targetId=99/);
|
||||
service.handlePushNotificationClick({ msgId: 'native-A', notificationExtras });
|
||||
await settle();
|
||||
assert.equal(routes.length, 1);
|
||||
service.handlePushNotificationClick({ msgId: 'native-B', notificationExtras });
|
||||
await settle();
|
||||
assert.equal(routes.length, 2);
|
||||
});
|
||||
|
||||
test('anonymous authentication cleanup preserves an owned click until its receiver logs in', async () => {
|
||||
const { service, storage, routes } = await fixture(undefined, false);
|
||||
service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'cold', receiverId: 42, targetId: 99 } });
|
||||
await settle();
|
||||
await service.unbindPushUser();
|
||||
await service.unbindPushUser();
|
||||
assert.equal(storage.get('chat-jpush-pending-route').clickId, 'private_message:cold');
|
||||
assert.equal(storage.get('chat-jpush-pending-route').ownerId, '42');
|
||||
storage.set('token', 'test-token');
|
||||
await service.bindPushUser(42);
|
||||
await settle();
|
||||
assert.match(routes.at(-1), /targetId=99/);
|
||||
assert.equal(storage.has('chat-jpush-pending-route'), false);
|
||||
});
|
||||
|
||||
test('an anonymous owned click is discarded after a different account logs in', async () => {
|
||||
const { service, storage, routes } = await fixture(undefined, false);
|
||||
service.handlePushNotificationClick({ extras: { type: 'private_message', taskId: 'cold', receiverId: 42, targetId: 99 } });
|
||||
await settle();
|
||||
await service.unbindPushUser();
|
||||
storage.set('token', 'other-token');
|
||||
await service.bindPushUser(43);
|
||||
await settle();
|
||||
assert.equal(routes.length, 1);
|
||||
assert.equal(storage.has('chat-jpush-pending-route'), false);
|
||||
});
|
||||
|
||||
test('explicit sign-out clears an anonymous pending click', async () => {
|
||||
const { service, storage } = await fixture(undefined, false);
|
||||
service.handlePushNotificationClick({ extras: { type: 'manual', taskId: 'manual-cold' } });
|
||||
await settle();
|
||||
await service.unbindPushUser({ clearPendingRoute: true });
|
||||
assert.equal(storage.has('chat-jpush-pending-route'), false);
|
||||
});
|
||||
|
||||
test('authentication cleanup clears a previously bound account pending click', async () => {
|
||||
const { service, storage } = await fixture();
|
||||
storage.set('chat-jpush-pending-route', { type: 'private_message', ownerId: '42', clickId: 'bound' });
|
||||
await service.unbindPushUser();
|
||||
assert.equal(storage.has('chat-jpush-pending-route'), false);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 当前聊天应用的极光 AppKey,与会员服务端应用保持一致。 */
|
||||
export const JPUSH_APP_KEY = "447fcdbce3b2dcac3091c0de";
|
||||
|
||||
/** 与服务端 JPUSH_APNS_PRODUCTION=true 默认环境一致,开发证书联调需同时切换两端。 */
|
||||
export const JPUSH_APNS_PRODUCTION = true;
|
||||
@@ -0,0 +1,55 @@
|
||||
/** 安装实例内只展示一次通知设置引导,退出和切换账号时保留。 */
|
||||
const GUIDE_SHOWN_KEY = 'chat-jpush-notification-guide-shown';
|
||||
/** 登录与前台恢复共用一次检查,避免重叠弹窗。 */
|
||||
let permissionTask = null;
|
||||
|
||||
const isNativeAndroid = () => {
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus === 'undefined' || !plus.runtime) return false;
|
||||
return uni.getDeviceInfo().platform === 'android';
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
return false;
|
||||
// #endif
|
||||
};
|
||||
|
||||
/** 沿用现有权限工具的可取消确认框,显示失败时按取消处理。 */
|
||||
const confirmNotificationSettings = () => new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: '开启消息通知',
|
||||
content: '开启后,应用在后台时也能及时收到新消息提醒。',
|
||||
confirmText: '去开启',
|
||||
cancelText: '暂不开启',
|
||||
success: (result) => resolve(Boolean(result.confirm)),
|
||||
fail: () => resolve(false),
|
||||
});
|
||||
});
|
||||
|
||||
/** 每次回到前台重查系统状态,已展示过的引导保持静默。 */
|
||||
const checkPermissionNow = async (nativePush) => {
|
||||
if (!isNativeAndroid() || !uni.getStorageSync('token')
|
||||
|| typeof nativePush?.isNotificationEnabled !== 'function'
|
||||
|| typeof nativePush?.goToAppNotificationSettings !== 'function') return null;
|
||||
|
||||
const status = nativePush.isNotificationEnabled();
|
||||
if (status === 1) return true;
|
||||
if (status !== 0) return null;
|
||||
if (uni.getStorageSync(GUIDE_SHOWN_KEY)) return false;
|
||||
|
||||
// 在弹窗前持久化,进程重启或用户拒绝后均不会再次打扰。
|
||||
uni.setStorageSync(GUIDE_SHOWN_KEY, true);
|
||||
const confirmed = await confirmNotificationSettings();
|
||||
if (confirmed && uni.getStorageSync('token')) {
|
||||
await nativePush.goToAppNotificationSettings();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 登录后和前台恢复时以 void 调用,查询及设置异常均不影响聊天。 */
|
||||
export const checkPushNotificationPermission = (nativePush) => {
|
||||
if (permissionTask) return permissionTask;
|
||||
permissionTask = Promise.resolve().then(() => checkPermissionNow(nativePush))
|
||||
.catch(() => null)
|
||||
.finally(() => { permissionTask = null; });
|
||||
return permissionTask;
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import PushApi from '@/sheep/api/member/push';
|
||||
import * as wsApi from './wssocket';
|
||||
import { checkPushNotificationPermission } from './jpush-permission';
|
||||
import { JPUSH_APP_KEY, JPUSH_APNS_PRODUCTION } from './jpush-config';
|
||||
// #ifdef APP-PLUS
|
||||
import * as nativePush from '@/uni_modules/jg-jpush-u';
|
||||
// #endif
|
||||
|
||||
/** 冷启动通知点击暂存键,只保存跳转标识,不保存聊天内容。 */
|
||||
const PENDING_ROUTE_KEY = 'chat-jpush-pending-route';
|
||||
/** 首次注册尚未完成时的补查间隔,单位毫秒。 */
|
||||
const REGISTRATION_RETRY_MS = 3000;
|
||||
/** 单次初始化最多补查十次,之后由连接或前台恢复事件继续同步。 */
|
||||
const REGISTRATION_RETRY_LIMIT = 10;
|
||||
/** 已处理点击的内存去重上限,避免原生冷启动回调重复打开页面。 */
|
||||
const HANDLED_CLICK_LIMIT = 50;
|
||||
/** 与服务端 JPushGateway 固定渠道一致,Android 8+ 使用此渠道控制声音和通知展示。 */
|
||||
const ANDROID_MESSAGE_CHANNEL_ID = 'htys_message';
|
||||
|
||||
/** 极光原生初始化状态,进程内只注册一次监听。 */
|
||||
let initialized = false;
|
||||
/** 当前安装实例的极光标识,不随业务账号变化。 */
|
||||
let registrationId = '';
|
||||
/** 当前要求绑定的业务会员 ID,与 IM 用户 ID 分开维护。 */
|
||||
let accountId = '';
|
||||
/** 账户代际,用于忽略换账号或退出后的异步结果。 */
|
||||
let accountVersion = 0;
|
||||
/** 已成功绑定的会员和安装实例组合。 */
|
||||
let boundKey = '';
|
||||
/** 串行执行绑定、解绑,防止旧账号迟到的请求覆盖新归属。 */
|
||||
let bindingTask = Promise.resolve();
|
||||
/** 当前绑定请求的组合,合并启动、登录和原生连接的重复同步。 */
|
||||
let bindingKey = '';
|
||||
/** 当前注册补查计时器,退出后立即停止。 */
|
||||
let registrationTimer = null;
|
||||
/** 当前注册补查次数,前台恢复后重新计数。 */
|
||||
let registrationRetries = 0;
|
||||
/** 跳转互斥锁,避免原生点击与聊天初始化同时消费。 */
|
||||
let routing = false;
|
||||
/** 进程内已经完成的原生点击标识。 */
|
||||
const handledClicks = new Set();
|
||||
|
||||
/** 判断当前是否为可调用极光原生模块的 App。 */
|
||||
const isNativeApp = () => {
|
||||
// #ifdef APP-PLUS
|
||||
return typeof plus !== 'undefined';
|
||||
// #endif
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 安全解析 Android 字符串 extras 与 iOS 字典回调。 */
|
||||
const asObject = (value) => {
|
||||
if (typeof value === 'string') {
|
||||
try { return asObject(JSON.parse(value)); } catch { return {}; }
|
||||
}
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
};
|
||||
|
||||
/** 获取已经确认的当前业务会员 ID,供通知路由校验账号归属。 */
|
||||
export const getPushAccountId = () => accountId;
|
||||
|
||||
/** iOS 系统角标跟随现有聊天未读数,避免 APNs 累加后在阅读消息时残留。 */
|
||||
export const syncPushBadge = (unreadCount) => {
|
||||
if (!initialized || !isNativeApp()) return;
|
||||
// #ifdef APP-PLUS
|
||||
try {
|
||||
if (uni.getSystemInfoSync().platform === 'ios') {
|
||||
nativePush.setBadgeNumber(Math.max(0, Math.floor(Number(unreadCount) || 0)));
|
||||
}
|
||||
} catch {
|
||||
console.warn('[JPush] 系统角标同步暂未完成');
|
||||
}
|
||||
// #endif
|
||||
};
|
||||
|
||||
/** 将绑定任务异常限制在推送能力内部,保持主站登录和退出可继续。 */
|
||||
const enqueueBinding = (operation) => {
|
||||
bindingTask = bindingTask.then(operation).catch(() => {
|
||||
console.warn('[JPush] 设备同步暂未成功,后续连接或回到前台时重试');
|
||||
scheduleRegistrationRetry();
|
||||
});
|
||||
return bindingTask;
|
||||
};
|
||||
|
||||
/** 在当前登录令牌下绑定本机,结果仅对发起请求时的账户代际生效。 */
|
||||
const syncBinding = () => {
|
||||
if (!accountId || !registrationId || !uni.getStorageSync('token')) return bindingTask;
|
||||
const key = `${accountId}:${registrationId}`;
|
||||
if (key === boundKey || key === bindingKey) return bindingTask;
|
||||
const version = accountVersion;
|
||||
const userId = accountId;
|
||||
const deviceId = registrationId;
|
||||
const token = uni.getStorageSync('token');
|
||||
bindingKey = key;
|
||||
return enqueueBinding(async () => {
|
||||
if (version !== accountVersion) return;
|
||||
try {
|
||||
const platform = String(uni.getSystemInfoSync().platform).toLowerCase();
|
||||
const response = await PushApi.bind({
|
||||
registrationId: deviceId,
|
||||
appKey: JPUSH_APP_KEY,
|
||||
alias: `u${userId}`,
|
||||
platform,
|
||||
provider: 'jpush',
|
||||
}, token);
|
||||
if (version !== accountVersion) return;
|
||||
if (response?.code !== 0) throw new Error('推送设备绑定暂未成功');
|
||||
// #ifdef APP-PLUS
|
||||
nativePush.setAlias(version + 1, `u${userId}`);
|
||||
// #endif
|
||||
boundKey = key;
|
||||
wsApi.setPushState({ registrationId: deviceId });
|
||||
void consumePendingPushRoute();
|
||||
} finally {
|
||||
if (bindingKey === key) bindingKey = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** 读取或更新极光注册标识,并让现有 WebSocket 心跳携带本机标识。 */
|
||||
const refreshRegistration = () => {
|
||||
if (!initialized) return;
|
||||
// #ifdef APP-PLUS
|
||||
const currentId = String(nativePush.getRegistrationId() || '').trim();
|
||||
if (currentId) {
|
||||
registrationId = currentId;
|
||||
wsApi.setPushState({ registrationId: accountId ? currentId : '' });
|
||||
void syncBinding();
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
scheduleRegistrationRetry();
|
||||
};
|
||||
|
||||
/** 注册尚未生成或服务端短时不可用时有界补试,恢复前台后可开启新一轮重试。 */
|
||||
const scheduleRegistrationRetry = () => {
|
||||
if (registrationTimer || registrationRetries >= REGISTRATION_RETRY_LIMIT || !accountId) return;
|
||||
registrationRetries += 1;
|
||||
registrationTimer = setTimeout(() => {
|
||||
registrationTimer = null;
|
||||
refreshRegistration();
|
||||
}, REGISTRATION_RETRY_MS);
|
||||
};
|
||||
|
||||
/** 从原生通知取出统一业务扩展字段,兼容 iOS 顶层和 Android notificationExtras。 */
|
||||
const notificationExtras = (raw) => {
|
||||
const message = asObject(raw);
|
||||
return {
|
||||
...message,
|
||||
...asObject(message.extra),
|
||||
...asObject(message.extras),
|
||||
...asObject(message.notificationExtras),
|
||||
...asObject(asObject(message.android).extras),
|
||||
...asObject(asObject(message.ios).extras),
|
||||
};
|
||||
};
|
||||
|
||||
/** 暂存合法通知点击,聊天数据或登录尚未就绪时由后续生命周期消费。 */
|
||||
export const handlePushNotificationClick = (raw) => {
|
||||
const extras = notificationExtras(raw);
|
||||
if (!['private_message', 'manual'].includes(extras.type)) return;
|
||||
const receiverId = String(extras.receiverId || '');
|
||||
const targetId = String(extras.targetId || extras.senderId || '');
|
||||
if (extras.type === 'private_message' && (!receiverId || !targetId)) return;
|
||||
if (accountId && receiverId && receiverId !== accountId) return;
|
||||
// 兼容升级前排队通知,Android 原生回调使用 msgId,iOS APNs 保留 _j_msgid。
|
||||
const clickId = `${extras.type}:${extras.taskId || extras.messageId || extras.msgId || extras.messageID || extras._j_msgid || ''}`;
|
||||
if (handledClicks.has(clickId)) return;
|
||||
uni.setStorageSync(PENDING_ROUTE_KEY, {
|
||||
type: extras.type,
|
||||
targetId,
|
||||
ownerId: receiverId || accountId,
|
||||
clickId,
|
||||
});
|
||||
getApp().$vm?.refreshUnreadBadge?.();
|
||||
void consumePendingPushRoute();
|
||||
};
|
||||
|
||||
/** 等待主站认证和 IM 离线同步后,使用稳定 IM ID 打开会话或消息列表。 */
|
||||
export const consumePendingPushRoute = async () => {
|
||||
if (routing) return;
|
||||
const pending = asObject(uni.getStorageSync(PENDING_ROUTE_KEY));
|
||||
if (!pending.type) return;
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1];
|
||||
if (!current) return;
|
||||
if (!uni.getStorageSync('token')) {
|
||||
if (current.route !== 'pages/login/login') {
|
||||
routing = true;
|
||||
try {
|
||||
await uni.navigateTo({ url: '/pages/login/login' });
|
||||
} catch {
|
||||
console.warn('[JPush] 登录页尚未就绪,保留通知跳转');
|
||||
} finally {
|
||||
routing = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!accountId) return;
|
||||
if (pending.ownerId && pending.ownerId !== accountId) {
|
||||
uni.removeStorageSync(PENDING_ROUTE_KEY);
|
||||
return;
|
||||
}
|
||||
const app = getApp().$vm;
|
||||
if (pending.type === 'private_message' && (!app?.configStore?.appInit || app?.chatStore?.loading)) return;
|
||||
const version = accountVersion;
|
||||
routing = true;
|
||||
try {
|
||||
if (pending.type === 'manual') {
|
||||
await uni.switchTab({ url: '/pages/tabbar/message' });
|
||||
} else {
|
||||
const currentChat = current.$vm?.chat;
|
||||
const sameChat = current.route === 'pages/message/chat-box'
|
||||
&& currentChat?.type === 'PRIVATE' && String(currentChat.targetId) === pending.targetId;
|
||||
if (!sameChat) {
|
||||
const url = `/pages/message/chat-box?chatType=PRIVATE&targetId=${encodeURIComponent(pending.targetId)}&receiverId=${encodeURIComponent(accountId)}`;
|
||||
if (current.route === 'pages/message/chat-box') await uni.redirectTo({ url });
|
||||
else await uni.navigateTo({ url });
|
||||
}
|
||||
}
|
||||
if (version !== accountVersion) return;
|
||||
handledClicks.add(pending.clickId);
|
||||
if (handledClicks.size > HANDLED_CLICK_LIMIT) handledClicks.delete(handledClicks.values().next().value);
|
||||
if (asObject(uni.getStorageSync(PENDING_ROUTE_KEY)).clickId === pending.clickId) {
|
||||
uni.removeStorageSync(PENDING_ROUTE_KEY);
|
||||
}
|
||||
} catch {
|
||||
console.warn('[JPush] 通知页面尚未就绪,保留跳转等待下一次恢复');
|
||||
} finally {
|
||||
routing = false;
|
||||
const next = asObject(uni.getStorageSync(PENDING_ROUTE_KEY));
|
||||
if (next.clickId && next.clickId !== pending.clickId) void consumePendingPushRoute();
|
||||
}
|
||||
};
|
||||
|
||||
/** 项目最低 Android 10,启动时幂等创建服务端指定的高优先级消息渠道。 */
|
||||
const createAndroidNotificationChannel = () => {
|
||||
// #ifdef APP-PLUS
|
||||
const NotificationChannel = plus.android.importClass('android.app.NotificationChannel');
|
||||
const NotificationManager = plus.android.importClass('android.app.NotificationManager');
|
||||
const manager = plus.android.runtimeMainActivity().getSystemService('notification');
|
||||
plus.android.importClass(manager);
|
||||
const channel = new NotificationChannel(ANDROID_MESSAGE_CHANNEL_ID, '消息通知', NotificationManager.IMPORTANCE_HIGH);
|
||||
channel.setDescription('新私聊消息和平台通知');
|
||||
channel.enableVibration(true);
|
||||
manager.createNotificationChannel(channel);
|
||||
// #endif
|
||||
};
|
||||
|
||||
/** 只在原生 App 初始化一次,先注册点击监听以接收原生缓存的冷启动事件。 */
|
||||
export const initializePush = () => {
|
||||
if (!isNativeApp() || initialized) return;
|
||||
// #ifdef APP-PLUS
|
||||
try {
|
||||
nativePush.setEventCallBack({ callback: (event) => {
|
||||
if (event.eventName === 'onClickMessage') handlePushNotificationClick(event.eventData);
|
||||
if (event.eventName === 'onRegister' || event.eventName === 'onConnected') refreshRegistration();
|
||||
} });
|
||||
nativePush.setDebug(false);
|
||||
const platform = String(uni.getSystemInfoSync().platform).toLowerCase();
|
||||
if (platform === 'ios') {
|
||||
nativePush.initPush({
|
||||
appkey: JPUSH_APP_KEY,
|
||||
channel: 'App Store',
|
||||
isProduction: JPUSH_APNS_PRODUCTION,
|
||||
advertisingId: '',
|
||||
});
|
||||
} else {
|
||||
createAndroidNotificationChannel();
|
||||
nativePush.init();
|
||||
nativePush.requestRequiredPermission();
|
||||
}
|
||||
initialized = true;
|
||||
refreshRegistration();
|
||||
} catch {
|
||||
console.warn('[JPush] 原生推送暂不可用,请确认使用包含极光插件的自定义基座');
|
||||
}
|
||||
// #endif
|
||||
};
|
||||
|
||||
/** 登录或恢复主站认证后,绑定业务会员,账户发生变化时清理旧账号的待处理点击。 */
|
||||
export const bindPushUser = (userId) => {
|
||||
if (!isNativeApp() || !userId) return Promise.resolve();
|
||||
const nextId = String(userId);
|
||||
if (accountId && accountId !== nextId) {
|
||||
uni.removeStorageSync(PENDING_ROUTE_KEY);
|
||||
boundKey = '';
|
||||
try { clearNotifications(); } catch { /* 继续新账号设备绑定。 */ }
|
||||
}
|
||||
if (accountId !== nextId) accountVersion += 1;
|
||||
accountId = nextId;
|
||||
initializePush();
|
||||
// #ifdef APP-PLUS
|
||||
void checkPushNotificationPermission(nativePush);
|
||||
// #endif
|
||||
registrationRetries = 0;
|
||||
refreshRegistration();
|
||||
void consumePendingPushRoute();
|
||||
return syncBinding();
|
||||
};
|
||||
|
||||
/** 清除本机通知,防止退出或换账号后旧消息继续保留在系统通知栏。 */
|
||||
const clearNotifications = () => {
|
||||
// #ifdef APP-PLUS
|
||||
if (initialized) nativePush.clearNotificationAll();
|
||||
// #endif
|
||||
};
|
||||
|
||||
/** 先使本地账户代际失效,再精确解绑,调用方可选择等待后再清除登录令牌。 */
|
||||
export const unbindPushUser = ({ clearPendingRoute = false } = {}) => {
|
||||
const token = uni.getStorageSync('token');
|
||||
const deviceId = registrationId;
|
||||
const hadBinding = Boolean(accountId || boundKey || bindingKey);
|
||||
accountVersion += 1;
|
||||
accountId = '';
|
||||
boundKey = '';
|
||||
bindingKey = '';
|
||||
clearTimeout(registrationTimer);
|
||||
registrationTimer = null;
|
||||
// 匿名启动的认证失败会重复清理会话,通知待办应保留到登录后校验接收账号。
|
||||
if (hadBinding || clearPendingRoute) uni.removeStorageSync(PENDING_ROUTE_KEY);
|
||||
handledClicks.clear();
|
||||
wsApi.setPushState({ registrationId: '' });
|
||||
try {
|
||||
// #ifdef APP-PLUS
|
||||
if (initialized) nativePush.deleteAlias(accountVersion + 1);
|
||||
// #endif
|
||||
clearNotifications();
|
||||
} catch {
|
||||
console.warn('[JPush] 本机通知清理暂未完成');
|
||||
}
|
||||
if (!hadBinding || !isNativeApp() || !deviceId || !token) return Promise.resolve();
|
||||
return enqueueBinding(() => PushApi.unbind(deviceId, token));
|
||||
};
|
||||
|
||||
/** 前后台变化立即通过现有 IM 心跳上报,前台恢复补查注册并消费冷启动路由。 */
|
||||
export const setPushForeground = (foreground) => {
|
||||
if (!isNativeApp()) return;
|
||||
wsApi.setPushState({ foreground });
|
||||
if (foreground) {
|
||||
// #ifdef APP-PLUS
|
||||
if (accountId) void checkPushNotificationPermission(nativePush);
|
||||
// #endif
|
||||
registrationRetries = 0;
|
||||
try { refreshRegistration(); } catch { /* 插件异常不阻止 App 回到前台。 */ }
|
||||
void consumePendingPushRoute();
|
||||
}
|
||||
};
|
||||
+65
-49
@@ -1,59 +1,75 @@
|
||||
import CHAT_ENV from '@/chat-env.js';
|
||||
import { ensurePermission, hasPermission, handlePermissionFailure } from '@/sheep/util/permission';
|
||||
|
||||
const rc = uni.getRecorderManager();
|
||||
// 录音开始时间
|
||||
let startTime = null;
|
||||
let checkIsEnable = () => {
|
||||
return true;
|
||||
};
|
||||
/** 原生录音管理器只注册一组回调,取消录音不会触发历史消息的重复上传。 */
|
||||
const recorder = uni.getRecorderManager();
|
||||
/** 本次录音实际开始的时间,单位毫秒。 */
|
||||
let startTime = 0;
|
||||
/** 本次启动任务的回调,结束后清除。 */
|
||||
let startCallbacks = null;
|
||||
/** 本次录音结束结果,允许 stop 回调早于上传入口到达。 */
|
||||
let stoppedRecording = null;
|
||||
/** 等待本次 stop 事件的上传任务。 */
|
||||
let stopCallback = null;
|
||||
|
||||
let start = () => {
|
||||
recorder.onStart(() => {
|
||||
startTime = Date.now();
|
||||
if (startCallbacks?.requiresFreshGesture) {
|
||||
recorder.stop();
|
||||
startCallbacks.reject(new Error('recording-permission-ready'));
|
||||
} else startCallbacks?.resolve();
|
||||
startCallbacks = null;
|
||||
});
|
||||
recorder.onStop((file) => {
|
||||
stoppedRecording = { file, duration: Math.round((Date.now() - startTime) / 1000) };
|
||||
stopCallback?.(stoppedRecording);
|
||||
stopCallback = null;
|
||||
});
|
||||
recorder.onError((error) => {
|
||||
void handlePermissionFailure('microphone', error);
|
||||
startCallbacks?.reject(error);
|
||||
startCallbacks = null;
|
||||
stopCallback?.({ error });
|
||||
stopCallback = null;
|
||||
});
|
||||
|
||||
/** 原生是否支持录音,授权在用户主动操作的 start 中处理。 */
|
||||
const checkIsEnable = () => Boolean(recorder);
|
||||
/** 授权完成后只使用仍有效的长按手势启动录音。 */
|
||||
const start = async (shouldStart = () => true) => {
|
||||
const authorizedBefore = hasPermission('microphone');
|
||||
if (!await ensurePermission('microphone', { feature: '聊天语音', purpose: '录制您主动发送的语音消息' })) {
|
||||
throw new Error('麦克风权限未开启');
|
||||
}
|
||||
// 系统授权框可能吞掉松手事件,首次授权成功后等待下一次明确的长按。
|
||||
if (!authorizedBefore && hasPermission('microphone')) throw new Error('recording-permission-ready');
|
||||
if (authorizedBefore && !shouldStart()) throw new Error('recording-cancelled');
|
||||
stoppedRecording = null;
|
||||
return new Promise((resolve, reject) => {
|
||||
rc.onStart(() => {
|
||||
startTime = new Date();
|
||||
resolve();
|
||||
});
|
||||
rc.onError((e) => {
|
||||
console.log(e);
|
||||
reject(e);
|
||||
});
|
||||
rc.start({
|
||||
format: 'mp3', // 录音格式,可选值:aac/mp3
|
||||
});
|
||||
startCallbacks = { resolve, reject, requiresFreshGesture: !authorizedBefore };
|
||||
recorder.start({ format: 'mp3' });
|
||||
});
|
||||
};
|
||||
|
||||
let close = () => {
|
||||
rc.stop();
|
||||
};
|
||||
|
||||
let upload = () => {
|
||||
/** 结束当前录音,是否上传由聊天组件依据取消状态决定。 */
|
||||
const close = () => recorder.stop();
|
||||
/** 读取本次结束结果后上传,避免重复注册 onStop 导致已取消录音也被上传。 */
|
||||
const upload = async () => {
|
||||
const recording = stoppedRecording || await new Promise((resolve) => { stopCallback = resolve; });
|
||||
if (recording.error) throw recording.error;
|
||||
return new Promise((resolve, reject) => {
|
||||
rc.onStop((wavFile, a, b) => {
|
||||
uni.uploadFile({
|
||||
url: CHAT_ENV.BASE_URL + '/file/upload',
|
||||
header: {
|
||||
accessToken: uni.getStorageSync('loginInfo').accessToken,
|
||||
},
|
||||
filePath: wavFile.tempFilePath,
|
||||
name: 'file',
|
||||
success: (res) => {
|
||||
let r = JSON.parse(res.data);
|
||||
if (r.code != 200) {
|
||||
reject(r.message);
|
||||
} else {
|
||||
const duration = (new Date().getTime() - startTime.getTime()) / 1000;
|
||||
const data = {
|
||||
duration: Math.round(duration),
|
||||
url: r.data,
|
||||
};
|
||||
resolve(data);
|
||||
}
|
||||
},
|
||||
fail: (e) => {
|
||||
reject(e);
|
||||
},
|
||||
});
|
||||
uni.uploadFile({
|
||||
url: CHAT_ENV.BASE_URL + '/file/upload',
|
||||
header: { accessToken: uni.getStorageSync('loginInfo').accessToken },
|
||||
filePath: recording.file.tempFilePath,
|
||||
name: 'file',
|
||||
success: (response) => {
|
||||
try {
|
||||
const result = JSON.parse(response.data);
|
||||
if (result.code !== 200) return reject(result.message);
|
||||
resolve({ duration: recording.duration, url: result.data });
|
||||
} catch { reject(new Error('语音上传响应异常,请重试')); }
|
||||
},
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ let duration = 0;
|
||||
let chunks = [];
|
||||
let stream = null;
|
||||
let startTime = null;
|
||||
// 浏览器不支持 Permissions API 时,记录本次页面已完成的麦克风授权。
|
||||
let microphoneReady = false;
|
||||
let checkIsEnable = () => {
|
||||
if (
|
||||
origin.indexOf('https') === -1 &&
|
||||
@@ -27,13 +29,31 @@ let checkIsEnable = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
let start = () => {
|
||||
let start = async (shouldStart = () => true) => {
|
||||
let authorizedBefore = microphoneReady;
|
||||
try {
|
||||
authorizedBefore = (await navigator.permissions.query({ name: 'microphone' })).state === 'granted';
|
||||
} catch { /* 不支持查询的浏览器在首次授权后等待下一次长按。 */ }
|
||||
return navigator.mediaDevices.getUserMedia({ audio: true }).then((audioStream) => {
|
||||
microphoneReady = true;
|
||||
if (!authorizedBefore) {
|
||||
audioStream.getTracks().forEach((track) => track.stop());
|
||||
throw new Error('recording-permission-ready');
|
||||
}
|
||||
if (!shouldStart()) {
|
||||
audioStream.getTracks().forEach((track) => track.stop());
|
||||
throw new Error('recording-cancelled');
|
||||
}
|
||||
startTime = new Date().getTime();
|
||||
chunks = [];
|
||||
stream = audioStream;
|
||||
rc = new MediaRecorder(stream);
|
||||
rc.start();
|
||||
}).catch((error) => {
|
||||
if (error?.name === 'NotAllowedError' || error?.name === 'PermissionDeniedError') {
|
||||
throw new Error('浏览器未允许麦克风,请在网站权限中开启后重试');
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+20
-2
@@ -9,6 +9,19 @@ let rec = null;
|
||||
let connectRetryTimer = null;
|
||||
let lastConnectTime = 0;
|
||||
let socketTask = null;
|
||||
/** 极光安装实例和 App 前后台状态,仅由原生推送模块更新。 */
|
||||
let pushState = { registrationId: '', foreground: true };
|
||||
/** WebSocket 业务登录已经完成,避免连接建立与认证之间提前发送状态心跳。 */
|
||||
let authenticated = false;
|
||||
|
||||
/** 生成向后兼容的可选设备状态,未接入原生极光的终端保持原协议。 */
|
||||
const pushHeartbeatData = () => pushState.registrationId ? { ...pushState } : {};
|
||||
|
||||
/** 更新原生设备状态并立即发送心跳,进入后台时保持现有 IM 连接行为。 */
|
||||
const setPushState = (state) => {
|
||||
pushState = { ...pushState, ...state };
|
||||
if (authenticated) heartCheck.start();
|
||||
};
|
||||
|
||||
const clearReconnectTimers = () => {
|
||||
if (rec) {
|
||||
@@ -44,6 +57,7 @@ const finishConnection = (task, event) => {
|
||||
}
|
||||
socketTask = null;
|
||||
isConnect = false;
|
||||
authenticated = false;
|
||||
heartCheck.stop();
|
||||
closeCallBack && closeCallBack(event);
|
||||
};
|
||||
@@ -123,6 +137,7 @@ let connect = (url, token) => {
|
||||
cmd: 0,
|
||||
data: {
|
||||
accessToken: token,
|
||||
...pushHeartbeatData(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -141,6 +156,7 @@ let connect = (url, token) => {
|
||||
}
|
||||
|
||||
if (sendInfo.cmd == 0) {
|
||||
authenticated = true;
|
||||
heartCheck.start();
|
||||
connectCallBack && connectCallBack();
|
||||
console.log('WebSocket登录成功');
|
||||
@@ -201,6 +217,7 @@ let close = (code = 1000) => {
|
||||
clearReconnectTimers();
|
||||
heartCheck.stop();
|
||||
isConnect = false;
|
||||
authenticated = false;
|
||||
if (!socketTask) {
|
||||
return;
|
||||
}
|
||||
@@ -216,6 +233,7 @@ let destroy = (code = 1000) => {
|
||||
const task = socketTask;
|
||||
socketTask = null;
|
||||
isConnect = false;
|
||||
authenticated = false;
|
||||
accessToken = '';
|
||||
wsUrl = '';
|
||||
closeSocketTask(task, code);
|
||||
@@ -230,7 +248,7 @@ let heartCheck = {
|
||||
sendMessage(
|
||||
JSON.stringify({
|
||||
cmd: 1,
|
||||
data: {},
|
||||
data: pushHeartbeatData(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -275,4 +293,4 @@ let onClose = (callback) => {
|
||||
closeCallBack = callback;
|
||||
};
|
||||
|
||||
export { connect, reconnect, close, destroy, sendMessage, onConnect, onMessage, onClose };
|
||||
export { connect, reconnect, close, destroy, sendMessage, onConnect, onMessage, onClose, setPushState };
|
||||
@@ -8,6 +8,7 @@
|
||||
@touchstart.prevent="onStartRecord"
|
||||
@touchmove.prevent="onTouchMove"
|
||||
@touchend.prevent="onEndRecord"
|
||||
@touchcancel.prevent="onCancel"
|
||||
>
|
||||
{{ recording ? '正在录音' : '长按 说话' }}</view
|
||||
>
|
||||
@@ -42,6 +43,9 @@
|
||||
data() {
|
||||
return {
|
||||
recording: false,
|
||||
// 授权和录音启动期间跟踪手势,松手后不再启动录音。
|
||||
holding: false,
|
||||
starting: false,
|
||||
moveToCancel: false,
|
||||
recordBarTop: 0,
|
||||
druation: 0,
|
||||
@@ -54,40 +58,51 @@
|
||||
this.moveToCancel = moveY < this.recordBarTop - 40;
|
||||
},
|
||||
onCancel() {
|
||||
this.holding = false;
|
||||
if (this.recording) {
|
||||
this.moveToCancel = true;
|
||||
this.onEndRecord();
|
||||
}
|
||||
},
|
||||
onStartRecord() {
|
||||
/* 用户第一次使用语音会唤醒录音权限请求,此时会导致@touchend失效,
|
||||
一直处于录音状态,这里允许用户再次点击发送语音并结束录音 */
|
||||
if (this.recording) {
|
||||
return;
|
||||
}
|
||||
// 权限用途弹框可能吞掉松手事件,首次授权完成后需要新的长按手势。
|
||||
if (this.recording || this.starting) return;
|
||||
this.holding = true;
|
||||
this.starting = true;
|
||||
console.log('开始录音');
|
||||
this.moveToCancel = false;
|
||||
this.initRecordBar();
|
||||
if (!this.$rc.checkIsEnable()) {
|
||||
this.starting = false;
|
||||
return;
|
||||
}
|
||||
this.$rc
|
||||
.start()
|
||||
.start(() => this.holding)
|
||||
.then(() => {
|
||||
if (!this.holding) {
|
||||
this.$rc.close();
|
||||
return;
|
||||
}
|
||||
this.recording = true;
|
||||
console.log('开始录音成功');
|
||||
// 开始计时
|
||||
this.startTimer();
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e?.message === 'recording-cancelled') return;
|
||||
if (e?.message === 'recording-permission-ready') {
|
||||
uni.showToast({ title: '麦克风已开启,请再次长按录音', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
console.log('录音失败' + JSON.stringify(e));
|
||||
uni.showToast({
|
||||
title: '录音失败',
|
||||
title: e?.message || '录音失败,请检查麦克风权限',
|
||||
icon: 'none',
|
||||
});
|
||||
});
|
||||
}).finally(() => { this.starting = false; });
|
||||
},
|
||||
onEndRecord() {
|
||||
this.holding = false;
|
||||
if (!this.recording) {
|
||||
return;
|
||||
}
|
||||
@@ -162,6 +177,8 @@
|
||||
},
|
||||
unmounted() {
|
||||
this.stopTimer();
|
||||
this.holding = false;
|
||||
if (this.recording) this.$rc.close();
|
||||
this.recording = false;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<script>
|
||||
import CHAT_ENV from '@/chat-env.js';
|
||||
import { prepareMediaSelection, handlePermissionFailure } from '@/sheep/util/permission';
|
||||
|
||||
export default {
|
||||
name: 'image-upload',
|
||||
@@ -51,11 +52,16 @@
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectAndUpload() {
|
||||
async selectAndUpload() {
|
||||
const sourceType = await prepareMediaSelection([this.sourceType], 'image', {
|
||||
feature: '发送聊天图片', purpose: '选择您主动发送给聊天对象的照片',
|
||||
});
|
||||
if (!sourceType) return;
|
||||
uni.chooseImage({
|
||||
count: this.maxCount, //最多可以选择的图片张数,默认9
|
||||
sourceType: [this.sourceType], //album 从相册选图,camera 使用相机,默认二者都有。如需直接开相机或直接选相册,请只使用一个选项
|
||||
sourceType, //album 从相册选图,camera 使用相机,默认二者都有。如需直接开相机或直接选相册,请只使用一个选项
|
||||
sizeType: ['original'], //original 原图,compressed 压缩图,默认二者都有
|
||||
fail: (error) => { void handlePermissionFailure(this.sourceType === 'camera' ? 'camera' : 'album-read', error); },
|
||||
success: (res) => {
|
||||
res.tempFiles.forEach((file) => {
|
||||
if (!this.onBefore || this.onBefore(file)) {
|
||||
|
||||
+15
-19
@@ -7,7 +7,7 @@
|
||||
"transformPx" : false,
|
||||
"app-plus" : {
|
||||
"compatible" : {
|
||||
"ignoreVersion" : true
|
||||
"ignoreVersion" : false
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"nvueCompiler" : "uni-app",
|
||||
@@ -27,6 +27,8 @@
|
||||
},
|
||||
"modules" : {
|
||||
"VideoPlayer" : {},
|
||||
"Barcode" : {},
|
||||
"Record" : {},
|
||||
"OAuth" : {},
|
||||
"Camera" : {},
|
||||
"Geolocation" : {},
|
||||
@@ -36,36 +38,27 @@
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.POST_NOTIFICATIONS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_MOCK_LOCATION\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CALL_PHONE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_TASKS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
|
||||
"<uses-permission android:name=\"android.permission.REQUEST_INSTALL_PACKAGES\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_MEDIA_IMAGES\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_MEDIA_VIDEO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_MEDIA_VISUAL_USER_SELECTED\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_SMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECEIVE_BOOT_COMPLETED\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
|
||||
"<uses-permission android:name=\"android.permission.SEND_SMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.SYSTEM_ALERT_WINDOW\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_CONTACTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.RECEIVE_USER_PRESENT\"/>"
|
||||
],
|
||||
"minSdkVersion" : 29,
|
||||
@@ -84,12 +77,14 @@
|
||||
"NSPhotoLibraryAddUsageDescription" : "需要同意访问您的相册才能保存该图片",
|
||||
"NSCameraUsageDescription" : "需要同意访问您的摄像头拍摄照片才能完善该条目",
|
||||
"NSUserTrackingUsageDescription" : "开启追踪并不会获取您在其它站点的隐私信息,该行为仅用于标识设备,保障服务安全和提升浏览体验",
|
||||
"NSMicrophoneUsageDescription" : ""
|
||||
"NSMicrophoneUsageDescription" : "用于录制您主动发送的聊天语音和拍摄视频中的声音",
|
||||
"NSLocationWhenInUseUsageDescription" : "用于获取当前城市、展示附近用户和门店,并记录您主动发布动态的位置"
|
||||
},
|
||||
"urltypes" : "",
|
||||
"capabilities" : {
|
||||
"entitlements" : {
|
||||
"com.apple.developer.associated-domains" : [ "applinks:shopro.sheepjs.com" ]
|
||||
"com.apple.developer.associated-domains" : [ "applinks:shopro.sheepjs.com" ],
|
||||
"aps-environment" : "production"
|
||||
}
|
||||
},
|
||||
"idfa" : true
|
||||
@@ -110,7 +105,8 @@
|
||||
"system" : {
|
||||
"__platform__" : [ "ios", "android" ]
|
||||
}
|
||||
}
|
||||
},
|
||||
"push" : {}
|
||||
},
|
||||
"orientation" : [ "portrait-primary" ],
|
||||
"splashscreen" : {
|
||||
|
||||
+26
-1
@@ -159,6 +159,13 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/brokerage-record",
|
||||
"style": {
|
||||
"navigationBarTitleText": "分销收益记录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/inviter/index",
|
||||
"style": {
|
||||
@@ -422,6 +429,17 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "app-update",
|
||||
"style": {
|
||||
"navigationBarTitleText": "应用更新",
|
||||
"navigationStyle": "custom",
|
||||
"disableSwipeBack": true,
|
||||
"app-plus": {
|
||||
"popGesture": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "gender-choose",
|
||||
"style": {
|
||||
@@ -467,6 +485,13 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/brokerage-record",
|
||||
"style": {
|
||||
"navigationBarTitleText": "分销收益记录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/inviter/index",
|
||||
"style": {
|
||||
@@ -556,4 +581,4 @@
|
||||
]
|
||||
// #endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,7 @@
|
||||
import CHAT_ENV from '@/chat-env.js';
|
||||
import RecentApi from '@/sheep/api/member/recent';
|
||||
import sheep from '@/sheep';
|
||||
import { getPushAccountId } from '@/common/jpush';
|
||||
// #ifdef H5
|
||||
import { getH5StatusBarHeight } from '@/sheep/platform/h5-shell';
|
||||
// #endif
|
||||
@@ -970,6 +971,32 @@ export default {
|
||||
}
|
||||
return chats.find((chat) => String(chat.targetId) === String(memberId)) || null;
|
||||
},
|
||||
/** 通知使用稳定 IM ID 定位会话,冷启动本地尚无会话时复用已有创建逻辑。 */
|
||||
async resolveNotificationChat(options) {
|
||||
const targetId = Number(options.targetId);
|
||||
if (options.chatType !== 'PRIVATE' || !Number.isSafeInteger(targetId) || targetId <= 0
|
||||
|| String(options.receiverId || '') !== getPushAccountId()) return null;
|
||||
let chat = this.chatStore.findChatByFriend(targetId);
|
||||
if (chat) return chat;
|
||||
let friend = this.friendStore.findFriend(targetId);
|
||||
if (!friend) {
|
||||
try {
|
||||
friend = await this.$http({ url: `/user/find/${targetId}`, method: 'GET' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// 用户查询期间若已换账号,禁止把上个账号的通知写入新会话缓存。
|
||||
if (String(options.receiverId) !== getPushAccountId()) return null;
|
||||
this.chatStore.openChat({
|
||||
type: 'PRIVATE',
|
||||
targetId,
|
||||
showName: friend.nickName || friend.showNickName || '未知用户',
|
||||
headImage: friend.headImageThumb || friend.headImage || '',
|
||||
isDnd: Boolean(friend.isDnd),
|
||||
});
|
||||
return this.chatStore.findChatByFriend(targetId);
|
||||
},
|
||||
goMessageTab() {
|
||||
uni.switchTab({
|
||||
url: '/pages/tabbar/message',
|
||||
@@ -1397,9 +1424,12 @@ export default {
|
||||
async onLoad(options) {
|
||||
const chatIdx = Number(options.chatIdx);
|
||||
const memberId = options.memberId === undefined ? null : Number(options.memberId);
|
||||
this.userId = memberId;
|
||||
// 当前会员与 IM 共用全局 ID,通知入口也需保留资料和赠礼功能依赖的会员 ID。
|
||||
this.userId = options.chatType === 'PRIVATE' ? Number(options.targetId) : memberId;
|
||||
await this.ensureChatStoreReady();
|
||||
this.chat = this.resolveChatByOptions(chatIdx, memberId);
|
||||
this.chat = options.chatType
|
||||
? await this.resolveNotificationChat(options)
|
||||
: this.resolveChatByOptions(chatIdx, memberId);
|
||||
if (!this.chat && memberId === -200) {
|
||||
await this.friendStore.loadFriend();
|
||||
this.chat = this.resolveChatByOptions(chatIdx, memberId);
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
}
|
||||
|
||||
if (!isEditMode.value || !params.issueLat || !params.issueLong) {
|
||||
const location = await getCurrentLocation();
|
||||
const location = await getCurrentLocation({ permissionUsage: { feature: '发布动态', purpose: '记录本条动态的位置并展示附近动态' } });
|
||||
params.issueLat = String(location.latitude);
|
||||
params.issueLong = String(location.longitude);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<wd-cell-group>
|
||||
<wd-cell title="用户协议" value="" is-link ellipsis @click="alert(2, '用户协议')" />
|
||||
<wd-cell title="隐私政策" value="" is-link ellipsis @click="alert(3, '隐私政策')" />
|
||||
<wd-cell title="版本" :value="'V' + version" is-link ellipsis />
|
||||
<wd-cell title="检查更新" :value="checking ? '检查中…' : 'V' + version" is-link ellipsis @click="checkUpdate" />
|
||||
</wd-cell-group>
|
||||
</view>
|
||||
|
||||
@@ -32,17 +32,32 @@ import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
|
||||
import { useMessage } from '@/uni_modules/wot-design-uni';
|
||||
import RecentApi from '@/sheep/api/member/recent';
|
||||
import appUpdates from '@/sheep/platform/app-update';
|
||||
|
||||
const message = useMessage();
|
||||
const version = ref('0.0.0');
|
||||
const checking = ref(false);
|
||||
|
||||
onShow(() => {
|
||||
const systemInfo = uni.getSystemInfoSync();
|
||||
// #ifdef APP-PLUS
|
||||
version.value = systemInfo.appWgtVersion ?? "0.0.0";
|
||||
// #endif
|
||||
// #ifdef APP-HARMONY
|
||||
version.value = systemInfo.appWgtVersion || systemInfo.appVersion || '0.0.0';
|
||||
// #endif
|
||||
})
|
||||
|
||||
async function checkUpdate() {
|
||||
if (checking.value) return;
|
||||
checking.value = true;
|
||||
try {
|
||||
await appUpdates.check({ silent: false, force: true });
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2-用户协议;3-隐私协议;
|
||||
async function alert(type: number | string, title: string = '') {
|
||||
const { data } = await RecentApi.getPromotionArticle({ id: type });
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<page-meta :page-style="$statusBarPageStyle" />
|
||||
<wd-config-provider theme="dark">
|
||||
<view class="update-page" :style="$statusBarPageStyle">
|
||||
<wd-navbar title="应用更新" safe-area-inset-top :bordered="false">
|
||||
<template #left>
|
||||
<button v-if="canLeave" class="back-button" aria-label="返回" @click="leave">
|
||||
<uni-icons type="back" size="24" color="currentColor" />
|
||||
</button>
|
||||
</template>
|
||||
</wd-navbar>
|
||||
<scroll-view scroll-y class="update-scroll">
|
||||
<view class="update-content">
|
||||
<image class="app-logo" src="@/static/logo.png" mode="aspectFit" />
|
||||
<template v-if="release">
|
||||
<text class="update-title">{{ state.phase === 'ready' ? '更新已准备就绪' : '发现新版本' }}</text>
|
||||
<text class="version-name">V{{ release.versionName }}</text>
|
||||
<view class="package-info">
|
||||
<text>{{ release.packageType === 'apk' ? '完整安装包' : '资源更新包' }}</text>
|
||||
<text class="info-separator">·</text>
|
||||
<text>{{ formatFileSize(release.fileSize) }}</text>
|
||||
<text v-if="release.forceUpdate" class="required-label">需要更新</text>
|
||||
</view>
|
||||
|
||||
<view class="release-notes">
|
||||
<text class="section-title">更新内容</text>
|
||||
<text class="notes-text" selectable>{{ release.updateContent }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showProgress" class="download-status" role="status" aria-live="polite">
|
||||
<view class="progress-label">
|
||||
<text>{{ phaseText }}</text>
|
||||
<text v-if="state.phase === 'downloading'">{{ state.progress }}%</text>
|
||||
</view>
|
||||
<progress :percent="state.progress" activeColor="var(--wot-color-theme, #d29e52)"
|
||||
backgroundColor="var(--wot-dark-background3, #444)" :stroke-width="5" />
|
||||
<text v-if="state.phase === 'downloading'" class="download-size">
|
||||
{{ formatFileSize(state.downloadedBytes) }} / {{ formatFileSize(release.fileSize) }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-else-if="phaseText" class="phase-message" role="status" aria-live="polite">
|
||||
{{ phaseText }}
|
||||
</view>
|
||||
<view v-if="state.error" class="error-message" role="alert">{{ state.error }}</view>
|
||||
|
||||
<view class="update-actions">
|
||||
<button class="primary-button" :disabled="busy" :loading="busy" @click="primaryAction">
|
||||
{{ primaryLabel }}
|
||||
</button>
|
||||
<button v-if="canLeave" class="secondary-button" @click="leave">
|
||||
{{ state.phase === 'ready' ? '稍后重启' : '稍后处理' }}
|
||||
</button>
|
||||
<button v-if="state.error || state.phase === 'permission'" class="text-button"
|
||||
:disabled="busy || checking" @click="recheck">重新检查发布状态</button>
|
||||
</view>
|
||||
<text v-if="state.blocking" class="required-hint">完成此更新后即可继续使用</text>
|
||||
<text v-if="state.installedVersion" class="current-version">
|
||||
当前资源版本 V{{ state.installedVersion.resourceVersionName }}
|
||||
</text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<text class="update-title">{{ state.error ? '暂时无法检查更新' : '当前无需更新' }}</text>
|
||||
<text class="empty-message">{{ state.error || '应用已是最新版本,或该版本的发布状态已调整。' }}</text>
|
||||
<button class="primary-button" :loading="checking" :disabled="checking" @click="recheck">重新检查</button>
|
||||
<button class="secondary-button" @click="leave">返回</button>
|
||||
</template>
|
||||
<button v-if="state.error" class="text-button" :disabled="busy" @click="openSettings">
|
||||
应用权限设置
|
||||
</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</wd-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onBackPress, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import appUpdates, { formatFileSize } from '@/sheep/platform/app-update';
|
||||
import { openAppPermissionSettings } from '@/sheep/util/permission';
|
||||
|
||||
const state = appUpdates.state;
|
||||
const release = computed(() => state.release);
|
||||
const checking = ref(false);
|
||||
const busy = computed(() => ['downloading', 'verifying', 'installing'].includes(state.phase));
|
||||
const canLeave = computed(() => !state.blocking && state.phase !== 'installing');
|
||||
const showProgress = computed(() => busy.value);
|
||||
const phaseText = computed(() => ({
|
||||
downloading: '正在下载更新包',
|
||||
verifying: '正在校验更新包',
|
||||
installing: '正在安装资源更新',
|
||||
permission: '请允许安装此来源的应用,返回后继续安装。',
|
||||
'installer-open': '已打开系统安装器,请完成安装。取消后可再次安装。',
|
||||
ready: '资源包已安装,重启应用后生效。',
|
||||
}[state.phase] || ''));
|
||||
const primaryLabel = computed(() => {
|
||||
if (state.phase === 'ready') return '立即重启';
|
||||
if (state.phase === 'permission') return '授权并继续安装';
|
||||
if (state.phase === 'installer-open' || appUpdates.filePath) return '继续安装';
|
||||
if (busy.value) return phaseText.value;
|
||||
return state.phase === 'error' ? '重试更新' : '下载并更新';
|
||||
});
|
||||
|
||||
function primaryAction() {
|
||||
if (state.phase === 'ready') appUpdates.restart();
|
||||
else void appUpdates.update();
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
if (!await openAppPermissionSettings()) {
|
||||
uni.showToast({ title: '请在系统设置中检查网络与应用权限', icon: 'none' });
|
||||
}
|
||||
}
|
||||
|
||||
async function recheck() {
|
||||
checking.value = true;
|
||||
try {
|
||||
await appUpdates.check({ silent: false, force: true });
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function leave() {
|
||||
if (!appUpdates.dismiss()) return;
|
||||
if (getCurrentPages().length > 1) uni.navigateBack();
|
||||
else uni.switchTab({ url: '/pages/tabbar/profile' });
|
||||
}
|
||||
|
||||
onShow(() => { void appUpdates.foreground(); });
|
||||
onBackPress(() => {
|
||||
if (!canLeave.value) return true;
|
||||
appUpdates.dismiss();
|
||||
return false;
|
||||
});
|
||||
onUnload(() => { appUpdates.dismiss(); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.update-page {
|
||||
--update-bg: var(--wot-dark-background, #222);
|
||||
--update-surface: var(--wot-dark-background2, #2b2b2b);
|
||||
--update-primary: var(--wot-color-theme, #d29e52);
|
||||
--update-text: var(--wot-dark-color-primary, #f5f5f5);
|
||||
--update-muted: var(--wot-dark-color-secondary, #c5c5c5);
|
||||
--update-border: var(--wot-dark-border-color, #555);
|
||||
--update-error: var(--wot-color-danger, #fa8585);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--update-bg);
|
||||
color: var(--update-text);
|
||||
}
|
||||
.update-scroll { flex: 1; min-height: 0; }
|
||||
.update-content {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px calc(32px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
.app-logo { width: 72px; height: 72px; margin: 0 auto 24px; border-radius: 18px; }
|
||||
.update-title { text-align: center; font-size: 24px; font-weight: 600; line-height: 1.4; }
|
||||
.version-name { margin-top: 8px; text-align: center; font-size: 20px; color: var(--update-primary); }
|
||||
.package-info { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; color: var(--update-muted); font-size: 13px; }
|
||||
.info-separator { color: var(--update-muted); }
|
||||
.required-label { padding: 2px 8px; border: 1px solid var(--update-border); border-radius: 4px; color: var(--update-text); }
|
||||
.release-notes { display: flex; flex-direction: column; margin-top: 32px; padding: 20px; background: var(--update-surface); border: 1px solid var(--update-border); border-radius: 12px; }
|
||||
.section-title { font-size: 16px; font-weight: 600; }
|
||||
.notes-text { margin-top: 12px; white-space: pre-wrap; word-break: break-word; font-size: 15px; line-height: 1.8; color: var(--update-muted); }
|
||||
.download-status, .phase-message { margin-top: 24px; font-size: 14px; line-height: 1.7; }
|
||||
.progress-label { display: flex; justify-content: space-between; margin-bottom: 12px; }
|
||||
.download-size { display: block; margin-top: 8px; font-size: 12px; color: var(--update-muted); }
|
||||
.error-message { margin-top: 16px; color: var(--update-error); font-size: 14px; line-height: 1.7; word-break: break-word; }
|
||||
.update-actions { display: flex; flex-direction: column; gap: 12px; margin-top: 32px; }
|
||||
.primary-button, .secondary-button, .text-button, .back-button { margin: 0; padding: 12px 16px; min-height: 48px; line-height: 1.5; font-size: 16px; border-radius: 10px; }
|
||||
.primary-button { width: 100%; background: var(--update-primary); color: #171717; font-weight: 600; }
|
||||
.secondary-button, .text-button { background: transparent; color: var(--update-muted); }
|
||||
.secondary-button { border: 1px solid var(--update-border); }
|
||||
.text-button { font-size: 14px; text-decoration: underline; }
|
||||
.back-button { display: flex; align-items: center; justify-content: center; min-width: 48px; padding: 8px; background: transparent; color: var(--update-primary); }
|
||||
button::after { border: 0; }
|
||||
button[disabled] { opacity: .6; }
|
||||
button:active { opacity: .8; }
|
||||
button:focus-visible { outline: 2px solid var(--update-primary); outline-offset: 3px; }
|
||||
.required-hint, .current-version { margin-top: 16px; text-align: center; font-size: 13px; line-height: 1.6; color: var(--update-muted); }
|
||||
.empty-message { margin: 20px 0 32px; text-align: center; line-height: 1.8; color: var(--update-muted); }
|
||||
@media (max-width: 360px) { .update-content { padding-inline: 16px; } }
|
||||
@media (orientation: landscape) { .update-content { padding-top: 16px; } .app-logo { width: 56px; height: 56px; } }
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||||
</style>
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ensurePermission, handlePermissionFailure } from '@/sheep/util/permission';
|
||||
import RecentApi from '@/sheep/api/member/recent';
|
||||
// @ts-ignore
|
||||
import sheep from '@/sheep/index';
|
||||
@@ -6,7 +7,7 @@ import sheep from '@/sheep/index';
|
||||
* @param certifyId 阿里人脸认证id
|
||||
* @param authId 发起认证的id
|
||||
*/
|
||||
const verify = (certifyId: string, authId: string) => {
|
||||
const verify = async (certifyId: string, authId: string) => {
|
||||
if (!certifyId) return uni.showToast({ title: 'certifyId不存在', icon: 'none' });
|
||||
if (!authId) return uni.showToast({ title: 'authId不存在', icon: 'none' });
|
||||
// const p = uni.getSystemInfoSync().platform;
|
||||
@@ -14,7 +15,14 @@ const verify = (certifyId: string, authId: string) => {
|
||||
// }
|
||||
console.log('certifyId', certifyId);
|
||||
|
||||
// 原生认证 SDK 仅存在于 Android/iOS,浏览器和小程序保持能力提示。
|
||||
// #ifndef APP-PLUS
|
||||
return uni.showToast({ title: '人脸认证仅支持 Android 或 iOS App', icon: 'none' });
|
||||
// #endif
|
||||
// #ifdef APP-PLUS
|
||||
if (!await ensurePermission('camera', { feature: '实人认证', purpose: '采集您本人面部画面完成身份核验' })) return;
|
||||
const aliyunVerify = uni.requireNativePlugin('AP-FaceDetectModule');
|
||||
if (!aliyunVerify?.verify) return uni.showToast({ title: '当前安装包未包含实人认证能力,请更新完整安装包', icon: 'none' });
|
||||
aliyunVerify.verify(
|
||||
{
|
||||
certifyId,
|
||||
@@ -49,6 +57,7 @@ const verify = (certifyId: string, authId: string) => {
|
||||
uni.showToast({ title: '系统错误', icon: 'none' });
|
||||
break;
|
||||
case 1003:
|
||||
void handlePermissionFailure('camera', response);
|
||||
RecentApi.cancelFaceVerify({ authId });
|
||||
uni.showToast({ title: '验证中断', icon: 'none' });
|
||||
break;
|
||||
@@ -63,6 +72,7 @@ const verify = (certifyId: string, authId: string) => {
|
||||
}
|
||||
},
|
||||
);
|
||||
// #endif
|
||||
};
|
||||
|
||||
export default verify;
|
||||
@@ -35,6 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ensurePermission } from '@/sheep/util/permission';
|
||||
import RecentApi from '@/sheep/api/member/recent';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
// @ts-ignore
|
||||
@@ -80,7 +81,9 @@ const createFaceVerify = async () => {
|
||||
// #ifdef APP-PLUS
|
||||
if (!userinfo.avatar) return uni.showToast({ title: '请先上传人脸照片', icon: 'error' });
|
||||
|
||||
if (!await ensurePermission('camera', { feature: '实人认证', purpose: '采集您本人面部画面完成身份核验' })) return;
|
||||
const aliyunVerify = uni.requireNativePlugin('AP-FaceDetectModule');
|
||||
if (!aliyunVerify?.getMetaInfo) return uni.showToast({ title: '当前安装包未包含实人认证能力,请更新完整安装包', icon: 'none' });
|
||||
const metaInfo = JSON.stringify(aliyunVerify.getMetaInfo());
|
||||
|
||||
// 创建男生人脸认证
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<view class="text-sm my-2 mx-8 c-#949494">请上传本人真实照片,更容易受到男士青睐</view>
|
||||
<view class="h-60rpx"></view>
|
||||
<view class="mx-8 mb-8">
|
||||
<wd-upload v-model:file-list="imageFileList" :action="baseUrl + apiPath + '/infra/file/upload'" :header="header"
|
||||
<wd-upload @chooseerror="onChoosePhotoError" :source-type="uploadSources" :before-choose="beforeChoosePhoto" v-model:file-list="imageFileList" :action="baseUrl + apiPath + '/infra/file/upload'" :header="header"
|
||||
image-mode="aspectFill" accept="image" multiple :limit="9" :max-size="3000000" @success="uploadSuccess"
|
||||
:before-remove="beforeRemove" />
|
||||
</view>
|
||||
@@ -41,11 +41,29 @@ import RecentApi from '@/sheep/api/member/recent';
|
||||
import { useMessage } from '@/uni_modules/wot-design-uni';
|
||||
|
||||
import useAblum from '../use-ablum';
|
||||
import { ref, watch } from 'vue';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { prepareMediaSelection, handlePermissionFailure, type MediaSource } from '@/sheep/util/permission';
|
||||
|
||||
const messageBox = useMessage();
|
||||
const { ablumList, getUserAlbum } = useAblum();
|
||||
|
||||
/** 选择器被系统拒绝后沿用统一的权限设置引导。 */
|
||||
const onChoosePhotoError = ({ error }: { error: unknown }) => {
|
||||
void handlePermissionFailure(uploadSources.value[0] === 'camera' ? 'camera' : 'album-read', error);
|
||||
};
|
||||
/** 每次选择前恢复来源菜单,授权后只打开用户选定的来源。 */
|
||||
const uploadSources = ref<MediaSource[]>(['album', 'camera']);
|
||||
/** Wot 现有选择钩子在权限流程完成后继续原有文件选择与上传。 */
|
||||
const beforeChoosePhoto = async ({ resolve }: { resolve: (allowed: boolean) => void }) => {
|
||||
const sources = await prepareMediaSelection(['album', 'camera'], 'image', {
|
||||
feature: '认证照片', purpose: '选择您主动提交的本人认证照片',
|
||||
});
|
||||
if (!sources) return resolve(false);
|
||||
uploadSources.value = sources;
|
||||
await nextTick();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
const imageFileList = ref([]);
|
||||
|
||||
watch(
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ensurePermission } from '@/sheep/util/permission';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import CapsuleCell from '../components/capsule-cell.vue';
|
||||
// @ts-ignore
|
||||
@@ -133,7 +134,7 @@
|
||||
const syncCurrentLocation = async (showError: boolean) => {
|
||||
let location: CurrentLocation;
|
||||
try {
|
||||
location = await getCurrentLocation({ type: 'wgs84', geocode: true });
|
||||
location = await getCurrentLocation({ type: 'wgs84', geocode: true, interactive: showError, permissionUsage: { feature: '魅力女生认证', purpose: '确认认证所在城市并展示附近用户' } });
|
||||
} catch (error) {
|
||||
locationAuth.value = false;
|
||||
if (showError) {
|
||||
@@ -179,7 +180,9 @@
|
||||
|
||||
console.log('userinfo:', userinfo.wechatNum);
|
||||
|
||||
if (!await ensurePermission('camera', { feature: '实人认证', purpose: '采集您本人面部画面完成身份核验' })) return;
|
||||
const aliyunVerify = uni.requireNativePlugin('AP-FaceDetectModule');
|
||||
if (!aliyunVerify?.getMetaInfo) return uni.showToast({ title: '当前安装包未包含实人认证能力,请更新完整安装包', icon: 'none' });
|
||||
const metaInfo = JSON.stringify(aliyunVerify.getMetaInfo());
|
||||
|
||||
// 创建女生人脸认证
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<text>{{ imageFileList?.length || 0 }}/9</text>
|
||||
</view>
|
||||
<view class="px-4">
|
||||
<wd-upload
|
||||
<wd-upload @chooseerror="onChoosePhotoError" :source-type="uploadSources" :before-choose="beforeChoosePhoto"
|
||||
v-model:file-list="imageFileList"
|
||||
:action="baseUrl + apiPath + '/infra/file/upload'"
|
||||
:header="header"
|
||||
@@ -34,7 +34,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { prepareMediaSelection, handlePermissionFailure, type MediaSource } from '@/sheep/util/permission';
|
||||
import useAblum from './use-ablum';
|
||||
import type { UploadFile } from '@/uni_modules/wot-design-uni/components/wd-upload/types';
|
||||
import { useMessage, useToast } from '@/uni_modules/wot-design-uni';
|
||||
@@ -54,6 +55,23 @@
|
||||
const toast = useToast();
|
||||
const { ablumList, getUserAlbum } = useAblum();
|
||||
|
||||
/** 选择器被系统拒绝后沿用统一的权限设置引导。 */
|
||||
const onChoosePhotoError = ({ error }: { error: unknown }) => {
|
||||
void handlePermissionFailure(uploadSources.value[0] === 'camera' ? 'camera' : 'album-read', error);
|
||||
};
|
||||
/** 每次选择前恢复来源菜单,授权后只打开用户选定的来源。 */
|
||||
const uploadSources = ref<MediaSource[]>(['album', 'camera']);
|
||||
/** Wot 现有选择钩子在权限流程完成后继续原有文件选择与上传。 */
|
||||
const beforeChoosePhoto = async ({ resolve }: { resolve: (allowed: boolean) => void }) => {
|
||||
const sources = await prepareMediaSelection(['album', 'camera'], 'image', {
|
||||
feature: '上传相册', purpose: '选择您主动上传至个人相册的照片',
|
||||
});
|
||||
if (!sources) return resolve(false);
|
||||
uploadSources.value = sources;
|
||||
await nextTick();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
const imageFileList = ref<UploadFile[]>([]);
|
||||
const videoFileList = ref<UploadFile[]>([]);
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
const syncLocation = async (showError: boolean) => {
|
||||
let location: CurrentLocation;
|
||||
try {
|
||||
location = await getCurrentLocation({ geocode: true });
|
||||
location = await getCurrentLocation({ geocode: true, interactive: showError, permissionUsage: { feature: '附近用户', purpose: '定位当前城市并展示附近的人' } });
|
||||
} catch (error) {
|
||||
isOpenGPS.value = false;
|
||||
if (showError) {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc';
|
||||
|
||||
const filename = new URL('../index.vue', import.meta.url);
|
||||
const source = await readFile(filename, 'utf8');
|
||||
const { descriptor } = parse(source);
|
||||
const formatterSource = await readFile(new URL('../../../sheep/hooks/use-brokerage.ts', import.meta.url), 'utf8');
|
||||
const formatter = { exports: {} };
|
||||
vm.runInNewContext(ts.transpileModule(formatterSource, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 },
|
||||
}).outputText, { exports: formatter.exports });
|
||||
|
||||
function h5Script(script) {
|
||||
const enabled = [true];
|
||||
return script.split('\n').filter((line) => {
|
||||
const directive = line.match(/^\s*\/\/\s*#(ifdef|ifndef|endif)\s*(.*)$/);
|
||||
if (!directive) return enabled.at(-1);
|
||||
const [, type, platforms] = directive;
|
||||
if (type === 'endif') enabled.pop();
|
||||
else {
|
||||
const matches = platforms.split(/\s*\|\|\s*/).includes('H5');
|
||||
enabled.push(enabled.at(-1) && (type === 'ifdef' ? matches : !matches));
|
||||
}
|
||||
return false;
|
||||
}).join('\n').replace(/^import[^\n]*\n/gm, '');
|
||||
}
|
||||
|
||||
const summary = () => ({
|
||||
code: 0,
|
||||
data: {
|
||||
withdrawPrice: 0,
|
||||
brokeragePrice: 20841,
|
||||
brokerageSummary: { settledPrice: 3510, pendingPrice: 0 },
|
||||
firstBrokerageUserCount: 43,
|
||||
secondBrokerageUserCount: 13,
|
||||
},
|
||||
});
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const lifecycle = {};
|
||||
const bindingCalls = [];
|
||||
const confirmations = [];
|
||||
const navigation = [];
|
||||
let bound;
|
||||
const store = reactive({
|
||||
isLogin: true,
|
||||
userInfo: { id: 300, nickname: '测试会员', sex: 2 },
|
||||
updateUserData: async () => {},
|
||||
});
|
||||
const api = {
|
||||
getBrokerageUserSummary: async () => summary(),
|
||||
getBrokerageCode: async () => ({ code: 0, data: { txWechatNum: 'test-account' } }),
|
||||
...overrides,
|
||||
};
|
||||
const context = vm.createContext({
|
||||
computed, reactive, ref,
|
||||
sheep: { $store: () => store },
|
||||
BrokerageApi: api,
|
||||
TradeConfigApi: { getTradeConfig: async () => ({ code: 0, data: {} }) },
|
||||
formatBrokeragePrice: formatter.exports.formatBrokeragePrice,
|
||||
fen2yuanSimple: (price) => (Number(price) / 100).toFixed(2),
|
||||
onLoad: (callback) => { lifecycle.load = callback; },
|
||||
onShow: (callback) => { lifecycle.show = callback; },
|
||||
onHide: (callback) => { lifecycle.hide = callback; },
|
||||
onUnload: (callback) => { lifecycle.unload = callback; },
|
||||
useMessage: () => ({ confirm: (options) => { confirmations.push(options); return Promise.resolve(); } }),
|
||||
useBindWechat: (callback) => {
|
||||
bound = callback;
|
||||
return { open: (value) => bindingCalls.push(value) };
|
||||
},
|
||||
jumpToWechatMiniProgram: async () => '/test-mini-program',
|
||||
saveImageToPhotosAlbum: () => {},
|
||||
window: { location: { href: '' } },
|
||||
uni: {
|
||||
getStorageSync: () => ({ id: 300 }),
|
||||
navigateTo: ({ url }) => navigation.push(url),
|
||||
showToast: () => {},
|
||||
},
|
||||
console,
|
||||
});
|
||||
vm.runInContext(h5Script(descriptor.scriptSetup.content) +
|
||||
'\nglobalThis.state = { state, getBrokerageUserSummary, getMiniProgramPoster, doTxItem, doTx };', context);
|
||||
return { ...context.state, store, api, lifecycle, bindingCalls, confirmations, navigation, bound };
|
||||
}
|
||||
|
||||
test('welfare page compiles and registers the income page for both supported route groups', async () => {
|
||||
assert.doesNotThrow(() => compileScript(descriptor, { id: 'brokerage-summary' }));
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: filename.pathname, id: 'brokerage-summary' }).errors, []);
|
||||
const pages = await readFile(new URL('../../../pages.json', import.meta.url), 'utf8');
|
||||
assert.equal((pages.match(/"path": "pages\/brokerage-record"/g) || []).length, 2);
|
||||
assert.match(source, /待结算/);
|
||||
assert.doesNotMatch(descriptor.template.content, /一级|二级/);
|
||||
});
|
||||
|
||||
test('income and pending cards use their respective ledger summary amounts', async () => {
|
||||
const h = fixture();
|
||||
await h.getBrokerageUserSummary();
|
||||
assert.equal(h.state.txTab[0].value, '35.10');
|
||||
assert.equal(h.state.txTab[1].title, '待结算');
|
||||
assert.equal(h.state.txTab[1].value, '0.00');
|
||||
assert.equal(h.state.txTab[2].title, '团队人数');
|
||||
assert.equal(h.state.txTab[2].value, 56);
|
||||
assert.equal(h.state.summaryError, '');
|
||||
assert.doesNotMatch(descriptor.template.content, /state\.pendingIncome/);
|
||||
});
|
||||
|
||||
test('real zero amounts and string Long totals are displayed exactly', async () => {
|
||||
const response = summary();
|
||||
response.data.brokerageSummary = { settledPrice: '9223372036854775807', pendingPrice: '1234567890123456789' };
|
||||
response.data.brokeragePrice = 0;
|
||||
const h = fixture({ getBrokerageUserSummary: async () => response });
|
||||
await h.getBrokerageUserSummary();
|
||||
assert.equal(h.state.txTab[0].value, '92233720368547758.07');
|
||||
assert.equal(h.state.txTab[1].value, '12345678901234567.89');
|
||||
assert.equal(formatter.exports.formatBrokeragePrice(undefined), '—');
|
||||
assert.equal(formatter.exports.formatBrokeragePrice('invalid'), '—');
|
||||
assert.equal(formatter.exports.formatBrokeragePrice(-101), '-1.01');
|
||||
});
|
||||
|
||||
test('missing new statistics show unknown pending income and an explicit error', async () => {
|
||||
const response = summary();
|
||||
delete response.data.brokerageSummary;
|
||||
const h = fixture({ getBrokerageUserSummary: async () => response });
|
||||
await h.getBrokerageUserSummary();
|
||||
assert.equal(h.state.txTab[0].value, '—');
|
||||
assert.equal(h.state.txTab[1].value, '—');
|
||||
assert.match(h.state.summaryError, /收益统计暂不可用/);
|
||||
});
|
||||
|
||||
test('failed refresh clears stale amounts and can be retried', async () => {
|
||||
const h = fixture();
|
||||
await h.getBrokerageUserSummary();
|
||||
h.api.getBrokerageUserSummary = async () => { throw new Error('offline'); };
|
||||
await h.getBrokerageUserSummary();
|
||||
assert.equal(h.state.txTab[0].value, '—');
|
||||
assert.equal(h.state.txTab[1].value, '—');
|
||||
assert.match(h.state.summaryError, /加载失败/);
|
||||
h.api.getBrokerageUserSummary = async () => summary();
|
||||
await h.getBrokerageUserSummary();
|
||||
assert.equal(h.state.txTab[0].value, '35.10');
|
||||
});
|
||||
|
||||
test('late requests cannot overwrite a newer summary or a changed account', async () => {
|
||||
const old = deferred();
|
||||
const h = fixture({ getBrokerageUserSummary: () => old.promise });
|
||||
const pending = h.getBrokerageUserSummary();
|
||||
h.api.getBrokerageUserSummary = async () => summary();
|
||||
await h.getBrokerageUserSummary();
|
||||
const oldResponse = summary();
|
||||
oldResponse.data.brokerageSummary.settledPrice = 99999;
|
||||
old.resolve(oldResponse);
|
||||
await pending;
|
||||
assert.equal(h.state.txTab[0].value, '35.10');
|
||||
const changed = deferred();
|
||||
h.api.getBrokerageUserSummary = () => changed.promise;
|
||||
const oldAccount = h.getBrokerageUserSummary();
|
||||
h.store.userInfo.id = 301;
|
||||
changed.resolve(summary());
|
||||
await oldAccount;
|
||||
assert.equal(h.state.txTab[0].value, '—');
|
||||
});
|
||||
|
||||
test('hiding invalidates summary and profile-delayed work before it reaches the API', async () => {
|
||||
const h = fixture();
|
||||
const profile = deferred();
|
||||
h.store.updateUserData = () => profile.promise;
|
||||
let requests = 0;
|
||||
h.api.getBrokerageUserSummary = async () => { requests += 1; return summary(); };
|
||||
const showing = h.lifecycle.show();
|
||||
h.lifecycle.hide();
|
||||
profile.resolve();
|
||||
await showing;
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test('income opens records, pending is display-only, and withdrawal button keeps its binding flow', async () => {
|
||||
const h = fixture();
|
||||
h.doTxItem(h.state.txTab[0]);
|
||||
assert.deepEqual(h.navigation, ['/pages/wechat/pages/brokerage-record']);
|
||||
await h.doTxItem(h.state.txTab[1]);
|
||||
assert.equal(h.bindingCalls.length, 0);
|
||||
assert.equal(h.navigation.length, 1);
|
||||
h.store.isLogin = false;
|
||||
await h.doTxItem(h.state.txTab[1]);
|
||||
assert.equal(h.confirmations.length, 0);
|
||||
h.store.isLogin = true;
|
||||
await h.doTx();
|
||||
assert.equal(h.bindingCalls.length, 1);
|
||||
assert.equal(h.navigation.length, 1);
|
||||
await h.bound();
|
||||
for (let index = 0; index < 5; index++) await Promise.resolve();
|
||||
assert.equal(h.state.wechatNum, 'test-account');
|
||||
});
|
||||
@@ -44,13 +44,17 @@
|
||||
</view>
|
||||
<!-- 提现相关 -->
|
||||
<view class="mx-20rpx mt-30rpx bg-color-dark rounded-12rpx px-6 py-4">
|
||||
<view class="flex items-center justify-center">
|
||||
<view class="flex items-start justify-center">
|
||||
<view class="flex-1 flex-col justify-center items-center" v-for="(v, i) in state.txTab" :key="i"
|
||||
@tap="doTxItem(v)">
|
||||
<text class="text-color-theme text-2xl"> {{ v.value }}</text>
|
||||
<text class="mt-10rpx">{{ v.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="state.summaryError" class="mt-20rpx text-xs text-color-theme" role="alert">
|
||||
<text>{{ state.summaryError }}</text>
|
||||
<text class="ml-15rpx underline" @tap="getBrokerageUserSummary">重试</text>
|
||||
</view>
|
||||
<view class="flex justify-center items-center mt-30rpx">
|
||||
<text
|
||||
class="bg-color-theme-dark active_cl_bg-color-theme text-base text-center px-80rpx py-12rpx rounded-full"
|
||||
@@ -111,10 +115,11 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onLoad, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
|
||||
import sheep from '@/sheep';
|
||||
import { fen2yuanSimple } from '@/sheep/hooks/useGoods';
|
||||
import { formatBrokeragePrice } from '@/sheep/hooks/use-brokerage';
|
||||
import BrokerageApi from '@/sheep/api/trade/brokerage';
|
||||
import TradeConfigApi from '@/sheep/api/trade/config.js';
|
||||
import { useMessage } from '@/uni_modules/wot-design-uni';
|
||||
@@ -125,7 +130,7 @@ import { saveImageToPhotosAlbum } from "@/sheep/util/file-tool"
|
||||
const message = useMessage();
|
||||
const jumpWechatMessage = useMessage('jump-wechat');
|
||||
|
||||
const userInfo = computed(() => sheep.$store('user')?.userInfo ?? null);
|
||||
const userInfo = computed(() => sheep.$store('user')?.userInfo ?? {});
|
||||
const isLogin = computed(() => sheep.$store('user').isLogin && state.loginInfoStorage != null);
|
||||
const userGenderImageSrc = computed(() => {
|
||||
const sex = userInfo.value.sex;
|
||||
@@ -166,18 +171,20 @@ const data = {
|
||||
const state = reactive({
|
||||
txTab: [
|
||||
{
|
||||
key: 'income',
|
||||
title: '累计收益',
|
||||
value: '0',
|
||||
path: '/pages/wechat/pages/gift/history',
|
||||
value: '—',
|
||||
path: '/pages/wechat/pages/brokerage-record',
|
||||
},
|
||||
{
|
||||
title: '可提现',
|
||||
value: '0',
|
||||
path: '/pages/wechat/pages/gift/index',
|
||||
key: 'pending',
|
||||
title: '待结算',
|
||||
value: '—',
|
||||
},
|
||||
{
|
||||
title: '成功邀请',
|
||||
value: '0',
|
||||
key: 'team',
|
||||
title: '团队人数',
|
||||
value: '—',
|
||||
path: '/pages/wechat/pages/inviter/index',
|
||||
},
|
||||
],
|
||||
@@ -190,15 +197,25 @@ const state = reactive({
|
||||
},
|
||||
],
|
||||
summary: {},
|
||||
summaryError: '',
|
||||
posterPic: '',
|
||||
posterUrl: '',
|
||||
wechatNum: '',
|
||||
loginInfoStorage: "",
|
||||
});
|
||||
|
||||
const { bindWechatNum, open: openBindWechat } = useBindWechat(() => {
|
||||
getTxConfig()
|
||||
})
|
||||
let summaryVersion = 0;
|
||||
let posterVersion = 0;
|
||||
const clearSummary = () => {
|
||||
state.summary = {};
|
||||
state.summaryError = '';
|
||||
state.txTab.forEach((tab) => { tab.value = '—'; });
|
||||
};
|
||||
|
||||
onHide(() => { summaryVersion += 1; posterVersion += 1; });
|
||||
onUnload(() => { summaryVersion += 1; posterVersion += 1; });
|
||||
|
||||
const { bindWechatNum, open: openBindWechat } = useBindWechat(() => getMiniProgramPoster());
|
||||
|
||||
onLoad(async () => {
|
||||
// #ifdef MP-WEIXIN
|
||||
@@ -209,12 +226,17 @@ onLoad(async () => {
|
||||
})
|
||||
|
||||
onShow(async () => {
|
||||
sheep.$store('user').updateUserData();
|
||||
const version = ++summaryVersion;
|
||||
posterVersion += 1;
|
||||
clearSummary();
|
||||
state.wechatNum = '';
|
||||
state.posterPic = '';
|
||||
state.posterUrl = '';
|
||||
await sheep.$store('user').updateUserData();
|
||||
if (version !== summaryVersion) return;
|
||||
state.loginInfoStorage = uni.getStorageSync('loginInfo') ?? null
|
||||
if (isLogin.value) {
|
||||
await getTradeConfig()
|
||||
await getBrokerageUserSummary()
|
||||
await getMiniProgramPoster()
|
||||
await Promise.all([getTradeConfig(), getBrokerageUserSummary(), getMiniProgramPoster()]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -241,27 +263,39 @@ const getTradeConfig = async () => {
|
||||
|
||||
// 获取收益统计
|
||||
const getBrokerageUserSummary = async () => {
|
||||
let { code, data } = await BrokerageApi.getBrokerageUserSummary();
|
||||
if (code != 0) return
|
||||
state.summary = data || {};
|
||||
// 累计收益
|
||||
if (state.summary.withdrawPrice !== undefined) {
|
||||
state.txTab[0].value = fen2yuanSimple(state.summary.withdrawPrice);
|
||||
const version = ++summaryVersion;
|
||||
const memberId = userInfo.value?.id;
|
||||
clearSummary();
|
||||
try {
|
||||
const { code, data } = await BrokerageApi.getBrokerageUserSummary();
|
||||
if (version !== summaryVersion || !isLogin.value || memberId !== userInfo.value?.id) return;
|
||||
if (code !== 0 || !data) throw new Error('收益数据加载失败,请重试');
|
||||
state.summary = data;
|
||||
state.txTab[0].value = formatBrokeragePrice(data.brokerageSummary?.settledPrice);
|
||||
state.txTab[1].value = formatBrokeragePrice(data.brokerageSummary?.pendingPrice);
|
||||
const firstCount = Number(data.firstBrokerageUserCount);
|
||||
const secondCount = Number(data.secondBrokerageUserCount);
|
||||
if (data.firstBrokerageUserCount != null && data.secondBrokerageUserCount != null &&
|
||||
Number.isFinite(firstCount) && Number.isFinite(secondCount)) {
|
||||
state.txTab[2].value = firstCount + secondCount;
|
||||
}
|
||||
if (data.brokerageSummary?.settledPrice == null || data.brokerageSummary?.pendingPrice == null) {
|
||||
state.summaryError = '收益统计暂不可用,请更新服务后重试';
|
||||
}
|
||||
} catch (error) {
|
||||
if (version === summaryVersion && isLogin.value && memberId === userInfo.value?.id) {
|
||||
state.summaryError = '收益数据加载失败,请重试';
|
||||
}
|
||||
}
|
||||
// 可提现
|
||||
if (state.summary.brokeragePrice !== undefined) {
|
||||
state.txTab[1].value = fen2yuanSimple(state.summary.brokeragePrice);
|
||||
}
|
||||
// 成功邀请用户
|
||||
const firstBrokerageUserCount = state.summary.firstBrokerageUserCount ?? 0;
|
||||
const secondBrokerageUserCount = state.summary.secondBrokerageUserCount ?? 0;
|
||||
state.txTab[2].value = firstBrokerageUserCount + secondBrokerageUserCount;
|
||||
};
|
||||
|
||||
// 获取分销海报
|
||||
const getMiniProgramPoster = async () => {
|
||||
const version = ++posterVersion;
|
||||
const memberId = userInfo.value?.id;
|
||||
try {
|
||||
let { code, data } = await BrokerageApi.getBrokerageCode();
|
||||
if (version !== posterVersion || !isLogin.value || memberId !== userInfo.value?.id) return;
|
||||
if (code != 0) throw new Error();
|
||||
state.posterPic = data?.posterPic || '';
|
||||
state.posterUrl = data?.url || '';
|
||||
@@ -333,7 +367,7 @@ const doTx = async () => {
|
||||
return;
|
||||
}
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.navigateTo({ url: '/pages/wechat/pages/withdraw' })
|
||||
uni.navigateTo({ url: '/pages/wechat/pages/withdraw?type=yj' })
|
||||
// #endif
|
||||
// TODO 由于是到账到微信,所以必须跳转到微信小程序
|
||||
// #ifndef MP-WEIXIN
|
||||
@@ -380,15 +414,14 @@ const authDialog = () => {
|
||||
}
|
||||
|
||||
const doTxItem = (item) => {
|
||||
if (!item.path) return;
|
||||
if (!isLogin.value) {
|
||||
authDialog()
|
||||
return;
|
||||
}
|
||||
if (item.path) {
|
||||
uni.navigateTo({
|
||||
url: item.path,
|
||||
});
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: item.path,
|
||||
});
|
||||
};
|
||||
|
||||
const doItem = (item) => {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import * as vue from 'vue';
|
||||
import { parse, compileScript, compileTemplate } from 'vue/compiler-sfc';
|
||||
|
||||
const filename = new URL('../brokerage-record.vue', import.meta.url);
|
||||
const source = await readFile(filename, 'utf8');
|
||||
const { descriptor } = parse(source);
|
||||
const transpile = (source) => ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
|
||||
function loadModule(source, imports = {}) {
|
||||
const context = {
|
||||
exports: {},
|
||||
URLSearchParams,
|
||||
require: (name) => {
|
||||
if (Object.hasOwn(imports, name)) return imports[name];
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(transpile(source), context);
|
||||
return context.exports;
|
||||
}
|
||||
|
||||
const scroll = loadModule(await readFile(new URL('../../../../sheep/hooks/use-scroll.ts', import.meta.url), 'utf8'), {
|
||||
vue,
|
||||
});
|
||||
const formatter = loadModule(await readFile(new URL('../../../../sheep/hooks/use-brokerage.ts', import.meta.url), 'utf8'));
|
||||
const apiSource = await readFile(new URL('../../../../sheep/api/trade/brokerage.js', import.meta.url), 'utf8');
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
};
|
||||
const page = (list, total = list.length) => ({ code: 0, data: { list, total } });
|
||||
const record = (id = 15) => ({
|
||||
id,
|
||||
userId: 300,
|
||||
sourceUserId: 465,
|
||||
bizType: 2,
|
||||
title: '会员开通佣金',
|
||||
description: '获得会员佣金 35.10',
|
||||
price: 3510,
|
||||
status: 1,
|
||||
createTime: '2026-11-02 18:39:32',
|
||||
});
|
||||
|
||||
function fixture(fetch = async () => page([])) {
|
||||
const calls = [];
|
||||
const lifecycle = {};
|
||||
const api = { fetch };
|
||||
const context = {
|
||||
exports: {},
|
||||
require: (name) => {
|
||||
if (name === 'vue') return vue;
|
||||
if (name === '@dcloudio/uni-app') return {
|
||||
onShow: (fn) => { lifecycle.shown = fn; },
|
||||
onHide: (fn) => { lifecycle.hidden = fn; },
|
||||
};
|
||||
if (name === '@/sheep/hooks/use-scroll') return scroll;
|
||||
if (name === '@/sheep/hooks/use-brokerage') return formatter;
|
||||
if (name === '@/sheep/api/trade/brokerage') return { default: {
|
||||
getBrokerageRecordPage: (params) => {
|
||||
calls.push({ ...params });
|
||||
return api.fetch(params);
|
||||
},
|
||||
} };
|
||||
if (name === '@/sheep/helper/utils') return { formatDate: (value) => value };
|
||||
if (name === '@/sheep/util/wechat-tool') return { wechatNavBack: () => {} };
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
};
|
||||
const scope = vue.effectScope();
|
||||
scope.run(() => vm.runInNewContext(transpile(descriptor.scriptSetup.content)
|
||||
+ '\nglobalThis.state = { activeTab, queryParams, tabsChange, listData, loadMoreStatus, refreshList, listLoadMore, error, retry, statusNames };', context));
|
||||
return { ...context, api, lifecycle, calls, stop: () => scope.stop() };
|
||||
}
|
||||
|
||||
test('income page script and template compile and bind record fields without wallet or withdrawal actions', () => {
|
||||
assert.doesNotThrow(() => compileScript(descriptor, { id: 'brokerage-record' }));
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: filename.pathname, id: 'brokerage-record' }).errors, []);
|
||||
assert.match(source, /record\.title/);
|
||||
assert.match(source, /record\.description/);
|
||||
assert.match(source, /formatDate\(record\.createTime\)/);
|
||||
assert.match(source, /formatBrokeragePrice\(record\.price\)/);
|
||||
assert.match(source, /record\.unfreezeTime/);
|
||||
assert.match(source, /@tap="retry"/);
|
||||
assert.match(source, /@refresh="refreshList"/);
|
||||
assert.match(source, /@loadMore="listLoadMore"/);
|
||||
assert.doesNotMatch(source, /getPayWallet|giftBalance|createBrokerageWithdraw|walletTxGiftApply/);
|
||||
});
|
||||
|
||||
test('page show defaults to settled income and preserves the backend record payload', async () => {
|
||||
const original = record();
|
||||
const h = fixture(async () => page([original]));
|
||||
await h.lifecycle.shown();
|
||||
assert.equal(h.calls.length, 1);
|
||||
assert.equal(h.calls[0].status, 1);
|
||||
assert.equal(h.calls[0].incomeOnly, true);
|
||||
assert.equal(h.calls[0].pageNo, 1);
|
||||
assert.equal(h.calls[0].pageSize, 10);
|
||||
assert.equal(h.state.listData.value[0].sourceUserId, 465);
|
||||
assert.equal(h.state.listData.value[0].description, original.description);
|
||||
assert.equal(h.state.listData.value[0].bizType, 2);
|
||||
assert.equal(formatter.formatBrokeragePrice(h.state.listData.value[0].price), '35.10');
|
||||
assert.equal(formatter.formatBrokeragePrice(undefined), '—');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('all four status tabs reset pagination and keep income-only filtering, including pending status zero', async () => {
|
||||
const h = fixture(async ({ pageNo }) => page([record(pageNo)], 30));
|
||||
await h.lifecycle.shown();
|
||||
await h.state.listLoadMore();
|
||||
assert.equal(h.calls.at(-1).pageNo, 2);
|
||||
for (const [index, status] of [[1, 0], [2, 2], [3, undefined], [0, 1]]) {
|
||||
await h.state.tabsChange({ index });
|
||||
assert.equal(h.state.activeTab.value, index);
|
||||
assert.equal(h.calls.at(-1).pageNo, 1);
|
||||
assert.equal(h.calls.at(-1).status, status);
|
||||
assert.equal(h.calls.at(-1).incomeOnly, true);
|
||||
}
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('page appends real pages, retains records on failure, and retries the failed page', async () => {
|
||||
let attempts = 0;
|
||||
const h = fixture(async ({ pageNo }) => {
|
||||
if (pageNo === 2 && ++attempts === 1) throw new Error('offline');
|
||||
return page([record(pageNo)], 2);
|
||||
});
|
||||
await h.lifecycle.shown();
|
||||
await h.state.listLoadMore();
|
||||
assert.equal(h.state.listData.value.length, 1);
|
||||
assert.match(h.state.error.value, /重试/);
|
||||
await h.state.retry();
|
||||
assert.deepEqual(h.calls.map((query) => query.pageNo), [1, 2, 2]);
|
||||
assert.equal(h.state.listData.value.length, 2);
|
||||
assert.equal(h.state.error.value, '');
|
||||
assert.equal(h.state.loadMoreStatus.value, 'noMore');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('late responses from a previous status do not replace the selected tab', async () => {
|
||||
const old = deferred();
|
||||
const h = fixture(({ status }) => status === 1 ? old.promise
|
||||
: Promise.resolve(page([{ ...record(), status: 0 }])));
|
||||
const shown = h.lifecycle.shown();
|
||||
await h.state.tabsChange({ index: 1 });
|
||||
old.resolve(page([record()]));
|
||||
await shown;
|
||||
assert.equal(h.state.listData.value[0].status, 0);
|
||||
assert.equal(h.state.activeTab.value, 1);
|
||||
assert.equal(h.state.statusNames[0], '待结算');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('returning from a hidden page refetches the same status and ignores the previous account response', async () => {
|
||||
const old = deferred();
|
||||
const h = fixture(() => old.promise);
|
||||
const previousShow = h.lifecycle.shown();
|
||||
h.lifecycle.hidden();
|
||||
h.api.fetch = async () => page([{ ...record(40), userId: 700 }]);
|
||||
await h.lifecycle.shown();
|
||||
assert.equal(h.calls.length, 2);
|
||||
assert.equal(h.calls[0].status, h.calls[1].status);
|
||||
assert.equal(h.calls[1].status, 1);
|
||||
assert.equal(h.calls[1].pageNo, 1);
|
||||
assert.equal(h.calls[1].incomeOnly, true);
|
||||
assert.equal(h.state.listData.value[0].userId, 700);
|
||||
old.resolve(page([{ ...record(), userId: 300 }]));
|
||||
await previousShow;
|
||||
assert.equal(h.state.listData.value[0].userId, 700);
|
||||
assert.equal(h.state.listData.value[0].id, 40);
|
||||
assert.equal(h.state.loadMoreStatus.value, 'noMore');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('record API passes a copied params object, preserves old calls, and does not mutate frozen input', async () => {
|
||||
const calls = [];
|
||||
const api = loadModule(apiSource, { '@/sheep/request': { default: async (config) => {
|
||||
calls.push(config);
|
||||
return page([]);
|
||||
} } }).default;
|
||||
const filters = Object.freeze({ pageNo: 2, pageSize: 8, status: undefined });
|
||||
await api.getBrokerageRecordPage(filters);
|
||||
assert.equal(calls[0].url, '/trade/brokerage-record/page');
|
||||
assert.equal(calls[0].method, 'GET');
|
||||
assert.notEqual(calls[0].params, filters);
|
||||
assert.equal(Object.hasOwn(filters, 'status'), true);
|
||||
assert.equal(calls[0].params.pageSize, 8);
|
||||
assert.equal(calls[0].params.incomeOnly, undefined);
|
||||
await api.getBrokerageRecordPage({ pageNo: 1, status: 0, incomeOnly: true });
|
||||
assert.equal(calls[1].params.status, 0);
|
||||
assert.equal(calls[1].params.incomeOnly, true);
|
||||
await api.getBrokerageRecordPage();
|
||||
assert.equal(Object.keys(calls[2].params).length, 0);
|
||||
});
|
||||
|
||||
test('real request URL serialization preserves special characters, pending zero, and the income-only flag', async () => {
|
||||
const utils = loadModule(await readFile(new URL('../../../../../node_modules/luch-request/src/lib/utils.js', import.meta.url), 'utf8'));
|
||||
const buildURL = loadModule(await readFile(new URL('../../../../../node_modules/luch-request/src/lib/helpers/buildURL.js', import.meta.url), 'utf8'), {
|
||||
'./../utils': utils,
|
||||
}).default;
|
||||
let config;
|
||||
const api = loadModule(apiSource, { '@/sheep/request': { default: async (options) => { config = options; return page([]); } } }).default;
|
||||
const nickname = '会员 & + / ? # = 测试';
|
||||
await api.getBrokerageRecordPage({ pageNo: 1, pageSize: 10, status: 0, incomeOnly: true, nickname });
|
||||
const url = new URL(buildURL(config.url, config.params), 'http://localhost');
|
||||
assert.equal(url.searchParams.get('nickname'), nickname);
|
||||
assert.equal(url.searchParams.get('status'), '0');
|
||||
assert.equal(url.searchParams.get('incomeOnly'), 'true');
|
||||
await api.getBrokerageRecordPage({ status: undefined, incomeOnly: false });
|
||||
const all = new URL(buildURL(config.url, config.params), 'http://localhost');
|
||||
assert.equal(all.searchParams.has('status'), false);
|
||||
assert.equal(all.searchParams.get('incomeOnly'), 'false');
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<page-meta :page-style="$statusBarPageStyle" />
|
||||
<wd-config-provider theme="dark">
|
||||
<view class="brokerage-record-page">
|
||||
<wd-navbar title="分销收益记录" safeAreaInsetTop :bordered="false">
|
||||
<template #left>
|
||||
<view class="brokerage-record-page__back" role="button" aria-label="返回" @tap="wechatNavBack">
|
||||
<uni-icons type="back" size="30" color="#D29E52" />
|
||||
</view>
|
||||
</template>
|
||||
</wd-navbar>
|
||||
<wd-tabs v-model="activeTab" @change="tabsChange">
|
||||
<wd-tab title="已结算" />
|
||||
<wd-tab title="待结算" />
|
||||
<wd-tab title="已取消" />
|
||||
<wd-tab title="全部" />
|
||||
</wd-tabs>
|
||||
<view v-if="error" class="brokerage-record-page__error" role="alert">
|
||||
<text>收益记录{{ error }}</text>
|
||||
<button class="brokerage-record-page__retry text-color-theme" size="mini" @tap="retry">重试</button>
|
||||
</view>
|
||||
<scroll-list
|
||||
class="brokerage-record-page__list"
|
||||
:loadMoreStatus="loadMoreStatus"
|
||||
@refresh="refreshList"
|
||||
@loadMore="listLoadMore"
|
||||
>
|
||||
<view v-if="!listData.length && !error && loadMoreStatus === 'noMore'" class="brokerage-record-page__empty">
|
||||
暂无收益记录
|
||||
</view>
|
||||
<view v-for="record in listData" :key="record.id" class="card-thin brokerage-record-page__record">
|
||||
<view class="brokerage-record-page__record-heading">
|
||||
<text class="brokerage-record-page__title">{{ record.title || '分销收益' }}</text>
|
||||
<text class="brokerage-record-page__price text-color-theme">{{ formatBrokeragePrice(record.price) }} 元</text>
|
||||
</view>
|
||||
<view v-if="record.description" class="brokerage-record-page__description">{{ record.description }}</view>
|
||||
<view class="brokerage-record-page__record-meta">
|
||||
<text>{{ record.createTime ? formatDate(record.createTime) : '—' }}</text>
|
||||
<text :class="{ 'text-color-theme': record.status === 1 }">{{ statusNames[record.status] || '—' }}</text>
|
||||
</view>
|
||||
<view v-if="record.status === 0 && record.unfreezeTime" class="brokerage-record-page__unfreeze">
|
||||
预计结算 {{ formatDate(record.unfreezeTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-list>
|
||||
</view>
|
||||
</wd-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { onHide, onShow } from '@dcloudio/uni-app';
|
||||
import BrokerageApi from '@/sheep/api/trade/brokerage';
|
||||
import ScrollList from '@/sheep/components/scroll-list/scroll-list.vue';
|
||||
import useScroll from '@/sheep/hooks/use-scroll';
|
||||
import { formatBrokeragePrice } from '@/sheep/hooks/use-brokerage';
|
||||
import { formatDate } from '@/sheep/helper/utils';
|
||||
import { wechatNavBack } from '@/sheep/util/wechat-tool';
|
||||
|
||||
const activeTab = ref(0);
|
||||
const statuses = [1, 0, 2, undefined];
|
||||
const statusNames: Record<number, string> = { 0: '待结算', 1: '已结算', 2: '已取消' };
|
||||
const queryParams = reactive({ status: 1 as number | undefined });
|
||||
const { listData, loadMoreStatus, refreshList, listLoadMore, error, retry, invalidate } = useScroll(
|
||||
(params) => BrokerageApi.getBrokerageRecordPage({ ...params, incomeOnly: true }),
|
||||
queryParams,
|
||||
);
|
||||
|
||||
const tabsChange = ({ index }: { index: number }) => {
|
||||
activeTab.value = index;
|
||||
queryParams.status = statuses[index];
|
||||
return refreshList();
|
||||
};
|
||||
|
||||
onShow(() => refreshList());
|
||||
onHide(invalidate);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.brokerage-record-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
|
||||
&__back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 24rpx 24rpx 0;
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
&__retry {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
min-height: 44px;
|
||||
background: transparent;
|
||||
line-height: 44px;
|
||||
|
||||
&::after {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 80rpx 0;
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
&__record {
|
||||
padding: 28rpx 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
&__record-heading,
|
||||
&__record-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
min-width: 0;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__price {
|
||||
flex-shrink: 0;
|
||||
max-width: 58%;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin-top: 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__record-meta,
|
||||
&__unfreeze {
|
||||
margin-top: 18rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,353 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import * as vue from 'vue';
|
||||
import { parse, compileTemplate } from 'vue/compiler-sfc';
|
||||
|
||||
const filename = new URL('../index.vue', import.meta.url);
|
||||
const source = await readFile(filename, 'utf8');
|
||||
const { descriptor } = parse(source);
|
||||
const transpile = (source) => ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
function loadModule(source, imports = {}) {
|
||||
const context = {
|
||||
exports: {},
|
||||
URLSearchParams,
|
||||
require: (name) => {
|
||||
if (Object.hasOwn(imports, name)) return imports[name];
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(transpile(source), context);
|
||||
return context.exports;
|
||||
}
|
||||
const hookModule = { exports: {} };
|
||||
vm.runInNewContext(transpile(await readFile(new URL('../../../../../sheep/hooks/use-scroll.ts', import.meta.url), 'utf8')), {
|
||||
exports: hookModule.exports,
|
||||
require: (name) => {
|
||||
if (name === 'vue') return vue;
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
});
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
};
|
||||
const page = (list, total = list.length) => ({ code: 0, data: { list, total } });
|
||||
const summary = () => ({ code: 0, data: { firstBrokerageUserCount: 43, secondBrokerageUserCount: 13 } });
|
||||
function fixture(overrides = {}) {
|
||||
const calls = { pages: [], summaries: 0 };
|
||||
const lifecycle = {};
|
||||
const api = {
|
||||
getBrokerageUserChildSummaryPage: async () => page([]),
|
||||
getBrokerageUserSummary: async () => summary(),
|
||||
...overrides,
|
||||
};
|
||||
const fetchPage = api.getBrokerageUserChildSummaryPage;
|
||||
api.getBrokerageUserChildSummaryPage = (query) => { calls.pages.push({ ...query }); return fetchPage(query); };
|
||||
const fetchSummary = api.getBrokerageUserSummary;
|
||||
api.getBrokerageUserSummary = () => { calls.summaries += 1; return fetchSummary(); };
|
||||
const context = {
|
||||
exports: {},
|
||||
require: (name) => {
|
||||
if (name === 'vue') return { ...vue, onBeforeUnmount: (fn) => { lifecycle.unmounted = fn; } };
|
||||
if (name === '@dcloudio/uni-app') return {
|
||||
onShow: (fn) => { lifecycle.shown = fn; },
|
||||
onHide: (fn) => { lifecycle.hidden = fn; },
|
||||
};
|
||||
if (name === '@/sheep/hooks/use-scroll') return { default: hookModule.exports.default };
|
||||
if (name === '@/sheep/api/trade/brokerage') return { default: api };
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
};
|
||||
const scope = vue.effectScope();
|
||||
scope.run(() => vm.runInNewContext(transpile(descriptor.scriptSetup.content) +
|
||||
'\nglobalThis.state = { state, activeTab, listData, listLoadMore, error, retry, handleSearch, tabsChange, refreshSummary, refreshTeam, incomeUnavailable, sortOptions, toggleSort, resetSort, handleSortKeydown };', context));
|
||||
return { ...context, api, calls, lifecycle, stop: () => { lifecycle.unmounted(); scope.stop(); } };
|
||||
}
|
||||
|
||||
test('team template compiles and displays separate contribution and personal income summaries', () => {
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: filename.pathname, id: 'team' }).errors, []);
|
||||
assert.match(source, /贡献按当前会员实际收到的佣金统计/);
|
||||
assert.match(source, /本人订单佣金单数/);
|
||||
assert.match(source, /member\.brokerageOrderCount \?\? '—'/);
|
||||
assert.doesNotMatch(source, /member\.orderCount/);
|
||||
assert.match(source, /formatBrokeragePrice\(member\.contributionSummary\?\.settledPrice\)/);
|
||||
assert.match(source, /formatBrokeragePrice\(member\.contributionSummary\?\.pendingPrice\)/);
|
||||
assert.match(source, /formatBrokeragePrice\(member\.brokerageSummary\?\.settledPrice\)/);
|
||||
assert.match(source, /formatBrokeragePrice\(member\.brokerageSummary\?\.pendingPrice\)/);
|
||||
assert.doesNotMatch(source, /member\.brokeragePrice/);
|
||||
assert.match(source, /@search="handleSearch" @change="handleSearch"/);
|
||||
});
|
||||
|
||||
test('page show and pull refresh fetch list and team counts, including successful zero counts', async () => {
|
||||
const h = fixture();
|
||||
await h.lifecycle.shown();
|
||||
assert.equal(h.calls.pages.length, 1);
|
||||
assert.equal(h.calls.summaries, 1);
|
||||
assert.equal(h.state.state.summary.firstBrokerageUserCount, 43);
|
||||
let completed = false;
|
||||
h.api.getBrokerageUserSummary = async () => ({ code: 0, data: { firstBrokerageUserCount: 0, secondBrokerageUserCount: 0 } });
|
||||
await h.state.refreshTeam(() => { completed = true; });
|
||||
assert.equal(completed, true);
|
||||
assert.equal(h.state.state.summary.firstBrokerageUserCount, 0);
|
||||
assert.equal(h.state.state.summary.secondBrokerageUserCount, 0);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('search and clear use the event value immediately and reset pagination', async () => {
|
||||
const h = fixture();
|
||||
await h.lifecycle.shown();
|
||||
await h.state.handleSearch({ value: '会员' });
|
||||
assert.equal(h.calls.pages.at(-1).nickname, '会员');
|
||||
assert.equal(h.calls.pages.at(-1).pageNo, 1);
|
||||
await h.state.handleSearch({ value: '' });
|
||||
assert.equal(h.calls.pages.at(-1).nickname, '');
|
||||
assert.equal(h.state.state.queryParams.nickname, '');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('fast level changes preserve the selected level when older responses arrive last', async () => {
|
||||
const first = deferred();
|
||||
const h = fixture({ getBrokerageUserChildSummaryPage: ({ level }) => level === 1
|
||||
? first.promise : Promise.resolve(page([{ id: 2, level: 2 }])) });
|
||||
const shown = h.lifecycle.shown();
|
||||
await h.state.tabsChange({ index: 1 });
|
||||
first.resolve(page([{ id: 1, level: 1 }]));
|
||||
await shown;
|
||||
assert.equal(h.state.activeTab.value, 1);
|
||||
assert.equal(h.state.listData.value[0].level, 2);
|
||||
assert.equal(h.calls.pages.at(-1).level, 2);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('summary errors expose an error without inventing zero counts and allow retry', async () => {
|
||||
const h = fixture({ getBrokerageUserSummary: async () => ({ code: 500, data: null }) });
|
||||
await h.lifecycle.shown();
|
||||
assert.equal(h.state.state.summary, null);
|
||||
assert.match(h.state.state.summaryError, /加载失败/);
|
||||
h.api.getBrokerageUserSummary = async () => summary();
|
||||
await h.state.refreshSummary();
|
||||
assert.equal(h.state.state.summaryError, '');
|
||||
assert.equal(h.state.state.summary.secondBrokerageUserCount, 13);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('late summary and list responses are ignored after page unmount', async () => {
|
||||
const list = deferred();
|
||||
const counts = deferred();
|
||||
const h = fixture({ getBrokerageUserChildSummaryPage: () => list.promise, getBrokerageUserSummary: () => counts.promise });
|
||||
const shown = h.lifecycle.shown();
|
||||
h.stop();
|
||||
list.resolve(page([{ id: 1 }]));
|
||||
counts.resolve(summary());
|
||||
await shown;
|
||||
assert.equal(h.state.state.summary, null);
|
||||
assert.equal(h.state.listData.value.length, 0);
|
||||
});
|
||||
|
||||
test('missing upgraded income fields produce an upgrade notice while successful zero summaries remain valid', async () => {
|
||||
const h = fixture({ getBrokerageUserChildSummaryPage: async ({ nickname }) => nickname === 'upgraded'
|
||||
? page([{
|
||||
id: 1,
|
||||
brokerageSummary: { settledPrice: '900719925474099301', pendingPrice: 0 },
|
||||
contributionSummary: { settledPrice: 0, pendingPrice: '0' },
|
||||
}])
|
||||
: page([{ id: 1, brokeragePrice: 12345 }]),
|
||||
});
|
||||
await h.lifecycle.shown();
|
||||
assert.equal(h.state.incomeUnavailable.value, true);
|
||||
assert.match(source, /收益统计暂不可用,请更新服务后重试/);
|
||||
await h.state.handleSearch({ value: 'upgraded' });
|
||||
assert.equal(h.state.incomeUnavailable.value, false);
|
||||
assert.equal(h.state.listData.value[0].contributionSummary.settledPrice, 0);
|
||||
assert.equal(h.state.listData.value[0].brokerageSummary.settledPrice, '900719925474099301');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('hiding clears team counts and a same-filter return fetches a fresh account context', async () => {
|
||||
const oldList = deferred();
|
||||
const oldSummary = deferred();
|
||||
let listRequests = 0;
|
||||
let summaryRequests = 0;
|
||||
const h = fixture({
|
||||
getBrokerageUserChildSummaryPage: () => ++listRequests === 1
|
||||
? oldList.promise : Promise.resolve(page([{ id: 'current-account' }])),
|
||||
getBrokerageUserSummary: () => ++summaryRequests === 1 ? oldSummary.promise : Promise.resolve({
|
||||
code: 0, data: { firstBrokerageUserCount: 1, secondBrokerageUserCount: 0 },
|
||||
}),
|
||||
});
|
||||
const previous = h.lifecycle.shown();
|
||||
h.state.state.summary = { firstBrokerageUserCount: 43, secondBrokerageUserCount: 13 };
|
||||
h.lifecycle.hidden();
|
||||
assert.equal(h.state.state.summary, null);
|
||||
await h.lifecycle.shown();
|
||||
oldList.resolve(page([{ id: 'previous-account' }]));
|
||||
oldSummary.resolve(summary());
|
||||
await previous;
|
||||
assert.equal(h.calls.pages.length, 2);
|
||||
assert.equal(h.state.listData.value[0].id, 'current-account');
|
||||
assert.equal(h.state.state.summary.firstBrokerageUserCount, 1);
|
||||
assert.equal(h.state.state.summary.secondBrokerageUserCount, 0);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('sorting controls use native buttons with direction feedback and preserve initial join-time order', async () => {
|
||||
const h = fixture();
|
||||
await h.lifecycle.shown();
|
||||
assert.equal(h.calls.pages[0]['sortingField.field'], undefined);
|
||||
assert.equal(h.calls.pages[0]['sortingField.order'], undefined);
|
||||
assert.ok(h.state.sortOptions.value.every((option) => !option.active && option.order === undefined));
|
||||
assert.match(source, /role="group" aria-label="团队金额排序"/);
|
||||
assert.match(source, /<button[^>]*v-for="option in sortOptions"/);
|
||||
assert.match(source, /:aria-pressed="option.active"/);
|
||||
assert.match(source, /:aria-label="option.ariaLabel"/);
|
||||
assert.match(source, /option.order === 'asc'/);
|
||||
assert.match(source, /option.order === 'desc'/);
|
||||
assert.match(source, /aria-hidden="true"/);
|
||||
assert.match(source, /min-height: 30px/);
|
||||
assert.match(source, /border-radius: 0 !important/);
|
||||
assert.doesNotMatch(source, /text-decoration: underline/);
|
||||
assert.match(source, /&:focus-visible/);
|
||||
assert.match(source, /恢复默认加入时间排序/);
|
||||
const sortingButtons = Array.from(descriptor.template.content.matchAll(/<button\b[^>]*>/g), (match) => match[0]);
|
||||
assert.equal(sortingButtons.length, 2);
|
||||
assert.ok(sortingButtons.every((button) => button.includes('role="button"') && button.includes('tabindex="0"')));
|
||||
assert.ok(sortingButtons.every((button) => button.includes('@keydown="handleSortKeydown')));
|
||||
assert.match(source, /:deep\(\.uni-scroll-view\)\s*\{\s*scrollbar-width: none/);
|
||||
assert.match(source, /:deep\(\.uni-scroll-view::-webkit-scrollbar\)/);
|
||||
assert.doesNotMatch(source, /\.sort\(/);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('same-field taps toggle descending and ascending, changing fields starts descending, and default clears both', async () => {
|
||||
const h = fixture();
|
||||
await h.lifecycle.shown();
|
||||
await h.state.toggleSort('contributionPrice');
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.field'], 'contributionPrice');
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc');
|
||||
assert.equal(h.state.sortOptions.value[0].active, true);
|
||||
assert.equal(h.state.sortOptions.value[0].order, 'desc');
|
||||
assert.match(h.state.sortOptions.value[0].ariaLabel, /当前从高到低,点击切换从低到高/);
|
||||
await h.state.toggleSort('contributionPrice');
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'asc');
|
||||
assert.match(h.state.sortOptions.value[0].ariaLabel, /当前从低到高,点击切换从高到低/);
|
||||
await h.state.toggleSort('incomePrice');
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.field'], 'incomePrice');
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc');
|
||||
assert.equal(h.state.sortOptions.value[0].active, false);
|
||||
assert.equal(h.state.sortOptions.value[0].order, undefined);
|
||||
assert.equal(h.state.sortOptions.value[1].active, true);
|
||||
await h.state.toggleSort('incomePrice');
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'asc');
|
||||
await h.state.resetSort();
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.field'], undefined);
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], undefined);
|
||||
assert.ok(h.calls.pages.every((query) => query.pageNo === 1));
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('sorting resets pagination while search and level changes retain the selected sort and backend record order', async () => {
|
||||
const h = fixture({ getBrokerageUserChildSummaryPage: async ({ pageNo }) => page(
|
||||
pageNo === 1 ? [{ id: 30 }, { id: 10 }] : [{ id: 20 }], 3,
|
||||
) });
|
||||
await h.lifecycle.shown();
|
||||
await h.state.listLoadMore();
|
||||
assert.equal(h.calls.pages.at(-1).pageNo, 2);
|
||||
await h.state.handleSearch({ value: '会员 & 测试' });
|
||||
await h.state.tabsChange({ index: 1 });
|
||||
await h.state.toggleSort('contributionPrice');
|
||||
assert.equal(h.calls.pages.at(-1).pageNo, 1);
|
||||
assert.equal(h.calls.pages.at(-1).level, 2);
|
||||
assert.equal(h.calls.pages.at(-1).nickname, '会员 & 测试');
|
||||
await h.state.listLoadMore();
|
||||
assert.equal(h.calls.pages.at(-1).pageNo, 2);
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc');
|
||||
assert.deepEqual(Array.from(h.state.listData.value, (member) => member.id), [30, 10, 20]);
|
||||
await h.state.handleSearch({ value: '' });
|
||||
assert.equal(h.calls.pages.at(-1).pageNo, 1);
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.field'], 'contributionPrice');
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc');
|
||||
await h.state.tabsChange({ index: 0 });
|
||||
assert.equal(h.calls.pages.at(-1).level, 1);
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('late descending responses cannot replace a newer ascending selection', async () => {
|
||||
const descending = deferred();
|
||||
const h = fixture({ getBrokerageUserChildSummaryPage: (query) => query['sortingField.order'] === 'desc'
|
||||
? descending.promise : Promise.resolve(page([{ id: 1, direction: query['sortingField.order'] }])),
|
||||
});
|
||||
await h.lifecycle.shown();
|
||||
const old = h.state.toggleSort('incomePrice');
|
||||
await h.state.toggleSort('incomePrice');
|
||||
descending.resolve(page([{ id: 9, direction: 'desc' }]));
|
||||
await old;
|
||||
assert.equal(h.state.listData.value[0].direction, 'asc');
|
||||
assert.equal(h.state.sortOptions.value[1].order, 'asc');
|
||||
assert.equal(h.calls.pages.at(-1).pageNo, 1);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('real request encoding preserves dotted sorting keys, filters, and default omission without mutating callers', async () => {
|
||||
const utils = loadModule(await readFile(new URL('../../../../../../node_modules/luch-request/src/lib/utils.js', import.meta.url), 'utf8'));
|
||||
const buildURL = loadModule(await readFile(new URL('../../../../../../node_modules/luch-request/src/lib/helpers/buildURL.js', import.meta.url), 'utf8'), {
|
||||
'./../utils': utils,
|
||||
}).default;
|
||||
let config;
|
||||
const api = loadModule(await readFile(new URL('../../../../../sheep/api/trade/brokerage.js', import.meta.url), 'utf8'), {
|
||||
'@/sheep/request': { default: async (options) => { config = options; return page([]); } },
|
||||
}).default;
|
||||
const filters = Object.freeze({
|
||||
pageNo: 2, pageSize: 10, level: 2, nickname: '会员 & + / ? # = 测试',
|
||||
'sortingField.field': 'contributionPrice', 'sortingField.order': 'asc',
|
||||
});
|
||||
await api.getBrokerageUserChildSummaryPage(filters);
|
||||
assert.notEqual(config.params, filters);
|
||||
const url = new URL(buildURL(config.url, config.params), 'http://localhost');
|
||||
assert.equal(url.pathname, '/trade/brokerage-user/child-summary-page');
|
||||
assert.equal(url.searchParams.get('sortingField.field'), 'contributionPrice');
|
||||
assert.equal(url.searchParams.get('sortingField.order'), 'asc');
|
||||
assert.equal(url.searchParams.get('nickname'), filters.nickname);
|
||||
assert.equal(url.searchParams.get('level'), '2');
|
||||
assert.equal(url.searchParams.get('pageNo'), '2');
|
||||
await api.getBrokerageUserChildSummaryPage({ 'sortingField.field': undefined, 'sortingField.order': undefined });
|
||||
const defaultUrl = new URL(buildURL(config.url, config.params), 'http://localhost');
|
||||
assert.equal(defaultUrl.searchParams.has('sortingField.field'), false);
|
||||
assert.equal(defaultUrl.searchParams.has('sortingField.order'), false);
|
||||
});
|
||||
|
||||
test('Enter and Space activate sorting once, prevent browser defaults, and ignore held-key repeats', async () => {
|
||||
const h = fixture();
|
||||
await h.lifecycle.shown();
|
||||
const keyboardEvent = (key, repeat = false) => Object.assign(new Event('keydown', { cancelable: true }), { key, repeat });
|
||||
const enter = keyboardEvent('Enter');
|
||||
await h.state.handleSortKeydown(enter, 'contributionPrice');
|
||||
assert.equal(enter.defaultPrevented, true);
|
||||
assert.equal(h.calls.pages.length, 2);
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'desc');
|
||||
const repeat = keyboardEvent('Enter', true);
|
||||
await h.state.handleSortKeydown(repeat, 'contributionPrice');
|
||||
assert.equal(repeat.defaultPrevented, true);
|
||||
assert.equal(h.calls.pages.length, 2);
|
||||
const space = keyboardEvent(' ');
|
||||
await h.state.handleSortKeydown(space, 'contributionPrice');
|
||||
assert.equal(space.defaultPrevented, true);
|
||||
assert.equal(h.calls.pages.length, 3);
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.order'], 'asc');
|
||||
const tab = keyboardEvent('Tab');
|
||||
await h.state.handleSortKeydown(tab, 'incomePrice');
|
||||
assert.equal(tab.defaultPrevented, false);
|
||||
assert.equal(h.calls.pages.length, 3);
|
||||
const restore = keyboardEvent('Enter');
|
||||
await h.state.handleSortKeydown(restore);
|
||||
assert.equal(restore.defaultPrevented, true);
|
||||
assert.equal(h.calls.pages.length, 4);
|
||||
assert.equal(h.calls.pages.at(-1)['sortingField.field'], undefined);
|
||||
h.stop();
|
||||
});
|
||||
@@ -1,116 +1,345 @@
|
||||
<!-- 页面 TODO 芋艿:该页面的实现代码需要优化,包括 js 和 css,以及相关的样式设计 -->
|
||||
<template>
|
||||
<page-meta :page-style="$statusBarPageStyle" />
|
||||
<wd-config-provider theme="dark">
|
||||
<wd-navbar title="我的团队" safeAreaInsetTop :bordered="false">
|
||||
<template #left>
|
||||
<uni-icons type="back" size="30" color="#D29E52" @tap="wechatNavBack" />
|
||||
</template>
|
||||
</wd-navbar>
|
||||
<wd-search v-model="state.queryParams.nickname" @search="refreshList" @change="refreshList" />
|
||||
<view class="mt-30rpx mx-30rpx">
|
||||
<wd-tabs v-model="activeTab" @change="tabsChange">
|
||||
<wd-tab :title="`一级(${state.summary.firstBrokerageUserCount || 0})`" />
|
||||
<wd-tab :title="`二级(${state.summary.secondBrokerageUserCount || 0})`" />
|
||||
</wd-tabs>
|
||||
<scroll-list v-if="[0, 1].includes(activeTab)" class="h-full mt-30rpx" :loadMoreStatus="loadMoreStatus"
|
||||
@refresh="refreshList" @loadMore="listLoadMore">
|
||||
<view class="flex items-center justify-between card-thin mb-30rpx px-20rpx py-16rpx"
|
||||
v-for="(v, i) in listData" :key="i">
|
||||
<view class="flex items-center">
|
||||
<image class="size-90rpx rounded-full" :src="v.avatar" />
|
||||
<view class="ml-15rpx">
|
||||
<view class="text-sm">{{ v.nickname }}</view>
|
||||
<view class="mt-6rpx text-xs">
|
||||
加入时间:
|
||||
{{ sheep.$helper.timeFormat(v.brokerageTime, 'yyyy-mm-dd hh:MM:ss') }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="flex-1 flex-col items-end">
|
||||
<view class="text-xs">{{ v.brokerageUserCount || 0 }}人</view>
|
||||
<view class="text-xs">{{ v.orderCount || 0 }}单</view>
|
||||
<view class="text-xs">{{ v.brokeragePrice || 0 }}元</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-list>
|
||||
<wd-config-provider theme="dark">
|
||||
<view class="team-page">
|
||||
<wd-navbar title="我的团队" safeAreaInsetTop :bordered="false">
|
||||
<template #left>
|
||||
<uni-icons type="back" size="30" color="#D29E52" @tap="wechatNavBack" />
|
||||
</template>
|
||||
</wd-navbar>
|
||||
<wd-search v-model="state.queryParams.nickname" @search="handleSearch" @change="handleSearch" />
|
||||
<view class="team-page__content">
|
||||
<view class="team-page__hint">人数为团队总人数,贡献按当前会员实际收到的佣金统计</view>
|
||||
<view v-if="state.summaryError" class="team-page__error" role="alert">
|
||||
<text>{{ state.summaryError }}</text>
|
||||
<text class="text-color-theme" @tap="refreshSummary">重试</text>
|
||||
</view>
|
||||
</wd-config-provider>
|
||||
<wd-tabs v-model="activeTab" @change="tabsChange">
|
||||
<wd-tab :title="`一级(${state.summary?.firstBrokerageUserCount ?? '—'})`" />
|
||||
<wd-tab :title="`二级(${state.summary?.secondBrokerageUserCount ?? '—'})`" />
|
||||
</wd-tabs>
|
||||
<view class="team-page__sorting" role="group" aria-label="团队金额排序">
|
||||
<button class="team-page__sort-button" role="button" tabindex="0"
|
||||
:class="{ 'team-page__sort-button--active': !state.queryParams['sortingField.field'] }"
|
||||
:aria-pressed="!state.queryParams['sortingField.field']" aria-label="恢复默认加入时间排序"
|
||||
@tap="resetSort" @keydown="handleSortKeydown($event)">
|
||||
<text class="team-page__sort-label">默认</text>
|
||||
</button>
|
||||
<button v-for="option in sortOptions" :key="option.field" class="team-page__sort-button" role="button" tabindex="0"
|
||||
:class="{ 'team-page__sort-button--active': option.active }" :aria-pressed="option.active"
|
||||
:aria-label="option.ariaLabel" @tap="toggleSort(option.field)" @keydown="handleSortKeydown($event, option.field)">
|
||||
<text class="team-page__sort-label">{{ option.label }}</text>
|
||||
<view class="team-page__sort-arrows" aria-hidden="true">
|
||||
<view class="team-page__sort-arrow team-page__sort-arrow--asc"
|
||||
:class="{ 'team-page__sort-arrow--active': option.order === 'asc' }" />
|
||||
<view class="team-page__sort-arrow team-page__sort-arrow--desc"
|
||||
:class="{ 'team-page__sort-arrow--active': option.order === 'desc' }" />
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="error" class="team-page__error" role="alert">
|
||||
<text>团队列表{{ error }}</text>
|
||||
<text class="text-color-theme" @tap="retry">重试</text>
|
||||
</view>
|
||||
<view v-if="incomeUnavailable" class="team-page__error" role="alert">
|
||||
<text>收益统计暂不可用,请更新服务后重试</text>
|
||||
<text class="text-color-theme" @tap="refreshList()">重试</text>
|
||||
</view>
|
||||
<scroll-list class="team-page__list" :loadMoreStatus="loadMoreStatus" @refresh="refreshTeam" @loadMore="listLoadMore">
|
||||
<view class="card-thin mb-30rpx px-20rpx py-16rpx" v-for="member in listData" :key="member.id">
|
||||
<view class="flex items-center min-w-0">
|
||||
<image class="size-90rpx rounded-full shrink-0" :src="member.avatar" />
|
||||
<view class="ml-15rpx min-w-0">
|
||||
<view class="text-sm">{{ member.nickname }}</view>
|
||||
<view class="mt-6rpx text-xs">
|
||||
加入时间 {{ sheep.$helper.timeFormat(member.brokerageTime, 'yyyy-mm-dd hh:MM:ss') }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="team-page__amounts">
|
||||
<view class="team-page__amount">
|
||||
<text class="text-xs">为我贡献</text>
|
||||
<text class="text-color-theme">{{ formatBrokeragePrice(member.contributionSummary?.settledPrice) }} 元</text>
|
||||
<text class="text-xs text-#ffffff80">待结算 {{ formatBrokeragePrice(member.contributionSummary?.pendingPrice) }} 元</text>
|
||||
</view>
|
||||
<view class="team-page__amount">
|
||||
<text class="text-xs">本人分销收益</text>
|
||||
<text>{{ formatBrokeragePrice(member.brokerageSummary?.settledPrice) }} 元</text>
|
||||
<text class="text-xs text-#ffffff80">待结算 {{ formatBrokeragePrice(member.brokerageSummary?.pendingPrice) }} 元</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="team-page__member-counts text-xs text-#ffffff80">
|
||||
<text>直属下级 {{ member.brokerageUserCount ?? '—' }} 人</text>
|
||||
<text>本人订单佣金单数 {{ member.brokerageOrderCount ?? '—' }} 单</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-list>
|
||||
</view>
|
||||
</view>
|
||||
</wd-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
import { onHide, onShow } from '@dcloudio/uni-app';
|
||||
import sheep from '@/sheep';
|
||||
import { onLoad, onShow, onReachBottom } from '@dcloudio/uni-app';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import ScrollList from '@/sheep/components/scroll-list/scroll-list.vue';
|
||||
import useScroll from '@/sheep/hooks/use-scroll';
|
||||
import { onPageScroll } from '@dcloudio/uni-app';
|
||||
import BrokerageApi from '@/sheep/api/trade/brokerage';
|
||||
import { formatBrokeragePrice } from '@/sheep/hooks/use-brokerage';
|
||||
import { wechatNavBack } from '@/sheep/util/wechat-tool';
|
||||
|
||||
const userInfo = computed(() => sheep.$store('user').userInfo);
|
||||
const headerBg = sheep.$url.css('/static/img/shop/user/withdraw_bg.png');
|
||||
|
||||
onPageScroll((e) => {
|
||||
state.scrollTop = e.scrollTop <= 100;
|
||||
});
|
||||
|
||||
const data = {
|
||||
sortNav: [
|
||||
{
|
||||
title: "团队排序"
|
||||
},
|
||||
{
|
||||
title: "金额排序"
|
||||
},
|
||||
{
|
||||
title: "订单排序"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
let sort = ref();
|
||||
const activeTab = ref(0);
|
||||
const state = reactive({
|
||||
summary: {},
|
||||
pagination: {
|
||||
pageNo: 1,
|
||||
pageSize: 8,
|
||||
list: [],
|
||||
total: 0,
|
||||
},
|
||||
queryParams: {
|
||||
level: 1,
|
||||
nickname: '',
|
||||
},
|
||||
summary: null,
|
||||
summaryError: '',
|
||||
queryParams: {
|
||||
level: 1,
|
||||
nickname: '',
|
||||
'sortingField.field': undefined,
|
||||
'sortingField.order': undefined,
|
||||
},
|
||||
});
|
||||
const sortOptions = computed(() => [
|
||||
{ field: 'contributionPrice', label: '为我贡献' },
|
||||
{ field: 'incomePrice', label: '本人分销收益' },
|
||||
].map((option) => {
|
||||
const active = state.queryParams['sortingField.field'] === option.field;
|
||||
const order = active ? state.queryParams['sortingField.order'] : undefined;
|
||||
const currentDirection = order === 'asc' ? '从低到高' : '从高到低';
|
||||
const nextDirection = order === 'asc' ? '从高到低' : '从低到高';
|
||||
return {
|
||||
...option,
|
||||
active,
|
||||
order,
|
||||
ariaLabel: active
|
||||
? `${option.label},当前${currentDirection},点击切换${nextDirection}`
|
||||
: `${option.label},点击按金额从高到低排序`,
|
||||
};
|
||||
}));
|
||||
const { listData, loadMoreStatus, refreshList, listLoadMore, error, retry, invalidate } = useScroll(
|
||||
BrokerageApi.getBrokerageUserChildSummaryPage,
|
||||
state.queryParams,
|
||||
);
|
||||
const incomeUnavailable = computed(() => listData.value.some((member) =>
|
||||
member.brokerageSummary?.settledPrice == null || member.brokerageSummary?.pendingPrice == null ||
|
||||
member.contributionSummary?.settledPrice == null || member.contributionSummary?.pendingPrice == null,
|
||||
));
|
||||
let summaryVersion = 0;
|
||||
let disposed = false;
|
||||
|
||||
const {
|
||||
listData,
|
||||
loadMoreStatus: loadMoreStatus,
|
||||
refreshList: refreshList,
|
||||
listLoadMore: listLoadMore,
|
||||
} = useScroll(BrokerageApi.getBrokerageUserChildSummaryPage, state.queryParams);
|
||||
const handleSearch = ({ value } = {}) => {
|
||||
state.queryParams.nickname = typeof value === 'string' ? value : '';
|
||||
return refreshList();
|
||||
};
|
||||
|
||||
const tabsChange = ({ index }) => {
|
||||
setType(index + 1)
|
||||
}
|
||||
activeTab.value = index;
|
||||
state.queryParams.level = index + 1;
|
||||
return refreshList();
|
||||
};
|
||||
|
||||
const toggleSort = (field) => {
|
||||
const sameField = state.queryParams['sortingField.field'] === field;
|
||||
state.queryParams['sortingField.order'] = sameField && state.queryParams['sortingField.order'] === 'desc'
|
||||
? 'asc' : 'desc';
|
||||
state.queryParams['sortingField.field'] = field;
|
||||
return refreshList();
|
||||
};
|
||||
|
||||
function setType(e) {
|
||||
state.pagination.list = [];
|
||||
state.queryParams.level = e + '';
|
||||
refreshList();
|
||||
}
|
||||
const resetSort = () => {
|
||||
state.queryParams['sortingField.field'] = undefined;
|
||||
state.queryParams['sortingField.order'] = undefined;
|
||||
return refreshList();
|
||||
};
|
||||
|
||||
onShow(async () => {
|
||||
await refreshList();
|
||||
// 概要数据
|
||||
let { data } = await BrokerageApi.getBrokerageUserSummary();
|
||||
state.summary = data;
|
||||
const handleSortKeydown = (event, field) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
if (event.repeat) return;
|
||||
return field ? toggleSort(field) : resetSort();
|
||||
};
|
||||
|
||||
const refreshSummary = async () => {
|
||||
const version = ++summaryVersion;
|
||||
state.summaryError = '';
|
||||
try {
|
||||
const response = await BrokerageApi.getBrokerageUserSummary();
|
||||
if (disposed || version !== summaryVersion) return;
|
||||
if (response?.code !== 0 || response.data?.firstBrokerageUserCount == null || response.data?.secondBrokerageUserCount == null) {
|
||||
throw new Error('Invalid summary response');
|
||||
}
|
||||
state.summary = response.data;
|
||||
} catch {
|
||||
if (!disposed && version === summaryVersion) state.summaryError = '团队人数加载失败';
|
||||
}
|
||||
};
|
||||
|
||||
const refreshTeam = async (done) => {
|
||||
try {
|
||||
await Promise.all([refreshList(), refreshSummary()]);
|
||||
} finally {
|
||||
if (typeof done === 'function') done();
|
||||
}
|
||||
};
|
||||
|
||||
onShow(() => refreshTeam());
|
||||
onHide(() => {
|
||||
invalidate();
|
||||
summaryVersion += 1;
|
||||
state.summary = null;
|
||||
state.summaryError = '';
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
summaryVersion += 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss" scoped>
|
||||
.team-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 8px;
|
||||
|
||||
:deep(.uni-scroll-view) {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
:deep(.uni-scroll-view::-webkit-scrollbar) {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__sorting {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
column-gap: 22px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__sort-button {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
border-radius: 0 !important;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #f9c26f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #f9c26f;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__sort-label {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__sort-arrows {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__sort-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-right: 3px solid transparent;
|
||||
border-left: 3px solid transparent;
|
||||
color: rgba(255, 255, 255, 0.36);
|
||||
|
||||
&--asc {
|
||||
border-bottom: 4px solid currentColor;
|
||||
}
|
||||
|
||||
&--desc {
|
||||
border-top: 4px solid currentColor;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #f9c26f;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 6px 0;
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__amounts {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 20rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
overflow-wrap: anywhere;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__member-counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx 24rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
import request from '@/sheep/request';
|
||||
|
||||
export interface AppVersionQuery {
|
||||
appId: string;
|
||||
platform: 'android' | 'ios' | 'harmony';
|
||||
nativeVersionCode: number;
|
||||
resourceVersionCode: number;
|
||||
packageName?: string;
|
||||
}
|
||||
|
||||
export interface AppVersionRelease {
|
||||
id: number;
|
||||
packageId: number;
|
||||
appId: string;
|
||||
platform: AppVersionQuery['platform'];
|
||||
platforms?: AppVersionQuery['platform'][];
|
||||
packageType: 'apk' | 'wgt';
|
||||
versionCode: number;
|
||||
versionName: string;
|
||||
nativeVersionCode?: number;
|
||||
resourceVersionCode: number;
|
||||
packageName?: string;
|
||||
minNativeVersionCode?: number;
|
||||
maxNativeVersionCode?: number;
|
||||
forceUpdate: boolean;
|
||||
updateContent: string;
|
||||
downloadUrl: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
sha256: string;
|
||||
md5: string;
|
||||
}
|
||||
|
||||
const custom = { showLoading: false, showError: false };
|
||||
|
||||
export default {
|
||||
check: (params: AppVersionQuery) => request({
|
||||
url: '/member/app-version/check',
|
||||
method: 'GET',
|
||||
params,
|
||||
sslVerify: true,
|
||||
custom,
|
||||
}),
|
||||
validate: (params: AppVersionQuery & { id: number }) => request({
|
||||
url: '/member/app-version/validate',
|
||||
method: 'GET',
|
||||
params,
|
||||
sslVerify: true,
|
||||
custom,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import request from '@/sheep/request';
|
||||
|
||||
/** 当前设备推送接口使用调用时的令牌,隔离退出或换账号后才完成的旧请求。 */
|
||||
const deviceRequest = (url, data, token) => request({
|
||||
url,
|
||||
method: 'POST',
|
||||
data,
|
||||
header: { Authorization: token },
|
||||
custom: {
|
||||
isToken: false,
|
||||
skipAuthRefresh: true,
|
||||
showLoading: false,
|
||||
showError: false,
|
||||
},
|
||||
});
|
||||
|
||||
/** 复用会员服务现有设备绑定契约,会员身份始终由服务端令牌确定。 */
|
||||
export default {
|
||||
/** 绑定当前安装实例到已登录会员,允许同设备重新登录后覆盖旧归属。 */
|
||||
bind: (data, token) => deviceRequest('/member/push/bind', data, token),
|
||||
/** 仅解除调用时会员拥有的当前安装实例。 */
|
||||
unbind: (registrationId, token) => deviceRequest('/member/push/unbind', { registrationId }, token),
|
||||
};
|
||||
@@ -27,16 +27,11 @@ const BrokerageApi = {
|
||||
});
|
||||
},
|
||||
// 获得分销记录分页
|
||||
getBrokerageRecordPage: (params) => {
|
||||
if (params.status === undefined) {
|
||||
delete params.status;
|
||||
}
|
||||
const queryString = Object.keys(params)
|
||||
.map((key) => encodeURIComponent(key) + '=' + params[key])
|
||||
.join('&');
|
||||
getBrokerageRecordPage: (params = {}) => {
|
||||
return request({
|
||||
url: `/trade/brokerage-record/page?${queryString}`,
|
||||
url: '/trade/brokerage-record/page',
|
||||
method: 'GET',
|
||||
params: { ...params },
|
||||
});
|
||||
},
|
||||
// 创建分销提现
|
||||
@@ -102,11 +97,11 @@ const BrokerageApi = {
|
||||
});
|
||||
},
|
||||
// 获得下级分销统计分页
|
||||
getBrokerageUserChildSummaryPage: (params) => {
|
||||
getBrokerageUserChildSummaryPage: (params = {}) => {
|
||||
return request({
|
||||
url: '/trade/brokerage-user/child-summary-page',
|
||||
method: 'GET',
|
||||
params,
|
||||
params: { ...params },
|
||||
});
|
||||
},
|
||||
// 获取分销码和分销海报
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import sheep from '@/sheep';
|
||||
import { prepareMediaSelection, handlePermissionFailure } from '@/sheep/util/permission';
|
||||
|
||||
// 组件数据
|
||||
const state = reactive({
|
||||
@@ -166,15 +167,32 @@
|
||||
/**
|
||||
* 扫一扫功能
|
||||
*/
|
||||
function onScan() {
|
||||
async function onScan() {
|
||||
const sources = await prepareMediaSelection(['camera', 'album'], 'image', {
|
||||
feature: '识别二维码', purpose: '使用相机或您主动选择的图片识别二维码与条码',
|
||||
});
|
||||
if (!sources) return;
|
||||
// #ifdef APP-PLUS
|
||||
if (sources[0] === 'album') {
|
||||
uni.chooseImage({
|
||||
count: 1, sourceType: ['album'],
|
||||
fail: (error) => { void handlePermissionFailure('album-read', error); },
|
||||
success: (result) => plus.barcode.scan(result.tempFilePaths[0],
|
||||
(_type, content) => showScanResult(content),
|
||||
() => uni.showToast({ title: '图片中未识别到二维码或条码', icon: 'none' })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
uni.scanCode({
|
||||
onlyFromCamera: false,
|
||||
onlyFromCamera: sources.length === 1,
|
||||
sound: 'default',
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
success: (res) => {
|
||||
showScanResult(res.result);
|
||||
},
|
||||
fail: (err) => {
|
||||
void handlePermissionFailure('camera', err);
|
||||
console.error(err);
|
||||
uni.showToast({
|
||||
title: err.errMsg === 'scanCode:fail cancel' ? '操作已取消' : '扫码失败',
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
*/
|
||||
import { reactive, ref, unref } from 'vue';
|
||||
import sheep from '@/sheep';
|
||||
import { saveImageToPhotosAlbum } from '@/sheep/util/file-tool';
|
||||
import { getPosterData } from '@/sheep/components/s-share-modal/canvas-poster/poster';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -80,16 +81,12 @@ const onSavePoster = () => {
|
||||
}
|
||||
|
||||
// 非H5 保存到相册
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath: painterImageUrl.value,
|
||||
success: (res) => {
|
||||
onClosePoster();
|
||||
sheep.$helper.toast('保存成功');
|
||||
},
|
||||
fail: (err) => {
|
||||
sheep.$helper.toast('保存失败');
|
||||
console.log('图片保存失败:', err);
|
||||
},
|
||||
saveImageToPhotosAlbum(painterImageUrl.value, () => {
|
||||
onClosePoster();
|
||||
sheep.$helper.toast('保存成功');
|
||||
}, (err) => {
|
||||
sheep.$helper.toast('保存失败');
|
||||
console.log('图片保存失败:', err);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
'use strict';
|
||||
import FileApi from '@/sheep/api/infra/file';
|
||||
import { prepareMediaSelection, handlePermissionFailure } from '@/sheep/util/permission';
|
||||
|
||||
const ERR_MSG_FAIL = 'chooseAndUploadFile:fail';
|
||||
|
||||
function chooseImage(opts) {
|
||||
async function chooseImage(opts) {
|
||||
const {
|
||||
count,
|
||||
sizeType = ['original', 'compressed'],
|
||||
sourceType = ['album', 'camera'],
|
||||
extension,
|
||||
} = opts;
|
||||
const sources = await prepareMediaSelection(sourceType, 'image', {
|
||||
feature: '上传图片', purpose: '选择您主动提交的头像、动态或其他业务图片',
|
||||
});
|
||||
if (!sources) throw { errMsg: ERR_MSG_FAIL + ' cancel' };
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.chooseImage({
|
||||
count,
|
||||
sizeType,
|
||||
sourceType,
|
||||
sourceType: sources,
|
||||
extension,
|
||||
success(res) {
|
||||
resolve(normalizeChooseAndUploadFileRes(res, 'image'));
|
||||
},
|
||||
fail(res) {
|
||||
void handlePermissionFailure(sources[0] === 'camera' ? 'camera' : 'album-read', res);
|
||||
reject({
|
||||
errMsg: res.errMsg.replace('chooseImage:fail', ERR_MSG_FAIL),
|
||||
});
|
||||
@@ -28,7 +34,7 @@ function chooseImage(opts) {
|
||||
});
|
||||
}
|
||||
|
||||
function chooseVideo(opts) {
|
||||
async function chooseVideo(opts) {
|
||||
const {
|
||||
count,
|
||||
camera,
|
||||
@@ -37,13 +43,17 @@ function chooseVideo(opts) {
|
||||
sourceType = ['album', 'camera'],
|
||||
extension,
|
||||
} = opts;
|
||||
const sources = await prepareMediaSelection(sourceType, 'video', {
|
||||
feature: '上传视频', purpose: '选择您主动发布的视频动态',
|
||||
});
|
||||
if (!sources) throw { errMsg: ERR_MSG_FAIL + ' cancel' };
|
||||
return new Promise((resolve, reject) => {
|
||||
// 微信小程序的 chooseMedia 会直接返回视频缩略图。
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.chooseMedia({
|
||||
count,
|
||||
mediaType: ['video'],
|
||||
sourceType,
|
||||
sourceType: sources,
|
||||
sizeType: compressed ? ['compressed'] : ['original'],
|
||||
maxDuration,
|
||||
camera,
|
||||
@@ -70,6 +80,7 @@ function chooseVideo(opts) {
|
||||
);
|
||||
},
|
||||
fail(res) {
|
||||
void handlePermissionFailure('camera', res);
|
||||
reject({
|
||||
errMsg: res.errMsg.replace('chooseMedia:fail', ERR_MSG_FAIL),
|
||||
});
|
||||
@@ -82,7 +93,7 @@ function chooseVideo(opts) {
|
||||
camera,
|
||||
compressed,
|
||||
maxDuration,
|
||||
sourceType,
|
||||
sourceType: sources,
|
||||
extension,
|
||||
success(res) {
|
||||
const { tempFilePath, thumbTempFilePath, duration, size, height, width } = res;
|
||||
@@ -111,6 +122,7 @@ function chooseVideo(opts) {
|
||||
);
|
||||
},
|
||||
fail(res) {
|
||||
void handlePermissionFailure(sources[0] === 'camera' ? 'camera' : 'album-read', res);
|
||||
reject({
|
||||
errMsg: res.errMsg.replace('chooseVideo:fail', ERR_MSG_FAIL),
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const emit = defineEmits<{
|
||||
const refresherTriggered = ref(false);
|
||||
|
||||
const loadMoreContent = reactive({
|
||||
contentdown: '正在加载...',
|
||||
contentdown: '上拉加载更多',
|
||||
contentrefresh: '正在加载...',
|
||||
contentnomore: '没有更多数据了',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import * as vue from 'vue';
|
||||
|
||||
const source = await readFile(new URL('../use-scroll.ts', import.meta.url), 'utf8');
|
||||
const script = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(script, {
|
||||
exports: module.exports,
|
||||
require: (name) => {
|
||||
if (name === 'vue') return vue;
|
||||
throw new Error(`Unexpected import ${name}`);
|
||||
},
|
||||
});
|
||||
const useScroll = module.exports.default;
|
||||
const page = (list, total = list.length) => ({ code: 0, data: { list, total } });
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
function fixture(fetch, initial = {}) {
|
||||
const calls = [];
|
||||
const params = vue.reactive(initial);
|
||||
const scope = vue.effectScope();
|
||||
const scroll = scope.run(() => useScroll((query) => {
|
||||
calls.push({ ...query });
|
||||
return fetch(query);
|
||||
}, params));
|
||||
return { scroll, params, calls, stop: () => scope.stop() };
|
||||
}
|
||||
|
||||
test('refresh callback contract remains compatible and non-function event payloads are accepted', async () => {
|
||||
const h = fixture(async () => page([], 0));
|
||||
let completed = 0;
|
||||
await h.scroll.refreshList(() => { completed += 1; });
|
||||
await h.scroll.refreshList({ value: 'search' });
|
||||
assert.equal(completed, 1);
|
||||
assert.equal(h.scroll.error.value, '');
|
||||
assert.equal(h.scroll.loadMoreStatus.value, 'noMore');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('pagination advances only on success, blocks duplicate loads, and retries the failed page', async () => {
|
||||
const secondPage = deferred();
|
||||
let secondAttempts = 0;
|
||||
const h = fixture(({ pageNo }) => {
|
||||
if (pageNo === 1) return Promise.resolve(page([{ id: 1 }], 3));
|
||||
if (pageNo === 2 && ++secondAttempts === 1) return secondPage.promise;
|
||||
return Promise.resolve(page([{ id: pageNo }], 3));
|
||||
});
|
||||
await h.scroll.refreshList();
|
||||
const loading = h.scroll.listLoadMore();
|
||||
assert.equal(h.scroll.loadMoreStatus.value, 'loading');
|
||||
const duplicate = h.scroll.listLoadMore();
|
||||
assert.equal(h.calls.length, 2);
|
||||
assert.equal(duplicate, loading);
|
||||
secondPage.reject(new Error('offline'));
|
||||
await loading;
|
||||
assert.match(h.scroll.error.value, /重试/);
|
||||
assert.equal(h.scroll.listData.value.length, 1);
|
||||
await h.scroll.retry();
|
||||
await h.scroll.listLoadMore();
|
||||
await h.scroll.listLoadMore();
|
||||
assert.deepEqual(h.calls.map((query) => query.pageNo), [1, 2, 2, 3]);
|
||||
assert.deepEqual(Array.from(h.scroll.listData.value, (row) => row.id), [1, 2, 3]);
|
||||
assert.equal(h.scroll.loadMoreStatus.value, 'noMore');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('explicit refresh starts a fresh request while loading another page remains blocked', async () => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
let requests = 0;
|
||||
const h = fixture(() => ++requests === 1 ? first.promise : second.promise);
|
||||
let completed = 0;
|
||||
const loading = h.scroll.refreshList(() => { completed += 1; });
|
||||
const latest = h.scroll.refreshList(() => { completed += 1; });
|
||||
const loadMore = h.scroll.listLoadMore();
|
||||
assert.equal(h.calls.length, 2);
|
||||
assert.equal(h.scroll.isRefreshing.value, true);
|
||||
first.resolve(page([{ id: 1 }], 2));
|
||||
await loading;
|
||||
assert.equal(h.scroll.listData.value.length, 0);
|
||||
assert.equal(h.scroll.isRefreshing.value, true);
|
||||
second.resolve(page([{ id: 2 }], 2));
|
||||
await Promise.all([latest, loadMore]);
|
||||
assert.equal(completed, 2);
|
||||
assert.equal(h.scroll.listData.value.length, 1);
|
||||
assert.equal(h.scroll.listData.value[0].id, 2);
|
||||
assert.equal(h.scroll.isRefreshing.value, false);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('filter changes and clears discard old refresh and load-more responses', async () => {
|
||||
const oldMore = deferred();
|
||||
const oldFilter = deferred();
|
||||
const h = fixture(({ level, nickname, pageNo }) => {
|
||||
if (level === 1 && pageNo === 2) return oldMore.promise;
|
||||
if (nickname === 'old') return oldFilter.promise;
|
||||
return Promise.resolve(page([{ level, nickname }], 2));
|
||||
}, { level: 1, nickname: '' });
|
||||
await h.scroll.refreshList();
|
||||
const more = h.scroll.listLoadMore();
|
||||
h.params.level = 2;
|
||||
h.params.nickname = 'old';
|
||||
const filter = h.scroll.refreshList();
|
||||
h.params.nickname = '';
|
||||
await h.scroll.refreshList();
|
||||
oldFilter.resolve(page([{ level: 2, nickname: 'old' }], 1));
|
||||
oldMore.resolve(page([{ level: 1, nickname: '' }], 100));
|
||||
await Promise.all([more, filter]);
|
||||
assert.equal(h.scroll.listData.value.length, 1);
|
||||
assert.equal(h.scroll.listData.value[0].level, 2);
|
||||
assert.equal(h.scroll.listData.value[0].nickname, '');
|
||||
assert.deepEqual(h.calls.map((query) => query.pageNo), [1, 2, 1, 1]);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('failed and malformed responses preserve prior data and retry refresh without advancing pages', async () => {
|
||||
let response = page([{ id: 1 }], 2);
|
||||
const h = fixture(async () => response);
|
||||
await h.scroll.refreshList();
|
||||
response = { code: 500, data: null };
|
||||
let completed = false;
|
||||
await h.scroll.refreshList(() => { completed = true; });
|
||||
assert.equal(completed, true);
|
||||
assert.equal(h.scroll.listData.value[0].id, 1);
|
||||
assert.match(h.scroll.error.value, /重试/);
|
||||
response = { code: 0, data: { list: [], total: null } };
|
||||
await h.scroll.retry();
|
||||
assert.match(h.scroll.error.value, /重试/);
|
||||
response = page([], 0);
|
||||
await h.scroll.retry();
|
||||
assert.deepEqual(h.calls.map((query) => query.pageNo), [1, 1, 1, 1]);
|
||||
assert.equal(h.scroll.listData.value.length, 0);
|
||||
assert.equal(h.scroll.error.value, '');
|
||||
assert.equal(h.scroll.loadMoreStatus.value, 'noMore');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('unmount ignores late responses and prevents new requests', async () => {
|
||||
const pending = deferred();
|
||||
const h = fixture(() => pending.promise);
|
||||
const loading = h.scroll.refreshList();
|
||||
h.stop();
|
||||
pending.resolve(page([{ id: 1 }], 1));
|
||||
await loading;
|
||||
await h.scroll.refreshList();
|
||||
await h.scroll.listLoadMore();
|
||||
assert.equal(h.scroll.listData.value.length, 0);
|
||||
assert.equal(h.calls.length, 1);
|
||||
});
|
||||
|
||||
test('unwrapped success envelopes remain supported', async () => {
|
||||
const h = fixture(async () => ({ data: { list: [{ id: 1 }], total: '1' } }));
|
||||
await h.scroll.refreshList();
|
||||
assert.equal(h.scroll.error.value, '');
|
||||
assert.equal(h.scroll.listData.value.length, 1);
|
||||
assert.equal(h.scroll.loadMoreStatus.value, 'noMore');
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('invalidation starts a fresh same-query request and ignores the previous account response', async () => {
|
||||
const previous = deferred();
|
||||
const current = deferred();
|
||||
let requests = 0;
|
||||
const h = fixture(() => ++requests === 1 ? previous.promise : current.promise, { level: 1 });
|
||||
const oldLoading = h.scroll.refreshList();
|
||||
h.scroll.invalidate();
|
||||
const newLoading = h.scroll.refreshList();
|
||||
assert.equal(h.calls.length, 2);
|
||||
assert.equal(h.calls[0].pageNo, 1);
|
||||
assert.equal(h.calls[1].pageNo, 1);
|
||||
previous.resolve(page([{ id: 'previous-account' }], 1));
|
||||
await oldLoading;
|
||||
assert.equal(h.scroll.listData.value.length, 0);
|
||||
assert.equal(h.scroll.isRefreshing.value, true);
|
||||
current.resolve(page([{ id: 'current-account' }], 1));
|
||||
await newLoading;
|
||||
assert.equal(h.scroll.listData.value[0].id, 'current-account');
|
||||
assert.equal(h.scroll.isRefreshing.value, false);
|
||||
h.stop();
|
||||
});
|
||||
|
||||
test('legacy successful empty PageResult responses end pagination with no loading error', async () => {
|
||||
for (const data of [{ list: null, total: null }, {}]) {
|
||||
const h = fixture(async () => ({ code: 0, data }));
|
||||
await h.scroll.refreshList();
|
||||
assert.equal(h.scroll.listData.value.length, 0);
|
||||
assert.equal(h.scroll.error.value, '');
|
||||
assert.equal(h.scroll.loadMoreStatus.value, 'noMore');
|
||||
await h.scroll.listLoadMore();
|
||||
assert.equal(h.calls.length, 1);
|
||||
h.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('incomplete page fields, invalid payloads, and failed empty responses still expose errors', async () => {
|
||||
for (const response of [
|
||||
{ code: 0, data: { list: [], total: null } },
|
||||
{ code: 0, data: { list: null, total: 0 } },
|
||||
{ code: 0, data: null },
|
||||
{ code: 0, data: [] },
|
||||
{ code: 500, data: { list: null, total: null } },
|
||||
]) {
|
||||
const h = fixture(async () => response);
|
||||
await h.scroll.refreshList();
|
||||
assert.match(h.scroll.error.value, /重试/);
|
||||
assert.equal(h.scroll.loadMoreStatus.value, 'more');
|
||||
h.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('refresh after a successful deletion reads new data instead of reusing a pre-deletion refresh', async () => {
|
||||
const oldPull = deferred();
|
||||
let hasItem = true;
|
||||
let requests = 0;
|
||||
const h = fixture(() => {
|
||||
requests += 1;
|
||||
if (requests === 2) return oldPull.promise;
|
||||
return Promise.resolve(page(hasItem ? [{ id: 1 }] : []));
|
||||
});
|
||||
await h.scroll.refreshList();
|
||||
const pull = h.scroll.refreshList();
|
||||
hasItem = false;
|
||||
await h.scroll.refreshList();
|
||||
oldPull.resolve(page([{ id: 1 }]));
|
||||
await pull;
|
||||
assert.equal(h.calls.length, 3);
|
||||
assert.equal(h.scroll.listData.value.length, 0);
|
||||
assert.equal(h.scroll.loadMoreStatus.value, 'noMore');
|
||||
h.stop();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/** 将整数分精确显示为元,兼容后端 Long 字符串。 */
|
||||
export function formatBrokeragePrice(price?: number | string | null): string {
|
||||
if (price === undefined || price === null) return '—';
|
||||
const value = String(price);
|
||||
if (!/^-?\d+$/.test(value)) return '—';
|
||||
const negative = value.startsWith('-');
|
||||
const digits = (negative ? value.slice(1) : value).replace(/^0+(?=\d)/, '').padStart(3, '0');
|
||||
const sign = negative && /[1-9]/.test(digits) ? '-' : '';
|
||||
return `${sign}${digits.slice(0, -2)}.${digits.slice(-2)}`;
|
||||
}
|
||||
@@ -1,60 +1,115 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
import { getCurrentScope, onScopeDispose, ref } from 'vue';
|
||||
|
||||
export default function useScroll(fetch: (...arg: any) => Promise<any>, params: any = {}) {
|
||||
const listData = ref<any[]>([]);
|
||||
const loadMoreStatus = ref<'more' | 'loading' | 'noMore'>('more');
|
||||
const pageParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
const total = ref(0);
|
||||
const isRefreshing = ref(false);
|
||||
const error = ref('');
|
||||
let total = 0;
|
||||
let nextPage = 1;
|
||||
let queryKey: string | undefined;
|
||||
let requestVersion = 0;
|
||||
let disposed = false;
|
||||
let retryRefresh = true;
|
||||
let activeRequest: {
|
||||
promise: Promise<void>;
|
||||
} | undefined;
|
||||
|
||||
const loadData = async (isRefresh: boolean = false) => {
|
||||
const loadData = (isRefresh: boolean): Promise<void> => {
|
||||
if (disposed) return Promise.resolve();
|
||||
const filters = { ...params };
|
||||
const key = JSON.stringify(filters);
|
||||
if (activeRequest && !isRefresh) {
|
||||
return activeRequest.promise;
|
||||
}
|
||||
if (queryKey !== key) {
|
||||
listData.value = [];
|
||||
total = 0;
|
||||
nextPage = 1;
|
||||
queryKey = key;
|
||||
}
|
||||
const pageNo = isRefresh ? 1 : nextPage;
|
||||
const query = { pageNo, pageSize: 10, ...filters };
|
||||
const version = ++requestVersion;
|
||||
isRefreshing.value = isRefresh;
|
||||
loadMoreStatus.value = 'loading';
|
||||
error.value = '';
|
||||
retryRefresh = isRefresh;
|
||||
const request = { promise: Promise.resolve() };
|
||||
activeRequest = request;
|
||||
request.promise = (async () => {
|
||||
try {
|
||||
const response = await fetch(query);
|
||||
if (disposed || version !== requestVersion) return;
|
||||
const page = response?.data;
|
||||
// 部分既有接口用无参 PageResult 返回 list、total 均为空的成功空页。
|
||||
const emptyPage = page != null && typeof page === 'object' && !Array.isArray(page) &&
|
||||
page.list == null && page.total == null;
|
||||
const rows = emptyPage ? [] : page?.list;
|
||||
const count = emptyPage ? 0 : Number(page?.total ?? NaN);
|
||||
if ((response?.code !== undefined && response.code !== 0) ||
|
||||
!Array.isArray(rows) || !Number.isFinite(count) || count < 0) {
|
||||
throw new Error('Invalid page response');
|
||||
}
|
||||
if (isRefresh) {
|
||||
listData.value = rows;
|
||||
} else {
|
||||
listData.value.push(...rows);
|
||||
}
|
||||
total = count;
|
||||
nextPage = pageNo + 1;
|
||||
} catch {
|
||||
if (!disposed && version === requestVersion) error.value = '加载失败,请重试';
|
||||
} finally {
|
||||
if (!disposed && version === requestVersion) {
|
||||
activeRequest = undefined;
|
||||
isRefreshing.value = false;
|
||||
loadMoreStatus.value = error.value || listData.value.length < total ? 'more' : 'noMore';
|
||||
}
|
||||
}
|
||||
})();
|
||||
return request.promise;
|
||||
};
|
||||
|
||||
const refreshList = async (done?: unknown) => {
|
||||
try {
|
||||
// if (pageParams.pageNo === 1) {
|
||||
// uni.showLoading({ title: '加载中...' });
|
||||
// }
|
||||
if (isRefresh) {
|
||||
isRefreshing.value = true;
|
||||
pageParams.pageNo = 1;
|
||||
const { data = [] } = await fetch({ ...pageParams, ...params });
|
||||
listData.value = data?.list ?? [];
|
||||
total.value = data?.total || 0;
|
||||
} else {
|
||||
const { data = [] } = await fetch({ ...pageParams, ...params });
|
||||
listData.value.push(...(data?.list ?? []));
|
||||
total.value = data?.total || 0;
|
||||
}
|
||||
pageParams.pageNo = pageParams.pageNo + 1;
|
||||
|
||||
if (listData.value?.length >= total.value) {
|
||||
loadMoreStatus.value = 'noMore';
|
||||
} else {
|
||||
loadMoreStatus.value = 'more';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
await loadData(true);
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
// uni.hideLoading();
|
||||
if (typeof done === 'function') done();
|
||||
}
|
||||
};
|
||||
|
||||
const refreshList = async (done: () => void = () => {}) => {
|
||||
await loadData(true);
|
||||
done();
|
||||
const listLoadMore = () => {
|
||||
if (activeRequest) return activeRequest.promise;
|
||||
if (queryKey !== JSON.stringify(params)) return loadData(true);
|
||||
if (error.value) return loadData(retryRefresh);
|
||||
if (loadMoreStatus.value === 'noMore') return Promise.resolve();
|
||||
return loadData(false);
|
||||
};
|
||||
|
||||
const listLoadMore = () => {
|
||||
loadData(false);
|
||||
const retry = () => loadData(retryRefresh);
|
||||
|
||||
const invalidate = () => {
|
||||
requestVersion += 1;
|
||||
activeRequest = undefined;
|
||||
queryKey = undefined;
|
||||
};
|
||||
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(() => {
|
||||
disposed = true;
|
||||
invalidate();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isRefreshing,
|
||||
listData,
|
||||
loadMoreStatus,
|
||||
refreshList,
|
||||
listLoadMore,
|
||||
error,
|
||||
retry,
|
||||
invalidate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ts = require('typescript');
|
||||
|
||||
async function fixture(platform = 'android') {
|
||||
const calls = { downloads: [], removed: [], installs: [] };
|
||||
const plus = {
|
||||
runtime: {
|
||||
appid: '__UNI__0A84F60', versionCode: '113', version: '1.1.3',
|
||||
getProperty: (_id, callback) => callback({ versionCode: '120', version: '1.2.0' }),
|
||||
install: (...args) => calls.installs.push(args),
|
||||
},
|
||||
android: { runtimeMainActivity: () => ({ getPackageName: () => 'com.test.friend' }) },
|
||||
downloader: {
|
||||
createDownload: (_url, options, complete) => {
|
||||
const task = { addEventListener: () => {}, start: () => {}, abort: () => {} };
|
||||
calls.downloads.push({ filePath: options.filename, complete, task });
|
||||
return task;
|
||||
},
|
||||
},
|
||||
io: {
|
||||
getFileInfo: (options) => options.success({ size: 2048, digest: 'abc' }),
|
||||
resolveLocalFileSystemURL: (path, success) => success({ remove: (done) => { calls.removed.push(path); done(); } }),
|
||||
},
|
||||
};
|
||||
const source = await readFile(new URL('../app-update-runtime.ts', import.meta.url), 'utf8');
|
||||
if (platform === 'ios') delete plus.android;
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020,
|
||||
} }).outputText;
|
||||
const context = vm.createContext({
|
||||
exports: {}, plus, setTimeout, clearTimeout,
|
||||
uni: { getDeviceInfo: () => ({ platform }) },
|
||||
require: (name) => {
|
||||
assert.equal(name, '@/manifest.json');
|
||||
return { appid: '__UNI__0A84F60', versionCode: 120, versionName: '1.2.0' };
|
||||
},
|
||||
});
|
||||
vm.runInContext(compiled, context);
|
||||
return { runtime: context.exports.default, calls };
|
||||
}
|
||||
|
||||
test('the native adapter reports installed APK and WGT resource versions independently', async () => {
|
||||
const { runtime } = await fixture();
|
||||
const version = await runtime.readVersion();
|
||||
assert.equal(version.nativeVersionCode, 113);
|
||||
assert.equal(version.resourceVersionCode, 120);
|
||||
assert.equal(version.packageName, 'com.test.friend');
|
||||
});
|
||||
|
||||
test('one WGT package follows the same resource installation flow on Android and iOS', async () => {
|
||||
for (const platform of ['android', 'ios']) {
|
||||
const { runtime, calls } = await fixture(platform);
|
||||
const version = await runtime.readVersion();
|
||||
assert.equal(version.platform, platform);
|
||||
assert.equal(version.resourceVersionCode, 120);
|
||||
const installing = runtime.installWgt('_doc/shared-update.wgt');
|
||||
assert.equal(calls.installs[0][0], '_doc/shared-update.wgt');
|
||||
assert.equal(calls.installs[0][1].force, false);
|
||||
calls.installs[0][2]();
|
||||
await installing;
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelled attempts remove their own file without sharing a retry file name', async () => {
|
||||
const { runtime, calls } = await fixture();
|
||||
const release = { packageId: 10, packageType: 'wgt', md5: 'abc', fileSize: 2048, downloadUrl: 'https://example.com/update.wgt' };
|
||||
const first = runtime.download(release, () => {});
|
||||
void first.promise.catch(() => {});
|
||||
first.cancel();
|
||||
const second = runtime.download(release, () => {});
|
||||
assert.notEqual(calls.downloads[0].filePath, calls.downloads[1].filePath);
|
||||
assert.deepEqual(calls.removed, [calls.downloads[0].filePath]);
|
||||
calls.downloads[1].complete({ filename: calls.downloads[1].filePath }, 200);
|
||||
assert.equal(await second.promise, calls.downloads[1].filePath);
|
||||
});
|
||||
|
||||
test('the native adapter rejects and removes a mismatching update package', async () => {
|
||||
const { runtime, calls } = await fixture();
|
||||
await assert.rejects(runtime.verifyFile('_doc/corrupt.wgt', { fileSize: 2048, md5: 'different' }), /校验失败/);
|
||||
assert.deepEqual(calls.removed, ['_doc/corrupt.wgt']);
|
||||
});
|
||||
|
||||
test('opening the Android installer does not depend on an APK success callback', async () => {
|
||||
const { runtime, calls } = await fixture();
|
||||
runtime.installApk('_doc/new.apk', () => {});
|
||||
assert.equal(calls.installs.length, 1);
|
||||
assert.equal(calls.installs[0][2], undefined);
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const release = {
|
||||
id: 12, packageId: 8, appId: '__UNI__0A84F60', platform: 'android', packageType: 'wgt',
|
||||
versionName: '1.1.4', versionCode: 114, resourceVersionCode: 114,
|
||||
forceUpdate: false, fileSize: 2048, md5: 'abcd', downloadUrl: 'https://files.example.com/114.wgt',
|
||||
};
|
||||
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((success, failure) => { resolve = success; reject = failure; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function fixture(overrides = {}) {
|
||||
const calls = { check: 0, validate: 0, download: 0, installWgt: 0, installApk: 0, cancelled: 0, toasts: [], pages: [], removed: [] };
|
||||
const installed = { appId: release.appId, platform: 'android', nativeVersionCode: 113, resourceVersionCode: 113 };
|
||||
const runtime = {
|
||||
isSupported: () => true,
|
||||
readVersion: async () => installed,
|
||||
download: () => {
|
||||
calls.download++;
|
||||
return { promise: Promise.resolve('_doc/update.wgt'), cancel: () => calls.cancelled++ };
|
||||
},
|
||||
verifyFile: async () => {},
|
||||
installWgt: async () => { calls.installWgt++; },
|
||||
installApk: () => { calls.installApk++; },
|
||||
restart: () => {},
|
||||
removeFile: async (path) => { calls.removed.push(path); },
|
||||
...overrides.runtime,
|
||||
};
|
||||
const api = {
|
||||
check: async () => { calls.check++; return { code: 0, data: { ...release } }; },
|
||||
validate: async () => { calls.validate++; return { code: 0, data: { ...release } }; },
|
||||
...overrides.api,
|
||||
};
|
||||
const guards = new Map();
|
||||
const uni = {
|
||||
showToast: ({ title }) => calls.toasts.push(title),
|
||||
showModal: async () => ({ confirm: true }),
|
||||
navigateTo: ({ url, complete }) => { calls.pages.push(url); complete?.(); },
|
||||
redirectTo: () => {},
|
||||
addInterceptor: (method, handler) => guards.set(method, handler),
|
||||
};
|
||||
const source = (await readFile(new URL('../app-update.js', import.meta.url), 'utf8'))
|
||||
.replace(/^import .+;\n/gm, '')
|
||||
.replace('export function formatFileSize', 'function formatFileSize')
|
||||
.replace('export default new AppUpdateManager();', 'globalThis.manager = new AppUpdateManager();');
|
||||
const context = vm.createContext({
|
||||
reactive: (value) => value,
|
||||
runtime, uni, console,
|
||||
getCurrentPages: () => [{ route: 'pages/tabbar/profile' }],
|
||||
getNetworkStatus: overrides.network || (async () => ({ connected: true, networkType: 'wifi' })),
|
||||
ensureAndroidInstallPermission: overrides.permission || (async () => true),
|
||||
canInstallApk: overrides.canInstall || (() => true),
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const manager = context.manager;
|
||||
manager.configure(api);
|
||||
manager.started = true;
|
||||
return { manager, calls, api, runtime, guards };
|
||||
}
|
||||
|
||||
test('concurrent checks share one request and successful automatic checks are throttled', async () => {
|
||||
const { manager, calls } = await fixture();
|
||||
await Promise.all([manager.check(), manager.check()]);
|
||||
await manager.check();
|
||||
assert.equal(calls.check, 1);
|
||||
await manager.check({ silent: false, force: true });
|
||||
assert.equal(calls.check, 2);
|
||||
});
|
||||
|
||||
test('a late gray release response is discarded after identity changes', async () => {
|
||||
const pending = deferred();
|
||||
const { manager } = await fixture({ api: { check: () => pending.promise } });
|
||||
const check = manager.check();
|
||||
await Promise.resolve();
|
||||
manager.identityChanged();
|
||||
pending.resolve({ code: 0, data: { ...release, forceUpdate: true } });
|
||||
await check;
|
||||
assert.equal(manager.state.release, null);
|
||||
assert.equal(manager.state.blocking, false);
|
||||
});
|
||||
|
||||
test('normal WGT update validates twice, verifies and waits for restart', async () => {
|
||||
const { manager, calls } = await fixture();
|
||||
await manager.check();
|
||||
await Promise.all([manager.update(), manager.update()]);
|
||||
assert.equal(calls.download, 1);
|
||||
assert.equal(calls.validate, 2);
|
||||
assert.equal(calls.installWgt, 1);
|
||||
assert.equal(manager.state.phase, 'ready');
|
||||
});
|
||||
|
||||
test('starting an update waits for a pending release refresh and installs its current result', async () => {
|
||||
const pending = deferred();
|
||||
const currentRelease = { ...release, id: 13, packageId: 9 };
|
||||
const { manager, calls, api } = await fixture();
|
||||
await manager.check();
|
||||
api.check = () => pending.promise;
|
||||
api.validate = async ({ id }) => {
|
||||
calls.validate++;
|
||||
assert.equal(id, currentRelease.id);
|
||||
return { code: 0, data: currentRelease };
|
||||
};
|
||||
const refresh = manager.check({ force: true });
|
||||
const update = manager.update();
|
||||
assert.equal(calls.validate, 0);
|
||||
pending.resolve({ code: 0, data: currentRelease });
|
||||
await refresh;
|
||||
await update;
|
||||
assert.equal(calls.validate, 2);
|
||||
assert.equal(calls.installWgt, 1);
|
||||
});
|
||||
|
||||
test('withdrawn versions stop before downloading', async () => {
|
||||
const { manager, calls } = await fixture({ api: { validate: async () => ({ code: 0, data: null }) } });
|
||||
await manager.check();
|
||||
await manager.update();
|
||||
assert.equal(calls.download, 0);
|
||||
assert.equal(manager.state.release, null);
|
||||
assert.equal(manager.state.blocking, false);
|
||||
});
|
||||
|
||||
test('offline recheck releases a cached mandatory gate', async () => {
|
||||
const { manager, api } = await fixture();
|
||||
api.check = async () => ({ code: 0, data: { ...release, forceUpdate: true } });
|
||||
await manager.check();
|
||||
assert.equal(manager.state.blocking, true);
|
||||
api.check = async () => false;
|
||||
await manager.check({ force: true });
|
||||
assert.equal(manager.state.blocking, false);
|
||||
assert.match(manager.state.error, /检查更新失败/);
|
||||
});
|
||||
|
||||
test('transport failure during validation releases the stale mandatory gate', async () => {
|
||||
const { manager, api } = await fixture();
|
||||
api.check = async () => ({ code: 0, data: { ...release, forceUpdate: true } });
|
||||
api.validate = async () => { throw new Error('网络不可用'); };
|
||||
await manager.check();
|
||||
await manager.update();
|
||||
assert.equal(manager.state.blocking, false);
|
||||
assert.equal(manager.state.phase, 'error');
|
||||
});
|
||||
|
||||
test('manual offline checks show the recovery page with settings and retry actions', async () => {
|
||||
const { manager, calls } = await fixture({ network: async () => ({ connected: false, networkType: 'none' }) });
|
||||
await manager.check({ silent: false, force: true });
|
||||
assert.equal(manager.state.release, null);
|
||||
assert.match(manager.state.error, /网络未连接/);
|
||||
assert.equal(calls.pages[0], '/pages/profile/app-update');
|
||||
});
|
||||
|
||||
test('APK install returns after opening the installer without waiting for a success callback', async () => {
|
||||
const apk = { ...release, packageType: 'apk', nativeVersionCode: 114 };
|
||||
const { manager, calls } = await fixture({ api: {
|
||||
check: async () => ({ code: 0, data: apk }),
|
||||
validate: async () => ({ code: 0, data: apk }),
|
||||
} });
|
||||
await manager.check();
|
||||
await manager.update();
|
||||
assert.equal(calls.installApk, 1);
|
||||
assert.equal(manager.state.phase, 'installer-open');
|
||||
});
|
||||
|
||||
test('denied install permission retains the downloaded APK for the settings return', async () => {
|
||||
let permitted = false;
|
||||
const apk = { ...release, packageType: 'apk' };
|
||||
const { manager, calls } = await fixture({
|
||||
api: { check: async () => ({ code: 0, data: apk }), validate: async () => ({ code: 0, data: apk }) },
|
||||
permission: async () => permitted,
|
||||
canInstall: () => permitted,
|
||||
});
|
||||
await manager.check();
|
||||
await manager.update();
|
||||
assert.equal(manager.state.phase, 'permission');
|
||||
assert.ok(manager.filePath);
|
||||
permitted = true;
|
||||
await manager.foreground();
|
||||
assert.equal(calls.download, 1);
|
||||
assert.equal(calls.installApk, 1);
|
||||
});
|
||||
|
||||
test('identity change cancels a download and ignores its late completion', async () => {
|
||||
const pending = deferred();
|
||||
let cancelled = false;
|
||||
const { manager, calls } = await fixture({ runtime: {
|
||||
download: () => ({ promise: pending.promise, cancel: () => { cancelled = true; } }),
|
||||
} });
|
||||
await manager.check();
|
||||
const update = manager.update();
|
||||
while (manager.state.phase !== 'downloading') await Promise.resolve();
|
||||
manager.identityChanged();
|
||||
pending.resolve('_doc/old-account.wgt');
|
||||
await update;
|
||||
assert.equal(cancelled, true);
|
||||
assert.equal(calls.installWgt, 0);
|
||||
assert.equal(manager.state.release, null);
|
||||
assert.equal(manager.state.phase, 'idle');
|
||||
});
|
||||
|
||||
test('leaving while validation is pending cancels the normal update action', async () => {
|
||||
const pending = deferred();
|
||||
const { manager, calls } = await fixture({ api: { validate: () => pending.promise } });
|
||||
await manager.check();
|
||||
const update = manager.update();
|
||||
await Promise.resolve();
|
||||
assert.equal(manager.dismiss(), true);
|
||||
pending.resolve({ code: 0, data: release });
|
||||
await update;
|
||||
assert.equal(calls.download, 0);
|
||||
});
|
||||
|
||||
test('corrupt files never reach installation and can be downloaded again', async () => {
|
||||
const { manager, calls } = await fixture({ runtime: {
|
||||
verifyFile: async () => { throw new Error('更新包校验失败'); },
|
||||
} });
|
||||
await manager.check();
|
||||
await manager.update();
|
||||
assert.equal(calls.installWgt, 0);
|
||||
assert.equal(manager.filePath, '');
|
||||
assert.equal(manager.state.phase, 'error');
|
||||
});
|
||||
|
||||
test('leaving during verification preserves the file and permits a new update attempt', async () => {
|
||||
const pending = deferred();
|
||||
let verification = 0;
|
||||
const { manager, calls } = await fixture({ runtime: {
|
||||
verifyFile: () => ++verification === 1 ? pending.promise : Promise.resolve(),
|
||||
} });
|
||||
await manager.check();
|
||||
const oldAttempt = manager.update();
|
||||
while (manager.state.phase !== 'verifying') await Promise.resolve();
|
||||
assert.equal(manager.dismiss(), true);
|
||||
assert.equal(manager.state.phase, 'downloaded');
|
||||
await manager.update();
|
||||
pending.resolve();
|
||||
await oldAttempt;
|
||||
assert.equal(calls.download, 1);
|
||||
assert.equal(calls.installWgt, 1);
|
||||
assert.equal(manager.state.phase, 'ready');
|
||||
});
|
||||
|
||||
test('mandatory update blocks business navigation and allows its own page', async () => {
|
||||
const { manager, guards } = await fixture();
|
||||
manager.installNavigationGuard();
|
||||
manager.state.blocking = true;
|
||||
assert.equal(guards.get('navigateTo').invoke({ url: '/pages/profile/app-update' }), true);
|
||||
assert.equal(guards.get('navigateBack').invoke({}), false);
|
||||
assert.equal(manager.dismiss(), false);
|
||||
});
|
||||
|
||||
test('an identity change during native WGT installation preserves the restart result', async () => {
|
||||
const pending = deferred();
|
||||
const { manager, calls } = await fixture({ runtime: { installWgt: () => pending.promise } });
|
||||
await manager.check();
|
||||
const update = manager.update();
|
||||
while (manager.state.phase !== 'installing') await Promise.resolve();
|
||||
manager.identityChanged();
|
||||
assert.equal(calls.removed.length, 0);
|
||||
assert.equal(manager.state.phase, 'installing');
|
||||
pending.resolve();
|
||||
await update;
|
||||
assert.equal(manager.state.phase, 'ready');
|
||||
assert.equal(manager.state.blocking, false);
|
||||
assert.equal(manager.state.release.forceUpdate, false);
|
||||
});
|
||||
|
||||
test('a failed native WGT install after logout clears the old candidate', async () => {
|
||||
const pending = deferred();
|
||||
const { manager } = await fixture({ runtime: { installWgt: () => pending.promise } });
|
||||
await manager.check();
|
||||
const update = manager.update();
|
||||
while (manager.state.phase !== 'installing') await Promise.resolve();
|
||||
manager.identityChanged();
|
||||
pending.reject(new Error('native install failed'));
|
||||
await update;
|
||||
assert.equal(manager.state.phase, 'idle');
|
||||
assert.equal(manager.state.release, null);
|
||||
assert.equal(manager.filePath, '');
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { appid, versionCode, versionName } from '@/manifest.json';
|
||||
import type { AppVersionQuery, AppVersionRelease } from '@/sheep/api/member/app-version';
|
||||
|
||||
export interface InstalledVersion extends AppVersionQuery {
|
||||
nativeVersionName: string;
|
||||
resourceVersionName: string;
|
||||
}
|
||||
|
||||
let downloadSequence = 0;
|
||||
|
||||
function positiveCode(value: unknown): number {
|
||||
const result = Number(value);
|
||||
if (!Number.isSafeInteger(result) || result <= 0) {
|
||||
throw new Error('当前安装包缺少有效版本信息,请安装完整应用后重试');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function supportsHarmonyUpdate(): boolean {
|
||||
const api = uni as any;
|
||||
return typeof api.installWgt === 'function' && typeof api.restart === 'function'
|
||||
&& typeof api.getFileInfo === 'function';
|
||||
}
|
||||
|
||||
function isSupported(): boolean {
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus !== 'undefined' && ['android', 'ios'].includes(uni.getDeviceInfo().platform)) {
|
||||
return true;
|
||||
}
|
||||
// #endif
|
||||
// #ifdef APP-HARMONY
|
||||
return supportsHarmonyUpdate();
|
||||
// #endif
|
||||
return false;
|
||||
}
|
||||
|
||||
async function readVersion(): Promise<InstalledVersion> {
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus !== 'undefined') {
|
||||
const platform = uni.getDeviceInfo().platform;
|
||||
if (platform !== 'android' && platform !== 'ios') throw new Error('当前系统暂不支持应用内升级');
|
||||
const resource = await new Promise<PlusRuntimeWidgetInfo>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('读取应用版本超时,请重试')), 8000);
|
||||
plus.runtime.getProperty(plus.runtime.appid!, (info) => {
|
||||
clearTimeout(timer);
|
||||
resolve(info);
|
||||
});
|
||||
});
|
||||
return {
|
||||
appId: plus.runtime.appid!,
|
||||
platform,
|
||||
nativeVersionCode: positiveCode(plus.runtime.versionCode),
|
||||
resourceVersionCode: positiveCode(resource.versionCode),
|
||||
nativeVersionName: plus.runtime.version || '',
|
||||
resourceVersionName: resource.version || '',
|
||||
packageName: platform === 'android'
|
||||
? plus.android.runtimeMainActivity().getPackageName() : undefined,
|
||||
};
|
||||
}
|
||||
// #endif
|
||||
// #ifdef APP-HARMONY
|
||||
if (supportsHarmonyUpdate()) {
|
||||
const info = uni.getAppBaseInfo();
|
||||
return {
|
||||
appId: info.appId || appid,
|
||||
platform: 'harmony',
|
||||
nativeVersionCode: positiveCode(info.appVersionCode),
|
||||
resourceVersionCode: positiveCode(versionCode),
|
||||
nativeVersionName: info.appVersion,
|
||||
resourceVersionName: versionName,
|
||||
};
|
||||
}
|
||||
// #endif
|
||||
throw new Error('当前运行环境暂不支持应用内升级');
|
||||
}
|
||||
|
||||
function download(release: AppVersionRelease, onProgress: (downloaded: number, total: number) => void) {
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus !== 'undefined') {
|
||||
let task: PlusDownloaderDownload;
|
||||
let rejectDownload: (error: Error) => void;
|
||||
const attempt = `${Date.now()}-${++downloadSequence}`;
|
||||
const filePath = `_doc/app-updates/${release.packageId}-${release.md5}-${attempt}.${release.packageType}`;
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
rejectDownload = reject;
|
||||
task = plus.downloader.createDownload(release.downloadUrl, {
|
||||
filename: filePath, timeout: 60, retry: 1,
|
||||
}, (result, status) => {
|
||||
if (status === 200 && result.filename) resolve(result.filename);
|
||||
else reject(new Error('更新包下载失败,请检查网络后重试'));
|
||||
});
|
||||
task.addEventListener('statechanged', (result) => {
|
||||
onProgress(result.downloadedSize || 0, result.totalSize || release.fileSize);
|
||||
});
|
||||
task.start();
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
task?.abort();
|
||||
rejectDownload?.(new Error('下载已取消'));
|
||||
void removeFile(filePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
// #endif
|
||||
// #ifdef APP-HARMONY
|
||||
let task: UniApp.DownloadTask;
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
task = uni.downloadFile({
|
||||
url: release.downloadUrl,
|
||||
timeout: 60000,
|
||||
success: (result) => result.statusCode === 200
|
||||
? resolve(result.tempFilePath) : reject(new Error('更新包下载失败,请重试')),
|
||||
fail: () => reject(new Error('下载已取消或网络不可用')),
|
||||
});
|
||||
task.onProgressUpdate((result) => onProgress(result.totalBytesWritten, result.totalBytesExpectedToWrite));
|
||||
});
|
||||
return { promise, cancel: () => task?.abort() };
|
||||
// #endif
|
||||
throw new Error('当前系统暂不支持下载更新');
|
||||
}
|
||||
|
||||
async function verifyFile(filePath: string, release: AppVersionRelease): Promise<void> {
|
||||
const info = await new Promise<{ size: number; digest?: string }>((resolve, reject) => {
|
||||
const options = { filePath, digestAlgorithm: 'md5', success: resolve,
|
||||
fail: () => reject(new Error('无法读取更新包,请重新下载')) };
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus !== 'undefined') {
|
||||
plus.io.getFileInfo(options);
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
uni.getFileInfo(options);
|
||||
});
|
||||
if (info.size !== release.fileSize || !info.digest
|
||||
|| info.digest.toLowerCase() !== release.md5.toLowerCase()) {
|
||||
await removeFile(filePath);
|
||||
throw new Error('更新包校验失败,请重新下载');
|
||||
}
|
||||
}
|
||||
|
||||
function installWgt(filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus !== 'undefined') {
|
||||
plus.runtime.install(filePath, { force: false }, () => resolve(),
|
||||
(error) => reject(new Error(error.message || '资源包安装失败,请重试')));
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
// #ifdef APP-HARMONY
|
||||
if (supportsHarmonyUpdate()) {
|
||||
(uni as any).installWgt({ filePath, success: resolve,
|
||||
fail: () => reject(new Error('资源包安装失败,请重试')) });
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
reject(new Error('当前系统暂不支持安装资源包'));
|
||||
});
|
||||
}
|
||||
|
||||
function installApk(filePath: string, onError: (error: Error) => void): void {
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus !== 'undefined' && uni.getDeviceInfo().platform === 'android') {
|
||||
// Android APK 安装成功没有回调,恢复前台时重新读取原生版本。
|
||||
plus.runtime.install(filePath, {}, undefined, (error) => {
|
||||
onError(new Error(error.message || '无法打开安装器,请重试'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
throw new Error('当前系统暂不支持 APK 安装');
|
||||
}
|
||||
|
||||
function restart(): void {
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus !== 'undefined') {
|
||||
plus.runtime.restart();
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
// #ifdef APP-HARMONY
|
||||
if (supportsHarmonyUpdate()) {
|
||||
(uni as any).restart();
|
||||
return;
|
||||
}
|
||||
// #endif
|
||||
throw new Error('请关闭应用后重新打开,以完成更新');
|
||||
}
|
||||
|
||||
function removeFile(filePath: string): Promise<void> {
|
||||
if (!filePath) return Promise.resolve();
|
||||
// #ifdef APP-PLUS
|
||||
if (typeof plus !== 'undefined') {
|
||||
return new Promise((resolve) => {
|
||||
plus.io.resolveLocalFileSystemURL(filePath,
|
||||
(entry) => entry.remove(() => resolve(), () => resolve()), () => resolve());
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
return new Promise((resolve) => uni.removeSavedFile({ filePath, complete: () => resolve() }));
|
||||
}
|
||||
|
||||
export default { isSupported, readVersion, download, verifyFile, installWgt, installApk, restart, removeFile };
|
||||
@@ -0,0 +1,350 @@
|
||||
import { reactive } from 'vue';
|
||||
import runtime from './app-update-runtime';
|
||||
import { getNetworkStatus, ensureAndroidInstallPermission, canInstallApk } from '@/sheep/util/permission';
|
||||
|
||||
const UPDATE_PAGE = '/pages/profile/app-update';
|
||||
const CHECK_INTERVAL = 30 * 60 * 1000;
|
||||
const ACTIVE_PHASES = ['downloading', 'verifying', 'installing'];
|
||||
|
||||
class AppUpdateManager {
|
||||
state = reactive({
|
||||
release: null,
|
||||
phase: 'idle',
|
||||
progress: 0,
|
||||
downloadedBytes: 0,
|
||||
error: '',
|
||||
blocking: false,
|
||||
installedVersion: null,
|
||||
});
|
||||
|
||||
api = null;
|
||||
started = false;
|
||||
revision = 0;
|
||||
lastChecked = 0;
|
||||
checkPromise = null;
|
||||
actionPromise = null;
|
||||
downloadTask = null;
|
||||
filePath = '';
|
||||
pageOpening = false;
|
||||
dismissedId = null;
|
||||
identityChangedDuringInstall = false;
|
||||
|
||||
configure(api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started || !runtime.isSupported()) return;
|
||||
this.initialize();
|
||||
void this.check({ force: true });
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.started = true;
|
||||
this.installNavigationGuard();
|
||||
uni.onNetworkStatusChange?.(({ isConnected }) => {
|
||||
if (isConnected) {
|
||||
void this.check({ force: true });
|
||||
} else if (!['installing', 'ready'].includes(this.state.phase)) {
|
||||
this.state.blocking = false;
|
||||
this.state.error = '网络未连接,请联网后重试';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
identityChanged() {
|
||||
this.revision++;
|
||||
this.lastChecked = 0;
|
||||
this.dismissedId = null;
|
||||
this.checkPromise = null;
|
||||
if (this.state.phase === 'installing') {
|
||||
// 原生安装开始后无法取消,保留文件与完成回调,避免登出导致资源已写入却丢失重启入口。
|
||||
this.identityChangedDuringInstall = true;
|
||||
this.state.blocking = false;
|
||||
return;
|
||||
}
|
||||
this.cancelTask();
|
||||
this.actionPromise = null;
|
||||
this.clearRelease();
|
||||
}
|
||||
|
||||
authenticated() {
|
||||
if (this.started) void this.check({ force: true });
|
||||
}
|
||||
|
||||
clearRelease() {
|
||||
this.state.release = null;
|
||||
this.state.blocking = false;
|
||||
this.state.phase = 'idle';
|
||||
this.state.progress = 0;
|
||||
this.state.error = '';
|
||||
}
|
||||
|
||||
cancelTask() {
|
||||
this.downloadTask?.cancel();
|
||||
this.downloadTask = null;
|
||||
if (this.filePath) void runtime.removeFile(this.filePath);
|
||||
this.filePath = '';
|
||||
}
|
||||
|
||||
installNavigationGuard() {
|
||||
for (const method of ['navigateTo', 'redirectTo', 'reLaunch', 'switchTab', 'navigateBack']) {
|
||||
uni.addInterceptor(method, {
|
||||
invoke: (options) => {
|
||||
if ((this.state.blocking || this.state.phase === 'installing')
|
||||
&& !options?.url?.startsWith(UPDATE_PAGE)) {
|
||||
this.present();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
present(showEmpty = false) {
|
||||
if ((!this.state.release && !showEmpty) || this.pageOpening) return;
|
||||
const pages = getCurrentPages();
|
||||
if (pages[pages.length - 1]?.route === UPDATE_PAGE.slice(1)) return;
|
||||
if (!pages.length) return;
|
||||
this.pageOpening = true;
|
||||
uni.navigateTo({
|
||||
url: UPDATE_PAGE,
|
||||
fail: () => uni.redirectTo({ url: UPDATE_PAGE }),
|
||||
complete: () => { this.pageOpening = false; },
|
||||
});
|
||||
}
|
||||
|
||||
async check({ silent = true, force = false } = {}) {
|
||||
if (!runtime.isSupported()) {
|
||||
if (!silent) uni.showToast({ title: '当前运行环境暂不支持应用内升级', icon: 'none' });
|
||||
return null;
|
||||
}
|
||||
if (!this.api) return null;
|
||||
if (!this.started) this.initialize();
|
||||
if (this.actionPromise || ACTIVE_PHASES.includes(this.state.phase) || this.state.phase === 'ready') {
|
||||
if (!silent || this.state.blocking) this.present();
|
||||
return this.state.release;
|
||||
}
|
||||
if (!force && silent && Date.now() - this.lastChecked < CHECK_INTERVAL) {
|
||||
if (this.state.blocking) this.present();
|
||||
return this.state.release;
|
||||
}
|
||||
if (!this.checkPromise) {
|
||||
const revision = this.revision;
|
||||
const pending = this.performCheck(revision);
|
||||
this.checkPromise = pending;
|
||||
pending.finally(() => {
|
||||
if (this.checkPromise === pending) this.checkPromise = null;
|
||||
});
|
||||
}
|
||||
const release = await this.checkPromise;
|
||||
if (release && (!silent || this.state.blocking || this.dismissedId !== release.id)) this.present();
|
||||
if (!silent && !release) {
|
||||
if (this.state.error) this.present(true);
|
||||
else uni.showToast({ title: '当前已是最新版本', icon: 'none' });
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
async performCheck(revision) {
|
||||
try {
|
||||
const network = await getNetworkStatus();
|
||||
if (!network.connected) throw new Error('网络未连接,请联网后重试');
|
||||
const installed = await runtime.readVersion();
|
||||
const response = await this.api.check(installed);
|
||||
if (revision !== this.revision) return null;
|
||||
if (!response || response.code !== 0) throw new Error(response?.msg || '检查更新失败,请稍后重试');
|
||||
this.lastChecked = Date.now();
|
||||
this.state.installedVersion = installed;
|
||||
this.state.error = '';
|
||||
const release = response.data;
|
||||
if (!release) {
|
||||
this.cancelTask();
|
||||
this.clearRelease();
|
||||
return null;
|
||||
}
|
||||
if (this.state.release?.packageId !== release.packageId) this.cancelTask();
|
||||
this.state.release = release;
|
||||
this.state.blocking = release.forceUpdate;
|
||||
this.state.phase = this.filePath ? 'downloaded' : 'available';
|
||||
return release;
|
||||
} catch (error) {
|
||||
if (revision !== this.revision) return null;
|
||||
// 断网和历史缓存不会持续阻塞业务,重试时再次验证强制更新状态。
|
||||
this.state.blocking = false;
|
||||
this.state.error = error?.message || '检查更新失败,请稍后重试';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async validate(revision) {
|
||||
const release = this.state.release;
|
||||
if (!release || revision !== this.revision) return false;
|
||||
const installed = await runtime.readVersion();
|
||||
let response;
|
||||
try {
|
||||
response = await this.api.validate({ ...installed, id: release.id });
|
||||
} catch (error) {
|
||||
if (revision === this.revision) this.state.blocking = false;
|
||||
throw error;
|
||||
}
|
||||
if (revision !== this.revision) return false;
|
||||
if (!response || response.code !== 0) {
|
||||
this.state.blocking = false;
|
||||
throw new Error(response?.msg || '无法确认版本状态,请联网后重试');
|
||||
}
|
||||
if (!response.data || response.data.packageId !== release.packageId) {
|
||||
this.cancelTask();
|
||||
this.clearRelease();
|
||||
uni.showToast({ title: '该版本发布状态已变化,请重新检查更新', icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
this.state.release = response.data;
|
||||
this.state.blocking = response.data.forceUpdate;
|
||||
return true;
|
||||
}
|
||||
|
||||
runAction(action) {
|
||||
if (this.actionPromise) return this.actionPromise;
|
||||
const revision = this.revision;
|
||||
const pending = action(revision).catch((error) => {
|
||||
if (revision !== this.revision) return;
|
||||
this.state.phase = 'error';
|
||||
this.state.error = error?.message || '更新失败,请重试';
|
||||
}).finally(() => {
|
||||
if (this.actionPromise === pending) this.actionPromise = null;
|
||||
});
|
||||
this.actionPromise = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.checkPromise) return this.checkPromise.then(() => this.update());
|
||||
return this.runAction(async (revision) => {
|
||||
this.state.error = '';
|
||||
if (!await this.validate(revision)) return;
|
||||
if (!this.filePath) {
|
||||
const network = await getNetworkStatus();
|
||||
if (revision !== this.revision) return;
|
||||
if (!network.connected) {
|
||||
this.state.blocking = false;
|
||||
throw new Error('网络未连接,请联网后重试');
|
||||
}
|
||||
if (network.networkType !== 'wifi') {
|
||||
const { confirm } = await uni.showModal({
|
||||
title: '使用移动网络下载',
|
||||
content: `更新包大小 ${formatFileSize(this.state.release.fileSize)},是否继续下载?`,
|
||||
confirmText: '继续下载',
|
||||
});
|
||||
if (!confirm || revision !== this.revision) return;
|
||||
}
|
||||
this.state.phase = 'downloading';
|
||||
this.state.progress = 0;
|
||||
this.state.downloadedBytes = 0;
|
||||
this.downloadTask = runtime.download(this.state.release, (downloaded, total) => {
|
||||
if (revision !== this.revision) return;
|
||||
this.state.downloadedBytes = downloaded;
|
||||
this.state.progress = total > 0 ? Math.min(100, Math.floor(downloaded * 100 / total)) : 0;
|
||||
});
|
||||
const path = await this.downloadTask.promise;
|
||||
if (revision !== this.revision) {
|
||||
void runtime.removeFile(path);
|
||||
return;
|
||||
}
|
||||
this.downloadTask = null;
|
||||
this.filePath = path;
|
||||
}
|
||||
await this.install(revision);
|
||||
});
|
||||
}
|
||||
|
||||
async install(revision) {
|
||||
if (!await this.validate(revision)) return;
|
||||
this.state.phase = 'verifying';
|
||||
const verifiedPath = this.filePath;
|
||||
try {
|
||||
await runtime.verifyFile(verifiedPath, this.state.release);
|
||||
} catch (error) {
|
||||
if (revision === this.revision && this.filePath === verifiedPath) this.filePath = '';
|
||||
throw error;
|
||||
}
|
||||
if (revision !== this.revision) return;
|
||||
const release = this.state.release;
|
||||
if (release.packageType === 'apk') {
|
||||
this.state.phase = 'permission';
|
||||
if (!await ensureAndroidInstallPermission() || revision !== this.revision) return;
|
||||
this.state.phase = 'installer-open';
|
||||
runtime.installApk(this.filePath, (error) => {
|
||||
if (revision !== this.revision) return;
|
||||
this.state.phase = 'error';
|
||||
this.state.error = error.message;
|
||||
});
|
||||
} else {
|
||||
this.state.phase = 'installing';
|
||||
try {
|
||||
await runtime.installWgt(this.filePath);
|
||||
} catch (error) {
|
||||
if (this.identityChangedDuringInstall) {
|
||||
this.identityChangedDuringInstall = false;
|
||||
this.cancelTask();
|
||||
this.clearRelease();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (revision !== this.revision && !this.identityChangedDuringInstall) return;
|
||||
if (this.identityChangedDuringInstall) {
|
||||
this.identityChangedDuringInstall = false;
|
||||
this.state.release = { ...release, forceUpdate: false };
|
||||
this.state.blocking = false;
|
||||
}
|
||||
this.state.phase = 'ready';
|
||||
this.state.progress = 100;
|
||||
}
|
||||
}
|
||||
|
||||
async foreground() {
|
||||
if (!this.started) return;
|
||||
if (this.actionPromise) return;
|
||||
if (this.state.phase === 'permission' && this.filePath && canInstallApk()) {
|
||||
await this.update();
|
||||
return;
|
||||
}
|
||||
if (this.state.phase === 'installer-open') {
|
||||
await this.check({ force: true });
|
||||
return;
|
||||
}
|
||||
void this.check();
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
if (this.state.blocking || this.state.phase === 'installing') return false;
|
||||
this.revision++;
|
||||
this.checkPromise = null;
|
||||
this.actionPromise = null;
|
||||
this.dismissedId = this.state.release?.id || null;
|
||||
this.downloadTask?.cancel();
|
||||
this.downloadTask = null;
|
||||
if (['downloading', 'verifying'].includes(this.state.phase)) {
|
||||
this.state.phase = this.filePath ? 'downloaded' : 'available';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
restart() {
|
||||
try {
|
||||
runtime.restart();
|
||||
} catch (error) {
|
||||
this.state.error = error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFileSize(value) {
|
||||
if (!value) return '0 KB';
|
||||
return value >= 1024 * 1024
|
||||
? `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||
: `${Math.max(1, Math.ceil(value / 1024))} KB`;
|
||||
}
|
||||
|
||||
export default new AppUpdateManager();
|
||||
@@ -18,6 +18,7 @@ import wechat from './provider/wechat/index.js';
|
||||
import apple from './provider/apple';
|
||||
import share from './share';
|
||||
import Pay from './pay';
|
||||
import appUpdates from './app-update';
|
||||
|
||||
const device = uni.getWindowInfo();
|
||||
const initialStatusBarHeight = getStatusBarHeight();
|
||||
@@ -55,6 +56,11 @@ if (os === 'ios') {
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-HARMONY
|
||||
name = 'App';
|
||||
platform = 'openPlatform';
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
name = 'WechatMiniProgram';
|
||||
platform = 'miniProgram';
|
||||
@@ -97,13 +103,12 @@ const payH5 = (payment, orderType, orderSN) => {
|
||||
* @param {Boolean} silence - 静默检查
|
||||
*/
|
||||
const checkUpdate = (silence = false) => {
|
||||
let canUpdate;
|
||||
// #ifdef MP-WEIXIN
|
||||
useProvider().checkUpdate(silence);
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
// TODO: 热更新
|
||||
// #ifdef APP-PLUS || APP-HARMONY
|
||||
return appUpdates.check({ silent: silence, force: !silence });
|
||||
// #endif
|
||||
};
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@ http.interceptors.response.use(
|
||||
(response) => {
|
||||
// 约定:如果是 /auth/ 下的 URL 地址,并且返回了 accessToken 说明是登录相关的接口,则自动设置登陆令牌
|
||||
if (response.config.url.indexOf('/member/auth/') >= 0 && response.data?.data?.accessToken) {
|
||||
$store('user').setToken(response.data.data.accessToken, response.data.data.refreshToken);
|
||||
$store('user').setToken(response.data.data.accessToken, response.data.data.refreshToken,
|
||||
response.config.url.includes('/member/auth/refresh-token'));
|
||||
if (response.data.data.chatToken) {
|
||||
chatTokenManager.saveTokenInfo(response.data.data.chatToken);
|
||||
}
|
||||
@@ -127,7 +128,7 @@ http.interceptors.response.use(
|
||||
// 自定义处理【error 错误提示】:如果需要显示错误提示,则显示错误提示
|
||||
if (response.data.code !== 0) {
|
||||
// 特殊:如果 401 错误码,则跳转到登录页 or 刷新令牌
|
||||
if (response.data.code === 401) {
|
||||
if (response.data.code === 401 && !response.config.custom.skipAuthRefresh) {
|
||||
return refreshToken(response.config);
|
||||
}
|
||||
// 特殊:处理分销用户绑定失败的提示
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import vm from 'node:vm';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((complete) => { resolve = complete; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const storage = new Map([['token', 'account-a']]);
|
||||
const requests = [];
|
||||
const boundUsers = [];
|
||||
const source = (await readFile(new URL('../user.js', import.meta.url), 'utf8'))
|
||||
.replace(/^import .+;\n/gm, '')
|
||||
.replace('export default user;', 'globalThis.userDefinition = user;');
|
||||
const context = vm.createContext({
|
||||
defineStore: (definition) => definition,
|
||||
clone: (value) => ({ ...value }), cloneDeep: (value) => JSON.parse(JSON.stringify(value)),
|
||||
uni: { getStorageSync: (key) => storage.get(key), setStorageSync: (key, value) => storage.set(key, value),
|
||||
removeStorageSync: (key) => storage.delete(key), reLaunch: () => {} },
|
||||
UserApi: { getUserInfo: () => { const request = deferred(); requests.push(request); return request.promise; } },
|
||||
PayWalletApi: { getPayWallet: async () => ({ code: 0, data: { balance: 0 } }) },
|
||||
appUpdates: { identityChanged: () => {}, authenticated: () => {} },
|
||||
bindPushUser: (id) => boundUsers.push(id), unbindPushUser: async () => {},
|
||||
$share: { getShareInfo: () => {}, bindBrokerageUser: () => {} },
|
||||
app: () => ({ platform: { bind_mobile: false } }),
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const definition = context.userDefinition;
|
||||
const store = { ...definition.state(), ...definition.actions };
|
||||
return { store, requests, storage, boundUsers };
|
||||
}
|
||||
|
||||
test('quick account changes fetch fresh membership data and discard old responses', async () => {
|
||||
const { store, requests, boundUsers } = await fixture();
|
||||
store.userInfo = { id: 1, sex: 1 };
|
||||
store.lastUpdateTime = Date.now();
|
||||
const oldRequest = store.getInfo();
|
||||
store.setToken('account-b', 'refresh-b');
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(store.userInfo.id, undefined);
|
||||
requests[1].resolve({ code: 0, data: { id: 2, sex: 1 } });
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
requests[0].resolve({ code: 0, data: { id: 1, sex: 1 } });
|
||||
await oldRequest;
|
||||
assert.equal(store.userInfo.id, 2);
|
||||
assert.deepEqual(boundUsers, [2]);
|
||||
});
|
||||
|
||||
test('token refresh keeps the account generation of an in-flight membership request', async () => {
|
||||
const { store, requests } = await fixture();
|
||||
const info = store.getInfo();
|
||||
store.lastUpdateTime = Date.now();
|
||||
store.setToken('account-a-refreshed', 'refresh-a', true);
|
||||
requests[0].resolve({ code: 0, data: { id: 1, sex: 1 } });
|
||||
await info;
|
||||
assert.equal(store.userInfo.id, 1);
|
||||
});
|
||||
+30
-4
@@ -10,6 +10,10 @@ import OrderApi from '@/sheep/api/trade/order';
|
||||
import CouponApi from '@/sheep/api/promotion/coupon';
|
||||
import chatTokenManager from '@/common/chat-token-manager';
|
||||
import * as wsApi from '@/common/wssocket';
|
||||
import { bindPushUser, unbindPushUser } from '@/common/jpush';
|
||||
import appUpdates from '@/sheep/platform/app-update';
|
||||
|
||||
let loginRevision = 0;
|
||||
|
||||
// 默认用户信息
|
||||
const defaultUserInfo = {
|
||||
@@ -52,8 +56,9 @@ const user = defineStore({
|
||||
actions: {
|
||||
// 获取用户信息
|
||||
async getInfo() {
|
||||
const revision = loginRevision;
|
||||
const { code, data } = await UserApi.getUserInfo();
|
||||
if (code !== 0) {
|
||||
if (code !== 0 || revision !== loginRevision || !this.isLogin) {
|
||||
return;
|
||||
}
|
||||
this.userInfo = data;
|
||||
@@ -65,8 +70,9 @@ const user = defineStore({
|
||||
|
||||
// 获得用户钱包
|
||||
async getWallet() {
|
||||
const revision = loginRevision;
|
||||
const { code, data } = await PayWalletApi.getPayWallet();
|
||||
if (code !== 0) {
|
||||
if (code !== 0 || revision !== loginRevision || !this.isLogin) {
|
||||
return;
|
||||
}
|
||||
this.userWallet = data;
|
||||
@@ -87,8 +93,20 @@ const user = defineStore({
|
||||
},
|
||||
|
||||
// 设置 token
|
||||
setToken(token = '', refreshToken = '') {
|
||||
setToken(token = '', refreshToken = '', isRefresh = false) {
|
||||
if (!isRefresh) {
|
||||
loginRevision++;
|
||||
this.lastUpdateTime = 0;
|
||||
if (token && token !== uni.getStorageSync('token')) {
|
||||
void unbindPushUser();
|
||||
this.userInfo = clone(defaultUserInfo);
|
||||
this.userWallet = clone(defaultUserWallet);
|
||||
}
|
||||
appUpdates.identityChanged();
|
||||
}
|
||||
if (token === '') {
|
||||
// 同时覆盖主站退出、401 与租户切换,旧请求自行携带调用时的令牌。
|
||||
void unbindPushUser();
|
||||
this.isLogin = false;
|
||||
uni.removeStorageSync('token');
|
||||
uni.removeStorageSync('refresh-token');
|
||||
@@ -103,6 +121,7 @@ const user = defineStore({
|
||||
|
||||
// 更新用户相关信息 (手动限流,5 秒之内不刷新)
|
||||
async updateUserData() {
|
||||
const revision = loginRevision;
|
||||
if (!this.isLogin) {
|
||||
this.resetUserData();
|
||||
return;
|
||||
@@ -116,6 +135,7 @@ const user = defineStore({
|
||||
|
||||
// 获取最新信息
|
||||
await this.getInfo();
|
||||
if (revision !== loginRevision || !this.isLogin) return;
|
||||
this.getWallet();
|
||||
// this.getNumData(); // 获取订单、优惠券等其他资产信息
|
||||
return this.userInfo;
|
||||
@@ -140,7 +160,12 @@ const user = defineStore({
|
||||
|
||||
// 登录后,加载各种信息
|
||||
async loginAfter() {
|
||||
const revision = loginRevision;
|
||||
await this.updateUserData();
|
||||
if (revision !== loginRevision || !this.isLogin) return;
|
||||
// 使用主站业务会员 ID 绑定,IM 用户 ID 仅用于聊天路由。
|
||||
void bindPushUser(this.userInfo.id);
|
||||
appUpdates.authenticated();
|
||||
|
||||
// 加载购物车
|
||||
// cart().getList();
|
||||
@@ -157,7 +182,8 @@ const user = defineStore({
|
||||
},
|
||||
|
||||
// 登出系统
|
||||
async logout() {
|
||||
async logout(isUnauthorized = false) {
|
||||
await unbindPushUser({ clearPendingRoute: !isUnauthorized });
|
||||
this.resetUserData();
|
||||
return !this.isLogin;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ts = require('typescript');
|
||||
const usage = { feature: '上传图片', purpose: '选择用户主动提交的图片' };
|
||||
|
||||
async function fixture(options = {}) {
|
||||
const { platform = 'android', sdk = 29, target = 29, granted = [],
|
||||
grantOnRequest = true, photoStatus = 4, installAllowed = false, network = 'wifi' } = options;
|
||||
const grants = new Set(granted);
|
||||
const calls = { modals: [], requests: [], settings: [] };
|
||||
function Intent(action) {
|
||||
this.action = action;
|
||||
this.setData = (value) => { this.data = value; };
|
||||
}
|
||||
const activity = {
|
||||
getPackageName: () => 'test.app', getApplicationInfo: () => ({ targetSdkVersion: target }),
|
||||
checkSelfPermission: (key) => grants.has(key) ? 0 : -1,
|
||||
startActivity: (intent) => calls.settings.push(intent.action),
|
||||
getPackageManager: () => ({ canRequestPackageInstalls: () => installAllowed }),
|
||||
};
|
||||
const plus = {
|
||||
runtime: { openURL: (url) => calls.settings.push(url) },
|
||||
android: {
|
||||
importClass: (name) => typeof name !== 'string' ? name
|
||||
: name === 'android.os.Build$VERSION' ? { SDK_INT: sdk }
|
||||
: name === 'android.content.Intent' ? Intent
|
||||
: name === 'android.net.Uri' ? { parse: (value) => value } : {},
|
||||
getAttribute: (value, key) => value[key], runtimeMainActivity: () => activity,
|
||||
requestPermissions: (keys, success) => {
|
||||
calls.requests.push(keys);
|
||||
if (grantOnRequest) keys.forEach((key) => grants.add(key));
|
||||
success({ granted: [...grants] });
|
||||
},
|
||||
},
|
||||
// 只提供状态读取 API,确保 iOS 首次申请不依赖 Native.js 的 ObjC block。
|
||||
ios: { import: () => ({ authorizationStatus: () => photoStatus,
|
||||
authorizationStatusForMediaType: () => photoStatus }), invoke: () => photoStatus },
|
||||
};
|
||||
const uni = {
|
||||
getDeviceInfo: () => ({ platform }), showToast: () => {},
|
||||
showModal: (value) => { calls.modals.push(value.title); value.success({ confirm: true }); },
|
||||
showActionSheet: (value) => value.success({ tapIndex: 0 }),
|
||||
getNetworkType: (value) => value.success({ networkType: network }),
|
||||
};
|
||||
const source = (await readFile(new URL('../permission.ts', import.meta.url), 'utf8'))
|
||||
.replace(/^[ \t]*\/\/ #ifdef MP\r?\n[\s\S]*?^[ \t]*\/\/ #endif\r?\n/gm, '');
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020,
|
||||
} }).outputText;
|
||||
const context = vm.createContext({ exports: {}, uni, plus: platform === 'web' ? undefined : plus });
|
||||
vm.runInContext(compiled, context);
|
||||
return { api: context.exports, calls };
|
||||
}
|
||||
|
||||
test('Android uses the full permission name and merges concurrent requests', async () => {
|
||||
const { api, calls } = await fixture();
|
||||
assert.deepEqual(await Promise.all([api.ensurePermission('camera', usage), api.ensurePermission('camera', usage)]), [true, true]);
|
||||
assert.equal(calls.requests.length, 1);
|
||||
assert.equal(calls.requests[0][0], 'android.permission.CAMERA');
|
||||
});
|
||||
|
||||
test('Android 10+ album saving needs no broad storage permission', async () => {
|
||||
const { api, calls } = await fixture();
|
||||
assert.equal(await api.ensurePermission('album-write', usage), true);
|
||||
assert.equal(calls.modals.length, 0);
|
||||
assert.equal(calls.requests.length, 0);
|
||||
});
|
||||
|
||||
test('coarse location and Android 14 selected photos remain usable', async () => {
|
||||
const { api } = await fixture({ sdk: 34, target: 34, granted: [
|
||||
'android.permission.ACCESS_COARSE_LOCATION', 'android.permission.READ_MEDIA_VISUAL_USER_SELECTED',
|
||||
] });
|
||||
assert.equal(await api.ensurePermission('location', usage), true);
|
||||
assert.equal(await api.ensurePermission('album-read', usage), true);
|
||||
});
|
||||
|
||||
test('background refresh checks permission without presenting dialogs', async () => {
|
||||
const { api, calls } = await fixture();
|
||||
assert.equal(await api.ensurePermission('location', usage, false), false);
|
||||
assert.equal(calls.modals.length, 0);
|
||||
});
|
||||
|
||||
test('Android rejection provides the application settings route', async () => {
|
||||
const { api, calls } = await fixture({ grantOnRequest: false });
|
||||
assert.equal(await api.ensurePermission('camera', usage), false);
|
||||
assert.equal(calls.settings[0], 'android.settings.APPLICATION_DETAILS_SETTINGS');
|
||||
});
|
||||
|
||||
test('iOS limited album access is accepted and first authorization is left to the native business API', async () => {
|
||||
const limited = await fixture({ platform: 'ios', photoStatus: 4 });
|
||||
assert.equal(await limited.api.ensurePermission('album-read', usage), true);
|
||||
assert.equal(limited.calls.modals.length, 0);
|
||||
const undetermined = await fixture({ platform: 'ios', photoStatus: 0 });
|
||||
assert.equal(await undetermined.api.ensurePermission('camera', usage), true);
|
||||
assert.equal(undetermined.calls.modals.length, 1);
|
||||
});
|
||||
|
||||
test('consecutive iOS SDK calls share the same first-use purpose notice', async () => {
|
||||
const { api, calls } = await fixture({ platform: 'ios', photoStatus: 0 });
|
||||
await api.ensurePermission('camera', usage);
|
||||
await api.ensurePermission('camera', usage);
|
||||
assert.equal(calls.modals.length, 1);
|
||||
});
|
||||
|
||||
test('unknown-source installation uses its dedicated settings route', async () => {
|
||||
const { api, calls } = await fixture();
|
||||
assert.equal(await api.ensureAndroidInstallPermission(), false);
|
||||
assert.equal(calls.requests.length, 0);
|
||||
assert.equal(calls.settings[0], 'android.settings.MANAGE_UNKNOWN_APP_SOURCES');
|
||||
});
|
||||
|
||||
test('H5 keeps its native source selector and reports offline state', async () => {
|
||||
const { api, calls } = await fixture({ platform: 'web', network: 'none' });
|
||||
const sources = await api.prepareMediaSelection(['album', 'camera'], 'image', usage);
|
||||
assert.equal(sources.length, 2);
|
||||
assert.equal(calls.modals.length, 0);
|
||||
assert.equal((await api.getNetworkStatus()).connected, false);
|
||||
});
|
||||
|
||||
async function recorderFixture(authorized = true) {
|
||||
const callbacks = {};
|
||||
const calls = { uploads: 0, stopListeners: 0, stopped: 0 };
|
||||
const recorder = {
|
||||
onStart: (callback) => { callbacks.start = callback; },
|
||||
onStop: (callback) => { callbacks.stop = callback; calls.stopListeners++; },
|
||||
onError: (callback) => { callbacks.error = callback; },
|
||||
start: () => callbacks.start(),
|
||||
stop: () => { calls.stopped++; callbacks.stop({ tempFilePath: '/voice.mp3' }); },
|
||||
};
|
||||
const source = (await readFile(new URL('../../../common/recorder-app.js', import.meta.url), 'utf8'))
|
||||
.replace(/^import .+;\n/gm, '')
|
||||
.replace('export { checkIsEnable, start, close, upload };', 'globalThis.api = { checkIsEnable, start, close, upload };');
|
||||
const context = vm.createContext({
|
||||
CHAT_ENV: { BASE_URL: 'https://example.com' }, hasPermission: () => authorized,
|
||||
ensurePermission: async () => true, handlePermissionFailure: async () => {},
|
||||
uni: { getRecorderManager: () => recorder, getStorageSync: () => ({ accessToken: 'test' }),
|
||||
uploadFile: (options) => { calls.uploads++; options.success({ data: '{"code":200,"data":"/voice.mp3"}' }); } },
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
return { api: context.api, calls };
|
||||
}
|
||||
|
||||
test('cancelled subsequent recordings never trigger historical uploads', async () => {
|
||||
const { api, calls } = await recorderFixture();
|
||||
await api.start(); api.close(); await api.upload();
|
||||
await api.start(); api.close();
|
||||
assert.equal(calls.uploads, 1);
|
||||
assert.equal(calls.stopListeners, 1);
|
||||
});
|
||||
|
||||
test('first iOS microphone authorization stops recording and requires a fresh gesture', async () => {
|
||||
const { api, calls } = await recorderFixture(false);
|
||||
await assert.rejects(api.start(() => false), /recording-permission-ready/);
|
||||
assert.equal(calls.stopped, 1);
|
||||
assert.equal(calls.uploads, 0);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ensurePermission, handlePermissionFailure } from './permission';
|
||||
|
||||
/**
|
||||
* 保存图片到相册
|
||||
* @param {string} filePath 图片地址
|
||||
@@ -430,7 +432,7 @@ const downloadImageForBrowser = async (filePath: string) => {
|
||||
};
|
||||
// #endif
|
||||
|
||||
export const saveImageToPhotosAlbum = (
|
||||
export const saveImageToPhotosAlbum = async (
|
||||
filePath: string,
|
||||
successCallback?: (res: any) => void,
|
||||
errorCallback?: (error: any) => void,
|
||||
@@ -441,6 +443,11 @@ export const saveImageToPhotosAlbum = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await ensurePermission('album-write', { feature: '保存图片', purpose: '将您主动保存的海报或图片写入系统相册' })) {
|
||||
errorCallback?.({ errMsg: 'saveImageToPhotosAlbum:fail auth deny' });
|
||||
return;
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
const saveImageForH5 = async () => {
|
||||
try {
|
||||
@@ -459,6 +466,7 @@ export const saveImageToPhotosAlbum = (
|
||||
}
|
||||
successCallback?.({ errMsg: 'saveImageToPhotosAlbum:ok' });
|
||||
} catch (error) {
|
||||
void handlePermissionFailure('album-write', error);
|
||||
errorCallback?.(error);
|
||||
}
|
||||
};
|
||||
@@ -470,7 +478,10 @@ export const saveImageToPhotosAlbum = (
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath,
|
||||
success: (res) => successCallback?.(res),
|
||||
fail: (error) => errorCallback?.(error),
|
||||
fail: (error) => {
|
||||
void handlePermissionFailure('album-write', error);
|
||||
errorCallback?.(error);
|
||||
},
|
||||
});
|
||||
// #endif
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ensurePermission, handlePermissionFailure, type PermissionUsage } from './permission';
|
||||
|
||||
export type LocationErrorCode =
|
||||
| 'permission-denied'
|
||||
| 'position-unavailable'
|
||||
@@ -23,6 +25,10 @@ export interface CurrentLocationOptions {
|
||||
geocode?: boolean;
|
||||
isHighAccuracy?: boolean;
|
||||
highAccuracyExpireTime?: number;
|
||||
/** 自动刷新只查询已有授权,手动定位允许弹出授权说明。 */
|
||||
interactive?: boolean;
|
||||
/** 定位权限用于当前业务的具体用途。 */
|
||||
permissionUsage?: PermissionUsage;
|
||||
}
|
||||
|
||||
export class LocationError extends Error {
|
||||
@@ -55,10 +61,14 @@ const normalizeUniLocationError = (error: any) => {
|
||||
return new LocationError('unknown', message);
|
||||
};
|
||||
|
||||
export const getCurrentLocation = (
|
||||
export const getCurrentLocation = async (
|
||||
options: CurrentLocationOptions = {},
|
||||
): Promise<CurrentLocation> =>
|
||||
new Promise((resolve, reject) => {
|
||||
): Promise<CurrentLocation> => {
|
||||
const granted = await ensurePermission('location', options.permissionUsage || {
|
||||
feature: '位置服务', purpose: '获取当前城市并展示附近用户或门店',
|
||||
}, options.interactive !== false);
|
||||
if (!granted) throw new LocationError('permission-denied');
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.getLocation({
|
||||
type: options.type || 'wgs84',
|
||||
geocode: options.geocode !== false,
|
||||
@@ -72,9 +82,13 @@ export const getCurrentLocation = (
|
||||
address: result.address,
|
||||
});
|
||||
},
|
||||
fail: (error: any) => reject(normalizeUniLocationError(error)),
|
||||
fail: (error: any) => {
|
||||
if (options.interactive !== false) void handlePermissionFailure('location', error);
|
||||
reject(normalizeUniLocationError(error));
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const isLocationError = (error: unknown): error is LocationError =>
|
||||
error instanceof LocationError;
|
||||
|
||||
+274
-150
@@ -1,164 +1,288 @@
|
||||
type PermissionId = 'location' | 'storage';
|
||||
/**
|
||||
* 权限请求状态
|
||||
* - none: 未请求
|
||||
* - granted: 已授权
|
||||
* - denied: 已拒绝|受限
|
||||
* - prompt: 未授权
|
||||
*/
|
||||
/** 业务实际使用的敏感能力,更新文件写入应用私有目录无需相册权限。 */
|
||||
export type PermissionId = 'camera' | 'microphone' | 'location' | 'album-read' | 'album-write';
|
||||
/** 用户完成授权操作后的统一结果。 */
|
||||
type RequestStatus = 'granted' | 'denied' | 'prompt';
|
||||
|
||||
/**
|
||||
* 跳转应用权限设置页面
|
||||
*/
|
||||
export const gotoAppPermissionSetting = () => {
|
||||
const system = uni.getSystemInfoSync();
|
||||
console.log('system:', system);
|
||||
if (system.platform === 'ios') {
|
||||
// @ts-ignore
|
||||
const UIApplication = plus.ios.import('UIApplication');
|
||||
const application2 = UIApplication.sharedApplication();
|
||||
// @ts-ignore
|
||||
const NSURL2 = plus.ios.import('NSURL');
|
||||
const setting2 = NSURL2.URLWithString('app-settings:');
|
||||
application2.openURL(setting2);
|
||||
plus.ios.deleteObject(setting2);
|
||||
plus.ios.deleteObject(NSURL2);
|
||||
plus.ios.deleteObject(application2);
|
||||
}
|
||||
if (system.platform == 'android') {
|
||||
const Intent = plus.android.importClass('android.content.Intent');
|
||||
const Settings = plus.android.importClass('android.provider.Settings');
|
||||
const Uri = plus.android.importClass('android.net.Uri');
|
||||
const mainActivity = plus.android.runtimeMainActivity();
|
||||
// @ts-ignore
|
||||
const intent = new Intent();
|
||||
// @ts-ignore
|
||||
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
|
||||
// @ts-ignore
|
||||
const uri = Uri.fromParts('package', mainActivity.getPackageName(), null);
|
||||
intent.setData(uri);
|
||||
// @ts-ignore
|
||||
mainActivity.startActivity(intent);
|
||||
}
|
||||
/** 权限告知对应的业务功能和使用目的。 */
|
||||
export interface PermissionUsage {
|
||||
feature: string;
|
||||
purpose: string;
|
||||
mediaType?: 'image' | 'video';
|
||||
}
|
||||
/** uni 媒体选择器支持的资源来源。 */
|
||||
export type MediaSource = 'album' | 'camera';
|
||||
/** 同一权限的并发请求共用任务,防止重复系统弹框。 */
|
||||
const pendingPermissions = new Map<string, Promise<boolean>>();
|
||||
/** iOS 系统尚未决定时记住本会话已说明的具体用途,连续 SDK 调用不会重复展示说明。 */
|
||||
const explainedIosPurposes = new Set<string>();
|
||||
/** 不同权限的用途说明按顺序展示。 */
|
||||
let permissionQueue: Promise<unknown> = Promise.resolve();
|
||||
/** 权限在界面中的名称。 */
|
||||
const permissionNames: Record<PermissionId, string> = {
|
||||
camera: '相机', microphone: '麦克风', location: '位置',
|
||||
'album-read': '相册读取', 'album-write': '保存到相册',
|
||||
};
|
||||
|
||||
export const requestPermission = (permissionId: PermissionId): Promise<RequestStatus> => {
|
||||
const system = uni.getSystemInfoSync();
|
||||
if (system.platform === 'android') {
|
||||
return requestAndroid(permissionId);
|
||||
}
|
||||
if (system.platform === 'ios') {
|
||||
return requestIos(permissionId);
|
||||
}
|
||||
return Promise.resolve('denied');
|
||||
/** 仅在真正存在 Android/iOS 原生桥时读取原生能力,浏览器和小程序不会访问 plus。 */
|
||||
const nativeBridge = (): any => (globalThis as any).plus;
|
||||
/** 获取当前原生系统,H5 浏览器的 platform=ios 不表示具备原生能力。 */
|
||||
const nativePlatform = (): string => {
|
||||
if (!nativeBridge()?.runtime) return '';
|
||||
const platform = uni.getDeviceInfo().platform;
|
||||
return platform === 'android' || platform === 'ios' ? platform : '';
|
||||
};
|
||||
/** 显示由用户决定的确认框,失败时视为取消。 */
|
||||
const confirm = (title: string, content: string, confirmText = '继续'): Promise<boolean> =>
|
||||
new Promise((resolve) => uni.showModal({
|
||||
title, content, confirmText, cancelText: '取消',
|
||||
success: (result) => resolve(Boolean(result.confirm)), fail: () => resolve(false),
|
||||
}));
|
||||
|
||||
/******************************** ios相关 ********************************/
|
||||
/** 打开当前应用的系统授权设置页,只有用户主动选择后才调用。 */
|
||||
export const openAppPermissionSettings = (): Promise<boolean> => new Promise((resolve) => {
|
||||
const bridge = nativeBridge();
|
||||
if (nativePlatform() === 'ios') {
|
||||
bridge.runtime.openURL('app-settings:', () => resolve(false));
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (nativePlatform() === 'android') {
|
||||
try {
|
||||
const Intent = bridge.android.importClass('android.content.Intent');
|
||||
const Uri = bridge.android.importClass('android.net.Uri');
|
||||
const activity = bridge.android.runtimeMainActivity();
|
||||
bridge.android.importClass(activity);
|
||||
const intent = new Intent('android.settings.APPLICATION_DETAILS_SETTINGS');
|
||||
intent.setData(Uri.parse(`package:${activity.getPackageName()}`));
|
||||
activity.startActivity(intent);
|
||||
resolve(true);
|
||||
} catch { resolve(false); }
|
||||
return;
|
||||
}
|
||||
// #ifdef MP
|
||||
uni.openSetting({ success: () => resolve(true), fail: () => resolve(false) });
|
||||
return;
|
||||
// #endif
|
||||
// #ifndef MP
|
||||
resolve(false);
|
||||
// #endif
|
||||
});
|
||||
/** 兼容现有权限工具的设置页入口。 */
|
||||
export const gotoAppPermissionSetting = openAppPermissionSettings;
|
||||
|
||||
const requestIos = (permissionId: PermissionId): Promise<RequestStatus> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
switch (permissionId) {
|
||||
case 'location':
|
||||
resolve(iosLocation());
|
||||
break;
|
||||
case 'storage':
|
||||
resolve(iosStorage());
|
||||
break;
|
||||
default:
|
||||
resolve('denied');
|
||||
break;
|
||||
}
|
||||
});
|
||||
/** 权限拒绝后保留明确的设置入口,返回后由原业务重新读取状态。 */
|
||||
const showDeniedGuide = async (permissionId: PermissionId): Promise<void> => {
|
||||
if (await confirm(`${permissionNames[permissionId]}权限未开启`,
|
||||
`请在系统设置中允许${permissionNames[permissionId]},返回应用后可重新操作。`, '去设置')) {
|
||||
await openAppPermissionSettings();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求ios定位权限
|
||||
* @returns 权限请求状态
|
||||
*/
|
||||
const iosLocation = (): RequestStatus => {
|
||||
let result: RequestStatus = 'denied';
|
||||
// @ts-ignore
|
||||
const cllocationManger = plus.ios.import('CLLocationManager');
|
||||
const enable = cllocationManger.locationServicesEnabled();
|
||||
const status = cllocationManger.authorizationStatus();
|
||||
if (!enable) {
|
||||
result = 'denied';
|
||||
} else if (status === 0) {
|
||||
result = 'prompt';
|
||||
} else if (status === 3 || status === 4) {
|
||||
result = 'granted';
|
||||
/** 根据系统与应用 target SDK 计算实际需申请的 Android 权限。 */
|
||||
const androidPermissionKeys = (permissionId: PermissionId, usage: PermissionUsage): string[] => {
|
||||
const bridge = nativeBridge();
|
||||
const Version = bridge.android.importClass('android.os.Build$VERSION');
|
||||
const sdk = Number(Version.SDK_INT);
|
||||
const activity = bridge.android.runtimeMainActivity();
|
||||
bridge.android.importClass(activity);
|
||||
const info = activity.getApplicationInfo();
|
||||
const targetSdk = Number(bridge.android.getAttribute(info, 'targetSdkVersion'));
|
||||
if (permissionId === 'camera') return ['android.permission.CAMERA'];
|
||||
if (permissionId === 'microphone') return ['android.permission.RECORD_AUDIO'];
|
||||
if (permissionId === 'location') return ['android.permission.ACCESS_COARSE_LOCATION', 'android.permission.ACCESS_FINE_LOCATION'];
|
||||
// Android 10+ 保存到公共相册由 MediaStore 完成,应用私有下载同样无需广泛存储授权。
|
||||
if (permissionId === 'album-write') return sdk >= 29 ? [] : ['android.permission.WRITE_EXTERNAL_STORAGE'];
|
||||
if (sdk >= 33 && targetSdk >= 33) {
|
||||
const key = usage.mediaType === 'video' ? 'READ_MEDIA_VIDEO' : 'READ_MEDIA_IMAGES';
|
||||
const keys = [`android.permission.${key}`];
|
||||
if (sdk >= 34 && targetSdk >= 34) keys.push('android.permission.READ_MEDIA_VISUAL_USER_SELECTED');
|
||||
return keys;
|
||||
}
|
||||
return ['android.permission.READ_EXTERNAL_STORAGE'];
|
||||
};
|
||||
/** 位置允许模糊定位,Android 14 用户选择的部分照片同样视为可用。 */
|
||||
const androidGranted = (permissionId: PermissionId, keys: string[]): boolean => {
|
||||
const activity = nativeBridge().android.runtimeMainActivity();
|
||||
nativeBridge().android.importClass(activity);
|
||||
const granted = keys.map((key) => activity.checkSelfPermission(key) === 0);
|
||||
return permissionId === 'location' || keys.includes('android.permission.READ_MEDIA_VISUAL_USER_SELECTED')
|
||||
? granted.some(Boolean) : granted.every(Boolean);
|
||||
};
|
||||
/** 查询 iOS 权限,照片的 limited 状态(4)允许使用用户选定的照片。 */
|
||||
const iosStatus = (permissionId: PermissionId): RequestStatus => {
|
||||
const ios = nativeBridge().ios;
|
||||
let status: number;
|
||||
if (permissionId === 'camera') status = ios.import('AVCaptureDevice').authorizationStatusForMediaType('vide');
|
||||
else if (permissionId === 'microphone') {
|
||||
const session = ios.import('AVAudioSession').sharedInstance();
|
||||
ios.importClass(session);
|
||||
status = Number(session.recordPermission());
|
||||
ios.deleteObject(session);
|
||||
return status === 1735552628 ? 'granted' : status === 1970168948 ? 'prompt' : 'denied';
|
||||
} else if (permissionId === 'location') {
|
||||
const manager = ios.import('CLLocationManager');
|
||||
if (!manager.locationServicesEnabled()) return 'denied';
|
||||
status = Number(manager.authorizationStatus());
|
||||
return status === 3 || status === 4 ? 'granted' : status === 0 ? 'prompt' : 'denied';
|
||||
} else {
|
||||
result = 'denied';
|
||||
const library = ios.import('PHPhotoLibrary');
|
||||
try {
|
||||
const result = ios.invoke(library, 'authorizationStatusForAccessLevel:', permissionId === 'album-write' ? 1 : 2);
|
||||
status = result == null ? Number(library.authorizationStatus()) : Number(result);
|
||||
} catch { status = Number(library.authorizationStatus()); }
|
||||
return status === 3 || status === 4 ? 'granted' : status === 0 ? 'prompt' : 'denied';
|
||||
}
|
||||
plus.ios.deleteObject(cllocationManger);
|
||||
return result;
|
||||
return status === 3 ? 'granted' : status === 0 ? 'prompt' : 'denied';
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求ios存储权限
|
||||
* @returns 权限请求状态
|
||||
*/
|
||||
const iosStorage = (): RequestStatus => {
|
||||
let result: RequestStatus = 'denied';
|
||||
// @ts-ignore
|
||||
const NSPhotoLibraryUsageDescription = plus.ios.import('NSPhotoLibraryUsageDescription');
|
||||
const status = NSPhotoLibraryUsageDescription.authorizationStatus();
|
||||
if (status === 0) {
|
||||
result = 'prompt';
|
||||
} else if (status === 3 || status === 4) {
|
||||
result = 'granted';
|
||||
} else {
|
||||
result = 'denied';
|
||||
/** 小程序使用平台 scope,媒体读取由系统选择器取得用户主动选择的文件。 */
|
||||
const ensureMiniProgramPermission = (permissionId: PermissionId, interactive: boolean): Promise<boolean> => {
|
||||
// #ifdef MP
|
||||
const scopes: Partial<Record<PermissionId, string>> = {
|
||||
camera: 'scope.camera', microphone: 'scope.record', location: 'scope.userLocation', 'album-write': 'scope.writePhotosAlbum',
|
||||
};
|
||||
const scope = scopes[permissionId];
|
||||
if (!scope) return Promise.resolve(true);
|
||||
return new Promise((resolve) => uni.getSetting({
|
||||
success: async ({ authSetting }) => {
|
||||
if ((authSetting as any)[scope]) return resolve(true);
|
||||
if (!interactive) return resolve(false);
|
||||
uni.authorize({ scope, success: () => resolve(true), fail: async () => {
|
||||
await showDeniedGuide(permissionId);
|
||||
resolve(false);
|
||||
} });
|
||||
}, fail: () => resolve(false),
|
||||
}));
|
||||
// #endif
|
||||
// #ifndef MP
|
||||
return Promise.resolve(true);
|
||||
// #endif
|
||||
};
|
||||
/** 执行权限流程,后台刷新只查询,用户主动操作才弹用途说明和设置引导。 */
|
||||
const ensurePermissionNow = async (permissionId: PermissionId, usage: PermissionUsage, interactive: boolean): Promise<boolean> => {
|
||||
const platform = nativePlatform();
|
||||
if (!platform) return ensureMiniProgramPermission(permissionId, interactive);
|
||||
try {
|
||||
const keys = platform === 'android' ? androidPermissionKeys(permissionId, usage) : [];
|
||||
const status = platform === 'android' ? (androidGranted(permissionId, keys) ? 'granted' : 'prompt') : iosStatus(permissionId);
|
||||
if (status === 'granted') return true;
|
||||
if (!interactive) return false;
|
||||
if (status === 'denied') {
|
||||
await showDeniedGuide(permissionId);
|
||||
return false;
|
||||
}
|
||||
const purposeKey = `${permissionId}:${usage.feature}:${usage.purpose}`;
|
||||
if (platform === 'ios' && explainedIosPurposes.has(purposeKey)) return true;
|
||||
if (!await confirm(`${permissionNames[permissionId]}使用说明`, `${usage.feature}需要${permissionNames[permissionId]},用于${usage.purpose}。`)) return false;
|
||||
if (platform === 'ios') explainedIosPurposes.add(purposeKey);
|
||||
const granted = platform === 'android' ? await new Promise<boolean>((resolve) => {
|
||||
nativeBridge().android.requestPermissions(keys, () => resolve(androidGranted(permissionId, keys)), () => resolve(false));
|
||||
}) : true;
|
||||
// iOS 尚未决定时由后续 uni 选择器、录音、定位或 SDK 原生能力触发系统授权。
|
||||
// Native.js 的 ObjC block 参数存在兼容限制,不能据其回调参数判断用户授权结果。
|
||||
if (!granted) await showDeniedGuide(permissionId);
|
||||
return granted;
|
||||
} catch {
|
||||
if (interactive) uni.showToast({ title: `${permissionNames[permissionId]}暂时不可用,请在系统设置中检查授权`, icon: 'none' });
|
||||
return false;
|
||||
}
|
||||
plus.ios.deleteObject(NSPhotoLibraryUsageDescription);
|
||||
return result;
|
||||
};
|
||||
|
||||
/******************************** andorid相关 ********************************/
|
||||
|
||||
/**
|
||||
* 请求android权限
|
||||
*/
|
||||
const requestAndroid = (permissionID: PermissionId): Promise<RequestStatus> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let androidPermissionKey = '';
|
||||
switch (permissionID) {
|
||||
case 'location':
|
||||
androidPermissionKey = 'android.permission.ACCESS_FINE_LOCATION';
|
||||
break;
|
||||
case 'storage':
|
||||
androidPermissionKey = 'android.permission.WRITE_EXTERNAL_STORAGE';
|
||||
break;
|
||||
}
|
||||
if (!androidPermissionKey) {
|
||||
resolve('denied');
|
||||
}
|
||||
plus.android.requestPermissions(
|
||||
[permissionID],
|
||||
function (resultObj) {
|
||||
let result: RequestStatus = 'denied';
|
||||
for (let i = 0; i < resultObj.granted.length; i++) {
|
||||
const grantedPermission = resultObj.granted[i];
|
||||
console.log('已获取的权限:');
|
||||
result = 'granted';
|
||||
}
|
||||
for (let i = 0; i < resultObj.deniedPresent.length; i++) {
|
||||
const deniedPresentPermission = resultObj.deniedPresent[i];
|
||||
console.log('拒绝本次申请的权限:');
|
||||
result = 'denied';
|
||||
}
|
||||
for (let i = 0; i < resultObj.deniedAlways.length; i++) {
|
||||
const deniedAlwaysPermission = resultObj.deniedAlways[i];
|
||||
console.log('永久拒绝申请的权限:');
|
||||
result = 'denied';
|
||||
}
|
||||
resolve(result);
|
||||
},
|
||||
function (error) {
|
||||
console.error(`|请求android权限|${permissionID}:`, error);
|
||||
resolve('denied');
|
||||
},
|
||||
);
|
||||
/** 只读检查原生授权,录音首次授权完成后等待新的长按手势,避免系统弹框吞掉松手事件。 */
|
||||
export const hasPermission = (permissionId: PermissionId, usage: PermissionUsage = { feature: '', purpose: '' }): boolean => {
|
||||
try {
|
||||
const platform = nativePlatform();
|
||||
if (platform === 'android') return androidGranted(permissionId, androidPermissionKeys(permissionId, usage));
|
||||
if (platform === 'ios') return iosStatus(permissionId) === 'granted';
|
||||
} catch { return false; }
|
||||
return true;
|
||||
};
|
||||
/** 合并并发权限流程。true 允许继续业务原生调用,首次 iOS 的实际授权结果由业务 API 回调确认。 */
|
||||
export const ensurePermission = (permissionId: PermissionId, usage: PermissionUsage, interactive = true): Promise<boolean> => {
|
||||
const key = `${permissionId}:${usage.mediaType || ''}:${interactive}`;
|
||||
const existing = pendingPermissions.get(key);
|
||||
if (existing) return existing;
|
||||
const task = permissionQueue.then(() => ensurePermissionNow(permissionId, usage, interactive));
|
||||
permissionQueue = task.catch(() => undefined);
|
||||
pendingPermissions.set(key, task);
|
||||
void task.finally(() => pendingPermissions.delete(key));
|
||||
return task;
|
||||
};
|
||||
/** 原生业务 API 实际被系统拒绝后提供设置入口,取消选择和普通业务错误维持原有反馈。 */
|
||||
export const handlePermissionFailure = async (permissionId: PermissionId, error: unknown): Promise<void> => {
|
||||
const detail = error as any;
|
||||
const message = String(detail?.errMsg || detail?.message || '');
|
||||
let denied = /permission|authorize|auth deny|denied|拒绝|权限/i.test(message);
|
||||
try {
|
||||
if (nativePlatform() === 'ios') denied = denied || iosStatus(permissionId) === 'denied';
|
||||
} catch { /* 原生状态查询失败时保留业务错误。 */ }
|
||||
if (denied && nativePlatform()) await showDeniedGuide(permissionId);
|
||||
// #ifdef MP
|
||||
if (denied && !nativePlatform()) await showDeniedGuide(permissionId);
|
||||
// #endif
|
||||
};
|
||||
/** 保留旧 location/storage 调用签名,所有申请统一经过用途说明。 */
|
||||
export const requestPermission = async (permissionId: 'location' | 'storage', usage?: PermissionUsage): Promise<RequestStatus> => {
|
||||
const id = permissionId === 'storage' ? 'album-read' : 'location';
|
||||
const allowed = await ensurePermission(id, usage || {
|
||||
feature: permissionId === 'storage' ? '选择图片' : '当前位置',
|
||||
purpose: permissionId === 'storage' ? '选择您主动上传的图片' : '提供附近用户和城市服务',
|
||||
});
|
||||
if (!allowed) return 'denied';
|
||||
return nativePlatform() === 'ios' ? iosStatus(id) : 'granted';
|
||||
};
|
||||
|
||||
/** 先选择真实来源,再申请实际需要的权限,视频拍摄同时检查麦克风。 */
|
||||
export const chooseMediaSource = async (sources: MediaSource[], mediaType: 'image' | 'video', usage: PermissionUsage): Promise<MediaSource | null> => {
|
||||
let source = sources[0];
|
||||
// H5 与小程序由其原生选择器展示资源来源,避免破坏浏览器文件选择的用户手势。
|
||||
if (!nativePlatform()) return source || 'album';
|
||||
if (sources.length > 1) {
|
||||
source = await new Promise<MediaSource>((resolve) => uni.showActionSheet({
|
||||
itemList: sources.map((item) => item === 'camera' ? (mediaType === 'video' ? '拍摄视频' : '拍照') : '从相册选择'),
|
||||
success: ({ tapIndex }) => resolve(sources[tapIndex]), fail: () => resolve(undefined as any),
|
||||
}));
|
||||
}
|
||||
if (!source || !await ensurePermission(source === 'camera' ? 'camera' : 'album-read', { ...usage, mediaType })) return null;
|
||||
if (source === 'camera' && mediaType === 'video' && !await ensurePermission('microphone', {
|
||||
feature: usage.feature, purpose: '录制您拍摄视频中的声音',
|
||||
})) return null;
|
||||
return source;
|
||||
};
|
||||
/** 准备媒体选择参数,浏览器和小程序保留原生来源选择及其用户手势。 */
|
||||
export const prepareMediaSelection = async (sources: MediaSource[], mediaType: 'image' | 'video', usage: PermissionUsage): Promise<MediaSource[] | null> => {
|
||||
if (!nativePlatform()) return sources;
|
||||
const source = await chooseMediaSource(sources, mediaType, usage);
|
||||
return source ? [source] : null;
|
||||
};
|
||||
/** 查询联网状态,网络失败时交由具体业务显示反馈。 */
|
||||
export const getNetworkStatus = (): Promise<{ connected: boolean; networkType: string }> => new Promise((resolve) =>
|
||||
uni.getNetworkType({ success: ({ networkType }) => resolve({ connected: networkType !== 'none', networkType }),
|
||||
fail: () => resolve({ connected: false, networkType: 'unknown' }) }));
|
||||
/** Android 8+ 未知来源安装是设置权限,不能通过 requestPermissions 申请。 */
|
||||
export const canInstallApk = (): boolean => {
|
||||
if (nativePlatform() !== 'android') return false;
|
||||
try {
|
||||
const bridge = nativeBridge();
|
||||
const Version = bridge.android.importClass('android.os.Build$VERSION');
|
||||
if (Number(Version.SDK_INT) < 26) return true;
|
||||
const activity = bridge.android.runtimeMainActivity();
|
||||
bridge.android.importClass(activity);
|
||||
const manager = activity.getPackageManager();
|
||||
bridge.android.importClass(manager);
|
||||
return Boolean(manager.canRequestPackageInstalls());
|
||||
} catch { return false; }
|
||||
};
|
||||
/** 按需引导允许安装当前应用下载的 APK,返回前台后由更新服务复查并续装。 */
|
||||
export const ensureAndroidInstallPermission = async (): Promise<boolean> => {
|
||||
if (canInstallApk()) return true;
|
||||
if (nativePlatform() !== 'android') return false;
|
||||
if (!await confirm('允许安装应用更新', '安装新版本需要允许本应用安装未知来源应用。请开启后返回,继续安装已下载的更新包。', '去设置')) return false;
|
||||
try {
|
||||
const bridge = nativeBridge();
|
||||
const Intent = bridge.android.importClass('android.content.Intent');
|
||||
const Uri = bridge.android.importClass('android.net.Uri');
|
||||
const activity = bridge.android.runtimeMainActivity();
|
||||
bridge.android.importClass(activity);
|
||||
const intent = new Intent('android.settings.MANAGE_UNKNOWN_APP_SOURCES');
|
||||
intent.setData(Uri.parse(`package:${activity.getPackageName()}`));
|
||||
activity.startActivity(intent);
|
||||
} catch { await openAppPermissionSettings(); }
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
# 极光推送原生依赖
|
||||
|
||||
来源 https://github.com/jiguang-official/jpush-uniapp-plugin
|
||||
|
||||
版本 1.3.2,固定提交 `16a5132c69bb035d818989e6656bfa77e922aee4`。
|
||||
|
||||
本项目保留 Android、iOS 实现、接口与所需原生资源。iOS 静态库跟随官方提交,Android 通过 Maven 引入 JPush 6.3.0。
|
||||
|
||||
本地适配将 APNs entitlement 设为与会员服务默认配置一致的 production,iOS 最低版本设为官方兼容范围 iOS 12,并增加 `clearNotificationAll` 用于退出时清理通知。开发证书联调需同时调整此 entitlement、`jpush-config.js` 和服务端 APNs 环境。
|
||||
|
||||
Android 通知点击在官方事件分发后通过系统启动 Intent 拉起应用,复用已有应用任务。JS 尚未初始化时沿用官方事件队列暂存点击,待 `setEventCallBack` 后回放。
|
||||
|
||||
原生应用配置放在与 package.json 同级的 AndroidManifest.xml、Info.plist 和 nativeResources 目录,供 HBuilderX 云打包直接收集。Android AppKey 和渠道在 nativeResources/android/manifestPlaceholders.json 配置,与 src/common/jpush-config.js 保持一致。
|
||||
|
||||
当前 UniApp Android 包名为 `com.zax.htys`,云打包时使用同一包名。极光应用需绑定此包名。
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"id": "jg-jpush-u",
|
||||
"displayName": "jg-jpush-u",
|
||||
"version": "1.3.2",
|
||||
"description": "jg-jpush-u",
|
||||
"keywords": [
|
||||
"jg-jpush-u"
|
||||
],
|
||||
"repository": "https://github.com/jiguang-official/jpush-uniapp-plugin",
|
||||
"engines": {
|
||||
"HBuilderX": "^4.25",
|
||||
"uni-app": "^4.75",
|
||||
"uni-app-x": "^4.75"
|
||||
},
|
||||
"dcloudext": {
|
||||
"type": "uts",
|
||||
"sale": {
|
||||
"regular": {
|
||||
"price": "0.00"
|
||||
},
|
||||
"sourcecode": {
|
||||
"price": "0.00"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"qq": ""
|
||||
},
|
||||
"declaration": {
|
||||
"ads": "无",
|
||||
"data": "说明文档地址:\nhttps://docs.jiguang.cn/jpush/client/jghgzy_a_i_h#sdk-申请系统权限的说明",
|
||||
"permissions": "说明文档地址:\nhttps://docs.jiguang.cn/jpush/client/jghgzy_a_i_h#sdk-申请系统权限的说明"
|
||||
},
|
||||
"npmurl": "",
|
||||
"darkmode": "x",
|
||||
"i18n": "x",
|
||||
"widescreen": "x"
|
||||
},
|
||||
"uni_modules": {
|
||||
"dependencies": [],
|
||||
"encrypt": [],
|
||||
"platforms": {
|
||||
"cloud": {
|
||||
"tcb": "√",
|
||||
"aliyun": "√",
|
||||
"alipay": "√"
|
||||
},
|
||||
"client": {
|
||||
"uni-app": {
|
||||
"vue": {
|
||||
"vue2": "-",
|
||||
"vue3": "-"
|
||||
},
|
||||
"web": {
|
||||
"safari": "-",
|
||||
"chrome": "-"
|
||||
},
|
||||
"app": {
|
||||
"vue": "-",
|
||||
"nvue": "-",
|
||||
"android": {
|
||||
"extVersion": "",
|
||||
"minVersion": "21"
|
||||
},
|
||||
"ios": {
|
||||
"extVersion": "",
|
||||
"minVersion": "12"
|
||||
},
|
||||
"harmony": {
|
||||
"extVersion": "",
|
||||
"minVersion": "12"
|
||||
}
|
||||
},
|
||||
"mp": {
|
||||
"weixin": "-",
|
||||
"alipay": "-",
|
||||
"toutiao": "-",
|
||||
"baidu": "-",
|
||||
"kuaishou": "-",
|
||||
"jd": "-",
|
||||
"harmony": "-",
|
||||
"qq": "-",
|
||||
"lark": "-",
|
||||
"xhs": "-"
|
||||
},
|
||||
"quickapp": {
|
||||
"huawei": "-",
|
||||
"union": "-"
|
||||
}
|
||||
},
|
||||
"uni-app-x": {
|
||||
"web": {
|
||||
"safari": "-",
|
||||
"chrome": "-"
|
||||
},
|
||||
"app": {
|
||||
"android": {
|
||||
"extVersion": "",
|
||||
"minVersion": "21"
|
||||
},
|
||||
"ios": {
|
||||
"extVersion": "",
|
||||
"minVersion": "12"
|
||||
},
|
||||
"harmony": {
|
||||
"extVersion": "",
|
||||
"minVersion": "12"
|
||||
}
|
||||
},
|
||||
"mp": {
|
||||
"weixin": "-"
|
||||
}
|
||||
},
|
||||
"App": {
|
||||
"app-android": "u",
|
||||
"app-harmony": "u",
|
||||
"app-ios": "u"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="cn.jiguang.uni_modules.jpush_u">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application>
|
||||
|
||||
<service
|
||||
android:name="uts.sdk.modules.jgJpushU.PushService"
|
||||
android:enabled="true"
|
||||
android:stopWithTask="true"
|
||||
android:exported="false"
|
||||
android:process=":pushcore">
|
||||
<intent-filter>
|
||||
<action android:name="cn.jiguang.user.service.action" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="uts.sdk.modules.jgJpushU.PushMessageReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="cn.jpush.android.intent.RECEIVER_MESSAGE" />
|
||||
|
||||
<category android:name="${applicationId}" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* PushMessageReceiver.uts
|
||||
* 极光推送消息接收器,继承自JPushMessageReceiver
|
||||
*/
|
||||
|
||||
import Context from 'android.content.Context';
|
||||
import Intent from 'android.content.Intent';
|
||||
import Log from 'android.util.Log';
|
||||
import Bundle from 'android.os.Bundle';
|
||||
import ArrayList from 'java.util.ArrayList';
|
||||
|
||||
import CmdMessage from 'cn.jpush.android.api.CmdMessage';
|
||||
import CustomMessage from 'cn.jpush.android.api.CustomMessage';
|
||||
import JPushMessage from 'cn.jpush.android.api.JPushMessage';
|
||||
import NotificationMessage from 'cn.jpush.android.api.NotificationMessage';
|
||||
import VoipDataMessage from 'cn.jpush.android.api.VoipDataMessage';
|
||||
import JPushMessageReceiver from 'cn.jpush.android.service.JPushMessageReceiver';
|
||||
import JSONObject from 'org.json.JSONObject';
|
||||
|
||||
// 导入事件回调管理器
|
||||
import { eventCallbackManager } from './index.uts';
|
||||
// 导入 EventCallBack 类型
|
||||
import type { EventCallBack } from '../interface.uts';
|
||||
|
||||
/**
|
||||
* 推送消息接收器类
|
||||
* 继承自极光推送的JPushMessageReceiver
|
||||
*/
|
||||
export class PushMessageReceiver extends JPushMessageReceiver {
|
||||
private static readonly TAG: string = "U-PushMessageReceiver";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
Log.e(PushMessageReceiver.TAG, "PushMessageReceiver constructor called");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将JPushMessage转换为JSON字符串
|
||||
*/
|
||||
private convertJPushMessageToJson(jPushMessage: JPushMessage): string {
|
||||
try {
|
||||
const jsonObject = new JSONObject();
|
||||
jsonObject.put("alias", jPushMessage.getAlias());
|
||||
jsonObject.put("tags", jPushMessage.getTags());
|
||||
jsonObject.put("pros", jPushMessage.getPros());
|
||||
jsonObject.put("checkTag", jPushMessage.getCheckTag());
|
||||
jsonObject.put("errorCode", jPushMessage.getErrorCode());
|
||||
jsonObject.put("tagCheckStateResult", jPushMessage.getTagCheckStateResult());
|
||||
jsonObject.put("isTagCheckOperator", jPushMessage.isTagCheckOperator());
|
||||
jsonObject.put("sequence", jPushMessage.getSequence());
|
||||
jsonObject.put("mobileNumber", jPushMessage.getMobileNumber());
|
||||
jsonObject.put("protoType", jPushMessage.getProtoType());
|
||||
jsonObject.put("action", jPushMessage.getAction());
|
||||
return jsonObject.toString();
|
||||
} catch (e) {
|
||||
Log.e(PushMessageReceiver.TAG, "convertJPushMessageToJson error: " + e.toString());
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将CustomMessage转换为JSON字符串
|
||||
*/
|
||||
private convertCustomMessageToJson(customMessage: CustomMessage): string {
|
||||
try {
|
||||
const jsonObject = new JSONObject();
|
||||
jsonObject.put("messageId", customMessage.messageId);
|
||||
jsonObject.put("extra", customMessage.extra);
|
||||
jsonObject.put("message", customMessage.message);
|
||||
jsonObject.put("contentType", customMessage.contentType);
|
||||
jsonObject.put("title", customMessage.title);
|
||||
jsonObject.put("senderId", customMessage.senderId);
|
||||
jsonObject.put("appId", customMessage.appId);
|
||||
jsonObject.put("platform", customMessage.platform);
|
||||
return jsonObject.toString();
|
||||
} catch (e) {
|
||||
Log.e(PushMessageReceiver.TAG, "convertCustomMessageToJson error: " + e.toString());
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将NotificationMessage转换为JSON字符串
|
||||
*/
|
||||
private convertNotificationMessageToJson(message: NotificationMessage): string {
|
||||
try {
|
||||
const jsonObject = new JSONObject();
|
||||
jsonObject.put("appkey", message.appkey);
|
||||
jsonObject.put("msgId", message.msgId);
|
||||
jsonObject.put("notificationContent", message.notificationContent);
|
||||
jsonObject.put("notificationAlertType", message.notificationAlertType);
|
||||
jsonObject.put("notificationTitle", message.notificationTitle);
|
||||
jsonObject.put("notificationSmallIcon", message.notificationSmallIcon);
|
||||
jsonObject.put("notificationLargeIcon", message.notificationLargeIcon);
|
||||
jsonObject.put("notificationExtras", message.notificationExtras);
|
||||
jsonObject.put("notificationStyle", message.notificationStyle);
|
||||
jsonObject.put("notificationBuilderId", message.notificationBuilderId);
|
||||
jsonObject.put("notificationBigText", message.notificationBigText);
|
||||
jsonObject.put("notificationBigPicPath", message.notificationBigPicPath);
|
||||
jsonObject.put("notificationInbox", message.notificationInbox);
|
||||
jsonObject.put("notificationPriority", message.notificationPriority);
|
||||
jsonObject.put("notificationImportance", message.notificationImportance);
|
||||
jsonObject.put("notificationCategory", message.notificationCategory);
|
||||
jsonObject.put("notificationId", message.notificationId);
|
||||
jsonObject.put("developerArg0", message.developerArg0);
|
||||
jsonObject.put("platform", message.platform);
|
||||
jsonObject.put("appId", message.appId);
|
||||
jsonObject.put("notificationType", message.notificationType);
|
||||
jsonObject.put("notificationChannelId", message.notificationChannelId);
|
||||
jsonObject.put("displayForeground", message.displayForeground);
|
||||
jsonObject.put("_webPagePath", message._webPagePath);
|
||||
jsonObject.put("isRichPush", message.isRichPush);
|
||||
jsonObject.put("richType", message.richType);
|
||||
jsonObject.put("deeplink", message.deeplink);
|
||||
jsonObject.put("failedAction", message.failedAction);
|
||||
jsonObject.put("failedLink", message.failedLink);
|
||||
jsonObject.put("targetPkgName", message.targetPkgName);
|
||||
jsonObject.put("sspWxAppId", message.sspWxAppId);
|
||||
jsonObject.put("sspWmOriginId", message.sspWmOriginId);
|
||||
jsonObject.put("sspWmType", message.sspWmType);
|
||||
jsonObject.put("isWmDeepLink", message.isWmDeepLink);
|
||||
jsonObject.put("inAppMsgType", message.inAppMsgType);
|
||||
jsonObject.put("inAppMsgShowType", message.inAppMsgShowType);
|
||||
jsonObject.put("inAppMsgShowPos", message.inAppMsgShowPos);
|
||||
jsonObject.put("inAppMsgTitle", message.inAppMsgTitle);
|
||||
jsonObject.put("inAppMsgContentBody", message.inAppMsgContentBody);
|
||||
jsonObject.put("inAppType", message.inAppType);
|
||||
jsonObject.put("inAppShowTarget", message.inAppShowTarget);
|
||||
jsonObject.put("inAppClickAction", message.inAppClickAction);
|
||||
jsonObject.put("inAppExtras", message.inAppExtras);
|
||||
return jsonObject.toString();
|
||||
} catch (e) {
|
||||
Log.e(PushMessageReceiver.TAG, "convertNotificationMessageToJson error: " + e.toString());
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将CmdMessage转换为JSON字符串
|
||||
*/
|
||||
private convertCmdMessageToJson(cmdMessage: CmdMessage): string {
|
||||
try {
|
||||
const jsonObject = new JSONObject();
|
||||
jsonObject.put("cmd", cmdMessage.cmd);
|
||||
jsonObject.put("errorCode", cmdMessage.errorCode);
|
||||
jsonObject.put("msg", cmdMessage.msg);
|
||||
if (cmdMessage.cmd == 2013) {
|
||||
const typedExtra = SubscriptionResultCodec.fromBundle(cmdMessage.extra);
|
||||
jsonObject.put("extra", typedExtra);
|
||||
jsonObject.put("commandData", typedExtra);
|
||||
if (typedExtra.has("channels")) jsonObject.put("channels", typedExtra.get("channels"));
|
||||
} else if (cmdMessage.cmd == 2012) {
|
||||
// 小米订阅消息需要向 UTS 层透传结构化的 channel 操作结果
|
||||
jsonObject.put("extra", this.convertBundleToJson(cmdMessage.extra));
|
||||
} else {
|
||||
// 保持历史命令的 extra 序列化行为,避免改变既有回调语义
|
||||
jsonObject.put("extra", cmdMessage.extra);
|
||||
}
|
||||
return jsonObject.toString();
|
||||
} catch (e) {
|
||||
Log.e(PushMessageReceiver.TAG, "convertCmdMessageToJson error: " + e.toString());
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将Bundle转换为JSONObject
|
||||
*/
|
||||
private convertBundleToJson(bundle: Bundle | null): JSONObject {
|
||||
try {
|
||||
const jsonObject = new JSONObject();
|
||||
if (bundle != null) {
|
||||
// 使用 ArrayList 来收集 Bundle 的键,避免 iterator() 歧义
|
||||
const keySet = bundle.keySet();
|
||||
const keyList = new ArrayList<string>(keySet);
|
||||
// 在 Kotlin 中 size 是属性,不是方法,所以直接访问
|
||||
const size: Int = keyList.size;
|
||||
for (let i: Int = 0; i < size; i++) {
|
||||
const key = keyList.get(i);
|
||||
jsonObject.put(key, bundle.get(key));
|
||||
}
|
||||
}
|
||||
return jsonObject;
|
||||
} catch (e) {
|
||||
Log.e(PushMessageReceiver.TAG, "convertBundleToJson error: " + e.toString());
|
||||
return new JSONObject();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件回调的通用方法
|
||||
*/
|
||||
private triggerEventCallback(eventName: string, eventData: string): void {
|
||||
const event: EventCallBack = {
|
||||
eventName: eventName,
|
||||
eventData: eventData
|
||||
};
|
||||
eventCallbackManager.triggerCallBack(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到自定义消息
|
||||
*/
|
||||
override onMessage(context: Context, customMessage: CustomMessage): void {
|
||||
super.onMessage(context, customMessage);
|
||||
Log.e(PushMessageReceiver.TAG, "[onMessage] ");
|
||||
|
||||
// 触发自定义消息回调
|
||||
this.triggerEventCallback('onCustomMessage', this.convertCustomMessageToJson(customMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性操作结果回调
|
||||
*/
|
||||
override onPropertyOperatorResult(context: Context, jPushMessage: JPushMessage): void {
|
||||
super.onPropertyOperatorResult(context, jPushMessage);
|
||||
Log.e(PushMessageReceiver.TAG, "[onPropertyOperatorResult] ");
|
||||
|
||||
// 触发属性操作结果回调
|
||||
this.triggerEventCallback('onPropertyOperatorResult', this.convertJPushMessageToJson(jPushMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知点击回调
|
||||
*/
|
||||
override onNotifyMessageOpened(context: Context, message: NotificationMessage): void {
|
||||
Log.e(PushMessageReceiver.TAG, "[onNotifyMessageOpened] ");
|
||||
super.onNotifyMessageOpened(context, message);
|
||||
|
||||
// 触发通知点击回调
|
||||
this.triggerEventCallback('onClickMessage', this.convertNotificationMessageToJson(message));
|
||||
|
||||
// 官方接收器只分发事件。先缓存点击,再通过系统入口恢复现有任务或冷启动应用。
|
||||
const launchIntent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
|
||||
if (launchIntent != null) {
|
||||
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
|
||||
context.startActivity(launchIntent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知展示回调
|
||||
*/
|
||||
override onNotifyMessageArrived(context: Context, message: NotificationMessage): void {
|
||||
Log.e(PushMessageReceiver.TAG, "[onNotifyMessageArrived] ");
|
||||
super.onNotifyMessageArrived(context, message);
|
||||
|
||||
// 触发通知展示回调
|
||||
this.triggerEventCallback('onNotifyMessageArrived', this.convertNotificationMessageToJson(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知被清除回调
|
||||
*/
|
||||
override onNotifyMessageDismiss(context: Context, message: NotificationMessage): void {
|
||||
super.onNotifyMessageDismiss(context, message);
|
||||
Log.e(PushMessageReceiver.TAG, "[onNotifyMessageDismiss] ");
|
||||
|
||||
// 触发通知被清除回调
|
||||
this.triggerEventCallback('onNotifyMessageDismiss', this.convertNotificationMessageToJson(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册ID回调
|
||||
*/
|
||||
override onRegister(context: Context, registrationId: string): void {
|
||||
super.onRegister(context, registrationId);
|
||||
Log.e(PushMessageReceiver.TAG, "[onRegister] " + registrationId);
|
||||
|
||||
// 触发注册回调
|
||||
this.triggerEventCallback('onRegister', registrationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态回调
|
||||
*/
|
||||
override onConnected(context: Context, isConnected: boolean): void {
|
||||
super.onConnected(context, isConnected);
|
||||
Log.e(PushMessageReceiver.TAG, "[onConnected] " + isConnected);
|
||||
|
||||
// 触发连接状态回调
|
||||
this.triggerEventCallback('onConnected', isConnected.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 命令结果回调
|
||||
*/
|
||||
override onCommandResult(context: Context, cmdMessage: CmdMessage): void {
|
||||
super.onCommandResult(context, cmdMessage);
|
||||
Log.e(PushMessageReceiver.TAG, "[onCommandResult] ");
|
||||
|
||||
// 触发命令结果回调
|
||||
this.triggerEventCallback('onCommandResult', this.convertCmdMessageToJson(cmdMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签操作结果回调
|
||||
*/
|
||||
override onTagOperatorResult(context: Context, jPushMessage: JPushMessage): void {
|
||||
Log.e(PushMessageReceiver.TAG, "[onTagOperatorResult] ");
|
||||
// TagAliasOperatorHelper.getInstance().onTagOperatorResult(context, jPushMessage);
|
||||
super.onTagOperatorResult(context, jPushMessage);
|
||||
|
||||
// 触发标签操作结果回调
|
||||
this.triggerEventCallback('onTagOperatorResult', this.convertJPushMessageToJson(jPushMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查标签操作结果回调
|
||||
*/
|
||||
override onCheckTagOperatorResult(context: Context, jPushMessage: JPushMessage): void {
|
||||
Log.e(PushMessageReceiver.TAG, "[onCheckTagOperatorResult] ");
|
||||
// TagAliasOperatorHelper.getInstance().onCheckTagOperatorResult(context, jPushMessage);
|
||||
super.onCheckTagOperatorResult(context, jPushMessage);
|
||||
|
||||
// 触发检查标签操作结果回调
|
||||
this.triggerEventCallback('onTagOperatorResult', this.convertJPushMessageToJson(jPushMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 别名操作结果回调
|
||||
*/
|
||||
override onAliasOperatorResult(context: Context, jPushMessage: JPushMessage): void {
|
||||
Log.e(PushMessageReceiver.TAG, "[onAliasOperatorResult] ");
|
||||
// TagAliasOperatorHelper.getInstance().onAliasOperatorResult(context, jPushMessage);
|
||||
super.onAliasOperatorResult(context, jPushMessage);
|
||||
|
||||
// 触发别名操作结果回调
|
||||
this.triggerEventCallback('onAliasOperatorResult', this.convertJPushMessageToJson(jPushMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号操作结果回调
|
||||
*/
|
||||
override onMobileNumberOperatorResult(context: Context, jPushMessage: JPushMessage): void {
|
||||
super.onMobileNumberOperatorResult(context, jPushMessage);
|
||||
Log.e(PushMessageReceiver.TAG, "[onMobileNumberOperatorResult] ");
|
||||
// TagAliasOperatorHelper.getInstance().onMobileNumberOperatorResult(context, jPushMessage);
|
||||
|
||||
// 触发手机号操作结果回调
|
||||
this.triggerEventCallback('onMobileNumberOperatorResult', this.convertJPushMessageToJson(jPushMessage));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知设置检查回调
|
||||
*/
|
||||
override onNotificationSettingsCheck(context: Context, isOn: boolean, source: Int): void {
|
||||
super.onNotificationSettingsCheck(context, isOn, source);
|
||||
Log.e(PushMessageReceiver.TAG, "[onNotificationSettingsCheck] isOn:" + isOn + ",source:" + source);
|
||||
|
||||
// 触发通知设置检查回调
|
||||
try {
|
||||
const jsonObject = new JSONObject();
|
||||
jsonObject.put("isOn", isOn);
|
||||
jsonObject.put("source", source);
|
||||
this.triggerEventCallback('onNotificationSettingsCheck', jsonObject.toString());
|
||||
} catch (e) {
|
||||
Log.e(PushMessageReceiver.TAG, "onNotificationSettingsCheck JSON error: " + e.toString());
|
||||
this.triggerEventCallback('onNotificationSettingsCheck', '{"isOn":false,"source":0}');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要显示通知
|
||||
*/
|
||||
override isNeedShowNotification(context: Context, notificationMessage: NotificationMessage, processName: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知未显示回调
|
||||
*/
|
||||
override onNotifyMessageUnShow(context: Context, message: NotificationMessage): void {
|
||||
super.onNotifyMessageUnShow(context, message);
|
||||
Log.e(PushMessageReceiver.TAG, "[onNotifyMessageUnShow] message:");
|
||||
|
||||
// 触发通知未显示回调
|
||||
this.triggerEventCallback('onNotifyMessageUnShow', this.convertNotificationMessageToJson(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用内消息展示回调
|
||||
*/
|
||||
override onInAppMessageShow(context: Context, message: NotificationMessage): void {
|
||||
super.onInAppMessageShow(context, message);
|
||||
Log.e(PushMessageReceiver.TAG, "[onInAppMessageShow], ");
|
||||
|
||||
// 触发应用内消息展示回调
|
||||
this.triggerEventCallback('onInAppMessageShow', this.convertNotificationMessageToJson(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用内消息点击回调
|
||||
*/
|
||||
override onInAppMessageClick(context: Context, message: NotificationMessage): void {
|
||||
super.onInAppMessageClick(context, message);
|
||||
Log.e(PushMessageReceiver.TAG, "[onInAppMessageClick], ");
|
||||
|
||||
// 触发应用内消息点击回调
|
||||
this.triggerEventCallback('onInAppMessageClick', this.convertNotificationMessageToJson(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取地理围栏列表的回调
|
||||
*/
|
||||
override onGeofenceReceived(context: Context, geofences: string): void {
|
||||
super.onGeofenceReceived(context, geofences);
|
||||
Log.e(PushMessageReceiver.TAG, "onGeofenceReceived: " + geofences);
|
||||
|
||||
// 触发地理围栏列表回调
|
||||
this.triggerEventCallback('onGeofenceReceived', geofences);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多操作按钮点击回调
|
||||
*/
|
||||
override onMultiActionClicked(context: Context, intent: Intent): void {
|
||||
super.onMultiActionClicked(context, intent);
|
||||
Log.e(PushMessageReceiver.TAG, "[onMultiActionClicked] 用户点击了通知栏按钮");
|
||||
|
||||
// 触发多操作按钮点击回调
|
||||
try {
|
||||
const jsonObject = new JSONObject();
|
||||
jsonObject.put("action", intent.getAction());
|
||||
jsonObject.put("extras", this.convertBundleToJson(intent.getExtras()));
|
||||
this.triggerEventCallback('onMultiActionClicked', jsonObject.toString());
|
||||
} catch (e) {
|
||||
Log.e(PushMessageReceiver.TAG, "onMultiActionClicked JSON error: " + e.toString());
|
||||
this.triggerEventCallback('onMultiActionClicked', '{"action":"","extras":{}}');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发地理围栏回调
|
||||
*/
|
||||
override onGeofenceRegion(context: Context, geofence: string, longitude: Double, latitude: Double): void {
|
||||
super.onGeofenceRegion(context, geofence, longitude, latitude);
|
||||
Log.e(PushMessageReceiver.TAG, "onGeofenceRegion: " + geofence);
|
||||
|
||||
// 触发地理围栏回调
|
||||
try {
|
||||
const jsonObject = new JSONObject();
|
||||
jsonObject.put("geofence", geofence);
|
||||
jsonObject.put("longitude", longitude);
|
||||
jsonObject.put("latitude", latitude);
|
||||
this.triggerEventCallback('onGeofenceRegion', jsonObject.toString());
|
||||
} catch (e) {
|
||||
Log.e(PushMessageReceiver.TAG, "onGeofenceRegion JSON error: " + e.toString());
|
||||
this.triggerEventCallback('onGeofenceRegion', '{"geofence":"","longitude":0,"latitude":0}');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VOIP 透传消息回调(小米/荣耀/OPPO/VIVO 厂商通道)
|
||||
*/
|
||||
override onVoipMessage(context: Context, voipDataMessage: VoipDataMessage): void {
|
||||
super.onVoipMessage(context, voipDataMessage);
|
||||
Log.e(PushMessageReceiver.TAG, "[onVoipMessage] ");
|
||||
|
||||
try {
|
||||
const jsonObject = new JSONObject();
|
||||
jsonObject.put("messageId", voipDataMessage.getMessageId());
|
||||
jsonObject.put("extraData", voipDataMessage.getExtraData());
|
||||
jsonObject.put("platform", voipDataMessage.getPlatform());
|
||||
jsonObject.put("voipSupportStatus", voipDataMessage.getVoipSupportStatus());
|
||||
this.triggerEventCallback('onVoipMessage', jsonObject.toString());
|
||||
} catch (e) {
|
||||
Log.e(PushMessageReceiver.TAG, "onVoipMessage JSON error: " + e.toString());
|
||||
this.triggerEventCallback('onVoipMessage', '{"messageId":"","extraData":"","platform":0}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* PushService.uts
|
||||
* 极光推送服务类,继承自JCommonService
|
||||
*/
|
||||
|
||||
import JCommonService from 'cn.jpush.android.service.JCommonService';
|
||||
|
||||
/**
|
||||
* 推送服务类
|
||||
* 继承自极光推送的JCommonService
|
||||
*/
|
||||
export class PushService extends JCommonService {
|
||||
constructor() {
|
||||
super();
|
||||
console.log("U-PushService", "constructor called");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package uts.sdk.modules.jgJpushU;
|
||||
|
||||
import android.os.Bundle;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import java.lang.reflect.Array;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Preserve SDK types for cmd2013 only; legacy command payloads remain unchanged. */
|
||||
public final class SubscriptionResultCodec {
|
||||
private SubscriptionResultCodec() {}
|
||||
public static JSONObject fromBundle(Bundle bundle) throws JSONException { return bundle == null ? new JSONObject() : (JSONObject) encode(bundle, 0); }
|
||||
private static Object encode(Object value, int depth) throws JSONException {
|
||||
if (value == null || depth > 16) return JSONObject.NULL;
|
||||
if (value instanceof Bundle) {
|
||||
JSONObject result = new JSONObject();
|
||||
for (String key : ((Bundle) value).keySet()) {
|
||||
if (!key.startsWith("intent_")) result.put(key, encode(((Bundle) value).get(key), depth + 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
JSONObject result = new JSONObject();
|
||||
for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
|
||||
if (entry.getKey() instanceof String && !((String) entry.getKey()).startsWith("intent_"))
|
||||
result.put((String) entry.getKey(), encode(entry.getValue(), depth + 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (value instanceof List || value.getClass().isArray()) {
|
||||
JSONArray result = new JSONArray();
|
||||
int size = value instanceof List ? ((List<?>) value).size() : Array.getLength(value);
|
||||
for (int i = 0; i < size; i++) result.put(encode(value instanceof List ? ((List<?>) value).get(i) : Array.get(value, i), depth + 1));
|
||||
return result;
|
||||
}
|
||||
return value instanceof String || value instanceof Boolean || value instanceof Number ? value : JSONObject.NULL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"dependencies": [
|
||||
"cn.jiguang.sdk:jpush:6.3.0"
|
||||
],
|
||||
"minSdkVersion": "21",
|
||||
"project": {
|
||||
"repositories": [
|
||||
"google()",
|
||||
"mavenCentral()"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import JPushInterface from 'cn.jpush.android.api.JPushInterface'
|
||||
import JCoreInterface from 'cn.jiguang.api.JCoreInterface'
|
||||
import JPushConfig from 'cn.jpush.android.data.JPushConfig'
|
||||
import ArrayList from 'java.util.ArrayList'
|
||||
|
||||
export * from './PushMessageReceiver.uts'
|
||||
export { PushService } from "./PushService.uts"
|
||||
|
||||
|
||||
/* 引入 interface.uts 文件中定义的变量 */
|
||||
import { EventCallBackParams, EventCallBack } from '../interface.uts';
|
||||
|
||||
const TAG = "JPUSH-uni-"
|
||||
|
||||
// 事件回调管理类
|
||||
class EventCallbackManager {
|
||||
private callBack : EventCallBackParams | null = null
|
||||
private cachedEvents : Array<EventCallBack> = []
|
||||
|
||||
setEventCallBack(param : EventCallBackParams) : void {
|
||||
console.log(TAG, "setEventCallBack")
|
||||
this.callBack = param
|
||||
|
||||
// 如果有缓存的事件,立即回调
|
||||
if (this.cachedEvents.length > 0) {
|
||||
console.log(TAG, "处理缓存事件,数量:", this.cachedEvents.length)
|
||||
for (let i = 0; i < this.cachedEvents.length; i++) {
|
||||
this.triggerCallBack(this.cachedEvents[i])
|
||||
}
|
||||
this.cachedEvents = []
|
||||
}
|
||||
}
|
||||
|
||||
triggerCallBack(event : EventCallBack) : void {
|
||||
const callBack = this.callBack
|
||||
if (callBack !== null) {
|
||||
console.log(TAG, "触发回调")
|
||||
const callback = callBack.callback
|
||||
if (callback !== null) {
|
||||
callback(event)
|
||||
} else {
|
||||
console.log(TAG, "未设置回调函数")
|
||||
}
|
||||
} else {
|
||||
console.log(TAG, "回调未设置,缓存事件")
|
||||
this.cachedEvents.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
getCallBack() : EventCallBackParams | null {
|
||||
return this.callBack
|
||||
}
|
||||
|
||||
hasCallBack() : boolean {
|
||||
const callBack = this.callBack
|
||||
return callBack !== null && callBack.callback !== null
|
||||
}
|
||||
}
|
||||
|
||||
// 全局事件回调管理器实例
|
||||
export const eventCallbackManager = new EventCallbackManager()
|
||||
|
||||
export const init = function (appKey? : string) {
|
||||
JPushInterface.setNotificationCallBackEnable(UTSAndroid.getAppContext(),true)
|
||||
if (appKey !== null) {
|
||||
console.log(TAG, "init with explicit appKey")
|
||||
// 创建JPushConfig并设置appKey
|
||||
const config = new JPushConfig()
|
||||
config.setjAppKey(appKey)
|
||||
JPushInterface.init(UTSAndroid.getAppContext(), config)
|
||||
} else {
|
||||
console.log(TAG, "init without appKey")
|
||||
JPushInterface.init(UTSAndroid.getAppContext())
|
||||
}
|
||||
}
|
||||
|
||||
export const setDebug = function (debug : boolean) {
|
||||
console.log(TAG, "setDebug", debug)
|
||||
JPushInterface.setDebugMode(debug)
|
||||
}
|
||||
|
||||
export const resumePush = function () {
|
||||
console.log(TAG, "resumePush")
|
||||
JPushInterface.resumePush(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
export const stopPush = function () {
|
||||
console.log(TAG, "stopPush")
|
||||
JPushInterface.stopPush(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
export const isPushStopped = function () : boolean {
|
||||
console.log(TAG, "isPushStopped")
|
||||
return JPushInterface.isPushStopped(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
export const getPushStatus = function () {
|
||||
console.log(TAG, "getPushStatus")
|
||||
return JPushInterface.getPushStatus(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
|
||||
export const setChannel = function (channel : string) {
|
||||
console.log(TAG, "setChannel", channel)
|
||||
JPushInterface.setChannel(UTSAndroid.getAppContext(), channel)
|
||||
}
|
||||
|
||||
export const getRegistrationId = function () : string {
|
||||
console.log(TAG, "getRegistrationId")
|
||||
return JPushInterface.getRegistrationID(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
|
||||
export const setLatestNotificationNumber = function (maxNum : Int) {
|
||||
console.log(TAG, "setLatestNotificationNumber", maxNum)
|
||||
JPushInterface.setLatestNotificationNumber(UTSAndroid.getAppContext(), maxNum)
|
||||
}
|
||||
|
||||
export const clearNotificationAll = function () {
|
||||
console.log(TAG, "clearNotificationAll")
|
||||
JPushInterface.clearAllNotifications(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
export const clearNotificationById = function (notificationId : Int) {
|
||||
console.log(TAG, "clearNotificationById", notificationId)
|
||||
JPushInterface.clearNotificationById(UTSAndroid.getAppContext(), notificationId)
|
||||
}
|
||||
|
||||
export const setTags = function (sequence : Int, tags : string[]) {
|
||||
console.log(TAG, "setTags", sequence, tags)
|
||||
// 将string[]转换为Set<string>以适配Android SDK
|
||||
const tagSet = new Set<string>()
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
tagSet.add(tags[i])
|
||||
}
|
||||
JPushInterface.setTags(UTSAndroid.getAppContext(), sequence, tagSet)
|
||||
}
|
||||
|
||||
export const addTags = function (sequence : Int, tags : string[]) {
|
||||
console.log(TAG, "addTags", sequence, tags)
|
||||
// 将string[]转换为Set<string>以适配Android SDK
|
||||
const tagSet = new Set<string>()
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
tagSet.add(tags[i])
|
||||
}
|
||||
JPushInterface.addTags(UTSAndroid.getAppContext(), sequence, tagSet)
|
||||
}
|
||||
|
||||
export const deleteTags = function (sequence : Int, tags : string[]) {
|
||||
console.log(TAG, "deleteTags", sequence, tags)
|
||||
// 将string[]转换为Set<string>以适配Android SDK
|
||||
const tagSet = new Set<string>()
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
tagSet.add(tags[i])
|
||||
}
|
||||
JPushInterface.deleteTags(UTSAndroid.getAppContext(), sequence, tagSet)
|
||||
}
|
||||
|
||||
export const cleanTags = function (sequence : Int) {
|
||||
console.log(TAG, "cleanTags", sequence)
|
||||
JPushInterface.cleanTags(UTSAndroid.getAppContext(), sequence)
|
||||
}
|
||||
|
||||
export const getAllTags = function (sequence : Int) {
|
||||
console.log(TAG, "getAllTags", sequence)
|
||||
JPushInterface.getAllTags(UTSAndroid.getAppContext(), sequence)
|
||||
}
|
||||
|
||||
export const checkTagBindState = function (sequence : Int, tag : string) {
|
||||
console.log(TAG, "checkTagBindState", sequence, tag)
|
||||
JPushInterface.checkTagBindState(UTSAndroid.getAppContext(), sequence, tag)
|
||||
}
|
||||
|
||||
export const setAlias = function (sequence : Int, alias : string) {
|
||||
console.log(TAG, "setAlias", sequence, alias)
|
||||
JPushInterface.setAlias(UTSAndroid.getAppContext(), sequence, alias)
|
||||
}
|
||||
|
||||
export const deleteAlias = function (sequence : Int) {
|
||||
console.log(TAG, "deleteAlias", sequence)
|
||||
JPushInterface.deleteAlias(UTSAndroid.getAppContext(), sequence)
|
||||
}
|
||||
|
||||
export const getAlias = function (sequence : Int) {
|
||||
console.log(TAG, "getAlias", sequence)
|
||||
JPushInterface.getAlias(UTSAndroid.getAppContext(), sequence)
|
||||
}
|
||||
|
||||
export const setMobileNumber = function (sequence : Int, mobileNumber : string) {
|
||||
console.log(TAG, "setMobileNumber", sequence, mobileNumber)
|
||||
JPushInterface.setMobileNumber(UTSAndroid.getAppContext(), sequence, mobileNumber)
|
||||
}
|
||||
|
||||
export const onResume = function () {
|
||||
console.log(TAG, "onResume")
|
||||
JPushInterface.onResume(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
export const onPause = function () {
|
||||
console.log(TAG, "onPause")
|
||||
JPushInterface.onPause(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
export const onFragmentResume = function (fragmentName : string) {
|
||||
console.log(TAG, "onFragmentResume", fragmentName)
|
||||
JPushInterface.onFragmentResume(UTSAndroid.getAppContext(), fragmentName)
|
||||
}
|
||||
|
||||
export const onFragmentPause = function (fragmentName : string) {
|
||||
console.log(TAG, "onFragmentPause", fragmentName)
|
||||
JPushInterface.onFragmentPause(UTSAndroid.getAppContext(), fragmentName)
|
||||
}
|
||||
|
||||
export const onKillProcess = function () {
|
||||
console.log(TAG, "onKillProcess")
|
||||
JPushInterface.onKillProcess(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
export const requestPermission = function () {
|
||||
console.log(TAG, "requestPermission")
|
||||
const activity = UTSAndroid.getUniActivity()
|
||||
if (activity != null) {
|
||||
JPushInterface.requestPermission(activity)
|
||||
} else {
|
||||
console.log(TAG, "requestPermission: getUniActivity() is null, fallback to getAppContext()")
|
||||
JPushInterface.requestPermission(UTSAndroid.getAppContext())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请必须权限(仅 Android,如 POST_NOTIFICATIONS,Android 13+)
|
||||
* 建议在 Activity 可见时调用(如首屏 onReady),以便正确弹出系统权限框。
|
||||
*/
|
||||
export const requestRequiredPermission = function () {
|
||||
console.log(TAG, "requestRequiredPermission")
|
||||
const activity = UTSAndroid.getUniActivity()
|
||||
if (activity != null) {
|
||||
JPushInterface.requestRequiredPermission(activity)
|
||||
} else {
|
||||
console.log(TAG, "requestRequiredPermission: no Activity, skip (call when Activity available)")
|
||||
}
|
||||
}
|
||||
|
||||
export const isNotificationEnabled = function () : number {
|
||||
console.log(TAG, "isNotificationEnabled")
|
||||
return JPushInterface.isNotificationEnabled(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
export const goToAppNotificationSettings = function () {
|
||||
console.log(TAG, "goToAppNotificationSettings")
|
||||
JPushInterface.goToAppNotificationSettings(UTSAndroid.getAppContext())
|
||||
}
|
||||
|
||||
export const setBadgeNumber = function (curNum : Int) {
|
||||
console.log(TAG, "setBadgeNumber", curNum)
|
||||
JPushInterface.setBadgeNumber(UTSAndroid.getAppContext(), curNum)
|
||||
}
|
||||
|
||||
export const setSmartPushEnable = function (isEnable : boolean) {
|
||||
console.log(TAG, "setSmartPushEnable", isEnable)
|
||||
JPushInterface.setSmartPushEnable(UTSAndroid.getAppContext(), isEnable)
|
||||
}
|
||||
|
||||
export const setGeofenceEnable = function (isEnable : boolean) {
|
||||
console.log(TAG, "setGeofenceEnable", isEnable)
|
||||
JPushInterface.setGeofenceEnable(UTSAndroid.getAppContext(), isEnable)
|
||||
}
|
||||
|
||||
export const setDataInsightsEnable = function (isEnable : boolean) {
|
||||
console.log(TAG, "setDataInsightsEnable", isEnable)
|
||||
JPushInterface.setDataInsightsEnable(UTSAndroid.getAppContext(), isEnable)
|
||||
}
|
||||
|
||||
export const testCountryCode = function (code:string) {
|
||||
JCoreInterface.testCountryCode(UTSAndroid.getAppContext(), code)
|
||||
}
|
||||
|
||||
export const setCountryCode = function (code:string) {
|
||||
JCoreInterface.setCountryCode(UTSAndroid.getAppContext(), code)
|
||||
}
|
||||
|
||||
export const setKeepLongConnInBackground = function (keep : boolean) {
|
||||
console.log(TAG, "setKeepLongConnInBackground", keep)
|
||||
JPushInterface.setKeepLongConnInBackground(UTSAndroid.getAppContext(), keep)
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求订阅小米推送消息渠道。
|
||||
* 仅已集成并注册小米通道的小米设备支持,单次最多处理 3 个 channelId。
|
||||
* 调用结果通过 onCommandResult 回调返回,cmd 为 2012。
|
||||
*/
|
||||
export const requestSubscribeChannel = function (channelIds : string[]) {
|
||||
console.log(TAG, "requestSubscribeChannel", channelIds)
|
||||
const channels = new ArrayList<string>()
|
||||
for (let i = 0; i < channelIds.length; i++) {
|
||||
channels.add(channelIds[i])
|
||||
}
|
||||
JPushInterface.requestSubscribeChannel(UTSAndroid.getAppContext(), channels)
|
||||
}
|
||||
|
||||
@UTSJS.keepAlive
|
||||
export function setEventCallBack(param : EventCallBackParams) : void {
|
||||
eventCallbackManager.setEventCallBack(param)
|
||||
}
|
||||
|
||||
|
||||
// New APIs: each native SDK completion owns only a request ID, never a page callback.
|
||||
import JPushUPSManager from 'cn.jpush.android.ups.JPushUPSManager'
|
||||
import UPSTurnCallBack from 'cn.jpush.android.ups.UPSTurnCallBack'
|
||||
import TokenResult from 'cn.jpush.android.ups.TokenResult'
|
||||
import Handler from 'android.os.Handler'
|
||||
import Looper from 'android.os.Looper'
|
||||
import Runnable from 'java.lang.Runnable'
|
||||
import AtomicLong from 'java.util.concurrent.atomic.AtomicLong'
|
||||
import { PushServiceResult, LiveActivityResult, LiveActivityPushTokenParams, LiveActivityPushToStartTokenParams } from '../interface.uts'
|
||||
|
||||
type PendingPushReply = { generation: Long, resolve: (value: PushServiceResult) => void, reject: (error: Error) => void }
|
||||
const pendingPushReplies = new Map<Long, PendingPushReply>()
|
||||
const requestSequence = new AtomicLong(0)
|
||||
const upgradeGeneration = new AtomicLong(0)
|
||||
class MainUpgradeTask implements Runnable {
|
||||
private action: (() => void) | null
|
||||
constructor(action: () => void) { this.action = action }
|
||||
override run(): void { const action = this.action; this.action = null; action?.() }
|
||||
}
|
||||
function upgradeOnMain(action: () => void): void {
|
||||
new Handler(Looper.getMainLooper()).post(new MainUpgradeTask(action))
|
||||
}
|
||||
function finishPushReply(id: Long, value: PushServiceResult | null, code: string | null): void {
|
||||
upgradeOnMain(() => {
|
||||
const pending = pendingPushReplies.get(id)
|
||||
if (pending == null) return
|
||||
pendingPushReplies.delete(id)
|
||||
if (pending.generation != upgradeGeneration.get()) return
|
||||
if (code != null) pending.reject(new Error(code))
|
||||
else if (value != null) pending.resolve(value)
|
||||
})
|
||||
}
|
||||
class UpgradePushCallback implements UPSTurnCallBack {
|
||||
private request: Long
|
||||
private enabled: boolean
|
||||
constructor(request: Long, enabled: boolean) { this.request = request; this.enabled = enabled }
|
||||
override onResult(result: TokenResult | null): void {
|
||||
if (result == null) { finishPushReply(this.request, null, 'NATIVE_EXCEPTION'); return }
|
||||
const value: PushServiceResult = { code: Number.from(result.getReturnCode()) }
|
||||
if (this.enabled) value.dispatched = true
|
||||
finishPushReply(this.request, value, null)
|
||||
}
|
||||
}
|
||||
export function clearUpgradeCallbacks(): void {
|
||||
upgradeGeneration.incrementAndGet()
|
||||
upgradeOnMain(() => {
|
||||
const current = upgradeGeneration.get()
|
||||
const expired = new Array<Long>()
|
||||
pendingPushReplies.forEach((reply, id) => { if (reply.generation != current) expired.push(id) })
|
||||
for (const id of expired) { pendingPushReplies.delete(id) }
|
||||
})
|
||||
}
|
||||
UTSAndroid.onAppActivityDestroy(() => { clearUpgradeCallbacks() })
|
||||
function changePushService(enabled: boolean): Promise<PushServiceResult> {
|
||||
const generation = upgradeGeneration.get()
|
||||
return new Promise<PushServiceResult>((resolve, reject) => {
|
||||
upgradeOnMain(() => {
|
||||
if (generation != upgradeGeneration.get()) { reject(new Error('BRIDGE_INVALIDATED')); return }
|
||||
const context = UTSAndroid.getAppContext()
|
||||
if (context == null) { reject(new Error('NO_CONTEXT')); return }
|
||||
const id = requestSequence.incrementAndGet()
|
||||
pendingPushReplies.set(id, { generation: generation, resolve: resolve, reject: reject })
|
||||
try {
|
||||
const callback = new UpgradePushCallback(id, enabled)
|
||||
if (enabled) JPushUPSManager.turnOnPush(context, callback)
|
||||
else JPushUPSManager.turnOffPush(context, callback)
|
||||
} catch (error) { finishPushReply(id, null, 'NATIVE_EXCEPTION') }
|
||||
})
|
||||
})
|
||||
}
|
||||
export function turnOffPush(): Promise<PushServiceResult> { return changePushService(false) }
|
||||
export function turnOnPush(): Promise<PushServiceResult> { return changePushService(true) }
|
||||
export function requestSubscribeChannels(channels: string[]): Promise<void> {
|
||||
if (channels.length < 1 || channels.length > 3) return Promise.reject(new Error('INVALID_ARGUMENT'))
|
||||
const unique = new Set<string>()
|
||||
for (const value of channels) {
|
||||
if (value.trim().length == 0 || unique.has(value)) return Promise.reject(new Error('INVALID_ARGUMENT'))
|
||||
unique.add(value)
|
||||
}
|
||||
const generation = upgradeGeneration.get()
|
||||
return new Promise<void>((resolve,reject) => {
|
||||
upgradeOnMain(() => {
|
||||
if (generation != upgradeGeneration.get()) { reject(new Error('BRIDGE_INVALIDATED')); return }
|
||||
const activity = UTSAndroid.getUniActivity()
|
||||
if (activity == null || activity.isFinishing() || activity.isDestroyed() || !activity.hasWindowFocus()) {
|
||||
reject(new Error('NO_ACTIVITY')); return
|
||||
}
|
||||
const list = new ArrayList<string>()
|
||||
for (const value of channels) list.add(value)
|
||||
try { JPushInterface.requestSubscribeChannels(activity,list); resolve() }
|
||||
catch (error) { reject(new Error('NATIVE_EXCEPTION')) }
|
||||
})
|
||||
})
|
||||
}
|
||||
export function initCrashHandler(): Promise<void> {
|
||||
return new Promise<void>((resolve,reject) => { upgradeOnMain(() => {
|
||||
try { JPushInterface.initCrashHandler(UTSAndroid.getAppContext()); resolve() }
|
||||
catch (error) { reject(new Error('NATIVE_EXCEPTION')) }
|
||||
}) })
|
||||
}
|
||||
export function stopCrashHandler(): Promise<void> {
|
||||
return new Promise<void>((resolve,reject) => { upgradeOnMain(() => {
|
||||
try { JPushInterface.stopCrashHandler(UTSAndroid.getAppContext()); resolve() }
|
||||
catch (error) { reject(new Error('NATIVE_EXCEPTION')) }
|
||||
}) })
|
||||
}
|
||||
export function getRegistrationIdAsync(): Promise<string> { return Promise.resolve(getRegistrationId()) }
|
||||
export function isPushStoppedAsync(): Promise<boolean> { return Promise.resolve(isPushStopped()) }
|
||||
export function setLiveActivityTokenReportEnabled(enable: boolean): Promise<void> { return new Promise<void>((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) }
|
||||
export function registerLiveActivityPushToken(params: LiveActivityPushTokenParams): Promise<LiveActivityResult> { return new Promise<LiveActivityResult>((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) }
|
||||
export function registerLiveActivityPushToStartToken(params: LiveActivityPushToStartTokenParams): Promise<LiveActivityResult> { return new Promise<LiveActivityResult>((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) }
|
||||
|
||||
export function initAsync(): Promise<void> { return new Promise<void>((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) }
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
public class JPushUpgradeCodec {
|
||||
private static let lifecycleLock = NSLock()
|
||||
private static var lifecycle: Int64 = 0
|
||||
public static func generation() -> Int64 { lifecycleLock.lock(); defer { lifecycleLock.unlock() }; return lifecycle }
|
||||
public static func invalidate() { lifecycleLock.lock(); lifecycle += 1; lifecycleLock.unlock() }
|
||||
public static func validIdentifier(_ value: String) -> Bool { !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
public static func validSequence(_ number: NSNumber) -> Bool { let value = number.doubleValue; return value.isFinite && value.rounded(.towardZero) == value && value >= Double(Int32.min) && value <= Double(Int32.max) }
|
||||
public static func validToken(_ value: String?) -> Bool {
|
||||
guard let value = value else { return true }
|
||||
return !value.isEmpty && value.utf8.count % 2 == 0 && value.utf8.allSatisfy { (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }
|
||||
}
|
||||
public static func decode(_ value: String?) -> Data? {
|
||||
guard let value = value, validToken(value) else { return nil }
|
||||
let bytes = Array(value.utf8)
|
||||
func nibble(_ b: UInt8) -> UInt8 { b <= 57 ? b - 48 : b <= 70 ? b - 55 : b - 87 }
|
||||
return Data(stride(from: 0, to: bytes.count, by: 2).map { nibble(bytes[$0]) * 16 + nibble(bytes[$0 + 1]) })
|
||||
}
|
||||
public static func encode(_ value: Data?) -> String? { value.map { $0.map { String(format: "%02x", $0) }.joined() } }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// JGCOREService.h
|
||||
// JCore
|
||||
//
|
||||
// Created by Shuni Huang on 2024/3/19.
|
||||
// Copyright © 2024 jiguang. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface JGCOREAPI : NSObject
|
||||
|
||||
+ (void)ci:(BOOL)enable;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// JGInforCollectionAuth.h
|
||||
// JCore
|
||||
//
|
||||
// Created by 豆瓣 on 2021/10/27.
|
||||
// Copyright © 2021 jiguang. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 合规接口,是否授权极光采集一定的设备信息
|
||||
@interface JGInforCollectionAuthItems : NSObject
|
||||
/// 是否授权,默认YES
|
||||
@property(nonatomic,assign)BOOL isAuth;
|
||||
@end
|
||||
|
||||
@interface JGInforCollectionAuth : NSObject
|
||||
|
||||
/// 设备信息采集授权接口(合规接口)
|
||||
/// 请务必在调用初始化、功能性接口前调用此接口进行合规授权
|
||||
/// @param authBlock auth:YES 则极光认为您同意极光采集一定的设备信息
|
||||
+(void)JCollectionAuth:(void(^_Nullable)(JGInforCollectionAuthItems *authInfo))authBlock;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
Binary file not shown.
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeCrashData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeDeviceID</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePreciseLocation</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"frameworks": [
|
||||
"UIKit.framework",
|
||||
"CFNetwork.framework",
|
||||
"CoreFoundation.framework",
|
||||
"CoreTelephony.framework",
|
||||
"SystemConfiguration.framework",
|
||||
"CoreGraphics.framework",
|
||||
"Foundation.framework",
|
||||
"Security.framework",
|
||||
"UserNotifications.framework",
|
||||
"AdSupport.framework",
|
||||
"WebKit.framework",
|
||||
"AppTrackingTransparency.framework",
|
||||
"StoreKit.framework",
|
||||
"libz.tbd",
|
||||
"libresolv.tbd"
|
||||
],
|
||||
"deploymentTarget": "12.0"
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
import { Data } from 'Foundation';
|
||||
import { Notification } from 'Foundation';
|
||||
import { NSNotification } from 'Foundation';
|
||||
|
||||
import { Dictionary, Set } from 'Swift';
|
||||
import { NSError } from 'Foundation';
|
||||
import { NSSet } from 'Foundation';
|
||||
import { UIDevice, UIApplication } from 'UIKit';
|
||||
import { ATTrackingManager, ATTrackingManagerAuthorizationStatus } from 'AppTrackingTransparency';
|
||||
import { ASIdentifierManager } from 'AdSupport';
|
||||
import { NotificationCenter } from 'Foundation';
|
||||
import { Selector, NSString } from 'Foundation';
|
||||
import { UNUserNotificationCenter } from 'UserNotifications';
|
||||
|
||||
/* 引入 interface.uts 文件中定义的变量 */
|
||||
import { EventCallBackParams, EventCallBack } from '../interface.uts';
|
||||
|
||||
const TAG = "| JIGUANG | JPUSH-uni-"
|
||||
|
||||
// 统一日志方法
|
||||
function log(message : string, ...args : any[]) : void {
|
||||
// 这里拼接args
|
||||
let fullMessage = message
|
||||
if (args != null && args.length > 0) {
|
||||
for (let arg of args) {
|
||||
fullMessage += " " + JSON.stringify(arg)!
|
||||
}
|
||||
}
|
||||
// 可以根据需要选择使用console.log或iOS的log
|
||||
console.log(TAG, fullMessage)
|
||||
// 如果需要使用iOS的log,可以取消注释下面的代码
|
||||
// 构建完整的消息字符串
|
||||
NSLog("%@ %@ ", TAG, fullMessage)
|
||||
}
|
||||
|
||||
// 事件回调管理类
|
||||
class EventCallbackManager {
|
||||
private callBack : EventCallBackParams | null = null
|
||||
private cachedEvents : Array<EventCallBack> = []
|
||||
|
||||
setEventCallBack(param : EventCallBackParams) : void {
|
||||
log("setEventCallBack")
|
||||
this.callBack = param
|
||||
|
||||
// 如果有缓存的事件,立即回调
|
||||
if (this.cachedEvents.length > 0) {
|
||||
log("处理缓存事件,数量:", this.cachedEvents.length)
|
||||
for (let event of this.cachedEvents) {
|
||||
this.triggerCallBack(event)
|
||||
}
|
||||
this.cachedEvents = []
|
||||
}
|
||||
}
|
||||
|
||||
triggerCallBack(event : EventCallBack) : void {
|
||||
if (this.callBack !== null) {
|
||||
log("触发回调")
|
||||
if (this.callBack!.callback !== null) {
|
||||
this.callBack!.callback!(event)
|
||||
} else {
|
||||
log("未设置回调函数,事件内容:", event)
|
||||
}
|
||||
} else {
|
||||
log("回调未设置,缓存事件")
|
||||
this.cachedEvents.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理回调的方法
|
||||
* @param eventName 事件名称
|
||||
* @param eventData 事件数据(JSON字符串)
|
||||
*/
|
||||
triggerEvent(eventName : string, eventData : string) : void {
|
||||
const callback : EventCallBack = {
|
||||
eventName: eventName,
|
||||
eventData: eventData
|
||||
}
|
||||
this.triggerCallBack(callback)
|
||||
}
|
||||
|
||||
getCallBack() : EventCallBackParams | null {
|
||||
return this.callBack
|
||||
}
|
||||
|
||||
hasCallBack() : boolean {
|
||||
return this.callBack !== null && this.callBack!.callback !== null
|
||||
}
|
||||
}
|
||||
|
||||
// 全局事件回调管理器实例
|
||||
const eventCallbackManager = new EventCallbackManager()
|
||||
|
||||
export type InitPushParams = {
|
||||
appkey : string,
|
||||
channel : string,
|
||||
isProduction : boolean,
|
||||
advertisingId : string
|
||||
}
|
||||
/**
|
||||
* iOS UTS插件实现 - 极光推送
|
||||
* 对应AppDelegate.m的功能
|
||||
*/
|
||||
export class JGPushIOSPlugin implements UTSiOSHookProxy {
|
||||
|
||||
// MARK: - 应用程序生命周期回调
|
||||
|
||||
/**
|
||||
* UTS插件创建时的回调
|
||||
*/
|
||||
onCreate() : void {
|
||||
log("JGPushIOSPlugin onCreate")
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用正常启动时的回调
|
||||
* 极光推送初始化
|
||||
*/
|
||||
applicationDidFinishLaunchingWithOptions(application : UIApplication | null, launchOptions : Map<UIApplication.LaunchOptionsKey, any> | null = null) : boolean {
|
||||
log("JGPushIOSPlugin applicationDidFinishLaunchingWithOptions")
|
||||
// 保存启动选项供后续使用
|
||||
JGPushTool.launchOptions = launchOptions
|
||||
|
||||
// 注册方法在DidFinishLaunching方法写一遍,否则点击通知冷启动app不会走系统的通知点击回调反复。
|
||||
const entity = JPUSHRegisterEntity()
|
||||
entity.types = 0
|
||||
JPUSHService.register(forRemoteNotificationConfig = entity, delegate = JGPushTool)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程通知注册成功时的回调
|
||||
* 极光推送:将deviceToken发送给服务端
|
||||
*/
|
||||
didRegisterForRemoteNotifications(deviceToken : Data | null) {
|
||||
log("JGPushIOSPlugin didRegisterForRemoteNotifications")
|
||||
JGPushTool.registerDeviceToken(deviceToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程通知注册失败时的回调
|
||||
*/
|
||||
didFailToRegisterForRemoteNotifications(error : NSError | null) {
|
||||
log("JGPushIOSPlugin didFailToRegisterForRemoteNotifications", error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到远程通知时的回调
|
||||
*/
|
||||
didReceiveRemoteNotification(userInfo : Map<AnyHashable, any> | null) {
|
||||
log("JGPushIOSPlugin didReceiveRemoteNotification", userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 极光推送模块实现
|
||||
*/
|
||||
class JGPushModule implements JPUSHRegisterDelegate {
|
||||
|
||||
launchOptions : Map<UIApplication.LaunchOptionsKey, any> | null = null
|
||||
|
||||
/**
|
||||
* 初始化极光推送
|
||||
*/
|
||||
initPush(param : InitPushParams) {
|
||||
log("JGPushModule initPush", param)
|
||||
|
||||
const method = Selector("didReceiveCustomMessage:")
|
||||
NotificationCenter.default.addObserver(this, selector = method, name = NSNotification.Name.jpfNetworkDidReceiveMessage, object = null)
|
||||
// NotificationCenter.default.addObserver(this, selector=method, name=NSNotification.Name("kJPUSHNetworkDidReceiveMessageNotification"), object=null)
|
||||
|
||||
const method1 = Selector("networkDidLogin:")
|
||||
NotificationCenter.default.addObserver(this, selector = method1, name = NSNotification.Name.jpfNetworkDidLogin, object = null)
|
||||
// NotificationCenter.default.addObserver(this, selector=method1, name=NSNotification.Name("kJPFNetworkDidLoginNotification"), object=null)
|
||||
|
||||
const method2 = Selector("networkDidClose:")
|
||||
NotificationCenter.default.addObserver(this, selector = method2, name = NSNotification.Name.jpfNetworkDidClose, object = null)
|
||||
|
||||
// 初始化极光推送服务
|
||||
JPUSHService.setup(withOption = this.launchOptions, appKey = param.appkey, channel = param.channel, apsForProduction = param.isProduction, advertisingIdentifier = param.advertisingId)
|
||||
|
||||
// 创建推送注册实体
|
||||
const entity = JPUSHRegisterEntity()
|
||||
const types = new JPAuthorizationOptions(rawValue = (JPAuthorizationOptions.alert.rawValue |
|
||||
JPAuthorizationOptions.sound.rawValue |
|
||||
JPAuthorizationOptions.badge.rawValue))
|
||||
entity.types = Int(types.rawValue)
|
||||
// 注册远程通知配置
|
||||
JPUSHService.register(forRemoteNotificationConfig = entity, delegate = this)
|
||||
|
||||
log("极光推送SDK初始化完成")
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册设备令牌
|
||||
*/
|
||||
registerDeviceToken(token : Data | null) {
|
||||
log("JGPushModule registerDeviceToken", token)
|
||||
JPUSHService.registerDeviceToken(token!)
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到自定义消息回调
|
||||
*/
|
||||
@objc didReceiveCustomMessage(notification : Notification) {
|
||||
log("JGPushModule didReceiveCustomMessage", notification)
|
||||
const userInfo = notification.userInfo
|
||||
|
||||
if (userInfo !== null) {
|
||||
const jsonString = JSON.stringify(userInfo! as Dictionary<String, Any>)
|
||||
if (jsonString !== null) {
|
||||
eventCallbackManager.triggerEvent("onCustomMessage", jsonString!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络连接成功回调
|
||||
*/
|
||||
@objc networkDidLogin(notification : Notification) {
|
||||
log("JGPushModule networkDidLogin", notification)
|
||||
|
||||
eventCallbackManager.triggerEvent("onConnected", "true")
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络连接关闭回调
|
||||
*/
|
||||
@objc networkDidClose(notification : Notification) {
|
||||
log("JGPushModule networkDidClose", notification)
|
||||
|
||||
eventCallbackManager.triggerEvent("onConnected", "false")
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS 10 Support - 应用在前台时收到通知
|
||||
*/
|
||||
jpushNotificationCenter(center : UNUserNotificationCenter, @argumentLabel("willPresent") notification : UNNotification, @argumentLabel("withCompletionHandler") completionHandler : (code : Int) => void) : void {
|
||||
log("JGPushModule jpushNotificationCenter willPresent")
|
||||
// Required
|
||||
const userInfo = notification.request.content.userInfo
|
||||
if ((notification.request.trigger?.isKind(of = UNPushNotificationTrigger.self)) != null) {
|
||||
JPUSHService.handleRemoteNotification(userInfo)
|
||||
}
|
||||
|
||||
const u = userInfo as Dictionary<String, Any>;
|
||||
const jsonString = JSON.stringify(u)
|
||||
eventCallbackManager.triggerEvent("onNotifyMessageArrived", jsonString!)
|
||||
|
||||
completionHandler(Int(UNNotificationPresentationOptions.alert.rawValue) | Int(UNNotificationPresentationOptions.sound.rawValue) | Int(UNNotificationPresentationOptions.badge.rawValue))
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS 10 Support - 用户点击通知
|
||||
*/
|
||||
jpushNotificationCenter(center : UNUserNotificationCenter, @argumentLabel("didReceive") response : UNNotificationResponse, @argumentLabel("withCompletionHandler") completionHandler : () => void) : void {
|
||||
log("JGPushModule jpushNotificationCenter didReceive")
|
||||
// Required
|
||||
const userInfo = response.notification.request.content.userInfo
|
||||
if ((response.notification.request.trigger?.isKind(of = UNPushNotificationTrigger.self)) != null) {
|
||||
JPUSHService.handleRemoteNotification(userInfo)
|
||||
|
||||
}
|
||||
|
||||
const u = userInfo as Dictionary<String, Any>;
|
||||
const jsonString = JSON.stringify(u)
|
||||
eventCallbackManager.triggerEvent("onClickMessage", jsonString!)
|
||||
completionHandler() // 系统要求执行这个方法
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS 12 Support - 用户从通知设置界面进入应用
|
||||
*/
|
||||
jpushNotificationCenter(center : UNUserNotificationCenter, @argumentLabel("openSettingsFor") notification : UNNotification) : void {
|
||||
log("JGPushModule jpushNotificationCenter openSettingsFor")
|
||||
if (notification !== null && (notification.request.trigger?.isKind(of = UNPushNotificationTrigger.self)) != null) {
|
||||
// 从通知界面直接进入应用
|
||||
log("从通知界面直接进入应用")
|
||||
} else {
|
||||
// 从通知设置界面进入应用
|
||||
log("从通知设置界面进入应用")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知权限授权回调
|
||||
*/
|
||||
jpushNotificationAuthorization(status : JPAuthorizationStatus, @argumentLabel("withInfo") info : Map<AnyHashable, any> | null) : void {
|
||||
log("JGPushModule jpushNotificationAuthorization", status)
|
||||
// 处理通知权限状态变化
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 全局极光推送工具实例
|
||||
const JGPushTool : JGPushModule = new JGPushModule()
|
||||
|
||||
// MARK: - 导出函数
|
||||
|
||||
|
||||
/**
|
||||
* 添加事件回调
|
||||
*/
|
||||
@UTSJS.keepAlive
|
||||
export function setEventCallBack(param : EventCallBackParams) : void {
|
||||
log("setEventCallBack", param)
|
||||
eventCallbackManager.setEventCallBack(param)
|
||||
}
|
||||
|
||||
// MARK: - 极光推送API方法
|
||||
|
||||
export function initPush(param : InitPushParams) : void {
|
||||
log("initPush", param);
|
||||
JGPushTool.initPush(param);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置调试模式
|
||||
*/
|
||||
export function setDebug(debug : boolean) : void {
|
||||
log("setDebug", debug)
|
||||
if (debug) {
|
||||
JPUSHService.setDebugMode()
|
||||
} else {
|
||||
JPUSHService.setLogOFF()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置进入后台是否允许长连接
|
||||
*/
|
||||
export function setBackgroundEnable(isEnable : boolean) : void {
|
||||
log("setBackgroundEnable:", isEnable)
|
||||
if (isEnable) {
|
||||
JPUSHService.setBackgroundEnable(true)
|
||||
} else {
|
||||
JPUSHService.setBackgroundEnable(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推送是否被停止的状态(iOS 6.0.0+)
|
||||
* @param callback (code, isStopped) code 为 0 表示成功,isStopped 为 true 表示推送已停止。code 为0 时,isStopped的值才有效。
|
||||
*/
|
||||
export function getPushStatus(callback : (code : number, isStopped : boolean) => void) : void {
|
||||
log("getPushStatus")
|
||||
const completion = (iResCode : Int, isStopped : Boolean) => {
|
||||
callback(iResCode as number, isStopped as boolean)
|
||||
}
|
||||
JPUSHService.getPushStatus(completion)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取RegistrationID
|
||||
*/
|
||||
export function getRegistrationId() : string {
|
||||
log("getRegistrationId")
|
||||
return JPUSHService.registrationID()
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置标签
|
||||
*/
|
||||
export function setTags(sequence : Int, tags : string[]) : void {
|
||||
log("setTags", sequence, tags)
|
||||
// 单独定义回调函数,并明确指定其类型
|
||||
const callback = (iResCode : Int, iTags : Swift.Set<AnyHashable> | null, seq : Int) => {
|
||||
const result = {
|
||||
code: iResCode,
|
||||
tags: iTags,
|
||||
sequence: seq
|
||||
}
|
||||
log("setTags completion", result)
|
||||
eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
const setTags:Swift.Set<String> = new Swift.Set<String>(tags)
|
||||
JPUSHService.setTags(setTags, completion = callback, seq = sequence)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 添加标签
|
||||
*/
|
||||
export function addTags(sequence : Int, tags : string[]) : void {
|
||||
log("addTags", sequence, tags)
|
||||
|
||||
const callback = (iResCode : Int, iTags : Swift.Set<AnyHashable> | null, seq : Int) => {
|
||||
const result = {
|
||||
code: iResCode,
|
||||
tags: iTags,
|
||||
sequence: seq
|
||||
}
|
||||
log("addTags completion", result)
|
||||
eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
|
||||
|
||||
const setTags:Swift.Set<String> = new Swift.Set<String>(tags)
|
||||
|
||||
JPUSHService.addTags(setTags, completion = callback, seq = sequence)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除标签
|
||||
*/
|
||||
export function deleteTags(sequence : Int, tags : string[]) : void {
|
||||
log("deleteTags", sequence, tags)
|
||||
|
||||
const callback = (iResCode : Int, iTags : Swift.Set<AnyHashable> | null, seq : Int) => {
|
||||
const result = {
|
||||
code: iResCode,
|
||||
tags: iTags,
|
||||
sequence: seq
|
||||
}
|
||||
log("deleteTags completion", result)
|
||||
eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
|
||||
const setTags:Swift.Set<String> = new Swift.Set<String>(tags)
|
||||
JPUSHService.deleteTags(setTags, completion = callback, seq = sequence)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有标签
|
||||
*/
|
||||
export function cleanTags(sequence : Int) : void {
|
||||
log("cleanTags", sequence)
|
||||
|
||||
const callback = (iResCode : Int, iTags : Swift.Set<AnyHashable> | null, seq : Int) => {
|
||||
const result = {
|
||||
code: iResCode,
|
||||
tags: iTags,
|
||||
sequence: seq
|
||||
}
|
||||
log("cleanTags completion", result)
|
||||
eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
|
||||
JPUSHService.cleanTags(callback, seq = sequence)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有标签
|
||||
*/
|
||||
export function getAllTags(sequence : Int) : void {
|
||||
log("getAllTags", sequence)
|
||||
|
||||
const callback = (iResCode : Int, iTags : Swift.Set<AnyHashable> | null, seq : Int) => {
|
||||
const result = {
|
||||
code: iResCode,
|
||||
tags: iTags,
|
||||
sequence: seq
|
||||
}
|
||||
log("getAllTags completion", result)
|
||||
eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
|
||||
JPUSHService.getAllTags(callback, seq = sequence)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证标签绑定状态
|
||||
*/
|
||||
export function checkTagBindState(sequence : Int, tag : string) : void {
|
||||
log("checkTagBindState", sequence, tag)
|
||||
|
||||
const callback =
|
||||
(iResCode : Int, iTags : Swift.Set<AnyHashable> | null, seq : Int, isBind : boolean) => {
|
||||
const result = {
|
||||
code: iResCode,
|
||||
tags: iTags,
|
||||
sequence: seq,
|
||||
isBind: isBind
|
||||
}
|
||||
log("validTag completion", result)
|
||||
eventCallbackManager.triggerEvent("onTagOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
// JPUSHService.validTag(tag, completion = callback, seq = sequence)
|
||||
JPUSHService.validTag(tag, completion = callback, seq = sequence)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 设置别名
|
||||
*/
|
||||
export function setAlias(sequence : Int, alias : string) : void {
|
||||
log("setAlias", sequence, alias)
|
||||
|
||||
|
||||
const callback : (iResCode : Int, iAlias : String | null, seq : Int) => void = (iResCode : Int, iAlias : String | null, seq : Int) => {
|
||||
const result = {
|
||||
code: iResCode,
|
||||
alias: iAlias,
|
||||
sequence: seq
|
||||
}
|
||||
log("setAlias completion", result)
|
||||
eventCallbackManager.triggerEvent("onAliasOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
|
||||
JPUSHService.setAlias(alias, completion = callback, seq = sequence)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 删除别名
|
||||
*/
|
||||
export function deleteAlias(sequence : Int) : void {
|
||||
log("deleteAlias", sequence)
|
||||
|
||||
const callback : (iResCode : Int, iAlias : String | null, seq : Int) => void = (iResCode : Int, iAlias : String | null, seq : Int) => {
|
||||
const result = {
|
||||
code: iResCode,
|
||||
alias: iAlias,
|
||||
sequence: seq
|
||||
}
|
||||
log("deleteAlias completion", result)
|
||||
eventCallbackManager.triggerEvent("onAliasOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
|
||||
JPUSHService.deleteAlias(callback, seq = sequence)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取别名
|
||||
*/
|
||||
export function getAlias(sequence : Int) : void {
|
||||
log("getAlias", sequence)
|
||||
|
||||
const callback : (iResCode : Int, iAlias : String | null, seq : Int) => void = (iResCode : Int, iAlias : String | null, seq : Int) => {
|
||||
const result = {
|
||||
code: iResCode,
|
||||
alias: iAlias,
|
||||
sequence: seq
|
||||
}
|
||||
log("getAlias completion", result)
|
||||
eventCallbackManager.triggerEvent("onAliasOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
|
||||
JPUSHService.getAlias(callback, seq = sequence)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置手机号码
|
||||
*/
|
||||
export function setMobileNumber(sequence : number, mobileNumber : string) : void {
|
||||
log("setMobileNumber", mobileNumber)
|
||||
|
||||
const callback : (error : NSError | null) => void = (error : NSError | null) => {
|
||||
if (error !== null) {
|
||||
log("setMobileNumber error", error);
|
||||
} else {
|
||||
log("setMobileNumber success");
|
||||
}
|
||||
|
||||
const result = {
|
||||
code: error === null ? 0 : (error as NSError).code,
|
||||
message: error === null ? "success" : error,
|
||||
mobileNumber:mobileNumber,
|
||||
sequence:sequence
|
||||
}
|
||||
log("setMobileNumber completion", result)
|
||||
eventCallbackManager.triggerEvent("onMobileNumberOperatorResult", JSON.stringify(result)!)
|
||||
}
|
||||
|
||||
JPUSHService.setMobileNumber(mobileNumber, completion = callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置角标数量
|
||||
*/
|
||||
export function setBadgeNumber(curNum : Int) : void {
|
||||
log("setBadgeNumber", curNum)
|
||||
// 调用极光推送接口设置角标
|
||||
JPUSHService.setBadge(curNum)
|
||||
// 同时调用系统接口设置角标
|
||||
if (UTSiOS.available("iOS 16.0, *")) {
|
||||
// iOS 16 及以上使用 UNUserNotificationCenter
|
||||
UNUserNotificationCenter.current().setBadgeCount(Int(curNum), withCompletionHandler = (error : NSError | null) => {
|
||||
if (error != null) {
|
||||
log("setBadgeCount error:", error)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// iOS 16 以下使用 UIApplication
|
||||
UIApplication.shared.applicationIconBadgeNumber = Int(curNum)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置角标
|
||||
*/
|
||||
export function resetBadge() : void {
|
||||
log("resetBadge")
|
||||
JPUSHService.resetBadge()
|
||||
// 同时调用系统接口重置角标
|
||||
if (UTSiOS.available("iOS 16.0, *")) {
|
||||
// iOS 16 及以上使用 UNUserNotificationCenter
|
||||
UNUserNotificationCenter.current().setBadgeCount(0, withCompletionHandler = (error : NSError | null) => {
|
||||
if (error != null) {
|
||||
log("resetBadge setBadgeCount error:", error)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// iOS 16 以下使用 UIApplication
|
||||
UIApplication.shared.applicationIconBadgeNumber = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用角标数量
|
||||
*/
|
||||
export function getBadgeNumber() : number {
|
||||
const badgeNumber = UIApplication.shared.applicationIconBadgeNumber
|
||||
log("getBadgeNumber", badgeNumber)
|
||||
return Number(badgeNumber)
|
||||
}
|
||||
|
||||
/** 退出业务账号时同步清除系统已展示通知和角标,避免下个账号看到旧消息。 */
|
||||
export function clearNotificationAll() : void {
|
||||
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
|
||||
resetBadge()
|
||||
}
|
||||
|
||||
|
||||
|
||||
import { DispatchQueue } from 'Dispatch'
|
||||
import { PushServiceResult, LiveActivityResult, LiveActivityPushTokenParams, LiveActivityPushToStartTokenParams } from '../interface.uts'
|
||||
type IOSPushReply = { generation: number, resolve: (value: PushServiceResult) => void, reject: (error: Error) => void }
|
||||
type IOSTokenReply = { generation: number, resolve: (value: LiveActivityResult) => void, reject: (error: Error) => void }
|
||||
const iosPushReplies = new Map<number, IOSPushReply>()
|
||||
const iosTokenReplies = new Map<number, IOSTokenReply>()
|
||||
let iosRequestSequence: number = 0
|
||||
export function clearUpgradeCallbacks(): void {
|
||||
JPushUpgradeCodec.invalidate()
|
||||
DispatchQueue.main.async(execute = () => {
|
||||
const current = Number.from(JPushUpgradeCodec.generation())
|
||||
const expiredPush = new Array<number>()
|
||||
const expiredToken = new Array<number>()
|
||||
iosPushReplies.forEach((reply, id) => { if (reply.generation != current) expiredPush.push(id) })
|
||||
iosTokenReplies.forEach((reply, id) => { if (reply.generation != current) expiredToken.push(id) })
|
||||
for (const id of expiredPush) { iosPushReplies.delete(id) }
|
||||
for (const id of expiredToken) { iosTokenReplies.delete(id) }
|
||||
})
|
||||
}
|
||||
function finishIOSPush(id: number, result: PushServiceResult): void {
|
||||
DispatchQueue.main.async(execute = () => {
|
||||
const pending = iosPushReplies.get(id)
|
||||
if (pending == null) return
|
||||
iosPushReplies.delete(id)
|
||||
if (pending.generation == Number.from(JPushUpgradeCodec.generation())) pending.resolve(result)
|
||||
})
|
||||
}
|
||||
export function turnOffPush(): Promise<PushServiceResult> {
|
||||
const generation = Number.from(JPushUpgradeCodec.generation())
|
||||
return new Promise<PushServiceResult>((resolve,reject) => {
|
||||
DispatchQueue.main.async(execute = () => {
|
||||
if (generation != Number.from(JPushUpgradeCodec.generation())) { reject(new Error('BRIDGE_INVALIDATED')); return }
|
||||
iosRequestSequence += 1
|
||||
const id = iosRequestSequence
|
||||
iosPushReplies.set(id, {generation:generation,resolve:resolve,reject:reject})
|
||||
JPUSHService.turnOffPush((code: Int) => { finishIOSPush(id, {code:Number.from(code)}) })
|
||||
})
|
||||
})
|
||||
}
|
||||
export function turnOnPush(): Promise<PushServiceResult> {
|
||||
const generation = Number.from(JPushUpgradeCodec.generation())
|
||||
return new Promise<PushServiceResult>((resolve,reject) => {
|
||||
DispatchQueue.main.async(execute = () => { if (generation != Number.from(JPushUpgradeCodec.generation())) { reject(new Error('BRIDGE_INVALIDATED')); return }; JPUSHService.turnOnPush(); resolve({dispatched:true}) })
|
||||
})
|
||||
}
|
||||
export function setLiveActivityTokenReportEnabled(enable: boolean): Promise<void> {
|
||||
const generation = Number.from(JPushUpgradeCodec.generation())
|
||||
return new Promise<void>((resolve,reject) => {
|
||||
DispatchQueue.main.async(execute = () => { if (generation != Number.from(JPushUpgradeCodec.generation())) { reject(new Error('BRIDGE_INVALIDATED')); return }; JPUSHService.setLiveActivityTokenReportEnabled(enable); resolve() })
|
||||
})
|
||||
}
|
||||
function registerLiveToken(identifier: string, token: string | null, sequence: number, start: boolean): Promise<LiveActivityResult> {
|
||||
if (!JPushUpgradeCodec.validIdentifier(identifier) || !JPushUpgradeCodec.validToken(token) || !JPushUpgradeCodec.validSequence(sequence)) {
|
||||
return new Promise<LiveActivityResult>((resolve,reject) => { reject(new Error('INVALID_ARGUMENT')) })
|
||||
}
|
||||
if (start) {
|
||||
if (UTSiOS.available("iOS 17.2, *")) { } else { return new Promise<LiveActivityResult>((resolve,reject) => { reject(new Error('UNSUPPORTED_OS')) }) }
|
||||
} else {
|
||||
if (UTSiOS.available("iOS 16.1, *")) { } else { return new Promise<LiveActivityResult>((resolve,reject) => { reject(new Error('UNSUPPORTED_OS')) }) }
|
||||
}
|
||||
const generation = Number.from(JPushUpgradeCodec.generation())
|
||||
return new Promise<LiveActivityResult>((resolve,reject) => {
|
||||
DispatchQueue.main.async(execute = () => {
|
||||
if (generation != Number.from(JPushUpgradeCodec.generation())) { reject(new Error('BRIDGE_INVALIDATED')); return }
|
||||
iosRequestSequence += 1
|
||||
const id = iosRequestSequence
|
||||
iosTokenReplies.set(id, {generation:generation,resolve:resolve,reject:reject})
|
||||
const completion = (code: Int, activityId: string | null, resultToken: Data | null, seq: Int) => {
|
||||
DispatchQueue.main.async(execute = () => {
|
||||
const pending = iosTokenReplies.get(id)
|
||||
if (pending == null) return
|
||||
iosTokenReplies.delete(id)
|
||||
if (pending.generation != Number.from(JPushUpgradeCodec.generation())) return
|
||||
pending.resolve({code:Number.from(code), activityId:activityId, token:JPushUpgradeCodec.encode(resultToken), sequence:Number.from(seq)})
|
||||
})
|
||||
}
|
||||
if (start) JPUSHService.registerLiveActivity(identifier, pushToStartToken=JPushUpgradeCodec.decode(token), completion=completion, seq=Int(sequence))
|
||||
else JPUSHService.registerLiveActivity(identifier, pushToken=JPushUpgradeCodec.decode(token), completion=completion, seq=Int(sequence))
|
||||
})
|
||||
})
|
||||
}
|
||||
export function registerLiveActivityPushToken(params: LiveActivityPushTokenParams): Promise<LiveActivityResult> {
|
||||
return registerLiveToken(params.activityId,params.token,params.sequence,false)
|
||||
}
|
||||
export function registerLiveActivityPushToStartToken(params: LiveActivityPushToStartTokenParams): Promise<LiveActivityResult> {
|
||||
return registerLiveToken(params.activityAttributes,params.token,params.sequence,true)
|
||||
}
|
||||
export function getRegistrationIdAsync(): Promise<string> { return Promise.resolve(getRegistrationId()) }
|
||||
export function isPushStoppedAsync(): Promise<boolean> { return new Promise<boolean>((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) }
|
||||
export function requestSubscribeChannels(channels: string[]): Promise<void> { return new Promise<void>((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) }
|
||||
export function initCrashHandler(): Promise<void> { return new Promise<void>((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) }
|
||||
export function stopCrashHandler(): Promise<void> { return new Promise<void>((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) }
|
||||
|
||||
export function initAsync(): Promise<void> { return new Promise<void>((resolve,reject) => { reject(new Error('UNSUPPORTED_PLATFORM')) }) }
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* interface.uts
|
||||
* uts插件接口定义文件,按规范定义接口可以在HBuilderX中更好的做到语法提示
|
||||
*/
|
||||
|
||||
export type EventCallBack = {
|
||||
eventName: string,
|
||||
eventData: string
|
||||
}
|
||||
|
||||
export type EventCallBackParams = {
|
||||
callback?: (res: EventCallBack) => void
|
||||
}
|
||||
|
||||
// /**
|
||||
// * 错误码
|
||||
// * 根据uni错误码规范要求,建议错误码以90开头,以下是错误码示例:
|
||||
// * - 9010001 错误信息1
|
||||
// * - 9010002 错误信息2
|
||||
// */
|
||||
// export type MyApiErrorCode = 9010001 | 9010002;
|
||||
// /**
|
||||
// * myApi 的错误回调参数
|
||||
// */
|
||||
// export interface MyApiFail extends IUniError {
|
||||
// errCode : MyApiErrorCode
|
||||
// };
|
||||
|
||||
// /* 异步函数定义 */
|
||||
// export type MyApi = (options : MyApiOptions) => void
|
||||
|
||||
// /* 同步函数定义 */
|
||||
// export type MyApiSync = (paramA : boolean) => MyApiResult
|
||||
|
||||
export type PushServiceResult = { code?: number, msg?: string, dispatched?: boolean }
|
||||
export type LiveActivityPushTokenParams = { activityId: string, token: string | null, sequence: number }
|
||||
export type LiveActivityPushToStartTokenParams = { activityAttributes: string, token: string | null, sequence: number }
|
||||
export type LiveActivityResult = { code: number, activityId: string | null, token: string | null, sequence: number }
|
||||
export type RequestSubscribeChannels = (channels: string[]) => Promise<void>
|
||||
export type TurnOffPush = () => Promise<PushServiceResult>
|
||||
export type TurnOnPush = () => Promise<PushServiceResult>
|
||||
export type CrashHandlerControl = () => Promise<void>
|
||||
export type LiveActivityTokenReportControl = (enable: boolean) => Promise<void>
|
||||
export type RegisterLiveActivityPushToken = (params: LiveActivityPushTokenParams) => Promise<LiveActivityResult>
|
||||
export type RegisterLiveActivityPushToStartToken = (params: LiveActivityPushToStartTokenParams) => Promise<LiveActivityResult>
|
||||
@@ -0,0 +1,39 @@
|
||||
// /* 此规范为 uni 规范,可以按照自己的需要选择是否实现 */
|
||||
// import { MyApiErrorCode, MyApiFail } from "./interface.uts"
|
||||
// /**
|
||||
// * 错误主题
|
||||
// * 注意:错误主题一般为插件名称,每个组件不同,需要使用时请更改。
|
||||
// * [可选实现]
|
||||
// */
|
||||
// export const UniErrorSubject = 'uts-api';
|
||||
|
||||
|
||||
// /**
|
||||
// * 错误信息
|
||||
// * @UniError
|
||||
// * [可选实现]
|
||||
// */
|
||||
// export const MyAPIErrors : Map<MyApiErrorCode, string> = new Map([
|
||||
// /**
|
||||
// * 错误码及对应的错误信息
|
||||
// */
|
||||
// [9010001, 'custom error mseeage1'],
|
||||
// [9010002, 'custom error mseeage2'],
|
||||
// ]);
|
||||
|
||||
|
||||
// /**
|
||||
// * 错误对象实现
|
||||
// */
|
||||
// export class MyApiFailImpl extends UniError implements MyApiFail {
|
||||
|
||||
// /**
|
||||
// * 错误对象构造函数
|
||||
// */
|
||||
// constructor(errCode : MyApiErrorCode) {
|
||||
// super();
|
||||
// this.errSubject = UniErrorSubject;
|
||||
// this.errCode = errCode;
|
||||
// this.errMsg = MyAPIErrors.get(errCode) ?? "";
|
||||
// }
|
||||
// }
|
||||
@@ -22,6 +22,7 @@ export default async (command: 'build' | 'serve', mode: string) => {
|
||||
});
|
||||
|
||||
const isH5 = process.env.UNI_PLATFORM === 'h5';
|
||||
const isApp = process.env.UNI_PLATFORM === 'app' || process.env.UNI_PLATFORM === 'app-plus';
|
||||
|
||||
if (isH5) {
|
||||
return {
|
||||
@@ -92,6 +93,8 @@ export default async (command: 'build' | 'serve', mode: string) => {
|
||||
Unocss(),
|
||||
viteCompression({
|
||||
verbose: false,
|
||||
// Android 将 .gz 与原资源按同一路径合并,App 打包直接保留原始资源。
|
||||
disable: isApp,
|
||||
}),
|
||||
uniReadPagesV3Plugin({
|
||||
pagesJsonDir: path.resolve(__dirname, './src/pages.json'),
|
||||
|
||||
Reference in new issue
Block a user