diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 00000000..d87d75f0
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/Info.plist b/Info.plist
new file mode 100644
index 00000000..0eaba5f2
--- /dev/null
+++ b/Info.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ UIBackgroundModes
+ remote-notification
+
+
diff --git a/nativeResources/android/manifestPlaceholders.json b/nativeResources/android/manifestPlaceholders.json
new file mode 100644
index 00000000..87b3327c
--- /dev/null
+++ b/nativeResources/android/manifestPlaceholders.json
@@ -0,0 +1,4 @@
+{
+ "JPUSH_APPKEY": "447fcdbce3b2dcac3091c0de",
+ "JPUSH_CHANNEL": "developer-default"
+}
diff --git a/package.json b/package.json
index b11a9446..519c4d42 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a7716d4c..5d328154 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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:
diff --git a/src/App.vue b/src/App.vue
index 7258d325..ee85267a 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -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();
diff --git a/src/common/__tests__/jpush-account-transition.test.mjs b/src/common/__tests__/jpush-account-transition.test.mjs
new file mode 100644
index 00000000..9985d099
--- /dev/null
+++ b/src/common/__tests__/jpush-account-transition.test.mjs
@@ -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);
+});
diff --git a/src/common/__tests__/jpush-permission.test.mjs b/src/common/__tests__/jpush-permission.test.mjs
new file mode 100644
index 00000000..4ce0503c
--- /dev/null
+++ b/src/common/__tests__/jpush-permission.test.mjs
@@ -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);
+});
diff --git a/src/common/__tests__/jpush.test.mjs b/src/common/__tests__/jpush.test.mjs
new file mode 100644
index 00000000..6c65eccf
--- /dev/null
+++ b/src/common/__tests__/jpush.test.mjs
@@ -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);
+});
diff --git a/src/common/jpush-config.js b/src/common/jpush-config.js
new file mode 100644
index 00000000..85db3f65
--- /dev/null
+++ b/src/common/jpush-config.js
@@ -0,0 +1,5 @@
+/** 当前聊天应用的极光 AppKey,与会员服务端应用保持一致。 */
+export const JPUSH_APP_KEY = "447fcdbce3b2dcac3091c0de";
+
+/** 与服务端 JPUSH_APNS_PRODUCTION=true 默认环境一致,开发证书联调需同时切换两端。 */
+export const JPUSH_APNS_PRODUCTION = true;
diff --git a/src/common/jpush-permission.js b/src/common/jpush-permission.js
new file mode 100644
index 00000000..ea56f2e3
--- /dev/null
+++ b/src/common/jpush-permission.js
@@ -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;
+};
diff --git a/src/common/jpush.js b/src/common/jpush.js
new file mode 100644
index 00000000..768077a5
--- /dev/null
+++ b/src/common/jpush.js
@@ -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();
+ }
+};
diff --git a/src/common/recorder-app.js b/src/common/recorder-app.js
index 389e5b65..6fee5e3e 100644
--- a/src/common/recorder-app.js
+++ b/src/common/recorder-app.js
@@ -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,
});
});
};
diff --git a/src/common/recorder-h5.js b/src/common/recorder-h5.js
index a82b3e8c..d00d55c7 100644
--- a/src/common/recorder-h5.js
+++ b/src/common/recorder-h5.js
@@ -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;
});
};
diff --git a/src/common/wssocket.js b/src/common/wssocket.js
index fc45eaf0..18d941d1 100644
--- a/src/common/wssocket.js
+++ b/src/common/wssocket.js
@@ -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 };
diff --git a/src/components/chat-record/chat-record.vue b/src/components/chat-record/chat-record.vue
index 28685d30..af8f1aa8 100644
--- a/src/components/chat-record/chat-record.vue
+++ b/src/components/chat-record/chat-record.vue
@@ -8,6 +8,7 @@
@touchstart.prevent="onStartRecord"
@touchmove.prevent="onTouchMove"
@touchend.prevent="onEndRecord"
+ @touchcancel.prevent="onCancel"
>
{{ recording ? '正在录音' : '长按 说话' }}
@@ -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;
},
};
diff --git a/src/components/image-upload/image-upload.vue b/src/components/image-upload/image-upload.vue
index ba949690..2e3b5166 100644
--- a/src/components/image-upload/image-upload.vue
+++ b/src/components/image-upload/image-upload.vue
@@ -6,6 +6,7 @@
+
+
diff --git a/src/pages/profile/face-verify/ali-face-verify.ts b/src/pages/profile/face-verify/ali-face-verify.ts
index d46d2c97..e806697f 100644
--- a/src/pages/profile/face-verify/ali-face-verify.ts
+++ b/src/pages/profile/face-verify/ali-face-verify.ts
@@ -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;
diff --git a/src/pages/profile/face-verify/boy-verify.vue b/src/pages/profile/face-verify/boy-verify.vue
index 51b4739b..713d1ac3 100644
--- a/src/pages/profile/face-verify/boy-verify.vue
+++ b/src/pages/profile/face-verify/boy-verify.vue
@@ -35,6 +35,7 @@
+
+
diff --git a/src/pages/wechat/pages/inviter/__tests__/inviter.test.mjs b/src/pages/wechat/pages/inviter/__tests__/inviter.test.mjs
new file mode 100644
index 00000000..7492aca8
--- /dev/null
+++ b/src/pages/wechat/pages/inviter/__tests__/inviter.test.mjs
@@ -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, /