This commit is contained in:
dzc committed 2026-11-11 19:38:29 +08:00
1 parent 7571180b55
commit 26e8a38893
78 files changed
+8035 -457

No files matched your search

+10
View File
@@ -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>
+8
View File
@@ -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"
}
+1
View File
@@ -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",
+168
View File
@@ -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
View File
@@ -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);
});
+149
View File
@@ -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);
});
+5
View File
@@ -0,0 +1,5 @@
/** 当前聊天应用的极光 AppKey,与会员服务端应用保持一致。 */
export const JPUSH_APP_KEY = "447fcdbce3b2dcac3091c0de";
/** 与服务端 JPUSH_APNS_PRODUCTION=true 默认环境一致,开发证书联调需同时切换两端。 */
export const JPUSH_APNS_PRODUCTION = true;
+55
View File
@@ -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;
};
+349
View File
@@ -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
View File
@@ -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,
});
});
};
+21 -1
View File
@@ -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
View File
@@ -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 };
+25 -8
View File
@@ -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;
},
};
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
}
+32 -2
View File
@@ -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);
+1 -1
View File
@@ -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);
}
+16 -1
View File
@@ -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 });
+195
View File
@@ -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());
// 创建女生人脸认证
+20 -2
View File
@@ -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[]>([]);
+1 -1
View File
@@ -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');
});
+70 -37
View File
@@ -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');
});
+175
View File
@@ -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();
});
+321 -92
View File
@@ -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>
+51
View File
@@ -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,
}),
};
+23
View File
@@ -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),
};
+5 -10
View File
@@ -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();
});
+10
View File
@@ -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)}`;
}
+92 -37
View File
@@ -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, '');
});
+205
View File
@@ -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 };
+350
View File
@@ -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();
+8 -3
View File
@@ -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
};
+3 -2
View File
@@ -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
View File
@@ -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);
});
+13 -2
View File
@@ -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
};
+18 -4
View File
@@ -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
View File
@@ -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;
};
+15
View File
@@ -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`,云打包时使用同一包名。极光应用需绑定此包名。
+121
View File
@@ -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
File diff suppressed because it is too large. Load diff
@@ -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) ?? "";
// }
// }
+3
View File
@@ -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'),