6 Commits

Author SHA1 Message Date
7ccbc5568d fix: 优化旅拍相册入口与二维码提示布局 2026-07-23 15:40:51 +08:00
787a166263 feat: 品牌化那拉提景区收款页 2026-07-23 15:39:34 +08:00
74f7370aab docs: 补充 type=13 新获客线索推送样本与字段说明。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:56:40 +08:00
1b2637f0ee fix: 推送点击仅按 type 路由到收款页或消息中心。
对齐后端消息类型约定,去掉 route/uri 等兼容字段,并补充联调日志与单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:20:36 +08:00
caeeb9a1cf feat: 接入消息未读数、全部已读与首页红点角标同步。
固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 18:02:43 +08:00
e7f1d777dd feat: integrate JPush notifications 2026-07-20 15:56:06 +08:00
82 changed files with 8647 additions and 2569 deletions

View File

@ -8,6 +8,9 @@ target 'suixinkan' do
pod 'UMCommon' pod 'UMCommon'
pod 'UMDevice' pod 'UMDevice'
pod 'UMAPM' pod 'UMAPM'
# 极光推送:固定无 IDFA 的 JCore避免引入 ATT 授权。
pod 'JCore', '5.4.2-noidfa'
pod 'JPush', '6.1.0'
pod 'IQKeyboardManagerSwift' pod 'IQKeyboardManagerSwift'
# 微信 Open SDK仅接入分享能力不调用支付 API。 # 微信 Open SDK仅接入分享能力不调用支付 API。
pod 'WechatOpenSDK-XCFramework', '~> 2.0.4' pod 'WechatOpenSDK-XCFramework', '~> 2.0.4'

View File

@ -46,6 +46,9 @@ PODS:
- IQKeyboardCore - IQKeyboardCore
- IQTextView (1.0.5): - IQTextView (1.0.5):
- IQKeyboardToolbar/Placeholderable - IQKeyboardToolbar/Placeholderable
- JCore (5.4.2-noidfa)
- JPush (6.1.0):
- JCore (> 5.1.1)
- UMAPM (2.0.7): - UMAPM (2.0.7):
- UMCommon - UMCommon
- UMCommon (7.5.11): - UMCommon (7.5.11):
@ -58,6 +61,8 @@ DEPENDENCIES:
- AMapLocation-NO-IDFA - AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA - AMapSearch-NO-IDFA
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
- JCore (= 5.4.2-noidfa)
- JPush (= 6.1.0)
- UMAPM - UMAPM
- UMCommon - UMCommon
- UMDevice - UMDevice
@ -77,6 +82,8 @@ SPEC REPOS:
- IQKeyboardToolbarManager - IQKeyboardToolbarManager
- IQTextInputViewNotification - IQTextInputViewNotification
- IQTextView - IQTextView
- JCore
- JPush
- UMAPM - UMAPM
- UMCommon - UMCommon
- UMDevice - UMDevice
@ -95,11 +102,13 @@ SPEC CHECKSUMS:
IQKeyboardToolbarManager: 242e858cc74e06e00aa989bea1355b24d4397854 IQKeyboardToolbarManager: 242e858cc74e06e00aa989bea1355b24d4397854
IQTextInputViewNotification: 3b9fb27a16e7ee8958cc9092cfb07a1a9e1fd559 IQTextInputViewNotification: 3b9fb27a16e7ee8958cc9092cfb07a1a9e1fd559
IQTextView: ae13b4922f22e6f027f62c557d9f4f236b19d5c7 IQTextView: ae13b4922f22e6f027f62c557d9f4f236b19d5c7
JCore: 696e0ac22f417bb0ac4729fc41bc2941aef14e2b
JPush: 891c497687be788b094bde368d51289355ee1112
UMAPM: ba9fbebe8dd2048b251c332ab0afc86e1ba5ea63 UMAPM: ba9fbebe8dd2048b251c332ab0afc86e1ba5ea63
UMCommon: d652b3b372a801b36db203f5b546ab3e15676898 UMCommon: d652b3b372a801b36db203f5b546ab3e15676898
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19 UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949 WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
PODFILE CHECKSUM: 0fd38f741cbaae39c8e7a70fa233f651d3b34d0f PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

1
Pods/JCore/README.md generated Normal file
View File

@ -0,0 +1 @@
# jcore-sdk

View File

@ -0,0 +1,44 @@
<?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>AvailableLibraries</key>
<array>
<dict>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libJCore.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>libJCore.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,64 @@
<?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>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>

Binary file not shown.

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,64 @@
<?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>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>

View File

@ -0,0 +1,44 @@
<?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>AvailableLibraries</key>
<array>
<dict>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>libJPush.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
<dict>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libJPush.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
<?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/>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
</dict>
</array>
</dict>
</plist>

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
<?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/>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
</dict>
</array>
</dict>
</plist>

11
Pods/Manifest.lock generated
View File

@ -46,6 +46,9 @@ PODS:
- IQKeyboardCore - IQKeyboardCore
- IQTextView (1.0.5): - IQTextView (1.0.5):
- IQKeyboardToolbar/Placeholderable - IQKeyboardToolbar/Placeholderable
- JCore (5.4.2-noidfa)
- JPush (6.1.0):
- JCore (> 5.1.1)
- UMAPM (2.0.7): - UMAPM (2.0.7):
- UMCommon - UMCommon
- UMCommon (7.5.11): - UMCommon (7.5.11):
@ -58,6 +61,8 @@ DEPENDENCIES:
- AMapLocation-NO-IDFA - AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA - AMapSearch-NO-IDFA
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
- JCore (= 5.4.2-noidfa)
- JPush (= 6.1.0)
- UMAPM - UMAPM
- UMCommon - UMCommon
- UMDevice - UMDevice
@ -77,6 +82,8 @@ SPEC REPOS:
- IQKeyboardToolbarManager - IQKeyboardToolbarManager
- IQTextInputViewNotification - IQTextInputViewNotification
- IQTextView - IQTextView
- JCore
- JPush
- UMAPM - UMAPM
- UMCommon - UMCommon
- UMDevice - UMDevice
@ -95,11 +102,13 @@ SPEC CHECKSUMS:
IQKeyboardToolbarManager: 242e858cc74e06e00aa989bea1355b24d4397854 IQKeyboardToolbarManager: 242e858cc74e06e00aa989bea1355b24d4397854
IQTextInputViewNotification: 3b9fb27a16e7ee8958cc9092cfb07a1a9e1fd559 IQTextInputViewNotification: 3b9fb27a16e7ee8958cc9092cfb07a1a9e1fd559
IQTextView: ae13b4922f22e6f027f62c557d9f4f236b19d5c7 IQTextView: ae13b4922f22e6f027f62c557d9f4f236b19d5c7
JCore: 696e0ac22f417bb0ac4729fc41bc2941aef14e2b
JPush: 891c497687be788b094bde368d51289355ee1112
UMAPM: ba9fbebe8dd2048b251c332ab0afc86e1ba5ea63 UMAPM: ba9fbebe8dd2048b251c332ab0afc86e1ba5ea63
UMCommon: d652b3b372a801b36db203f5b546ab3e15676898 UMCommon: d652b3b372a801b36db203f5b546ab3e15676898
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19 UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949 WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
PODFILE CHECKSUM: 0fd38f741cbaae39c8e7a70fa233f651d3b34d0f PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
${PODS_ROOT}/Target Support Files/JCore/JCore-xcframeworks.sh
${PODS_ROOT}/JCore/jcore-noidfa-ios-5.4.2.xcframework

View File

@ -0,0 +1 @@
${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/jcore-noidfa-ios-5.4.2.framework

View File

@ -0,0 +1,121 @@
#!/bin/sh
set -e
set -u
set -o pipefail
function on_error {
echo "$(realpath -mq "${0}"):$1: error: Unexpected failure"
}
trap 'on_error $LINENO' ERR
# This protects against multiple targets copying the same framework dependency at the same time. The solution
# was originally proposed here: https://lists.samba.org/archive/rsync/2008-February/020158.html
RSYNC_PROTECT_TMP_FILES=(--filter "P .*.??????")
variant_for_slice()
{
case "$1" in
"jcore-noidfa-ios-5.4.2.xcframework/ios-arm64")
echo ""
;;
"jcore-noidfa-ios-5.4.2.xcframework/ios-arm64_x86_64-simulator")
echo "simulator"
;;
esac
}
archs_for_slice()
{
case "$1" in
"jcore-noidfa-ios-5.4.2.xcframework/ios-arm64")
echo "arm64"
;;
"jcore-noidfa-ios-5.4.2.xcframework/ios-arm64_x86_64-simulator")
echo "arm64 x86_64"
;;
esac
}
copy_dir()
{
local source="$1"
local destination="$2"
# Use filter instead of exclude so missing patterns don't throw errors.
echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" \"${source}*\" \"${destination}\""
rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" "${source}"/* "${destination}"
}
SELECT_SLICE_RETVAL=""
select_slice() {
local xcframework_name="$1"
xcframework_name="${xcframework_name##*/}"
local paths=("${@:2}")
# Locate the correct slice of the .xcframework for the current architectures
local target_path=""
# Split archs on space so we can find a slice that has all the needed archs
local target_archs=$(echo $ARCHS | tr " " "\n")
local target_variant=""
if [[ "$PLATFORM_NAME" == *"simulator" ]]; then
target_variant="simulator"
fi
if [[ ! -z ${EFFECTIVE_PLATFORM_NAME+x} && "$EFFECTIVE_PLATFORM_NAME" == *"maccatalyst" ]]; then
target_variant="maccatalyst"
fi
for i in ${!paths[@]}; do
local matched_all_archs="1"
local slice_archs="$(archs_for_slice "${xcframework_name}/${paths[$i]}")"
local slice_variant="$(variant_for_slice "${xcframework_name}/${paths[$i]}")"
for target_arch in $target_archs; do
if ! [[ "${slice_variant}" == "$target_variant" ]]; then
matched_all_archs="0"
break
fi
if ! echo "${slice_archs}" | tr " " "\n" | grep -F -q -x "$target_arch"; then
matched_all_archs="0"
break
fi
done
if [[ "$matched_all_archs" == "1" ]]; then
# Found a matching slice
echo "Selected xcframework slice ${paths[$i]}"
SELECT_SLICE_RETVAL=${paths[$i]}
break
fi
done
}
install_xcframework() {
local basepath="$1"
local name="$2"
local package_type="$3"
local paths=("${@:4}")
# Locate the correct slice of the .xcframework for the current architectures
select_slice "${basepath}" "${paths[@]}"
local target_path="$SELECT_SLICE_RETVAL"
if [[ -z "$target_path" ]]; then
echo "warning: [CP] $(basename ${basepath}): Unable to find matching slice in '${paths[@]}' for the current build architectures ($ARCHS) and platform (${EFFECTIVE_PLATFORM_NAME-${PLATFORM_NAME}})."
return
fi
local source="$basepath/$target_path"
local destination="${PODS_XCFRAMEWORKS_BUILD_DIR}/${name}"
if [ ! -d "$destination" ]; then
mkdir -p "$destination"
fi
copy_dir "$source/" "$destination"
echo "Copied $source to $destination"
}
install_xcframework "${PODS_ROOT}/JCore/jcore-noidfa-ios-5.4.2.xcframework" "JCore" "library" "ios-arm64" "ios-arm64_x86_64-simulator"

View File

@ -0,0 +1,17 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/JCore
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_ROOT}/JCore"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers"
LIBRARY_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore"
OTHER_LDFLAGS = $(inherited) -l"JCore" -l"resolv" -l"z" -framework "CFNetwork" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreTelephony" -framework "Foundation" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "WebKit" -weak_framework "Network" -weak_framework "UserNotifications"
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE}
PODS_ROOT = ${SRCROOT}
PODS_TARGET_SRCROOT = ${PODS_ROOT}/JCore
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
SKIP_INSTALL = YES
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
VALID_ARCHS = x86_64 arm64

View File

@ -0,0 +1,17 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/JCore
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_ROOT}/JCore"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers"
LIBRARY_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore"
OTHER_LDFLAGS = $(inherited) -l"JCore" -l"resolv" -l"z" -framework "CFNetwork" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreTelephony" -framework "Foundation" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "WebKit" -weak_framework "Network" -weak_framework "UserNotifications"
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE}
PODS_ROOT = ${SRCROOT}
PODS_TARGET_SRCROOT = ${PODS_ROOT}/JCore
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
SKIP_INSTALL = YES
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
VALID_ARCHS = x86_64 arm64

View File

@ -0,0 +1,2 @@
${PODS_ROOT}/Target Support Files/JPush/JPush-xcframeworks.sh
${PODS_ROOT}/JPush/jpush-ios-6.1.0.xcframework

View File

@ -0,0 +1 @@
${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/jpush-ios-6.1.0.framework

View File

@ -0,0 +1,121 @@
#!/bin/sh
set -e
set -u
set -o pipefail
function on_error {
echo "$(realpath -mq "${0}"):$1: error: Unexpected failure"
}
trap 'on_error $LINENO' ERR
# This protects against multiple targets copying the same framework dependency at the same time. The solution
# was originally proposed here: https://lists.samba.org/archive/rsync/2008-February/020158.html
RSYNC_PROTECT_TMP_FILES=(--filter "P .*.??????")
variant_for_slice()
{
case "$1" in
"jpush-ios-6.1.0.xcframework/ios-arm64")
echo ""
;;
"jpush-ios-6.1.0.xcframework/ios-arm64_x86_64-simulator")
echo "simulator"
;;
esac
}
archs_for_slice()
{
case "$1" in
"jpush-ios-6.1.0.xcframework/ios-arm64")
echo "arm64"
;;
"jpush-ios-6.1.0.xcframework/ios-arm64_x86_64-simulator")
echo "arm64 x86_64"
;;
esac
}
copy_dir()
{
local source="$1"
local destination="$2"
# Use filter instead of exclude so missing patterns don't throw errors.
echo "rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter \"- CVS/\" --filter \"- .svn/\" --filter \"- .git/\" --filter \"- .hg/\" \"${source}*\" \"${destination}\""
rsync --delete -av "${RSYNC_PROTECT_TMP_FILES[@]}" --links --filter "- CVS/" --filter "- .svn/" --filter "- .git/" --filter "- .hg/" "${source}"/* "${destination}"
}
SELECT_SLICE_RETVAL=""
select_slice() {
local xcframework_name="$1"
xcframework_name="${xcframework_name##*/}"
local paths=("${@:2}")
# Locate the correct slice of the .xcframework for the current architectures
local target_path=""
# Split archs on space so we can find a slice that has all the needed archs
local target_archs=$(echo $ARCHS | tr " " "\n")
local target_variant=""
if [[ "$PLATFORM_NAME" == *"simulator" ]]; then
target_variant="simulator"
fi
if [[ ! -z ${EFFECTIVE_PLATFORM_NAME+x} && "$EFFECTIVE_PLATFORM_NAME" == *"maccatalyst" ]]; then
target_variant="maccatalyst"
fi
for i in ${!paths[@]}; do
local matched_all_archs="1"
local slice_archs="$(archs_for_slice "${xcframework_name}/${paths[$i]}")"
local slice_variant="$(variant_for_slice "${xcframework_name}/${paths[$i]}")"
for target_arch in $target_archs; do
if ! [[ "${slice_variant}" == "$target_variant" ]]; then
matched_all_archs="0"
break
fi
if ! echo "${slice_archs}" | tr " " "\n" | grep -F -q -x "$target_arch"; then
matched_all_archs="0"
break
fi
done
if [[ "$matched_all_archs" == "1" ]]; then
# Found a matching slice
echo "Selected xcframework slice ${paths[$i]}"
SELECT_SLICE_RETVAL=${paths[$i]}
break
fi
done
}
install_xcframework() {
local basepath="$1"
local name="$2"
local package_type="$3"
local paths=("${@:4}")
# Locate the correct slice of the .xcframework for the current architectures
select_slice "${basepath}" "${paths[@]}"
local target_path="$SELECT_SLICE_RETVAL"
if [[ -z "$target_path" ]]; then
echo "warning: [CP] $(basename ${basepath}): Unable to find matching slice in '${paths[@]}' for the current build architectures ($ARCHS) and platform (${EFFECTIVE_PLATFORM_NAME-${PLATFORM_NAME}})."
return
fi
local source="$basepath/$target_path"
local destination="${PODS_XCFRAMEWORKS_BUILD_DIR}/${name}"
if [ ! -d "$destination" ]; then
mkdir -p "$destination"
fi
copy_dir "$source/" "$destination"
echo "Copied $source to $destination"
}
install_xcframework "${PODS_ROOT}/JPush/jpush-ios-6.1.0.xcframework" "JPush" "library" "ios-arm64" "ios-arm64_x86_64-simulator"

View File

@ -0,0 +1,15 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/JPush
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE}
PODS_ROOT = ${SRCROOT}
PODS_TARGET_SRCROOT = ${PODS_ROOT}/JPush
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
SKIP_INSTALL = YES
SWIFT_INCLUDE_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore"
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES

View File

@ -0,0 +1,15 @@
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/JPush
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
PODS_DEVELOPMENT_LANGUAGE = ${DEVELOPMENT_LANGUAGE}
PODS_ROOT = ${SRCROOT}
PODS_TARGET_SRCROOT = ${PODS_ROOT}/JPush
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
PRODUCT_BUNDLE_IDENTIFIER = org.cocoapods.${PRODUCT_NAME:rfc1034identifier}
SKIP_INSTALL = YES
SWIFT_INCLUDE_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore"
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES

View File

@ -219,6 +219,14 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
## JCore
Copyright jpush.cn
## JPush
Copyright jiguang.cn
## UMAPM ## UMAPM
Copyright 2011 - 2020 umeng.com. All rights reserved. Copyright 2011 - 2020 umeng.com. All rights reserved.

View File

@ -302,6 +302,26 @@ SOFTWARE.
<key>Type</key> <key>Type</key>
<string>PSGroupSpecifier</string> <string>PSGroupSpecifier</string>
</dict> </dict>
<dict>
<key>FooterText</key>
<string>Copyright jpush.cn</string>
<key>License</key>
<string>Copyright</string>
<key>Title</key>
<string>JCore</string>
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
<dict>
<key>FooterText</key>
<string>Copyright jiguang.cn</string>
<key>License</key>
<string>Copyright</string>
<key>Title</key>
<string>JPush</string>
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
<dict> <dict>
<key>FooterText</key> <key>FooterText</key>
<string>Copyright 2011 - 2020 umeng.com. All rights reserved. <string>Copyright 2011 - 2020 umeng.com. All rights reserved.

View File

@ -3,13 +3,13 @@ ARCHS = $(ARCHS_STANDARD)
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
ENABLE_USER_SCRIPT_SANDBOXING = NO ENABLE_USER_SCRIPT_SANDBOXING = NO
EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64 EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/AMap3DMap-NO-IDFA" "${PODS_ROOT}/AMapFoundation-NO-IDFA" "${PODS_ROOT}/AMapLocation-NO-IDFA" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/AMap3DMap-NO-IDFA" "${PODS_ROOT}/AMapFoundation-NO-IDFA" "${PODS_ROOT}/AMapLocation-NO-IDFA" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush" "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -ObjC -l"c++" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "AMapFoundationKit" -framework "AMapLocationKit" -framework "AMapSearchKit" -framework "Combine" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "GLKit" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "MAMapKit" -framework "OpenGLES" -framework "QuartzCore" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "UMAPM" -framework "UMCommon" -framework "UMDevice" -framework "WebKit" -framework "WechatOpenSDK" OTHER_LDFLAGS = $(inherited) -ObjC -l"JCore" -l"JPush" -l"c++" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "AMapFoundationKit" -framework "AMapLocationKit" -framework "AMapSearchKit" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "Foundation" -framework "GLKit" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "MAMapKit" -framework "OpenGLES" -framework "QuartzCore" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "UMAPM" -framework "UMCommon" -framework "UMDevice" -framework "WebKit" -framework "WechatOpenSDK" -weak_framework "Network" -weak_framework "UserNotifications"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AMap3DMap-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapFoundation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapLocation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapSearch-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMAPM" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMDevice" "-F${PODS_CONFIGURATION_BUILD_DIR}/WechatOpenSDK-XCFramework" OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AMap3DMap-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapFoundation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapLocation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapSearch-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "-F${PODS_CONFIGURATION_BUILD_DIR}/JCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/JPush" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMAPM" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMDevice" "-F${PODS_CONFIGURATION_BUILD_DIR}/WechatOpenSDK-XCFramework"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)

View File

@ -3,13 +3,13 @@ ARCHS = $(ARCHS_STANDARD)
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
ENABLE_USER_SCRIPT_SANDBOXING = NO ENABLE_USER_SCRIPT_SANDBOXING = NO
EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64 EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/AMap3DMap-NO-IDFA" "${PODS_ROOT}/AMapFoundation-NO-IDFA" "${PODS_ROOT}/AMapLocation-NO-IDFA" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/AMap3DMap-NO-IDFA" "${PODS_ROOT}/AMapFoundation-NO-IDFA" "${PODS_ROOT}/AMapLocation-NO-IDFA" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks' LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift LIBRARY_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush" "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
OTHER_LDFLAGS = $(inherited) -ObjC -l"c++" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "AMapFoundationKit" -framework "AMapLocationKit" -framework "AMapSearchKit" -framework "Combine" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "GLKit" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "MAMapKit" -framework "OpenGLES" -framework "QuartzCore" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "UMAPM" -framework "UMCommon" -framework "UMDevice" -framework "WebKit" -framework "WechatOpenSDK" OTHER_LDFLAGS = $(inherited) -ObjC -l"JCore" -l"JPush" -l"c++" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "AMapFoundationKit" -framework "AMapLocationKit" -framework "AMapSearchKit" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "Foundation" -framework "GLKit" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "MAMapKit" -framework "OpenGLES" -framework "QuartzCore" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "UMAPM" -framework "UMCommon" -framework "UMDevice" -framework "WebKit" -framework "WechatOpenSDK" -weak_framework "Network" -weak_framework "UserNotifications"
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AMap3DMap-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapFoundation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapLocation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapSearch-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMAPM" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMDevice" "-F${PODS_CONFIGURATION_BUILD_DIR}/WechatOpenSDK-XCFramework" OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AMap3DMap-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapFoundation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapLocation-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/AMapSearch-NO-IDFA" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "-F${PODS_CONFIGURATION_BUILD_DIR}/JCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/JPush" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMAPM" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMDevice" "-F${PODS_CONFIGURATION_BUILD_DIR}/WechatOpenSDK-XCFramework"
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)

View File

@ -2,10 +2,10 @@ ARCHS = $(ARCHS_STANDARD)
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
ENABLE_USER_SCRIPT_SANDBOXING = NO ENABLE_USER_SCRIPT_SANDBOXING = NO
EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64 EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/AMap3DMap-NO-IDFA" "${PODS_ROOT}/AMapFoundation-NO-IDFA" "${PODS_ROOT}/AMapLocation-NO-IDFA" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/AMap3DMap-NO-IDFA" "${PODS_ROOT}/AMapFoundation-NO-IDFA" "${PODS_ROOT}/AMapLocation-NO-IDFA" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
OTHER_LDFLAGS = $(inherited) -l"c++" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "Combine" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "GLKit" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "OpenGLES" -framework "QuartzCore" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "WebKit" OTHER_LDFLAGS = $(inherited) -l"c++" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "Foundation" -framework "GLKit" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "OpenGLES" -framework "QuartzCore" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "WebKit" -weak_framework "Network" -weak_framework "UserNotifications"
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
PODS_PODFILE_DIR_PATH = ${SRCROOT}/. PODS_PODFILE_DIR_PATH = ${SRCROOT}/.

View File

@ -2,10 +2,10 @@ ARCHS = $(ARCHS_STANDARD)
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
ENABLE_USER_SCRIPT_SANDBOXING = NO ENABLE_USER_SCRIPT_SANDBOXING = NO
EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64 EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/AMap3DMap-NO-IDFA" "${PODS_ROOT}/AMapFoundation-NO-IDFA" "${PODS_ROOT}/AMapLocation-NO-IDFA" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/AMap3DMap-NO-IDFA" "${PODS_ROOT}/AMapFoundation-NO-IDFA" "${PODS_ROOT}/AMapLocation-NO-IDFA" "${PODS_ROOT}/AMapSearch-NO-IDFA" "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
OTHER_LDFLAGS = $(inherited) -l"c++" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "Combine" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "GLKit" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "OpenGLES" -framework "QuartzCore" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "WebKit" OTHER_LDFLAGS = $(inherited) -l"c++" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreLocation" -framework "CoreTelephony" -framework "CoreText" -framework "ExternalAccessory" -framework "Foundation" -framework "GLKit" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "OpenGLES" -framework "QuartzCore" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "WebKit" -weak_framework "Network" -weak_framework "UserNotifications"
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
PODS_PODFILE_DIR_PATH = ${SRCROOT}/. PODS_PODFILE_DIR_PATH = ${SRCROOT}/.

View File

@ -0,0 +1,484 @@
# 后端消息推送格式
## 1. 文档说明
- 本文整理后端经极光推送发送到 Android 和 iOS 的通知消息格式。
- 当前内容基于截至 2026-07-23 的 Android、iOS 实际收包日志、两端客户端现有解析逻辑,以及后端开发人员提供的 `PushMsg` 类型定义。
- 后端当前定义了 `1``13``100``999` 共 15 个业务消息类型;目前已取得 `type = 1`(付款成功)、`type = 2`(退款成功)、`type = 6`(扫码成功)和 `type = 13`(新获客线索)的收包样本。
- 本文描述的是推送协议,不是普通 HTTP 接口响应格式。
## 2. 统一业务结构
极光在 Android 和 iOS 上交付的原始结构不同,但当前消息都可以归一化为以下业务结构:
```json
{
"title": "通知标题",
"content": "通知正文",
"msg_id": 1858,
"type": 6,
"data": {
"order_type": 14
}
}
```
### 2.1 公共业务字段
| 字段 | 当前类型 | 说明 |
| --- | --- | --- |
| `title` | string | 通知标题。Android 为 `notificationTitle`iOS 为 `aps.alert.title`。 |
| `content` | string | 通知正文。Android 为 `notificationContent`iOS 为 `aps.alert.body`。 |
| `msg_id` | int | 后端业务消息 ID。它与极光消息 ID 不是同一个字段。 |
| `type` | int | 业务消息类型,是客户端业务分发的主要依据。 |
| `data` | object | 随消息类型变化的业务数据。 |
> “当前类型”表示现有样本中观察到的 JSON 类型。后端应保持字段类型稳定,避免同一个字段在不同推送中交替使用数字、字符串或 `null`。
## 3. Android 收包格式
Android 通过极光 `JPushMessageReceiver.onNotifyMessageArrived` 收到通知。标题、正文和附加字段分别由 SDK 提供,其中 `extras` 是 JSON 字符串:
```text
[onNotifyMessageArrived] msgId=<极光消息ID>, title=<标题>, content=<正文>, extras=<业务JSON字符串>
```
`extras` 的当前结构为:
```json
{
"data": {},
"msg_id": 0,
"type": 0
}
```
### 3.1 扫码成功原始日志
```text
[onNotifyMessageArrived] msgId=18103321643292031, title=扫码成功通知, content=扫码成功, extras={"data":{"order_type":14},"msg_id":1852,"type":6}
```
对应的 `extras`
```json
{
"data": {
"order_type": 14
},
"msg_id": 1852,
"type": 6
}
```
### 3.2 付款成功原始日志
```text
[onNotifyMessageArrived] msgId=18103321644290274, title=付款成功通知, content=您的订单 #260722128005 已支付成功,支付金额 ¥0.01, extras={"data":{"order_amount":"0.01","order_number":"260722128005","order_type":14,"pay_time":1784711036,"remark":""},"msg_id":1853,"type":1}
```
对应的 `extras`
```json
{
"data": {
"order_amount": "0.01",
"order_number": "260722128005",
"order_type": 14,
"pay_time": 1784711036,
"remark": ""
},
"msg_id": 1853,
"type": 1
}
```
### 3.3 退款成功原始日志
```text
[onNotifyMessageArrived] msgId=18103321800527159, title=退款成功通知, content=您的订单 #260722128002 已成功退款,退款金额 ¥0.01, extras={"data":{},"msg_id":1867,"type":2}
```
对应的 `extras`
```json
{
"data": {},
"msg_id": 1867,
"type": 2
}
```
## 4. iOS 收包格式
iOS 通过 APNs/极光收到 `userInfo` 字典。与 Android 相比:
- 标题和正文位于 `aps.alert`
- Android `extras` 中的 `msg_id``type``data` 在 iOS 中位于 payload 顶层。
- `_j_*` 为极光传输层内部字段,不应作为业务分发依据。
通用结构如下:
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321726881995,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "通知正文",
"title": "通知标题"
},
"sound": "default"
},
"data": {},
"msg_id": 0,
"type": 0
}
```
> 原始日志中该内部字段显示为 `*j\_data*`。本文按极光其他内部字段的命名方式记为 `_j_data`;后续联调时应以设备收到的真实字典 key 为准。该字段目前不参与业务解析。
### 4.1 扫码成功 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321726881995,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "扫码成功",
"title": "扫码成功通知"
},
"sound": "default"
},
"data": {
"order_type": 14
},
"msg_id": 1858,
"type": 6
}
```
### 4.2 付款成功 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321720587147,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "您的订单 #260722128006 已支付成功,支付金额 ¥0.01",
"title": "付款成功通知"
},
"sound": "default"
},
"data": {
"order_amount": "0.01",
"order_number": "260722128006",
"order_type": 14,
"pay_time": 1784712671,
"remark": ""
},
"msg_id": 1856,
"type": 1
}
```
### 4.3 退款成功 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321792603316,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "您的订单 #260722128005 已成功退款,退款金额 ¥0.01",
"title": "退款成功通知"
},
"sound": "default"
},
"data": {},
"msg_id": 1862,
"type": 2
}
```
### 4.4 新获客线索 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103323696468714,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "获客员哦啦啦提交了一条新线索,请及时查看并跟进",
"title": "新获客线索通知"
},
"sound": "default"
},
"data": {
"lead_id": 75,
"sale_user_id": 6,
"saler_name": "哦啦啦"
},
"msg_id": 1869,
"type": 13
}
```
## 5. Android 与 iOS 字段对应关系
| 语义 | Android | iOS | 是否用于业务 |
| --- | --- | --- | --- |
| 极光消息 ID | SDK `message.msgId` | `_j_msgid` | 否,仅用于推送链路日志与排查 |
| 通知标题 | SDK `message.notificationTitle` | `aps.alert.title` | 是,用于展示 |
| 通知正文 | SDK `message.notificationContent` | `aps.alert.body` | 是,用于展示 |
| 后端业务消息 ID | `extras.msg_id` | 顶层 `msg_id` | 是 |
| 业务消息类型 | `extras.type` | 顶层 `type` | 是,作为业务分发主键 |
| 业务数据 | `extras.data` | 顶层 `data` | 是,结构由 `type` 决定 |
| 极光业务标记 | SDK 内部处理 | `_j_business` | 否 |
| 极光附加元数据 | SDK 内部处理 | `_j_data` | 否 |
| 极光用户 ID | SDK 内部处理 | `_j_uid` | 否 |
| APNs 展示配置 | 不适用 | `aps` | 仅 iOS 系统使用 |
### 5.1 两种消息 ID 不可混用
- `msgId` / `_j_msgid`:极光生成的传输层消息 ID当前样本为 17 位数字。
- `msg_id`:后端生成的业务消息 ID当前样本为 `1852``1853``1856``1858`
- 客户端日志、去重或上报时必须明确需要哪一种 ID不能仅用名称相近而互相替代。
- 极光消息 ID 已超过 JavaScript 可安全表示的整数范围;若需要经过 JavaScript、WebView 或其他只能安全处理 53 位整数的链路,应按字符串传递。
## 6. 消息类型清单
### 6.1 后端 `PushMsg` 完整类型定义
定义位置:后端 `PushMsg.php` 第 49 行附近。
| `type` | 消息类型 | 后端备注 | 当前收包样本 |
| ---: | --- | --- | --- |
| `1` | 付款成功通知 | 上文实际示例即为此类型 | Android、iOS 均已取得 |
| `2` | 退款成功通知 | — | Android、iOS 均已取得 |
| `3` | 下单成功通知 | App 无需推送,当前未使用 | 暂无 |
| `4` | 签到成功通知 | — | 暂无 |
| `5` | 付尾款成功通知 | — | 暂无 |
| `6` | 扫码成功通知 | — | Android、iOS 均已取得 |
| `7` | 付定金成功通知 | — | 暂无 |
| `8` | 订单核销成功通知 | — | 暂无 |
| `9` | 剪辑完成通知 | — | 暂无 |
| `10` | 修图完成通知 | — | 暂无 |
| `11` | 跟拍签到通知 | — | 暂无 |
| `12` | 剪辑师新任务通知 | 类型名称映射中漏配 | 暂无 |
| `13` | 新获客线索通知 | 获客员提交新线索 | iOS 已取得Android 暂无 |
| `100` | 用户登出通知 | 账号在其他设备登录 | 暂无 |
| `999` | 系统消息 | 当前未使用 | 暂无 |
后端特别说明:
- `type` 表示业务消息类型,不代表推送渠道。
- 推送渠道的取值另有定义:`1` 为极光推送,`2` 为微信小程序,`999` 为不推送。
- `type = 12` 虽然已经定义,但尚未加入后端 `MAP_TYPE_NAMES`。后端调用 `getTypeName(12)` 时会返回“未知消息类型”,需要后端补充映射。
- “暂无收包样本”只表示当前文档还没有该类型的完整 payload不能据此认为该类型未启用。收到后应继续补充其标题、正文、`data` 字段和点击行为。
### 6.2 `type = 1`:付款成功
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `付款成功通知` |
| 正文示例 | `您的订单 #260722128006 已支付成功,支付金额 ¥0.01` |
| 客户端点击行为 | Android、iOS 均进入收款记录页 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| `order_amount` | string | `"0.01"` | 支付金额。使用十进制字符串,避免浮点精度问题。 |
| `order_number` | string | `"260722128006"` | 业务订单号。 |
| `order_type` | int | `14` | 订单类型;当前值表示“摄影师跟拍线下扫码”。 |
| `pay_time` | int | `1784712671` | 支付时间。当前样本为 Unix 秒级时间戳Android 现有代码同时兼容秒和毫秒。 |
| `remark` | string | `""` | 付款备注;没有备注时当前返回空字符串。 |
示例:
```json
{
"type": 1,
"msg_id": 1856,
"data": {
"order_amount": "0.01",
"order_number": "260722128006",
"order_type": 14,
"pay_time": 1784712671,
"remark": ""
}
}
```
### 6.3 `type = 2`:退款成功
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `退款成功通知` |
| 正文示例 | `您的订单 #260722128005 已成功退款,退款金额 ¥0.01` |
| 客户端点击行为 | Android、iOS 均进入消息中心 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| — | object | `{}` | 当前 Android、iOS 样本均为空对象。 |
示例:
```json
{
"type": 2,
"msg_id": 1862,
"data": {}
}
```
> 当前订单号和退款金额只存在于展示正文中,没有以结构化字段放入 `data`。客户端如果需要可靠地刷新指定订单、展示退款详情或执行金额计算,应由后端在 `data` 中提供字段,不应解析自然语言正文。
### 6.4 `type = 6`:扫码成功
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `扫码成功通知` |
| 正文示例 | `扫码成功` |
| 客户端点击行为 | Android、iOS 均进入收款详情页 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| `order_type` | int | `14` | 订单类型;`14` 在 Android 现有代码中表示“摄影师跟拍线下扫码”。 |
示例:
```json
{
"type": 6,
"msg_id": 1858,
"data": {
"order_type": 14
}
}
```
### 6.5 `type = 13`:新获客线索
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `新获客线索通知` |
| 正文示例 | `获客员哦啦啦提交了一条新线索,请及时查看并跟进` |
| 客户端点击行为 | Android、iOS 均进入消息中心 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| `lead_id` | int | `75` | 新增获客线索的唯一 ID。 |
| `sale_user_id` | int | `6` | 提交线索的获客员 ID。 |
| `saler_name` | string | `"哦啦啦"` | 获客员名称,与通知正文中的名称一致。 |
示例:
```json
{
"type": 13,
"msg_id": 1869,
"data": {
"lead_id": 75,
"sale_user_id": 6,
"saler_name": "哦啦啦"
}
}
```
> 当前点击路由只根据 `type` 判断,`type = 13` 属于默认分支,因此进入消息中心;`data` 中的线索字段不会改变跳转页面。
## 7. 客户端解析约定
1. Android、iOS 的通知点击路由只根据业务字段 `type` 判断,不读取标题、正文、`data``route``uri``action` 或极光内部字段决定页面。
2. `type = 1`:进入收款记录页。
3. `type = 6`:进入收款详情页。
4. 其他任意 `type`、缺少 `type`、类型无法解析或 payload 损坏:进入消息中心。
5. Android 从 `notificationExtras` 或厂商通道包装字段中提取 `type`iOS 优先读取 payload 顶层 `type`,并兼容常见推送包装层中的 `type`
6. `data` 仍可按消息类型用于页面内容刷新,但不参与通知点击路由。新增字段应保持向后兼容,客户端应忽略当前版本不认识的额外字段。
7. 金额继续使用字符串;时间戳建议统一为 Unix 秒级整数,并在协议中固定单位。
8. `remark` 等可空业务字段建议固定返回空字符串或明确约定 `null`,不要在多种表示之间切换。
## 8. 新增消息类型记录模板
后续收到新类型时,复制以下内容追加到“消息类型清单”:
````markdown
### 6.x `type = <类型值>`<消息名称>
| 项目 | 当前值 |
| --- | --- |
| 首次发现日期 | `YYYY-MM-DD` |
| 标题示例 | `<title>` |
| 正文示例 | `<content>` |
| 客户端业务行为 | `<展示刷新或跳转行为>` |
`data` 字段:
| 字段 | 类型 | 必有 | 说明 |
| --- | --- | --- | --- |
| `<field>` | `<type>` | `<//待确认>` | `<说明>` |
Android `extras` 示例:
```json
{
"data": {},
"msg_id": 0,
"type": 0
}
```
iOS 业务字段示例:
```json
{
"data": {},
"msg_id": 0,
"type": 0
}
```
````
记录新类型时至少保留以下信息:
- Android 和 iOS 各一份原始收包日志。
- `type`、`msg_id` 和完整 `data`。
- 标题、正文以及消息点击后的期望页面。
- 每个字段的 JSON 类型、是否必有、空值表示方式和时间/金额单位。
- 同一业务事件在两端的字段是否一致。
## 9. 待后端确认项
| 项目 | 当前观察 | 需要确认 |
| --- | --- | --- |
| `msg_id` | 与极光消息 ID 不同 | 是否对应消息中心记录 ID以及是否用于已读/送达状态上报 |
| `data` | `type = 1`、`2`、`6`、`13` 的样本中都存在;`type = 2` 为 `{}` | 所有业务推送是否保证存在;无业务字段时是否统一返回 `{}` |
| 退款成功的 `data` | 当前为空对象,订单号和退款金额只在正文中 | 是否补充 `order_number`、`refund_amount`、`refund_time`、`order_type` 等结构化字段 |
| 其他类型的 `data` | 后端已定义消息名称,但尚未提供 payload | 分别确认 `type = 3``5`、`7``12`、`100`、`999` 的字段结构、必填项及点击行为 |
| `pay_time` | 当前为秒级时间戳 | 后端是否统一保证使用 Unix 秒,且时区语义固定为 UTC 时间戳 |
| `remark` | 无备注时为空字符串 | 是否可能返回 `null` 或省略 |
| `order_type` | 当前为 `14` | 完整枚举和值含义由哪份接口文档维护 |
| 标题与正文 | 当前均存在 | 是否为所有通知消息的必有字段 |
| 推送渠道字段 | 后端已给出渠道取值 `1`、`2`、`999` | payload 或发送接口中对应字段的准确名称 |
| `type = 12` | `MAP_TYPE_NAMES` 中漏配 | 后端补充映射,避免 `getTypeName(12)` 返回“未知消息类型” |
| 新增 `type` | 当前定义维护在 `PushMsg.php` | 后端新增类型前是否可同步类型值、字段结构和客户端行为 |

View File

@ -0,0 +1,204 @@
# 消息中心未读数量与全部已读接口需求
## 1. 需求背景
iOS App 需要实现以下功能:
1. 首页展示消息中心入口,有未读消息时显示红点。
2. 获取最新未读消息数量后,同步更新 App 桌面图标右上角的角标数量。
3. 用户点击某条未读消息并成功标记已读后,立即更新最新未读数量和桌面角标。
4. 消息中心导航栏右上角提供“全部已读”按钮。
5. 全部标记已读成功后,更新首页红点,并将桌面角标更新为服务端返回的最新未读数量。
## 2. 当前已有接口
### 2.1 获取消息列表
```http
GET /api/app/msg/list
```
当前参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `last_id` | int | 否 | 分页游标,首次请求传 `0` |
| `limit` | int | 否 | 每页数量 |
| `unread` | int | 否 | `0` 获取全部消息,`1` 只获取未读消息 |
该接口仅返回分页消息,没有返回未读消息总数。客户端若通过分页遍历全部未读消息来统计数量,请求次数和数据流量都会随着未读消息数量增长,因此不适合作为桌面角标的常规数据来源。
### 2.2 标记单条消息已读
```http
POST /api/app/msg/read?id={message_id}
```
当前接口可以标记单条消息已读,但成功响应没有返回操作后的最新未读数量。
## 3. 需要新增或调整的接口
### 3.1 获取未读消息总数
建议新增:
```http
GET /api/app/msg/unread-count
```
#### 请求说明
- 不需要业务请求参数。
- 根据当前登录 Token 识别用户及业务账号。
- 返回当前账号下尚未读取的消息总数。
#### 成功响应示例
```json
{
"code": 100000,
"msg": "success",
"data": {
"unread_count": 12
}
}
```
#### 字段说明
| 字段 | 类型 | 必有 | 说明 |
| --- | --- | --- | --- |
| `unread_count` | int | 是 | 当前账号的最新未读消息数量,最小值为 `0` |
### 3.2 全部标记为已读
建议新增:
```http
POST /api/app/msg/read-all
```
#### 请求说明
- 不需要业务请求参数和请求体。
- 根据当前登录 Token 识别用户及业务账号。
- 将当前账号下请求执行时已经存在的所有未读消息标记为已读。
- 接口需要支持幂等调用;没有未读消息时仍返回成功。
#### 成功响应示例
```json
{
"code": 100000,
"msg": "success",
"data": {
"updated_count": 12,
"unread_count": 0
}
}
```
#### 字段说明
| 字段 | 类型 | 必有 | 说明 |
| --- | --- | --- | --- |
| `updated_count` | int | 是 | 本次实际被更新为已读的消息数量 |
| `unread_count` | int | 是 | 操作完成后的最新未读消息数量 |
> 客户端应以响应中的 `unread_count` 为准,不应直接假定其一定为 `0`。例如“全部已读”执行期间如果有新消息到达,操作完成后仍可能存在新的未读消息。
### 3.3 调整单条已读接口响应
保留现有接口:
```http
POST /api/app/msg/read?id={message_id}
```
建议成功响应增加最新未读数量:
```json
{
"code": 100000,
"msg": "success",
"data": {
"unread_count": 11
}
}
```
#### 字段说明
| 字段 | 类型 | 必有 | 说明 |
| --- | --- | --- | --- |
| `unread_count` | int | 是 | 单条消息标记已读后的最新未读消息数量 |
#### 幂等要求
- 对已经处于已读状态的消息重复调用时,接口仍返回成功和最新 `unread_count`
- 消息不存在或不属于当前账号时,返回明确的业务错误,不能修改其他账号的消息。
## 4. 统一约定
### 4.1 账号范围
- 所有接口必须根据登录 Token 限定当前用户及当前业务账号的数据范围。
- 景区账号、门店账号等不同业务账号之间的未读数量不能互相污染。
- 切换账号后,客户端会重新调用未读数量接口。
### 4.2 数值约束
- `unread_count``updated_count` 必须返回 JSON 整数。
- 数值不得为负数。
- 即使数量为 `0`,字段也必须存在,不能返回 `null`、空字符串或省略字段。
### 4.3 一致性与并发
- 单条已读或全部已读操作及其响应中的 `unread_count` 应基于同一账号范围。
- `read-all` 应尽量在事务内完成批量更新和剩余未读数统计。
- 如果操作期间有新消息写入,响应应返回操作完成时数据库中的真实 `unread_count`
- 客户端始终以最近一次成功接口响应中的 `unread_count` 更新首页红点和桌面角标。
### 4.4 错误响应
沿用现有统一业务响应结构,例如:
```json
{
"code": 100001,
"msg": "消息不存在或无权操作",
"data": null
}
```
建议至少区分以下情况:
- Token 无效或登录已过期。
- 单条消息不存在。
- 单条消息不属于当前账号。
- 数据库更新失败。
## 5. 客户端调用时机
| 场景 | 调用接口 | 客户端处理 |
| --- | --- | --- |
| 登录后首次进入首页 | `GET /msg/unread-count` | 更新首页红点和桌面角标 |
| App 回到前台 | `GET /msg/unread-count` | 校准最新未读数量 |
| 前台收到新消息推送 | `GET /msg/unread-count` | 更新首页红点和桌面角标 |
| 点击一条未读消息 | `POST /msg/read` | 使用返回的 `unread_count` 更新红点和桌面角标 |
| 点击“全部已读” | `POST /msg/read-all` | 刷新消息列表,使用返回的 `unread_count` 更新红点和桌面角标 |
| 切换业务账号 | `GET /msg/unread-count` | 使用新账号的数量覆盖旧账号角标 |
| 退出登录 | 无 | 客户端将桌面角标清零 |
## 6. 验收标准
1. 未读数量为 `0` 时,接口稳定返回 `{"unread_count": 0}`
2. 存在多页未读消息时,未读数量接口返回完整总数,而不是当前页数量。
3. 单条未读消息标记成功后,返回的 `unread_count` 与数据库实际剩余未读数一致。
4. 对同一消息重复调用单条已读接口不会重复扣减未读数量。
5. 全部已读接口可一次处理当前账号的所有未读消息,并返回实际更新数量。
6. 重复调用全部已读接口仍成功,返回 `updated_count = 0`
7. 不同业务账号的未读数量相互隔离。
8. 并发到达新消息时,响应中的 `unread_count` 能反映操作完成时的真实剩余数量。
9. 所有接口继续使用项目现有统一鉴权、业务状态码和响应信封格式。

View File

@ -208,6 +208,11 @@
TargetAttributes = { TargetAttributes = {
93FF17C42FFB84950033C304 = { 93FF17C42FFB84950033C304 = {
CreatedOnToolsVersion = 26.6; CreatedOnToolsVersion = 26.6;
SystemCapabilities = {
com.apple.Push = {
enabled = 1;
};
};
}; };
A1B2C3D32FFB84950033C305 = { A1B2C3D32FFB84950033C305 = {
CreatedOnToolsVersion = 26.6; CreatedOnToolsVersion = 26.6;
@ -422,6 +427,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 5BBB56C2281879245220A9F3 /* Pods-suixinkan.debug.xcconfig */; baseConfigurationReference = 5BBB56C2281879245220A9F3 /* Pods-suixinkan.debug.xcconfig */;
buildSettings = { buildSettings = {
APS_ENVIRONMENT = development;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
@ -487,6 +493,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */; baseConfigurationReference = 1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */;
buildSettings = { buildSettings = {
APS_ENVIRONMENT = production;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;

View File

@ -29,6 +29,7 @@ final class NetworkServices {
let liveAPI: LiveAPI let liveAPI: LiveAPI
let cloudDriveAPI: CloudDriveAPI let cloudDriveAPI: CloudDriveAPI
let messageCenterAPI: MessageCenterAPI let messageCenterAPI: MessageCenterAPI
let pushAPI: PushAPI
let wildPhotographerReportAPI: WildPhotographerReportAPI let wildPhotographerReportAPI: WildPhotographerReportAPI
let uploadAPI: UploadAPI let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService let ossUploadService: OSSUploadService
@ -54,6 +55,7 @@ final class NetworkServices {
liveAPI = LiveAPI(client: client) liveAPI = LiveAPI(client: client)
cloudDriveAPI = CloudDriveAPI(client: client) cloudDriveAPI = CloudDriveAPI(client: client)
messageCenterAPI = MessageCenterAPI(client: client) messageCenterAPI = MessageCenterAPI(client: client)
pushAPI = PushAPI(client: client)
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client) wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
uploadAPI = UploadAPI(client: client) uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
@ -84,6 +86,7 @@ final class NetworkServices {
liveAPI = LiveAPI(client: apiClient) liveAPI = LiveAPI(client: apiClient)
cloudDriveAPI = CloudDriveAPI(client: apiClient) cloudDriveAPI = CloudDriveAPI(client: apiClient)
messageCenterAPI = MessageCenterAPI(client: apiClient) messageCenterAPI = MessageCenterAPI(client: apiClient)
pushAPI = PushAPI(client: apiClient)
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient) wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)

View File

@ -4,6 +4,7 @@
import IQKeyboardManagerSwift import IQKeyboardManagerSwift
import IQKeyboardToolbarManager import IQKeyboardToolbarManager
import UIKit import UIKit
import UserNotifications
@main @main
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@ -18,9 +19,36 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty { if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty {
AMapBootstrap.configureIfNeeded() AMapBootstrap.configureIfNeeded()
} }
PushNotificationManager.shared.initializeIfPrivacyAccepted(launchOptions: launchOptions)
return true return true
} }
/// APNs Device Token
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
PushNotificationManager.shared.handleDeviceToken(deviceToken)
}
/// APNs
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
PushNotificationManager.shared.handleRegistrationError(error)
}
///
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
PushNotificationManager.shared.handleRemoteNotification(userInfo)
completionHandler(.newData)
}
// MARK: UISceneSession Lifecycle // MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "payment_nalati_background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "payment_nalati_logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -0,0 +1,30 @@
import Foundation
/// Registration ID
@MainActor
protocol PushRegistrationServing: AnyObject {
/// Registration ID
func registerJPushID(_ registrationID: String) async throws
}
/// API Android 使 Registration ID
@MainActor
final class PushAPI: PushRegistrationServing {
private let client: APIClient
/// 使 API
init(client: APIClient) {
self.client = client
}
/// Registration ID沿 `jpush_reg_id`
func registerJPushID(_ registrationID: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/user/register-jpush-id",
queryItems: [URLQueryItem(name: "jpush_reg_id", value: registrationID)]
)
)
}
}

View File

@ -0,0 +1,86 @@
import Foundation
///
enum PushDestination: Sendable, Equatable {
case paymentRecord
case paymentDetails
case messageCenter
}
/// /APNs payload
struct PushPayload: Sendable, Equatable {
private let type: String
/// userInfo
nonisolated init(userInfo: [AnyHashable: Any]) {
type = Self.extractType(from: userInfo)
}
/// payload
nonisolated init(values: [String: String]) {
type = Self.normalizedType(values["type"])
}
///
nonisolated var normalizedValues: [String: String] {
type.isEmpty ? [:] : ["type": type]
}
///
nonisolated var destination: PushDestination {
switch type {
case "1":
return .paymentRecord
case "6":
return .paymentDetails
default:
return .messageCenter
}
}
private nonisolated static func extractType(
from dictionary: [AnyHashable: Any],
depth: Int = 0
) -> String {
guard depth <= 4 else { return "" }
let directType = normalizedType(dictionary["type"])
guard directType.isEmpty else { return directType }
for key in nestedTypeContainerKeys {
guard let value = dictionary[key] else { continue }
if let nested = value as? [AnyHashable: Any] {
let nestedType = extractType(from: nested, depth: depth + 1)
if !nestedType.isEmpty { return nestedType }
continue
}
guard let text = value as? String,
let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data),
let nested = object as? [String: Any]
else { continue }
let bridged = Dictionary(uniqueKeysWithValues: nested.map {
(AnyHashable($0.key), $0.value)
})
let nestedType = extractType(from: bridged, depth: depth + 1)
if !nestedType.isEmpty { return nestedType }
}
return ""
}
private nonisolated static func normalizedType(_ value: Any?) -> String {
switch value {
case let string as String:
return string.trimmingCharacters(in: .whitespacesAndNewlines)
case let number as NSNumber:
return number.stringValue
default:
return ""
}
}
private nonisolated static let nestedTypeContainerKeys = [
"extras", "extra", "JMessageExtra", "n_extras",
]
}

View File

@ -0,0 +1,422 @@
import UIKit
import UserNotifications
/// App 便
@MainActor
protocol ApplicationIconBadgeSetting: AnyObject {
///
func setBadgeCount(_ count: Int) async
}
/// 使 UserNotifications App
@MainActor
final class SystemApplicationIconBadgeSetter: ApplicationIconBadgeSetting {
///
static let shared = SystemApplicationIconBadgeSetter()
private init() {}
func setBadgeCount(_ count: Int) async {
do {
try await UNUserNotificationCenter.current().setBadgeCount(max(count, 0))
} catch {
#if DEBUG
print("Update application icon badge failed: \(error.localizedDescription)")
#endif
}
}
}
/// SDK 便使
@MainActor
protocol PushSDKProviding: AnyObject {
/// APNs
func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?, isProduction: Bool)
///
func requestAuthorization(delegate: JPUSHRegisterDelegate)
/// APNs Device Token
func registerDeviceToken(_ deviceToken: Data)
/// Registration ID
func fetchRegistrationID(completion: @escaping (Int32, String?) -> Void)
}
/// `@Sendable`
private final class RegistrationIDCompletionBox: @unchecked Sendable {
let completion: (Int32, String?) -> Void
init(completion: @escaping (Int32, String?) -> Void) {
self.completion = completion
}
}
/// SDK AppKey
@MainActor
final class JPushSDKAdapter: PushSDKProviding {
private static let appKey = "194a633c2a6b79c2f1033c40"
private static let channel = "App Store"
/// SDKDebug Release
func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?, isProduction: Bool) {
JPUSHService.setup(
withOption: launchOptions,
appKey: Self.appKey,
channel: Self.channel,
apsForProduction: isProduction
)
#if DEBUG
JPUSHService.setDebugMode()
#else
JPUSHService.setLogOFF()
#endif
}
/// alertbadgesound
func requestAuthorization(delegate: JPUSHRegisterDelegate) {
let entity = JPUSHRegisterEntity()
entity.types = 1 | 2 | 4
JPUSHService.register(forRemoteNotificationConfig: entity, delegate: delegate)
}
/// APNs Device Token
func registerDeviceToken(_ deviceToken: Data) {
JPUSHService.registerDeviceToken(deviceToken)
}
/// Registration ID
func fetchRegistrationID(completion: @escaping (Int32, String?) -> Void) {
let box = RegistrationIDCompletionBox(completion: completion)
JPUSHService.registrationIDCompletionHandler { resultCode, registrationID in
box.completion(resultCode, registrationID)
}
}
}
/// Registration ID
@MainActor
final class PushNotificationManager: NSObject {
///
static let shared = PushNotificationManager()
///
private enum Key {
static let registrationID = "key_jpush_registration_id"
static let uploadedRegistrationIDSuffix = "jpush_uploaded_registration_id"
}
private let sdk: any PushSDKProviding
private let api: any PushRegistrationServing
private let appStore: AppStore
private let defaults: UserDefaults
private let router: any PushRouting
private let applicationIconBadgeSetter: any ApplicationIconBadgeSetting
private var isInitialized = false
private var didRequestAuthorization = false
private var uploadTask: Task<Void, Never>?
private var queuedForcedUpload = false
private var isFetchingRegistrationID = false
private var queuedForcedFetch = false
/// 使
init(
sdk: (any PushSDKProviding)? = nil,
api: (any PushRegistrationServing)? = nil,
appStore: AppStore = .shared,
defaults: UserDefaults = .standard,
router: (any PushRouting)? = nil,
applicationIconBadgeSetter: (any ApplicationIconBadgeSetting)? = nil
) {
self.sdk = sdk ?? JPushSDKAdapter()
self.api = api ?? NetworkServices.shared.pushAPI
self.appStore = appStore
self.defaults = defaults
self.router = router ?? PushRouteCoordinator.shared
self.applicationIconBadgeSetter = applicationIconBadgeSetter ?? SystemApplicationIconBadgeSetter.shared
super.init()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
/// Scene
func attach(window: UIWindow) {
router.attach(window: window)
}
/// SDK
func initializeIfPrivacyAccepted(
launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) {
guard appStore.session.privacyAgreementAccepted, !isInitialized else { return }
isInitialized = true
sdk.initialize(launchOptions: launchOptions, isProduction: Self.isProductionBuild)
observeJPushNetworkLogin()
refreshRegistrationID(forceUpload: false)
}
///
func handleLoginCompleted() {
initializeIfPrivacyAccepted()
guard isInitialized, appStore.session.isLoggedIn else { return }
if !didRequestAuthorization {
didRequestAuthorization = true
sdk.requestAuthorization(delegate: self)
}
bindCurrentAccount()
}
///
func handleAccountSwitched() {
guard appStore.session.isLoggedIn else { return }
bindCurrentAccount()
}
/// 退
func handleLogout() {
uploadTask?.cancel()
uploadTask = nil
queuedForcedUpload = false
router.resetPendingRoute()
Task { await updateApplicationIconBadgeCount(0) }
}
/// App Icon
func updateApplicationIconBadgeCount(_ count: Int) async {
await applicationIconBadgeSetter.setBadgeCount(max(count, 0))
}
/// App Registration ID
func retryPendingRegistrationUpload() {
guard appStore.session.isLoggedIn else { return }
uploadCachedRegistrationID(force: false)
refreshRegistrationID(forceUpload: false)
}
///
func routePendingNotificationIfPossible() {
router.routePendingIfPossible()
}
/// APNs Device Token
func handleDeviceToken(_ deviceToken: Data) {
guard isInitialized else { return }
sdk.registerDeviceToken(deviceToken)
refreshRegistrationID(forceUpload: false)
}
/// APNs
func handleRegistrationError(_ error: Error) {
#if DEBUG
print("JPush APNs registration failed: \(error.localizedDescription)")
#endif
}
///
func handleNotificationTap(
payload: PushPayload,
requestIdentifier: String
) {
router.handle(
destination: payload.destination,
requestIdentifier: requestIdentifier
)
}
/// Scene
func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "tap(cold-start)")
JPUSHService.handleRemoteNotification(userInfo)
handleNotificationTap(
payload: PushPayload(userInfo: userInfo),
requestIdentifier: response.notification.request.identifier
)
}
///
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
Self.logReceivedPush(userInfo, source: "background")
JPUSHService.handleRemoteNotification(userInfo)
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
}
/// payload 便
private nonisolated static func logReceivedPush(
_ userInfo: [AnyHashable: Any],
source: String
) {
#if DEBUG
let payloadText: String
let normalized = Dictionary(uniqueKeysWithValues: userInfo.compactMap { key, value in
(key as? String).map { ($0, value) }
})
if JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(
withJSONObject: normalized,
options: [.prettyPrinted, .sortedKeys]
),
let text = String(data: data, encoding: .utf8) {
payloadText = text
} else {
payloadText = String(describing: userInfo)
}
print("[Push] received (\(source)):\n\(payloadText)")
#endif
}
private func observeJPushNetworkLogin() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleJPushNetworkLogin),
name: .jpfNetworkDidLogin,
object: nil,
)
}
@objc private func handleJPushNetworkLogin() {
refreshRegistrationID(forceUpload: false)
}
private func refreshRegistrationID(forceUpload: Bool) {
guard isInitialized else { return }
queuedForcedFetch = queuedForcedFetch || forceUpload
guard !isFetchingRegistrationID else { return }
isFetchingRegistrationID = true
sdk.fetchRegistrationID { [weak self] resultCode, registrationID in
Task { @MainActor in
guard let self else { return }
let shouldForceUpload = self.queuedForcedFetch
self.queuedForcedFetch = false
self.isFetchingRegistrationID = false
guard resultCode == 0 else { return }
let normalized = registrationID?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !normalized.isEmpty else { return }
self.defaults.set(normalized, forKey: Key.registrationID)
self.upload(registrationID: normalized, force: shouldForceUpload)
}
}
}
private func uploadCachedRegistrationID(force: Bool) {
let registrationID = cachedRegistrationID
guard !registrationID.isEmpty else { return }
upload(registrationID: registrationID, force: force)
}
private func bindCurrentAccount() {
if cachedRegistrationID.isEmpty {
refreshRegistrationID(forceUpload: true)
} else {
uploadCachedRegistrationID(force: true)
refreshRegistrationID(forceUpload: false)
}
}
private var cachedRegistrationID: String {
defaults.string(forKey: Key.registrationID)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
private func upload(registrationID: String, force: Bool) {
guard appStore.session.isLoggedIn,
let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix)
else { return }
if !force, defaults.string(forKey: uploadedKey) == registrationID {
return
}
if uploadTask != nil {
queuedForcedUpload = queuedForcedUpload || force
return
}
let accountScope = appStore.session.accountCachePrefix
uploadTask = Task { [weak self] in
guard let self else { return }
var succeeded = false
do {
try await self.api.registerJPushID(registrationID)
if self.appStore.session.accountCachePrefix == accountScope {
self.defaults.set(registrationID, forKey: uploadedKey)
}
succeeded = true
} catch is CancellationError {
// 退
} catch {
#if DEBUG
print("JPush Registration ID upload failed: \(error.localizedDescription)")
#endif
}
self.uploadTask = nil
let shouldForceAgain = self.queuedForcedUpload
self.queuedForcedUpload = false
if succeeded, shouldForceAgain {
self.uploadCachedRegistrationID(force: true)
}
}
}
private nonisolated static var isProductionBuild: Bool {
#if DEBUG
false
#else
true
#endif
}
}
extension PushNotificationManager: JPUSHRegisterDelegate {
/// UI
nonisolated func jpushNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (Int) -> Void
) {
let userInfo = notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "foreground")
JPUSHService.handleRemoteNotification(userInfo)
Task { @MainActor in
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
}
let options: UNNotificationPresentationOptions = [.banner, .list, .sound, .badge]
completionHandler(Int(options.rawValue))
}
/// Actor
nonisolated func jpushNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "tap")
JPUSHService.handleRemoteNotification(userInfo)
let payload = PushPayload(userInfo: userInfo)
let requestIdentifier = response.notification.request.identifier
Task { @MainActor [weak self] in
self?.handleNotificationTap(
payload: payload,
requestIdentifier: requestIdentifier
)
}
completionHandler()
}
///
nonisolated func jpushNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification
) {}
///
nonisolated func jpushNotificationAuthorization(
_ status: JPAuthorizationStatus,
withInfo info: [AnyHashable: Any]?
) {}
}

View File

@ -0,0 +1,98 @@
import UIKit
/// UIKit
@MainActor
protocol PushRouting: AnyObject {
/// Scene
func attach(window: UIWindow)
///
func handle(destination: PushDestination, requestIdentifier: String)
///
func routePendingIfPossible()
/// 退
func resetPendingRoute()
}
/// Tab
@MainActor
final class PushRouteCoordinator: PushRouting {
///
static let shared = PushRouteCoordinator()
///
private struct PendingRoute {
let destination: PushDestination
let requestIdentifier: String
}
private weak var window: UIWindow?
private var pendingRoute: PendingRoute?
private var handledRequestIdentifiers: [String] = []
private let appStore: AppStore
///
init(appStore: AppStore = .shared) {
self.appStore = appStore
}
/// Scene
func attach(window: UIWindow) {
self.window = window
}
/// request identifier
func handle(destination: PushDestination, requestIdentifier: String) {
let identifier = requestIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
if !identifier.isEmpty, handledRequestIdentifiers.contains(identifier) {
return
}
markHandled(identifier)
let route = PendingRoute(destination: destination, requestIdentifier: identifier)
guard appStore.session.isLoggedIn, mainTabController != nil else {
pendingRoute = route
return
}
perform(route)
}
///
func routePendingIfPossible() {
guard appStore.session.isLoggedIn,
mainTabController != nil,
let route = pendingRoute
else { return }
pendingRoute = nil
perform(route)
}
/// 退
func resetPendingRoute() {
pendingRoute = nil
}
private var mainTabController: MainTabBarController? {
window?.rootViewController as? MainTabBarController
}
private func perform(_ route: PendingRoute) {
guard let mainTabController else {
pendingRoute = route
return
}
mainTabController.dismiss(animated: false)
mainTabController.openPushDestination(route.destination)
}
private func markHandled(_ identifier: String) {
guard !identifier.isEmpty else { return }
handledRequestIdentifiers.append(identifier)
if handledRequestIdentifiers.count > 50 {
handledRequestIdentifiers.removeFirst(handledRequestIdentifiers.count - 50)
}
}
}

View File

@ -9,10 +9,40 @@ import Foundation
struct RolePermissionResponse: Codable, Equatable, Sendable { struct RolePermissionResponse: Codable, Equatable, Sendable {
let role: RoleInfo let role: RoleInfo
let scenic: [ScenicInfo] let scenic: [ScenicInfo]
let store: [RolePermissionStoreInfo]
init(role: RoleInfo, scenic: [ScenicInfo] = []) { enum CodingKeys: String, CodingKey {
case role
case scenic
case store
}
init(
role: RoleInfo,
scenic: [ScenicInfo] = [],
store: [RolePermissionStoreInfo] = []
) {
self.role = role self.role = role
self.scenic = scenic self.scenic = scenic
self.store = store
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
role = try container.decode(RoleInfo.self, forKey: .role)
scenic = try container.decodeIfPresent([ScenicInfo].self, forKey: .scenic) ?? []
store = try container.decodeIfPresent([RolePermissionStoreInfo].self, forKey: .store) ?? []
}
}
/// 使 `role-permission` `store`
struct RolePermissionStoreInfo: Codable, Equatable, Sendable {
let id: Int
let name: String
init(id: Int = 0, name: String = "") {
self.id = id
self.name = name
} }
} }

View File

@ -17,18 +17,23 @@ final class HomeCommonMenuStore {
/// URI /// URI
func savedCommonURIs(accountScope: String, roleCode: String) -> [String] { func savedCommonURIs(accountScope: String, roleCode: String) -> [String] {
guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return [] } guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return [] }
return defaults.stringArray(forKey: key) ?? [] let saved = defaults.stringArray(forKey: key) ?? []
let sanitized = saved.filter(HomeMenuCatalog.isCustomizable(uri:))
if sanitized != saved {
defaults.set(sanitized, forKey: key)
}
return sanitized
} }
/// URI /// URI
func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) { func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) {
guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return } guard let key = storageKey(accountScope: accountScope, roleCode: roleCode) else { return }
defaults.set(uris, forKey: key) defaults.set(uris.filter(HomeMenuCatalog.isCustomizable(uri:)), forKey: key)
} }
/// URI 4 /// URI 4
static func defaultCommonURIs(from permissions: [HomePermissionItem]) -> [String] { static func defaultCommonURIs(from permissions: [HomePermissionItem]) -> [String] {
let uris = permissions.map(\.uri) let uris = permissions.map(\.uri).filter(HomeMenuCatalog.isCustomizable(uri:))
return uris.count > 4 ? Array(uris.prefix(4)) : uris return uris.count > 4 ? Array(uris.prefix(4)) : uris
} }
@ -51,8 +56,9 @@ final class HomeCommonMenuStore {
commonURIs: [String] commonURIs: [String]
) -> (common: [HomeMenuItem], more: [HomeMenuItem]) { ) -> (common: [HomeMenuItem], more: [HomeMenuItem]) {
let commonSet = Set(commonURIs) let commonSet = Set(commonURIs)
let common = allMenus.filter { commonSet.contains($0.uri) } let customizableMenus = allMenus.filter { HomeMenuCatalog.isCustomizable(uri: $0.uri) }
let more = allMenus.filter { !commonSet.contains($0.uri) } let common = customizableMenus.filter { commonSet.contains($0.uri) }
let more = customizableMenus.filter { !commonSet.contains($0.uri) }
return (common, more) return (common, more)
} }

View File

@ -8,6 +8,8 @@ import Foundation
/// catalog permission URI /// catalog permission URI
enum HomeMenuCatalog { enum HomeMenuCatalog {
private static let fixedHomeEntryURIs: Set<String> = ["message_center"]
/// Android `Constants.menuList` /// Android `Constants.menuList`
static let allItems: [HomeMenuItem] = [ static let allItems: [HomeMenuItem] = [
HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"), HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"),
@ -45,6 +47,7 @@ enum HomeMenuCatalog {
/// ///
static func visibleMenus(from permissions: [HomePermissionItem]) -> [HomeMenuItem] { static func visibleMenus(from permissions: [HomePermissionItem]) -> [HomeMenuItem] {
permissions.compactMap { permission in permissions.compactMap { permission in
guard isCustomizable(uri: permission.uri) else { return nil }
guard let catalog = allItems.first(where: { $0.uri == permission.uri }) else { return nil } guard let catalog = allItems.first(where: { $0.uri == permission.uri }) else { return nil }
return HomeMenuItem( return HomeMenuItem(
uri: catalog.uri, uri: catalog.uri,
@ -59,4 +62,9 @@ enum HomeMenuCatalog {
static func item(for uri: String) -> HomeMenuItem? { static func item(for uri: String) -> HomeMenuItem? {
allItems.first { $0.uri == uri } allItems.first { $0.uri == uri }
} }
///
static func isCustomizable(uri: String) -> Bool {
!fixedHomeEntryURIs.contains(uri)
}
} }

View File

@ -48,6 +48,7 @@ final class AllFunctionsViewModel {
/// ///
func addToCommon(_ menu: HomeMenuItem) { func addToCommon(_ menu: HomeMenuItem) {
guard HomeMenuCatalog.isCustomizable(uri: menu.uri) else { return }
guard !commonMenus.contains(where: { $0.uri == menu.uri }) else { return } guard !commonMenus.contains(where: { $0.uri == menu.uri }) else { return }
commonMenus.append(menu) commonMenus.append(menu)
moreMenus.removeAll { $0.uri == menu.uri } moreMenus.removeAll { $0.uri == menu.uri }

View File

@ -25,6 +25,7 @@ final class HomeViewModel {
private(set) var reportTimeText = "" private(set) var reportTimeText = ""
private(set) var isLocationReporting = false private(set) var isLocationReporting = false
private(set) var needsPermissionReload = false private(set) var needsPermissionReload = false
private(set) var unreadMessageCount = 0
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)? var onShowMessage: ((String) -> Void)?
@ -65,6 +66,7 @@ final class HomeViewModel {
var isOnline: Bool { locationStateStore.isOnline } var isOnline: Bool { locationStateStore.isOnline }
var countdownDisplayText: String { locationStateStore.countdownDisplayText } var countdownDisplayText: String { locationStateStore.countdownDisplayText }
var reminderMinutes: Int { locationStateStore.reminderMinutes } var reminderMinutes: Int { locationStateStore.reminderMinutes }
var hasUnreadMessages: Bool { unreadMessageCount > 0 }
/// 线 /// 线
func initialize(api: HomeAPI) async { func initialize(api: HomeAPI) async {
@ -76,6 +78,28 @@ final class HomeViewModel {
/// ///
func markNeedsPermissionReload() { func markNeedsPermissionReload() {
needsPermissionReload = true needsPermissionReload = true
unreadMessageCount = 0
}
/// `true`
func refreshUnreadMessageStatus(api: any MessageCenterServing) async -> Bool {
do {
let response = try await api.unreadCount()
unreadMessageCount = response.unreadCount
notifyStateChange()
return true
} catch is CancellationError {
return false
} catch {
//
return false
}
}
/// 使
func updateUnreadMessageCount(_ count: Int) {
unreadMessageCount = max(count, 0)
notifyStateChange()
} }
/// ///

View File

@ -11,8 +11,14 @@ protocol MessageCenterServing {
/// ///
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse
/// ///
func markAsRead(messageId: Int) async throws func unreadCount() async throws -> MessageUnreadCountResponse
///
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse
///
func markAllAsRead() async throws -> MessageReadAllResponse
/// ///
func delete(messageId: Int) async throws func delete(messageId: Int) async throws
@ -43,9 +49,16 @@ final class MessageCenterAPI: MessageCenterServing {
) )
} }
/// GET /api/app/msg/unread-count
func unreadCount() async throws -> MessageUnreadCountResponse {
try await client.send(
APIRequest(method: .get, path: "/api/app/msg/unread-count")
)
}
/// POST /api/app/msg/read /// POST /api/app/msg/read
func markAsRead(messageId: Int) async throws { func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse {
let _: EmptyPayload = try await client.send( try await client.send(
APIRequest( APIRequest(
method: .post, method: .post,
path: "/api/app/msg/read", path: "/api/app/msg/read",
@ -54,6 +67,13 @@ final class MessageCenterAPI: MessageCenterServing {
) )
} }
/// POST /api/app/msg/read-all
func markAllAsRead() async throws -> MessageReadAllResponse {
try await client.send(
APIRequest(method: .post, path: "/api/app/msg/read-all")
)
}
/// POST /api/app/msg/delete /// POST /api/app/msg/delete
func delete(messageId: Int) async throws { func delete(messageId: Int) async throws {
let _: EmptyPayload = try await client.send( let _: EmptyPayload = try await client.send(

View File

@ -32,6 +32,46 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
} }
} }
///
struct MessageUnreadCountResponse: Decodable, Equatable, Sendable {
let unreadCount: Int
enum CodingKeys: String, CodingKey {
case unreadCount = "unread_count"
}
init(unreadCount: Int = 0) {
self.unreadCount = max(unreadCount, 0)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0)
}
}
///
struct MessageReadAllResponse: Decodable, Equatable, Sendable {
let updatedCount: Int
let unreadCount: Int
enum CodingKeys: String, CodingKey {
case updatedCount = "updated_count"
case unreadCount = "unread_count"
}
init(updatedCount: Int = 0, unreadCount: Int = 0) {
self.updatedCount = max(updatedCount, 0)
self.unreadCount = max(unreadCount, 0)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
updatedCount = max(try container.decodeLossyInt(forKey: .updatedCount) ?? 0, 0)
unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0)
}
}
/// Android `MsgResponse` /// Android `MsgResponse`
struct MessageListResponse: Decodable, Equatable, Sendable { struct MessageListResponse: Decodable, Equatable, Sendable {
let hasMore: Bool let hasMore: Bool

View File

@ -11,11 +11,13 @@ final class MessageCenterViewModel {
private(set) var isLoading = false private(set) var isLoading = false
private(set) var isRefreshing = false private(set) var isRefreshing = false
private(set) var isLoadingMore = false private(set) var isLoadingMore = false
private(set) var isMarkingAllAsRead = false
private(set) var canLoadMore = false private(set) var canLoadMore = false
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)? var onShowMessage: ((String) -> Void)?
var onOpenDetail: ((MessageItem) -> Void)? var onOpenDetail: ((MessageItem) -> Void)?
var onUnreadMessageCountChange: ((Int) -> Void)?
private let pageSize = 20 private let pageSize = 20
private var lastId = 0 private var lastId = 0
@ -55,9 +57,10 @@ final class MessageCenterViewModel {
} }
do { do {
try await api.markAsRead(messageId: id) let response = try await api.markAsRead(messageId: id)
let readItem = item.markedRead() let readItem = item.markedRead()
items = items.map { $0.id == id ? readItem : $0 } items = items.map { $0.id == id ? readItem : $0 }
notifyUnreadMessageCountChanged(response.unreadCount)
onOpenDetail?(readItem) onOpenDetail?(readItem)
} catch is CancellationError { } catch is CancellationError {
return return
@ -66,6 +69,30 @@ final class MessageCenterViewModel {
} }
} }
///
func markAllAsRead(api: any MessageCenterServing) async -> MessageReadAllResponse? {
guard !isLoading, !isMarkingAllAsRead else { return nil }
isMarkingAllAsRead = true
notifyStateChange()
defer {
isMarkingAllAsRead = false
notifyStateChange()
}
do {
let response = try await api.markAllAsRead()
items = items.map { $0.isRead ? $0 : $0.markedRead() }
notifyUnreadMessageCountChanged(response.unreadCount)
onShowMessage?(response.updatedCount > 0 ? "已全部标记为已读" : "暂无未读消息")
return response
} catch is CancellationError {
return nil
} catch {
onShowMessage?("全部标记已读失败,请稍后重试")
return nil
}
}
/// ///
func removeMessage(id: Int) { func removeMessage(id: Int) {
items.removeAll { $0.id == id } items.removeAll { $0.id == id }
@ -110,6 +137,16 @@ final class MessageCenterViewModel {
private func notifyStateChange() { private func notifyStateChange() {
onStateChange?() onStateChange?()
} }
private func notifyUnreadMessageCountChanged(_ count: Int) {
let normalizedCount = max(count, 0)
onUnreadMessageCountChange?(normalizedCount)
NotificationCenter.default.post(
name: NotificationName.unreadMessageCountDidChange,
object: nil,
userInfo: [NotificationUserInfoKey.unreadCount: normalizedCount]
)
}
} }
/// ViewModel /// ViewModel

View File

@ -25,6 +25,17 @@ final class PaymentAPI {
) )
} }
/// Logo
func payPageConfig(scenicId: Int) async throws -> PayPageConfigResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/pay-page-config",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
/// 7 /// 7
func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse { func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse {
try await client.send( try await client.send(

View File

@ -5,6 +5,83 @@
import Foundation import Foundation
///
struct PayPageConfigResponse: Decodable, Sendable, Equatable {
let background: String
let logo: String
let title: String
let subtitle: String
init(background: String, logo: String, title: String, subtitle: String) {
self.background = background
self.logo = logo
self.title = title
self.subtitle = subtitle
}
}
///
struct PayPageConfig: Codable, Sendable, Equatable {
let background: String
let logo: String
let title: String
let subtitle: String
///
static let nalatiDefault = PayPageConfig(
background: "",
logo: "",
title: "那拉提旅拍管理收费平台",
subtitle: "那拉提景区"
)
///
func merging(_ response: PayPageConfigResponse) -> PayPageConfig {
PayPageConfig(
background: Self.validRemoteImageURL(response.background) ?? background,
logo: Self.validRemoteImageURL(response.logo) ?? logo,
title: response.title.trimmedNonEmpty ?? title,
subtitle: response.subtitle.trimmedNonEmpty ?? subtitle
)
}
private static func validRemoteImageURL(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
url.scheme?.lowercased() == "https",
url.host != nil else {
return nil
}
return trimmed
}
}
///
enum PayPageBrandingPolicy {
/// ID
static let nalatiScenicId = 128
///
static func isNalati(scenicId: Int) -> Bool {
scenicId == nalatiScenicId
}
}
///
enum PaymentAccountDisplayFormatter {
/// 使
static func photographerName(userName: String, realName: String) -> String {
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-"
}
///
static func storeNames(_ names: [String]) -> String {
var seen = Set<String>()
let resolved = names.compactMap(\.trimmedNonEmpty).filter { seen.insert($0).inserted }
return resolved.isEmpty ? "-" : resolved.joined(separator: "")
}
}
/// URL /// URL
struct PayCodeResponse: Decodable, Sendable, Equatable { struct PayCodeResponse: Decodable, Sendable, Equatable {
let staticPayURL: String let staticPayURL: String
@ -101,3 +178,10 @@ struct PaymentMerchantInfo: Sendable, Equatable {
company: "扬州元智享网络科技有限公司" company: "扬州元智享网络科技有限公司"
) )
} }
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}

View File

@ -0,0 +1,51 @@
//
// PayPageConfigCache.swift
// suixinkan
//
import Foundation
/// ViewModel
protocol PayPageConfigCaching {
///
func config(scenicId: Int) -> PayPageConfig?
///
func save(_ config: PayPageConfig, scenicId: Int)
}
/// 使 UserDefaults
final class PayPageConfigCache: PayPageConfigCaching {
private static let version = "v1"
private let defaults: UserDefaults
private let environmentNamespace: String
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
/// 使 API
init(
defaults: UserDefaults = .standard,
environment: APIEnvironment = .current
) {
self.defaults = defaults
environmentNamespace = environment.baseURL.host ?? environment.baseURL.absoluteString
}
func config(scenicId: Int) -> PayPageConfig? {
guard scenicId > 0,
let data = defaults.data(forKey: key(scenicId: scenicId)) else {
return nil
}
return try? decoder.decode(PayPageConfig.self, from: data)
}
func save(_ config: PayPageConfig, scenicId: Int) {
guard scenicId > 0, let data = try? encoder.encode(config) else { return }
defaults.set(data, forKey: key(scenicId: scenicId))
}
private func key(scenicId: Int) -> String {
"payment.pay_page_config.\(Self.version).\(environmentNamespace).\(scenicId)"
}
}

View File

@ -17,6 +17,9 @@ final class PaymentCollectionDetailsViewModel {
private(set) var showAmountDialog = false private(set) var showAmountDialog = false
private(set) var amount = "" private(set) var amount = ""
private(set) var remark = "" private(set) var remark = ""
private(set) var scenicId = 0
private(set) var payPageConfig = PayPageConfig.nalatiDefault
private(set) var brandingRefreshVersion = 0
let merchantInfo = PaymentMerchantInfo.defaultInfo let merchantInfo = PaymentMerchantInfo.defaultInfo
@ -26,9 +29,21 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = "" private var staticPayURL = ""
private var dynamicPayURL = "" private var dynamicPayURL = ""
private let appStore: AppStore private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching
init(appStore: AppStore = .shared) { init(
appStore: AppStore = .shared,
payPageConfigCache: PayPageConfigCaching = PayPageConfigCache()
) {
self.appStore = appStore self.appStore = appStore
self.payPageConfigCache = payPageConfigCache
scenicId = appStore.session.currentScenicId
scenicName = appStore.session.currentScenicName
staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
if PayPageBrandingPolicy.isNalati(scenicId: scenicId) {
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
}
} }
var hasPayCode: Bool { var hasPayCode: Bool {
@ -39,10 +54,32 @@ final class PaymentCollectionDetailsViewModel {
scenicName.isEmpty ? "暂无景区" : scenicName scenicName.isEmpty ? "暂无景区" : scenicName
} }
var usesNalatiBranding: Bool {
PayPageBrandingPolicy.isNalati(scenicId: scenicId)
}
var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName(
userName: appStore.session.userName,
realName: appStore.session.realName
)
}
var displayStoreNames: String {
let names = appStore.permissions.rolePermissionList().flatMap(\.store).map(\.name)
return PaymentAccountDisplayFormatter.storeNames(names)
}
func refreshLocalState() { func refreshLocalState() {
scenicId = appStore.session.currentScenicId
scenicName = appStore.session.currentScenicName scenicName = appStore.session.currentScenicName
staffId = appStore.session.userId staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
if usesNalatiBranding {
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
} else {
payPageConfig = .nalatiDefault
}
notifyStateChange() notifyStateChange()
} }
@ -56,7 +93,7 @@ final class PaymentCollectionDetailsViewModel {
notifyStateChange() notifyStateChange()
} }
let scenicId = appStore.session.currentScenicId let scenicId = self.scenicId
guard scenicId > 0 else { guard scenicId > 0 else {
clearPayCode() clearPayCode()
return return
@ -72,8 +109,27 @@ final class PaymentCollectionDetailsViewModel {
} }
} }
func loadPayPageConfig(api: PaymentAPI) async {
let requestedScenicId = appStore.session.currentScenicId
guard PayPageBrandingPolicy.isNalati(scenicId: requestedScenicId) else { return }
do {
let response = try await api.payPageConfig(scenicId: requestedScenicId)
guard appStore.session.currentScenicId == requestedScenicId else { return }
let resolved = payPageConfig.merging(response)
payPageConfigCache.save(resolved, scenicId: requestedScenicId)
payPageConfig = resolved
brandingRefreshVersion += 1
notifyStateChange()
} catch {
//
}
}
func refresh(api: PaymentAPI) async { func refresh(api: PaymentAPI) async {
await loadPayCode(api: api) async let payCode: Void = loadPayCode(api: api)
async let pageConfig: Void = loadPayPageConfig(api: api)
_ = await (payCode, pageConfig)
} }
func showSetAmountDialog() { func showSetAmountDialog() {

View File

@ -42,5 +42,9 @@
</array> </array>
</dict> </dict>
</dict> </dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@ -24,7 +24,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
self.window = window self.window = window
(UIApplication.shared.delegate as? AppDelegate)?.window = window (UIApplication.shared.delegate as? AppDelegate)?.window = window
PushNotificationManager.shared.attach(window: window)
registerNotifications() registerNotifications()
if AppStore.shared.session.isLoggedIn {
DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted()
}
}
if let response = connectionOptions.notificationResponse {
PushNotificationManager.shared.handleNotificationResponse(response)
}
} }
func sceneDidDisconnect(_ scene: UIScene) { func sceneDidDisconnect(_ scene: UIScene) {
@ -43,6 +54,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
_ = WeChatManager.shared.handleContinueUserActivity(userActivity) _ = WeChatManager.shared.handleContinueUserActivity(userActivity)
} }
func sceneDidBecomeActive(_ scene: UIScene) {
PushNotificationManager.shared.retryPendingRegistrationUpload()
}
private func registerNotifications() { private func registerNotifications() {
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
@ -62,19 +77,35 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
name: NotificationName.userDidLogin, name: NotificationName.userDidLogin,
object: nil object: nil
) )
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAccountDidSwitch),
name: NotificationName.accountDidSwitch,
object: nil
)
} }
@objc private func handleSessionDidExpire() { @objc private func handleSessionDidExpire() {
PushNotificationManager.shared.handleLogout()
AppStore.shared.logout() AppStore.shared.logout()
AppRouter.setRoot(.login, on: window) AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogout() { @objc private func handleUserDidLogout() {
PushNotificationManager.shared.handleLogout()
AppStore.shared.logout() AppStore.shared.logout()
AppRouter.setRoot(.login, on: window) AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogin() { @objc private func handleUserDidLogin() {
AppRouter.setRoot(.mainTab, on: window) AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted()
PushNotificationManager.shared.routePendingNotificationIfPossible()
}
}
@objc private func handleAccountDidSwitch() {
PushNotificationManager.shared.handleAccountSwitched()
} }
} }

View File

@ -13,6 +13,7 @@ final class HomeViewController: BaseViewController {
private let viewModel = HomeViewModel() private let viewModel = HomeViewModel()
private let homeAPI = NetworkServices.shared.homeAPI private let homeAPI = NetworkServices.shared.homeAPI
private let messageCenterAPI = NetworkServices.shared.messageCenterAPI
private let scenicHeaderView = HomeScenicHeaderView() private let scenicHeaderView = HomeScenicHeaderView()
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
@ -74,6 +75,7 @@ final class HomeViewController: BaseViewController {
Task { @MainActor in self?.showToast(message) } Task { @MainActor in self?.showToast(message) }
} }
scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() } scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() }
scenicHeaderView.onMessageTap = { [weak self] in self?.openMessageCenter() }
collectionView.delegate = self collectionView.delegate = self
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@ -88,11 +90,26 @@ final class HomeViewController: BaseViewController {
name: NotificationName.scenicDidChange, name: NotificationName.scenicDidChange,
object: nil object: nil
) )
NotificationCenter.default.addObserver(
self,
selector: #selector(handleUnreadMessageCountDidChange(_:)),
name: NotificationName.unreadMessageCountDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleUnreadMessageCountDidChange(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.setNavigationBarHidden(true, animated: animated)
if hasInitialized {
Task { await refreshUnreadMessageStatus() }
}
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
@ -272,6 +289,7 @@ final class HomeViewController: BaseViewController {
private func initializeHome() async { private func initializeHome() async {
showLoading() showLoading()
await viewModel.initialize(api: homeAPI) await viewModel.initialize(api: homeAPI)
await refreshUnreadMessageStatus()
hideLoading() hideLoading()
applyViewModel() applyViewModel()
@ -287,7 +305,10 @@ final class HomeViewController: BaseViewController {
@MainActor @MainActor
private func applyViewModel() { private func applyViewModel() {
scenicHeaderView.apply(scenicName: viewModel.currentScenicName) scenicHeaderView.apply(
scenicName: viewModel.currentScenicName,
hasUnreadMessages: viewModel.hasUnreadMessages
)
let showWorkBlock = !viewModel.isMinimalTopRole let showWorkBlock = !viewModel.isMinimalTopRole
let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil
@ -499,6 +520,10 @@ final class HomeViewController: BaseViewController {
pushScenicSelection() pushScenicSelection()
} }
private func openMessageCenter() {
navigationController?.pushViewController(MessageCenterViewController(), animated: true)
}
private func pushScenicSelection() { private func pushScenicSelection() {
navigationController?.setNavigationBarHidden(false, animated: true) navigationController?.setNavigationBarHidden(false, animated: true)
let controller = ScenicSelectionViewController() let controller = ScenicSelectionViewController()
@ -529,6 +554,7 @@ final class HomeViewController: BaseViewController {
viewModel.markNeedsPermissionReload() viewModel.markNeedsPermissionReload()
Task { Task {
await viewModel.reloadIfNeeded(api: homeAPI) await viewModel.reloadIfNeeded(api: homeAPI)
await refreshUnreadMessageStatus()
applyViewModel() applyViewModel()
await evaluateDialogsWithDelay() await evaluateDialogsWithDelay()
} }
@ -541,6 +567,21 @@ final class HomeViewController: BaseViewController {
applyViewModel() applyViewModel()
} }
} }
@objc private func handleUnreadMessageCountDidChange(_ notification: Notification) {
guard hasInitialized else { return }
if let count = notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int {
viewModel.updateUnreadMessageCount(count)
Task { await PushNotificationManager.shared.updateApplicationIconBadgeCount(count) }
} else {
Task { await refreshUnreadMessageStatus() }
}
}
private func refreshUnreadMessageStatus() async {
guard await viewModel.refreshUnreadMessageStatus(api: messageCenterAPI) else { return }
await PushNotificationManager.shared.updateApplicationIconBadgeCount(viewModel.unreadMessageCount)
}
} }
extension HomeViewController: UICollectionViewDelegate { extension HomeViewController: UICollectionViewDelegate {

View File

@ -9,10 +9,16 @@ import UIKit
/// ///
final class HomeScenicHeaderView: UIView { final class HomeScenicHeaderView: UIView {
///
var onTap: (() -> Void)? var onTap: (() -> Void)?
///
var onMessageTap: (() -> Void)?
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down")) private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down"))
private let messageButton = UIButton(type: .system)
private let unreadDotView = UIView()
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -25,8 +31,24 @@ final class HomeScenicHeaderView: UIView {
arrowView.contentMode = .scaleAspectFit arrowView.contentMode = .scaleAspectFit
arrowView.setContentCompressionResistancePriority(.required, for: .horizontal) arrowView.setContentCompressionResistancePriority(.required, for: .horizontal)
var messageConfiguration = UIButton.Configuration.plain()
messageConfiguration.image = UIImage(systemName: "bell")
messageConfiguration.baseForegroundColor = AppColor.textPrimary
messageConfiguration.contentInsets = .zero
messageButton.configuration = messageConfiguration
messageButton.accessibilityIdentifier = "home_message_button"
messageButton.addTarget(self, action: #selector(handleMessageTap), for: .touchUpInside)
unreadDotView.backgroundColor = AppColor.danger
unreadDotView.layer.cornerRadius = 4
unreadDotView.isUserInteractionEnabled = false
unreadDotView.isHidden = true
unreadDotView.accessibilityIdentifier = "home_message_unread_dot"
addSubview(titleLabel) addSubview(titleLabel)
addSubview(arrowView) addSubview(arrowView)
addSubview(messageButton)
messageButton.addSubview(unreadDotView)
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.leading.equalTo(safeAreaLayoutGuide).offset(AppSpacing.screenHorizontalInset) make.leading.equalTo(safeAreaLayoutGuide).offset(AppSpacing.screenHorizontalInset)
@ -34,12 +56,23 @@ final class HomeScenicHeaderView: UIView {
} }
arrowView.snp.makeConstraints { make in arrowView.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs) make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs)
make.trailing.lessThanOrEqualTo(safeAreaLayoutGuide).offset(-AppSpacing.screenHorizontalInset) make.trailing.lessThanOrEqualTo(messageButton.snp.leading).offset(-AppSpacing.xs)
make.centerY.equalTo(safeAreaLayoutGuide) make.centerY.equalTo(safeAreaLayoutGuide)
make.width.height.equalTo(16) make.width.height.equalTo(16)
} }
messageButton.snp.makeConstraints { make in
make.trailing.equalTo(safeAreaLayoutGuide).offset(-AppSpacing.screenHorizontalInset)
make.centerY.equalTo(safeAreaLayoutGuide)
make.width.height.equalTo(44)
}
unreadDotView.snp.makeConstraints { make in
make.centerX.equalTo(messageButton.snp.centerX).offset(9)
make.centerY.equalTo(messageButton.snp.centerY).offset(-9)
make.width.height.equalTo(8)
}
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tap.delegate = self
addGestureRecognizer(tap) addGestureRecognizer(tap)
} }
@ -48,12 +81,26 @@ final class HomeScenicHeaderView: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func apply(scenicName: String) { ///
func apply(scenicName: String, hasUnreadMessages: Bool) {
let trimmed = scenicName.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = trimmed.isEmpty ? "请选择景区" : trimmed titleLabel.text = trimmed.isEmpty ? "请选择景区" : trimmed
unreadDotView.isHidden = !hasUnreadMessages
messageButton.accessibilityLabel = hasUnreadMessages ? "消息中心,有未读消息" : "消息中心"
} }
@objc private func handleTap() { @objc private func handleTap() {
onTap?() onTap?()
} }
@objc private func handleMessageTap() {
onMessageTap?()
}
}
extension HomeScenicHeaderView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard let touchedView = touch.view else { return true }
return touchedView !== messageButton && !touchedView.isDescendant(of: messageButton)
}
} }

View File

@ -65,6 +65,29 @@ final class MainTabBarController: UITabBarController {
} }
} }
/// Tab
func openPushDestination(_ destination: PushDestination) {
let targetTab = AppTab.home
selectTab(targetTab)
guard let navigationController = tabNavigationControllers[targetTab] else { return }
_ = navigationController.popToRootViewController(animated: false)
let controller: UIViewController?
switch destination {
case .paymentRecord:
controller = PaymentCollectionRecordViewController()
case .paymentDetails:
controller = PaymentCollectionDetailsViewController()
case .messageCenter:
controller = MessageCenterViewController()
}
if let controller {
navigationController.pushViewController(controller, animated: false)
}
}
private func configureTabBarAppearance() { private func configureTabBarAppearance() {
tabBar.tintColor = AppColor.primary tabBar.tintColor = AppColor.primary
tabBar.unselectedItemTintColor = AppColor.textTabInactive tabBar.unselectedItemTintColor = AppColor.textTabInactive

View File

@ -19,6 +19,12 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
private let loadingIndicator = UIActivityIndicatorView(style: .medium) private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let footerSpinner = UIActivityIndicatorView(style: .medium) private let footerSpinner = UIActivityIndicatorView(style: .medium)
private let emptyLabel = UILabel() private let emptyLabel = UILabel()
private lazy var markAllReadButton = UIBarButtonItem(
title: "全部已读",
style: .plain,
target: self,
action: #selector(markAllReadTapped)
)
private var dataSource: UITableViewDiffableDataSource<Section, MessageItem>! private var dataSource: UITableViewDiffableDataSource<Section, MessageItem>!
@ -46,6 +52,7 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
override func setupNavigationBar() { override func setupNavigationBar() {
title = "消息中心" title = "消息中心"
navigationItem.rightBarButtonItem = markAllReadButton
} }
override func setupUI() { override func setupUI() {
@ -79,7 +86,8 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
override func setupConstraints() { override func setupConstraints() {
tableView.snp.makeConstraints { make in tableView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide) make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.bottom.equalToSuperview()
} }
loadingIndicator.snp.makeConstraints { make in loadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview() make.center.equalToSuperview()
@ -103,6 +111,11 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
self?.openDetail(message) self?.openDetail(message)
} }
} }
viewModel.onUnreadMessageCountChange = { count in
Task { @MainActor in
await PushNotificationManager.shared.updateApplicationIconBadgeCount(count)
}
}
} }
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
@ -125,6 +138,12 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
} }
} }
@objc private func markAllReadTapped() {
Task { @MainActor in
await viewModel.markAllAsRead(api: api)
}
}
private func configureDataSource() { private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Section, MessageItem>(tableView: tableView) { tableView, indexPath, item in dataSource = UITableViewDiffableDataSource<Section, MessageItem>(tableView: tableView) { tableView, indexPath, item in
let cell = tableView.dequeueReusableCell( let cell = tableView.dequeueReusableCell(
@ -154,6 +173,7 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
} }
tableView.tableFooterView = footerView() tableView.tableFooterView = footerView()
markAllReadButton.isEnabled = !viewModel.isLoading && !viewModel.isMarkingAllAsRead
} }
private func applySnapshot(animated: Bool) { private func applySnapshot(animated: Bool) {

View File

@ -3,6 +3,7 @@
// suixinkan // suixinkan
// //
import Kingfisher
import SnapKit import SnapKit
import UIKit import UIKit
@ -13,10 +14,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let paymentAPI = NetworkServices.shared.paymentAPI private let paymentAPI = NetworkServices.shared.paymentAPI
private let scrollView = UIScrollView() private let scrollView = UIScrollView()
private let contentContainerView = UIView()
private let contentStack = UIStackView() private let contentStack = UIStackView()
private let backgroundImageView = UIImageView()
private let brandHeaderStack = UIStackView()
private let brandLogoImageView = UIImageView()
private let brandTitleLabel = UILabel()
private let brandSubtitleLabel = UILabel()
private let qrCardView = UIView() private let qrCardView = UIView()
private let scenicNameLabel = UILabel() private let scenicNameLabel = UILabel()
private let qrContainerView = UIView()
private let qrImageView = UIImageView() private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView() private let qrPlaceholderView = UIView()
private let qrErrorLabel = UILabel() private let qrErrorLabel = UILabel()
@ -29,26 +38,75 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let scenicRow = PaymentInfoRowView(title: "景区名称") private let scenicRow = PaymentInfoRowView(title: "景区名称")
private let merchantRow = PaymentInfoRowView(title: "收款方") private let merchantRow = PaymentInfoRowView(title: "收款方")
private let staffRow = PaymentInfoRowView(title: "员工 ID") private let staffRow = PaymentInfoRowView(title: "员工 ID")
private let photographerRow = PaymentInfoRowView(title: "摄影师名称")
private let storeRow = PaymentInfoRowView(title: "店铺名称")
private let recordRow = PaymentNavigationRowView(title: "收款记录") private let recordRow = PaymentNavigationRowView(title: "收款记录")
private let settingsCardView = UIView()
private let voiceCardView = UIView() private let voiceCardView = UIView()
private let voiceTitleLabel = UILabel() private let voiceTitleLabel = UILabel()
private let voiceSwitch = UISwitch() private let voiceSwitch = UISwitch()
private var amountDialog: PaymentSetAmountDialogView? private var amountDialog: PaymentSetAmountDialogView?
private var appliedBrandConfig: PayPageConfig?
private var appliedBrandingRefreshVersion = -1
private var previousStandardAppearance: UINavigationBarAppearance?
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
private var previousCompactAppearance: UINavigationBarAppearance?
private var previousNavigationTintColor: UIColor?
private var previousNavigationTranslucency = false
private var brandScreenHeight: CGFloat {
UIScreen.main.bounds.height
}
private var brandLogoHeight: CGFloat {
if brandScreenHeight <= 700 { return 82 }
if brandScreenHeight <= 820 { return 96 }
return 112
}
private var brandQRContainerSize: CGFloat {
if brandScreenHeight <= 700 { return 170 }
if brandScreenHeight <= 820 { return 190 }
return 210
}
private var brandQRImageSize: CGFloat {
if brandScreenHeight <= 700 { return 146 }
if brandScreenHeight <= 820 { return 166 }
return 182
}
private var brandSectionSpacing: CGFloat {
if brandScreenHeight <= 700 { return 8 }
if brandScreenHeight <= 820 { return 10 }
return 12
}
private var brandHeaderToQRSpacing: CGFloat {
if brandScreenHeight <= 700 { return 16 }
if brandScreenHeight <= 820 { return 18 }
return 20
}
override func setupNavigationBar() { override func setupNavigationBar() {
title = "收款详情" title = viewModel.usesNalatiBranding ? nil : "收款详情"
} }
override func setupUI() { override func setupUI() {
view.backgroundColor = AppColor.pageBackground let usesBranding = viewModel.usesNalatiBranding
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
scrollView.showsVerticalScrollIndicator = !usesBranding
scrollView.isScrollEnabled = !usesBranding
scrollView.alwaysBounceVertical = false
contentStack.axis = .vertical contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
qrCardView.backgroundColor = .white qrCardView.backgroundColor = usesBranding ? .clear : .white
qrCardView.layer.cornerRadius = AppRadius.lg qrCardView.layer.cornerRadius = AppRadius.lg
scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium) scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium)
@ -57,9 +115,16 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrImageView.contentMode = .scaleAspectFit qrImageView.contentMode = .scaleAspectFit
qrImageView.isHidden = true qrImageView.isHidden = true
if usesBranding {
qrContainerView.backgroundColor = .white
qrContainerView.layer.cornerRadius = 10
qrContainerView.clipsToBounds = true
qrContainerView.isHidden = true
qrContainerView.addSubview(qrImageView)
}
qrPlaceholderView.backgroundColor = AppColor.inputBackground qrPlaceholderView.backgroundColor = AppColor.inputBackground
qrPlaceholderView.layer.cornerRadius = 24 qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
qrErrorLabel.text = "未选景区或网络问题" qrErrorLabel.text = "未选景区或网络问题"
@ -73,19 +138,29 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
actionRow.axis = .horizontal actionRow.axis = .horizontal
actionRow.alignment = .center actionRow.alignment = .center
actionRow.spacing = AppSpacing.xl actionRow.spacing = usesBranding ? AppSpacing.lg : AppSpacing.xl
if usesBranding {
actionRow.backgroundColor = UIColor.white.withAlphaComponent(0.88)
actionRow.layer.cornerRadius = 24
actionRow.isLayoutMarginsRelativeArrangement = true
actionRow.directionalLayoutMargins = NSDirectionalEdgeInsets(
top: 10,
leading: 24,
bottom: 10,
trailing: 24
)
}
configureLinkButton(setAmountButton, title: "设置金额") configureLinkButton(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码") configureLinkButton(saveQRButton, title: "保存二维码")
infoCardView.backgroundColor = .white configureCard(infoCardView, branded: usesBranding)
infoCardView.layer.cornerRadius = AppRadius.lg
recordRow.backgroundColor = .white recordRow.backgroundColor = usesBranding ? .clear : .white
recordRow.layer.cornerRadius = AppRadius.lg recordRow.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
voiceCardView.backgroundColor = .white voiceCardView.backgroundColor = usesBranding ? .clear : .white
voiceCardView.layer.cornerRadius = AppRadius.lg voiceCardView.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
voiceTitleLabel.text = "收款到账语音提醒" voiceTitleLabel.text = "收款到账语音提醒"
voiceTitleLabel.font = .systemFont(ofSize: 14) voiceTitleLabel.font = .systemFont(ofSize: 14)
@ -93,49 +168,112 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
voiceSwitch.onTintColor = AppColor.primary voiceSwitch.onTintColor = AppColor.primary
view.addSubview(scrollView) if usesBranding {
scrollView.addSubview(contentStack) configureBrandHeader()
backgroundImageView.contentMode = .scaleAspectFill
backgroundImageView.clipsToBounds = true
backgroundImageView.image = UIImage(named: "payment_nalati_background")
view.addSubview(backgroundImageView)
}
view.addSubview(scrollView)
scrollView.addSubview(contentContainerView)
contentContainerView.addSubview(contentStack)
if usesBranding {
contentStack.addArrangedSubview(brandHeaderStack)
contentStack.setCustomSpacing(brandHeaderToQRSpacing, after: brandHeaderStack)
}
contentStack.addArrangedSubview(qrCardView) contentStack.addArrangedSubview(qrCardView)
contentStack.addArrangedSubview(infoCardView) contentStack.addArrangedSubview(infoCardView)
if usesBranding {
configureCard(settingsCardView, branded: true)
contentStack.addArrangedSubview(settingsCardView)
} else {
contentStack.addArrangedSubview(recordRow) contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView) contentStack.addArrangedSubview(voiceCardView)
}
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
let qrContentStack = UIStackView(arrangedSubviews: [ let qrContentStack = UIStackView(arrangedSubviews: [
scenicNameLabel, scenicNameLabel,
qrImageView, qrDisplayView,
qrPlaceholderView, qrPlaceholderView,
actionRow, actionRow,
]) ])
qrContentStack.axis = .vertical qrContentStack.axis = .vertical
qrContentStack.alignment = .center qrContentStack.alignment = .center
qrContentStack.spacing = AppSpacing.lg qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
qrCardView.addSubview(qrContentStack) qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrErrorLabel) qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton) qrPlaceholderView.addSubview(refreshButton)
actionRow.addArrangedSubview(setAmountButton) actionRow.addArrangedSubview(setAmountButton)
if usesBranding {
let divider = UIView()
divider.backgroundColor = UIColor(hex: 0xD6E1EE)
actionRow.addArrangedSubview(divider)
divider.snp.makeConstraints { make in
make.width.equalTo(1)
make.height.equalTo(24)
}
}
actionRow.addArrangedSubview(saveQRButton) actionRow.addArrangedSubview(saveQRButton)
let infoStack = UIStackView(arrangedSubviews: [scenicRow, merchantRow, staffRow]) let infoRows = usesBranding
? [photographerRow, storeRow]
: [scenicRow, merchantRow, staffRow]
let infoStack = UIStackView(arrangedSubviews: infoRows)
infoStack.axis = .vertical infoStack.axis = .vertical
infoCardView.addSubview(infoStack) infoCardView.addSubview(infoStack)
voiceCardView.addSubview(voiceTitleLabel) voiceCardView.addSubview(voiceTitleLabel)
voiceCardView.addSubview(voiceSwitch) voiceCardView.addSubview(voiceSwitch)
qrContentStack.snp.makeConstraints { make in if usesBranding {
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 32, left: 16, bottom: 32, right: 16)) let settingsStack = UIStackView(arrangedSubviews: [recordRow, voiceCardView])
settingsStack.axis = .vertical
settingsCardView.addSubview(settingsStack)
settingsStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
} }
let divider = UIView()
divider.backgroundColor = UIColor(hex: 0xD9E2EA).withAlphaComponent(0.75)
settingsCardView.addSubview(divider)
divider.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.height.equalTo(0.5)
}
}
qrContentStack.snp.makeConstraints { make in
let verticalInset: CGFloat = usesBranding ? 0 : 32
make.edges.equalToSuperview().inset(
UIEdgeInsets(top: verticalInset, left: 16, bottom: verticalInset, right: 16)
)
}
scenicNameLabel.isHidden = usesBranding
scenicNameLabel.snp.makeConstraints { make in scenicNameLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
} }
if usesBranding {
qrContainerView.snp.makeConstraints { make in
make.width.height.equalTo(brandQRContainerSize)
}
qrImageView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.height.equalTo(brandQRImageSize)
}
} else {
qrImageView.snp.makeConstraints { make in qrImageView.snp.makeConstraints { make in
make.width.height.equalTo(182) make.width.height.equalTo(182)
} }
}
qrPlaceholderView.snp.makeConstraints { make in qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(182) make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
} }
qrErrorLabel.snp.makeConstraints { make in qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56) make.top.equalToSuperview().offset(56)
@ -162,12 +300,36 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
} }
override func setupConstraints() { override func setupConstraints() {
if viewModel.usesNalatiBranding {
backgroundImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
scrollView.snp.makeConstraints { make in scrollView.snp.makeConstraints { make in
if viewModel.usesNalatiBranding {
make.leading.trailing.equalToSuperview()
make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(-44)
make.bottom.equalTo(view.safeAreaLayoutGuide)
} else {
make.edges.equalTo(view.safeAreaLayoutGuide) make.edges.equalTo(view.safeAreaLayoutGuide)
} }
}
contentContainerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width)
if viewModel.usesNalatiBranding {
make.height.equalTo(scrollView.snp.height)
}
}
contentStack.snp.makeConstraints { make in contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
if viewModel.usesNalatiBranding {
make.centerX.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview()
make.bottom.lessThanOrEqualToSuperview()
} else {
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
} }
} }
@ -188,13 +350,30 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
applyViewModel()
Task { await loadData() } Task { await loadData() }
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyBrandNavigationAppearanceIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
restoreNavigationAppearanceIfNeeded()
}
override var preferredStatusBarStyle: UIStatusBarStyle {
viewModel.usesNalatiBranding ? .darkContent : .default
}
private func loadData() async { private func loadData() async {
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
showLoading() showLoading()
await viewModel.loadPayCode(api: paymentAPI) await viewModel.loadPayCode(api: paymentAPI)
hideLoading() hideLoading()
await pageConfig
} }
@MainActor @MainActor
@ -203,15 +382,20 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
scenicRow.setValue(viewModel.displayScenicName) scenicRow.setValue(viewModel.displayScenicName)
merchantRow.setValue(viewModel.merchantInfo.company) merchantRow.setValue(viewModel.merchantInfo.company)
staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId) staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId)
photographerRow.setValue(viewModel.displayPhotographerName)
storeRow.setValue(viewModel.displayStoreNames)
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
applyBrandConfigIfNeeded()
if let image = viewModel.qrImage { if let image = viewModel.qrImage {
qrImageView.image = image qrImageView.image = image
qrImageView.isHidden = false qrImageView.isHidden = false
qrContainerView.isHidden = !viewModel.usesNalatiBranding
qrPlaceholderView.isHidden = true qrPlaceholderView.isHidden = true
actionRow.isHidden = false actionRow.isHidden = false
} else { } else {
qrImageView.isHidden = true qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.isHidden = false qrPlaceholderView.isHidden = false
actionRow.isHidden = true actionRow.isHidden = true
} }
@ -254,6 +438,181 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
button.titleLabel?.font = .systemFont(ofSize: 14) button.titleLabel?.font = .systemFont(ofSize: 14)
} }
private func configureCard(_ view: UIView, branded: Bool) {
view.backgroundColor = branded ? UIColor.white.withAlphaComponent(0.9) : .white
view.layer.cornerRadius = branded ? 18 : AppRadius.lg
view.clipsToBounds = true
}
private func configureBrandHeader() {
brandHeaderStack.axis = .vertical
brandHeaderStack.alignment = .center
brandHeaderStack.spacing = brandScreenHeight <= 700 ? 6 : 8
brandLogoImageView.image = UIImage(named: "payment_nalati_logo")
brandLogoImageView.contentMode = .scaleAspectFit
brandLogoImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
brandTitleLabel.font = .systemFont(
ofSize: brandScreenHeight <= 700 ? 21 : 23,
weight: .bold
)
brandTitleLabel.textColor = UIColor(hex: 0x102D54)
brandTitleLabel.textAlignment = .center
brandTitleLabel.numberOfLines = 1
brandTitleLabel.adjustsFontSizeToFitWidth = true
brandTitleLabel.minimumScaleFactor = 0.8
brandSubtitleLabel.font = .systemFont(
ofSize: brandScreenHeight <= 700 ? 15 : 16,
weight: .medium
)
brandSubtitleLabel.textColor = UIColor(hex: 0x183A61)
brandSubtitleLabel.textAlignment = .center
let subtitleRow = UIStackView()
subtitleRow.axis = .horizontal
subtitleRow.alignment = .center
subtitleRow.spacing = 9
let leftLine = makeBrandLine()
let leftDiamond = makeBrandDiamond()
let rightDiamond = makeBrandDiamond()
let rightLine = makeBrandLine()
[leftLine, leftDiamond, brandSubtitleLabel, rightDiamond, rightLine].forEach(
subtitleRow.addArrangedSubview
)
brandHeaderStack.addArrangedSubview(brandLogoImageView)
brandHeaderStack.addArrangedSubview(brandTitleLabel)
brandHeaderStack.addArrangedSubview(subtitleRow)
brandHeaderStack.setCustomSpacing(
brandScreenHeight <= 700 ? 8 : 10,
after: brandLogoImageView
)
brandLogoImageView.snp.makeConstraints { make in
make.width.equalToSuperview().inset(8)
make.height.equalTo(brandLogoHeight)
}
brandTitleLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(8)
}
[leftLine, rightLine].forEach { line in
line.snp.makeConstraints { make in
make.width.equalTo(42)
make.height.equalTo(1)
}
}
[leftDiamond, rightDiamond].forEach { diamond in
diamond.snp.makeConstraints { make in
make.width.height.equalTo(6)
}
}
}
private func makeBrandLine() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0x27496D).withAlphaComponent(0.75)
return view
}
private func makeBrandDiamond() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0x27496D)
view.transform = CGAffineTransform(rotationAngle: .pi / 4)
return view
}
@MainActor
private func applyBrandConfigIfNeeded() {
guard viewModel.usesNalatiBranding else { return }
let config = viewModel.payPageConfig
brandTitleLabel.text = config.title
brandSubtitleLabel.text = config.subtitle
let versionChanged = appliedBrandingRefreshVersion != viewModel.brandingRefreshVersion
guard appliedBrandConfig != config || versionChanged else { return }
let forceRefresh = appliedBrandingRefreshVersion >= 0 && versionChanged
setBrandImage(
backgroundImageView,
urlString: config.background,
fallbackName: "payment_nalati_background",
forceRefresh: forceRefresh
)
setBrandImage(
brandLogoImageView,
urlString: config.logo,
fallbackName: "payment_nalati_logo",
forceRefresh: forceRefresh
)
appliedBrandConfig = config
appliedBrandingRefreshVersion = viewModel.brandingRefreshVersion
}
private func setBrandImage(
_ imageView: UIImageView,
urlString: String,
fallbackName: String,
forceRefresh: Bool
) {
let fallback = UIImage(named: fallbackName)
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
url.scheme?.lowercased() == "https",
url.host != nil else {
imageView.image = fallback
return
}
var options: KingfisherOptionsInfo = [
.keepCurrentImageWhileLoading,
.cacheOriginalImage,
.transition(.fade(0.2)),
]
if forceRefresh {
options.append(.forceRefresh)
}
imageView.kf.setImage(with: url, placeholder: fallback, options: options)
}
private func applyBrandNavigationAppearanceIfNeeded() {
guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return }
previousStandardAppearance = navigationBar.standardAppearance
previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance
previousCompactAppearance = navigationBar.compactAppearance
previousNavigationTintColor = navigationBar.tintColor
previousNavigationTranslucency = navigationBar.isTranslucent
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
appearance.shadowColor = .clear
appearance.titleTextAttributes = [
.foregroundColor: UIColor(hex: 0x111827),
.font: UIFont.app(.title),
]
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
navigationBar.tintColor = UIColor(hex: 0x111827)
navigationBar.isTranslucent = true
}
private func restoreNavigationAppearanceIfNeeded() {
guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return }
if let previousStandardAppearance {
navigationBar.standardAppearance = previousStandardAppearance
}
navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance
navigationBar.compactAppearance = previousCompactAppearance
navigationBar.tintColor = previousNavigationTintColor
navigationBar.isTranslucent = previousNavigationTranslucency
previousStandardAppearance = nil
previousScrollEdgeAppearance = nil
previousCompactAppearance = nil
previousNavigationTintColor = nil
}
@objc private func setAmountTapped() { @objc private func setAmountTapped() {
viewModel.showSetAmountDialog() viewModel.showSetAmountDialog()
} }
@ -310,7 +669,7 @@ private final class PaymentInfoRowView: UIView {
valueLabel.font = .systemFont(ofSize: 14) valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary valueLabel.textColor = AppColor.textPrimary
valueLabel.textAlignment = .right valueLabel.textAlignment = .right
valueLabel.numberOfLines = 2 valueLabel.numberOfLines = 0
divider.backgroundColor = UIColor(hex: 0xE5E7EB) divider.backgroundColor = UIColor(hex: 0xE5E7EB)

View File

@ -20,7 +20,9 @@ final class TravelAlbumCodeDialogViewController: UIViewController {
private let albumNameLabel = UILabel() private let albumNameLabel = UILabel()
private let qrImageView = UIImageView() private let qrImageView = UIImageView()
private let loadingIndicator = UIActivityIndicatorView(style: .medium) private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let hintLabel = UILabel() private let hintLabel = TravelAlbumCodeHintLabel(
contentInsets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
)
private let downloadButton = UIButton(type: .system) private let downloadButton = UIButton(type: .system)
init(state: TravelAlbumEntryViewModel.AlbumCodeState) { init(state: TravelAlbumEntryViewModel.AlbumCodeState) {
@ -117,8 +119,8 @@ final class TravelAlbumCodeDialogViewController: UIViewController {
hintLabel.snp.makeConstraints { make in hintLabel.snp.makeConstraints { make in
make.top.equalTo(qrImageView.snp.bottom).offset(18) make.top.equalTo(qrImageView.snp.bottom).offset(18)
make.centerX.equalToSuperview() make.centerX.equalToSuperview()
make.height.equalTo(34)
make.leading.greaterThanOrEqualToSuperview().offset(20) make.leading.greaterThanOrEqualToSuperview().offset(20)
make.trailing.lessThanOrEqualToSuperview().offset(-20)
} }
downloadButton.snp.makeConstraints { make in downloadButton.snp.makeConstraints { make in
make.top.equalTo(hintLabel.snp.bottom).offset(20) make.top.equalTo(hintLabel.snp.bottom).offset(20)
@ -160,3 +162,30 @@ final class TravelAlbumCodeDialogViewController: UIViewController {
onDownload?() onDownload?()
} }
} }
/// Android
private final class TravelAlbumCodeHintLabel: UILabel {
private let contentInsets: UIEdgeInsets
init(contentInsets: UIEdgeInsets) {
self.contentInsets = contentInsets
super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: contentInsets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(
width: size.width + contentInsets.left + contentInsets.right,
height: size.height + contentInsets.top + contentInsets.bottom
)
}
}

View File

@ -184,7 +184,8 @@ extension TravelAlbumEntryViewController: UITableViewDelegate {
/// ///
private final class TravelAlbumHeroCard: UIControl { private final class TravelAlbumHeroCard: UIControl {
private let gradientLayer = CAGradientLayer() private let gradientLayer = CAGradientLayer()
private let plusView = UIImageView(image: UIImage(systemName: "plus")) private let plusView = UIView()
private let plusImageView = UIImageView(image: UIImage(systemName: "plus"))
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let subtitleLabel = UILabel() private let subtitleLabel = UILabel()
private let arrowLabel = UILabel() private let arrowLabel = UILabel()
@ -202,10 +203,11 @@ private final class TravelAlbumHeroCard: UIControl {
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
layer.insertSublayer(gradientLayer, at: 0) layer.insertSublayer(gradientLayer, at: 0)
plusView.tintColor = AppColor.primary
plusView.contentMode = .scaleAspectFit
plusView.backgroundColor = .white plusView.backgroundColor = .white
plusView.layer.cornerRadius = 17 plusView.layer.cornerRadius = 17
plusView.clipsToBounds = true
plusImageView.tintColor = AppColor.primary
plusImageView.contentMode = .scaleAspectFit
titleLabel.text = "新建相册任务" titleLabel.text = "新建相册任务"
titleLabel.font = .systemFont(ofSize: 19, weight: .medium) titleLabel.font = .systemFont(ofSize: 19, weight: .medium)
@ -218,6 +220,7 @@ private final class TravelAlbumHeroCard: UIControl {
arrowLabel.font = .systemFont(ofSize: 36, weight: .light) arrowLabel.font = .systemFont(ofSize: 36, weight: .light)
addSubview(plusView) addSubview(plusView)
plusView.addSubview(plusImageView)
addSubview(titleLabel) addSubview(titleLabel)
addSubview(subtitleLabel) addSubview(subtitleLabel)
addSubview(arrowLabel) addSubview(arrowLabel)
@ -226,6 +229,10 @@ private final class TravelAlbumHeroCard: UIControl {
make.centerY.equalToSuperview() make.centerY.equalToSuperview()
make.size.equalTo(34) make.size.equalTo(34)
} }
plusImageView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(26)
}
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(30) make.top.equalToSuperview().offset(30)
make.leading.equalTo(plusView.snp.trailing).offset(16) make.leading.equalTo(plusView.snp.trailing).offset(16)

View File

@ -22,6 +22,10 @@
#if __has_include(<UMCommon/UMCommon.h>) #if __has_include(<UMCommon/UMCommon.h>)
#import <UMCommon/UMCommon.h> #import <UMCommon/UMCommon.h>
#endif #endif
#if __has_include(<JPUSHService.h>)
#import <JPUSHService.h>
#endif
#import "Features/ScenicQueue/Services/AliyunNuiTTSBridge.h" #import "Features/ScenicQueue/Services/AliyunNuiTTSBridge.h"
#if __has_include(<UMAPM/UMAPM.h>) #if __has_include(<UMAPM/UMAPM.h>)

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>aps-environment</key>
<string>$(APS_ENVIRONMENT)</string>
<key>com.apple.developer.associated-domains</key> <key>com.apple.developer.associated-domains</key>
<array> <array>
<string>applinks:www.zhifly.cn</string> <string>applinks:www.zhifly.cn</string>

View File

@ -29,7 +29,7 @@ final class AllFunctionsViewModelTests: XCTestCase {
super.tearDown() super.tearDown()
} }
func testLoadFunctionsUsesDefaultFirstFourWhenNoSavedURIs() { func testLoadFunctionsUsesDefaultFirstFourAndExcludesMessageCenter() {
appStore.permissions.savePermissionItems([ appStore.permissions.savePermissionItems([
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"),
@ -44,7 +44,7 @@ final class AllFunctionsViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.commonMenus.map(\.uri), [ XCTAssertEqual(viewModel.commonMenus.map(\.uri), [
"wallet", "cloud_management", "task_management", "system_settings", "wallet", "cloud_management", "task_management", "system_settings",
]) ])
XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["message_center"]) XCTAssertTrue(viewModel.moreMenus.isEmpty)
XCTAssertEqual( XCTAssertEqual(
menuStore.savedCommonURIs( menuStore.savedCommonURIs(
accountScope: appStore.session.accountCachePrefix, accountScope: appStore.session.accountCachePrefix,

View File

@ -23,14 +23,26 @@ final class HomeCommonMenuStoreTests: XCTestCase {
super.tearDown() super.tearDown()
} }
func testDefaultCommonURIsTakeFirstFour() { func testDefaultCommonURIsTakeFirstFourAndExcludeMessageCenter() {
let permissions = (1...6).map { let permissions = [
HomePermissionItem(id: "0", name: "消息", uri: "message_center"),
] + (1...5).map {
HomePermissionItem(id: "\($0)", name: "菜单\($0)", uri: "uri_\($0)") HomePermissionItem(id: "\($0)", name: "菜单\($0)", uri: "uri_\($0)")
} }
let uris = HomeCommonMenuStore.defaultCommonURIs(from: permissions) let uris = HomeCommonMenuStore.defaultCommonURIs(from: permissions)
XCTAssertEqual(uris, ["uri_1", "uri_2", "uri_3", "uri_4"]) XCTAssertEqual(uris, ["uri_1", "uri_2", "uri_3", "uri_4"])
} }
func testSavedMessageCenterIsRemovedFromLegacyCommonURIs() {
let key = "scenic_user_u1_role_photographer_common_uris"
defaults.set(["message_center", "wallet"], forKey: key)
let uris = store.savedCommonURIs(accountScope: "scenic_user_u1", roleCode: "photographer")
XCTAssertEqual(uris, ["wallet"])
XCTAssertEqual(defaults.stringArray(forKey: key), ["wallet"])
}
func testSavedCommonURIsOverrideDefault() { func testSavedCommonURIsOverrideDefault() {
let permissions = [ let permissions = [
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
@ -65,8 +77,12 @@ final class HomeCommonMenuStoreTests: XCTestCase {
HomeMenuItem(uri: "wallet", title: "钱包", iconName: "wallet.pass"), HomeMenuItem(uri: "wallet", title: "钱包", iconName: "wallet.pass"),
HomeMenuItem(uri: "cloud_management", title: "云盘", iconName: "icloud"), HomeMenuItem(uri: "cloud_management", title: "云盘", iconName: "icloud"),
HomeMenuItem(uri: "task_management", title: "任务", iconName: "checklist"), HomeMenuItem(uri: "task_management", title: "任务", iconName: "checklist"),
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"),
] ]
let split = HomeCommonMenuStore.splitMenus(from: menus, commonURIs: ["wallet", "task_management"]) let split = HomeCommonMenuStore.splitMenus(
from: menus,
commonURIs: ["wallet", "task_management", "message_center"]
)
XCTAssertEqual(split.common.map(\.uri), ["wallet", "task_management"]) XCTAssertEqual(split.common.map(\.uri), ["wallet", "task_management"])
XCTAssertEqual(split.more.map(\.uri), ["cloud_management"]) XCTAssertEqual(split.more.map(\.uri), ["cloud_management"])
} }
@ -75,7 +91,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
let item = HomeMenuCatalog.item(for: "report_photographer") let item = HomeMenuCatalog.item(for: "report_photographer")
XCTAssertEqual(item?.title, "举报摄影师") XCTAssertEqual(item?.title, "举报摄影师")
XCTAssertEqual(item?.iconName, "shield.fill") XCTAssertEqual(item?.iconName, "shield.lefthalf.filled")
} }
func testHomeMenuCatalogUsesIOS16CompatibleIcons() { func testHomeMenuCatalogUsesIOS16CompatibleIcons() {

View File

@ -90,10 +90,10 @@ final class HomeViewModelTests: XCTestCase {
let menuStore = HomeCommonMenuStore(defaults: defaults) let menuStore = HomeCommonMenuStore(defaults: defaults)
appStore.permissions.savePermissionItems([ appStore.permissions.savePermissionItems([
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), HomePermissionItem(id: "2", name: "消息", uri: "message_center"),
HomePermissionItem(id: "3", name: "任务", uri: "task_management"), HomePermissionItem(id: "3", name: "云盘", uri: "cloud_management"),
HomePermissionItem(id: "4", name: "设置", uri: "system_settings"), HomePermissionItem(id: "4", name: "任务", uri: "task_management"),
HomePermissionItem(id: "5", name: "消息", uri: "message_center"), HomePermissionItem(id: "5", name: "设置", uri: "system_settings"),
]) ])
let viewModel = HomeViewModel(appStore: appStore, commonMenuStore: menuStore) let viewModel = HomeViewModel(appStore: appStore, commonMenuStore: menuStore)
@ -231,6 +231,50 @@ final class HomeViewModelTests: XCTestCase {
XCTAssertEqual(session.requests.count, 1) XCTAssertEqual(session.requests.count, 1)
} }
func testUnreadMessageStatusUsesCountEndpointAndUpdatesBadgeState() async {
let api = HomeMessageCenterAPIMock()
api.unreadCountResponses = [
MessageUnreadCountResponse(unreadCount: 9),
MessageUnreadCountResponse(unreadCount: 0),
]
let viewModel = HomeViewModel(appStore: appStore)
await viewModel.refreshUnreadMessageStatus(api: api)
XCTAssertTrue(viewModel.hasUnreadMessages)
XCTAssertEqual(viewModel.unreadMessageCount, 9)
await viewModel.refreshUnreadMessageStatus(api: api)
XCTAssertFalse(viewModel.hasUnreadMessages)
XCTAssertEqual(viewModel.unreadMessageCount, 0)
XCTAssertEqual(api.unreadCountCallCount, 2)
}
func testScenicHeaderMessageButtonShowsUnreadStateAndHandlesTap() throws {
let headerView = HomeScenicHeaderView(frame: CGRect(x: 0, y: 0, width: 390, height: 100))
var tapCount = 0
headerView.onMessageTap = { tapCount += 1 }
headerView.apply(scenicName: "测试景区", hasUnreadMessages: true)
headerView.layoutIfNeeded()
let button = try XCTUnwrap(
findSubview(in: headerView) { $0.accessibilityIdentifier == "home_message_button" } as? UIButton
)
let unreadDot = try XCTUnwrap(
findSubview(in: headerView) { $0.accessibilityIdentifier == "home_message_unread_dot" }
)
XCTAssertEqual(button.bounds.size, CGSize(width: 44, height: 44))
XCTAssertEqual(button.accessibilityLabel, "消息中心,有未读消息")
XCTAssertFalse(unreadDot.isHidden)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(tapCount, 1)
headerView.apply(scenicName: "测试景区", hasUnreadMessages: false)
XCTAssertEqual(button.accessibilityLabel, "消息中心")
XCTAssertTrue(unreadDot.isHidden)
}
func testRefreshLocalDisplayStateClearsWhenNameMissing() { func testRefreshLocalDisplayStateClearsWhenNameMissing() {
appStore.session.currentScenicId = 10 appStore.session.currentScenicId = 10
appStore.session.currentScenicName = "" appStore.session.currentScenicName = ""
@ -283,6 +327,45 @@ final class HomeViewModelTests: XCTestCase {
} }
return nil return nil
} }
private func findSubview(in view: UIView, matching predicate: (UIView) -> Bool) -> UIView? {
if predicate(view) {
return view
}
for subview in view.subviews {
if let match = findSubview(in: subview, matching: predicate) {
return match
}
}
return nil
}
}
///
@MainActor
private final class HomeMessageCenterAPIMock: MessageCenterServing {
var unreadCountResponses: [MessageUnreadCountResponse] = []
var unreadCountCallCount = 0
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
MessageListResponse()
}
func unreadCount() async throws -> MessageUnreadCountResponse {
unreadCountCallCount += 1
guard !unreadCountResponses.isEmpty else { return MessageUnreadCountResponse() }
return unreadCountResponses.removeFirst()
}
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse {
MessageUnreadCountResponse()
}
func markAllAsRead() async throws -> MessageReadAllResponse {
MessageReadAllResponse()
}
func delete(messageId: Int) async throws {}
} }
private extension UIColor { private extension UIColor {

View File

@ -53,21 +53,37 @@ final class MessageCenterAPITests: XCTestCase {
XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123")) XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123"))
} }
func testReadAndDeleteUseAndroidPathsAndBody() async throws { func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws {
let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}")]) let session = MockURLSession(responses: [
envelopeJSON(#"{"unread_count":"12"}"#),
envelopeJSON(#"{"unread_count":11}"#),
envelopeJSON(#"{"updated_count":11,"unread_count":0}"#),
envelopeJSON("{}"),
])
let api = MessageCenterAPI(client: APIClient(environment: .testing, session: session)) let api = MessageCenterAPI(client: APIClient(environment: .testing, session: session))
try await api.markAsRead(messageId: 21) let unread = try await api.unreadCount()
let read = try await api.markAsRead(messageId: 21)
let readAll = try await api.markAllAsRead()
try await api.delete(messageId: 21) try await api.delete(messageId: 21)
XCTAssertEqual(session.requests[0].httpMethod, "POST") XCTAssertEqual(unread.unreadCount, 12)
XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/read") XCTAssertEqual(session.requests[0].httpMethod, "GET")
let readQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/unread-count")
XCTAssertEqual(read.unreadCount, 11)
XCTAssertEqual(session.requests[1].httpMethod, "POST")
XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/read")
let readQuery = URLComponents(url: try XCTUnwrap(session.requests[1].url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(readQuery?.first { $0.name == "id" }?.value, "21") XCTAssertEqual(readQuery?.first { $0.name == "id" }?.value, "21")
XCTAssertEqual(session.requests[1].httpMethod, "POST") XCTAssertEqual(readAll, MessageReadAllResponse(updatedCount: 11, unreadCount: 0))
XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/delete") XCTAssertEqual(session.requests[2].httpMethod, "POST")
let deleteBody = try bodyJSON(session.requests[1]) XCTAssertEqual(session.requests[2].url?.path, "/api/app/msg/read-all")
XCTAssertEqual(session.requests[3].httpMethod, "POST")
XCTAssertEqual(session.requests[3].url?.path, "/api/app/msg/delete")
let deleteBody = try bodyJSON(session.requests[3])
XCTAssertEqual(deleteBody["id"] as? Int, 21) XCTAssertEqual(deleteBody["id"] as? Int, 21)
} }

View File

@ -32,17 +32,77 @@ final class MessageCenterViewModelTests: XCTestCase {
func testUnreadSelectionMarksReadAndOpensDetail() async { func testUnreadSelectionMarksReadAndOpensDetail() async {
let api = MockMessageCenterAPI() let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 3, isRead: false)])] api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 3, isRead: false)])]
api.readResponse = MessageUnreadCountResponse(unreadCount: 4)
let viewModel = MessageCenterViewModel() let viewModel = MessageCenterViewModel()
var opened: MessageItem? var opened: MessageItem?
var callbackCount: Int?
viewModel.onOpenDetail = { opened = $0 } viewModel.onOpenDetail = { opened = $0 }
viewModel.onUnreadMessageCountChange = { callbackCount = $0 }
let unreadChanged = expectation(
forNotification: NotificationName.unreadMessageCountDidChange,
object: nil,
handler: { notification in
notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int == 4
}
)
await viewModel.loadInitial(api: api) await viewModel.loadInitial(api: api)
await viewModel.selectMessage(id: 3, api: api) await viewModel.selectMessage(id: 3, api: api)
await fulfillment(of: [unreadChanged], timeout: 1)
XCTAssertEqual(api.readCalls, [3]) XCTAssertEqual(api.readCalls, [3])
XCTAssertEqual(opened?.id, 3) XCTAssertEqual(opened?.id, 3)
XCTAssertEqual(opened?.isRead, true) XCTAssertEqual(opened?.isRead, true)
XCTAssertEqual(viewModel.items.first?.isRead, true) XCTAssertEqual(viewModel.items.first?.isRead, true)
XCTAssertEqual(callbackCount, 4)
}
func testMarkAllAsReadUpdatesItemsAndPublishesLatestCount() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(items: [
makeMessage(id: 1, isRead: false),
makeMessage(id: 2, isRead: true),
])]
api.readAllResponse = MessageReadAllResponse(updatedCount: 1, unreadCount: 0)
let viewModel = MessageCenterViewModel()
var callbackCount: Int?
var message: String?
viewModel.onUnreadMessageCountChange = { callbackCount = $0 }
viewModel.onShowMessage = { message = $0 }
let unreadChanged = expectation(
forNotification: NotificationName.unreadMessageCountDidChange,
object: nil,
handler: { notification in
notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int == 0
}
)
await viewModel.loadInitial(api: api)
let response = await viewModel.markAllAsRead(api: api)
await fulfillment(of: [unreadChanged], timeout: 1)
XCTAssertEqual(api.readAllCallCount, 1)
XCTAssertEqual(response, MessageReadAllResponse(updatedCount: 1, unreadCount: 0))
XCTAssertTrue(viewModel.items.allSatisfy(\.isRead))
XCTAssertEqual(callbackCount, 0)
XCTAssertEqual(message, "已全部标记为已读")
}
func testMarkAllAsReadFailureKeepsUnreadState() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(items: [makeMessage(id: 6, isRead: false)])]
api.readAllError = TestError(message: "fail")
let viewModel = MessageCenterViewModel()
var message: String?
viewModel.onShowMessage = { message = $0 }
await viewModel.loadInitial(api: api)
let response = await viewModel.markAllAsRead(api: api)
XCTAssertNil(response)
XCTAssertEqual(api.readAllCallCount, 1)
XCTAssertEqual(viewModel.items.first?.isRead, false)
XCTAssertEqual(message, "全部标记已读失败,请稍后重试")
} }
func testReadSelectionOpensDetailWithoutCallingReadAPI() async { func testReadSelectionOpensDetailWithoutCallingReadAPI() async {
@ -125,8 +185,12 @@ private final class MockMessageCenterAPI: MessageCenterServing {
var listResponses: [MessageListResponse] = [] var listResponses: [MessageListResponse] = []
var listCalls: [(lastId: Int, limit: Int, unread: Int)] = [] var listCalls: [(lastId: Int, limit: Int, unread: Int)] = []
var readCalls: [Int] = [] var readCalls: [Int] = []
var readResponse = MessageUnreadCountResponse()
var readAllResponse = MessageReadAllResponse()
var readAllCallCount = 0
var deleteCalls: [Int] = [] var deleteCalls: [Int] = []
var readError: Error? var readError: Error?
var readAllError: Error?
var deleteError: Error? var deleteError: Error?
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse { func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
@ -135,9 +199,20 @@ private final class MockMessageCenterAPI: MessageCenterServing {
return listResponses.removeFirst() return listResponses.removeFirst()
} }
func markAsRead(messageId: Int) async throws { func unreadCount() async throws -> MessageUnreadCountResponse {
MessageUnreadCountResponse()
}
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse {
readCalls.append(messageId) readCalls.append(messageId)
if let readError { throw readError } if let readError { throw readError }
return readResponse
}
func markAllAsRead() async throws -> MessageReadAllResponse {
readAllCallCount += 1
if let readAllError { throw readAllError }
return readAllResponse
} }
func delete(messageId: Int) async throws { func delete(messageId: Int) async throws {

View File

@ -79,6 +79,24 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.isVoiceBroadcastOpen) XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
} }
func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() {
appStore.session.userName = ""
appStore.permissions.saveRolePermissionList([
RolePermissionResponse(
role: RoleInfo(id: 41, name: "", roleCode: "photographer"),
store: [
RolePermissionStoreInfo(id: 1, name: ""),
RolePermissionStoreInfo(id: 2, name: ""),
]
),
])
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "")
XCTAssertEqual(viewModel.displayStoreNames, "")
}
func testLoadPayCodeClearsQRWhenScenicMissing() async { func testLoadPayCodeClearsQRWhenScenicMissing() async {
appStore.session.currentScenicId = 0 appStore.session.currentScenicId = 0
let session = MockURLSession(responses: []) let session = MockURLSession(responses: [])
@ -90,4 +108,74 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.hasPayCode) XCTAssertFalse(viewModel.hasPayCode)
XCTAssertEqual(session.requests.count, 0) XCTAssertEqual(session.requests.count, 0)
} }
func testNalatiLoadsCachedConfigBeforeRefreshingRemoteConfig() async throws {
let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests.Config")!
defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests.Config")
let cache = PayPageConfigCache(defaults: defaults, environment: .testing)
let cached = PayPageConfig(
background: "https://cache.example.com/background.png",
logo: "https://cache.example.com/logo.png",
title: "缓存标题",
subtitle: "缓存副标题"
)
cache.save(cached, scenicId: 128)
appStore.session.currentScenicId = 128
appStore.session.currentScenicName = "那拉提景区"
let responseJSON = """
{"code":100000,"msg":"success","data":{
"background":"https://new.example.com/background.png",
"logo":"",
"title":"最新标题",
"subtitle":""
}}
""".data(using: .utf8)!
let session = MockURLSession(responses: [responseJSON])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionDetailsViewModel(
appStore: appStore,
payPageConfigCache: cache
)
XCTAssertEqual(viewModel.payPageConfig, cached)
await viewModel.loadPayPageConfig(api: api)
XCTAssertEqual(viewModel.payPageConfig.background, "https://new.example.com/background.png")
XCTAssertEqual(viewModel.payPageConfig.logo, cached.logo)
XCTAssertEqual(viewModel.payPageConfig.title, "最新标题")
XCTAssertEqual(viewModel.payPageConfig.subtitle, cached.subtitle)
XCTAssertEqual(viewModel.brandingRefreshVersion, 1)
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/pay-page-config")
XCTAssertEqual(
URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "scenic_id" })?
.value,
"128"
)
}
func testNonNalatiDoesNotRequestPayPageConfig() async {
let session = MockURLSession(responses: [])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
await viewModel.loadPayPageConfig(api: api)
XCTAssertFalse(viewModel.usesNalatiBranding)
XCTAssertEqual(session.requests.count, 0)
}
func testPayPageConfigCacheIsEnvironmentScoped() {
let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests.Environment")!
defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests.Environment")
let production = PayPageConfigCache(defaults: defaults, environment: .production)
let testing = PayPageConfigCache(defaults: defaults, environment: .testing)
production.save(.nalatiDefault, scenicId: 128)
XCTAssertEqual(production.config(scenicId: 128), .nalatiDefault)
XCTAssertNil(testing.config(scenicId: 128))
}
} }

View File

@ -8,6 +8,88 @@ import XCTest
/// ///
final class PaymentModelsTests: XCTestCase { final class PaymentModelsTests: XCTestCase {
func testPayPageConfigResponseDecodesBrandFields() throws {
let json = """
{
"background": "https://example.com/background.png",
"logo": "https://example.com/logo.png",
"title": "那拉提旅拍管理收费平台",
"subtitle": "那拉提景区"
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(PayPageConfigResponse.self, from: json)
XCTAssertEqual(response.background, "https://example.com/background.png")
XCTAssertEqual(response.logo, "https://example.com/logo.png")
XCTAssertEqual(response.title, "那拉提旅拍管理收费平台")
XCTAssertEqual(response.subtitle, "那拉提景区")
}
func testPayPageConfigMergesOnlyUsableRemoteFields() {
let cached = PayPageConfig(
background: "https://cache.example.com/background.png",
logo: "https://cache.example.com/logo.png",
title: "缓存标题",
subtitle: "缓存副标题"
)
let merged = cached.merging(
PayPageConfigResponse(
background: "",
logo: "http://unsafe.example.com/logo.png",
title: " 最新标题 ",
subtitle: ""
)
)
XCTAssertEqual(merged.background, cached.background)
XCTAssertEqual(merged.logo, cached.logo)
XCTAssertEqual(merged.title, "最新标题")
XCTAssertEqual(merged.subtitle, cached.subtitle)
}
func testPayPageBrandingOnlyEnablesNalatiScenicId() {
XCTAssertTrue(PayPageBrandingPolicy.isNalati(scenicId: 128))
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 127))
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
}
func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() {
XCTAssertEqual(
PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"),
"当前摄影师"
)
XCTAssertEqual(
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
"一号店、二号店"
)
XCTAssertEqual(PaymentAccountDisplayFormatter.storeNames([]), "-")
}
func testRolePermissionResponseDecodesAllAccountStores() throws {
let json = """
{
"role": {
"id": 41,
"name": "",
"role_code": "photographer",
"notes": null,
"permission": []
},
"scenic": [],
"store": [
{"id": 37, "name": "", "status": 1},
{"id": 38, "name": "", "status": 1}
]
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(RolePermissionResponse.self, from: json)
XCTAssertEqual(response.store.map(\.name), ["随心旅拍", "空中草原店"])
}
func testPayCodeResponseDecodesSnakeCaseFields() throws { func testPayCodeResponseDecodesSnakeCaseFields() throws {
let json = """ let json = """
{ {

View File

@ -0,0 +1,357 @@
import UIKit
import XCTest
@testable import suixinkan
/// payloadRegistration ID
@MainActor
final class PushNotificationTests: XCTestCase {
private var defaults: UserDefaults!
private var appStore: AppStore!
private var suiteName: String!
override func setUp() {
super.setUp()
suiteName = "PushNotificationTests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
appStore = AppStore(defaults: defaults)
}
override func tearDown() {
if let suiteName {
defaults.removePersistentDomain(forName: suiteName)
}
defaults = nil
appStore = nil
suiteName = nil
super.tearDown()
}
func testPayloadRoutesPaymentTypesToTheirPages() {
XCTAssertEqual(
PushPayload(userInfo: ["type": 1, "route": "message_center"]).destination,
.paymentRecord
)
XCTAssertEqual(
PushPayload(userInfo: ["type": "6", "action": "task_management"]).destination,
.paymentDetails
)
}
func testPayloadIgnoresNonTypeRoutingFieldsAndOtherTypesFallBackToMessageCenter() {
let conflictingFields: [AnyHashable: Any] = [
"type": 2,
"route": "payment",
"uri": "/payment_qr",
"action": "收款",
]
[0, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 100, 999].forEach { type in
XCTAssertEqual(PushPayload(userInfo: ["type": type]).destination, .messageCenter)
}
XCTAssertEqual(PushPayload(userInfo: conflictingFields).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["route": "payment"]).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["data": ["type": 6]]).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["type": 999]).destination, .messageCenter)
}
func testPayloadReadsTypeFromNestedJSONStringWithoutUsingOtherFields() {
let nested = PushPayload(userInfo: [
"extras": #"{"type":6,"route":"message_center"}"#,
])
XCTAssertEqual(nested.destination, .paymentDetails)
}
func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws {
let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())])
let api = PushAPI(client: APIClient(environment: .testing, session: session))
try await api.registerJPushID("registration-id-123")
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/app/user/register-jpush-id")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "jpush_reg_id" }?.value, "registration-id-123")
}
func testPrivacyGatePreventsSDKInitialization() {
let sdk = PushSDKMock(registrationID: "reg-id")
let manager = makeManager(sdk: sdk)
manager.initializeIfPrivacyAccepted()
XCTAssertEqual(sdk.initializeCount, 0)
appStore.session.privacyAgreementAccepted = true
manager.initializeIfPrivacyAccepted()
manager.initializeIfPrivacyAccepted()
XCTAssertEqual(sdk.initializeCount, 1)
}
func testEmptyRegistrationIDDoesNotUpload() async {
authenticate(userID: "100")
let sdk = PushSDKMock(registrationID: nil)
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: sdk, api: api)
manager.handleLoginCompleted()
await Task.yield()
await Task.yield()
XCTAssertTrue(api.registrationIDs.isEmpty)
}
func testLoggedOutStateDoesNotUpload() async {
appStore.session.privacyAgreementAccepted = true
let sdk = PushSDKMock(registrationID: "reg-id")
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: sdk, api: api)
manager.initializeIfPrivacyAccepted()
await Task.yield()
await Task.yield()
XCTAssertTrue(api.registrationIDs.isEmpty)
}
func testLoginRequestsAuthorizationAndUploadsRegistrationIDOnce() async {
authenticate(userID: "100")
let sdk = PushSDKMock(registrationID: "reg-id")
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: sdk, api: api)
manager.handleLoginCompleted()
await waitUntil { api.registrationIDs.count == 1 }
manager.retryPendingRegistrationUpload()
await Task.yield()
XCTAssertEqual(sdk.initializeCount, 1)
XCTAssertEqual(sdk.authorizationRequestCount, 1)
XCTAssertEqual(api.registrationIDs, ["reg-id"])
}
func testAccountSwitchForcesRegistrationIDRebind() async {
authenticate(userID: "100")
let sdk = PushSDKMock(registrationID: "reg-id")
let api = PushRegistrationAPIMock()
let manager = makeManager(sdk: sdk, api: api)
manager.handleLoginCompleted()
await waitUntil { api.registrationIDs.count == 1 }
appStore.session.userId = "200"
manager.handleAccountSwitched()
await waitUntil { api.registrationIDs.count == 2 }
XCTAssertEqual(api.registrationIDs, ["reg-id", "reg-id"])
}
func testFailedRegistrationUploadRetriesOnNextActivation() async {
authenticate(userID: "100")
let sdk = PushSDKMock(registrationID: "reg-id")
let api = PushRegistrationAPIMock(errors: [PushTestError.expectedFailure, nil])
let manager = makeManager(sdk: sdk, api: api)
manager.handleLoginCompleted()
await waitUntil { api.registrationIDs.count == 1 }
await waitUntil { api.completedCallCount == 1 }
manager.retryPendingRegistrationUpload()
await waitUntil { api.registrationIDs.count == 2 }
XCTAssertEqual(api.registrationIDs, ["reg-id", "reg-id"])
}
func testNotificationTapRoutesButOrdinaryLifecycleDoesNot() {
authenticate(userID: "100")
let router = PushRouterMock()
let manager = makeManager(router: router)
manager.initializeIfPrivacyAccepted()
XCTAssertTrue(router.destinations.isEmpty)
manager.handleNotificationTap(
payload: PushPayload(userInfo: ["type": 1, "route": "task_management"]),
requestIdentifier: "request-1"
)
XCTAssertEqual(router.destinations, [.paymentRecord])
}
func testRemoteNotificationNotifiesUnreadMessageStateChange() async {
let manager = makeManager()
let unreadChanged = expectation(
forNotification: NotificationName.unreadMessageCountDidChange,
object: nil
)
manager.handleRemoteNotification(["route": "message_center"])
await fulfillment(of: [unreadChanged], timeout: 1)
}
func testApplicationIconBadgeUsesLatestNonnegativeCount() async {
let badgeSetter = ApplicationIconBadgeSetterMock()
let manager = makeManager(badgeSetter: badgeSetter)
await manager.updateApplicationIconBadgeCount(12)
await manager.updateApplicationIconBadgeCount(-2)
XCTAssertEqual(badgeSetter.counts, [12, 0])
}
func testRouteCoordinatorWaitsForLoginAndDeduplicatesColdStartResponse() {
let coordinator = PushRouteCoordinator(appStore: appStore)
let window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = UINavigationController(rootViewController: UIViewController())
coordinator.attach(window: window)
coordinator.handle(destination: .paymentDetails, requestIdentifier: "cold-request")
authenticate(userID: "100")
let mainTab = MainTabBarController()
mainTab.loadViewIfNeeded()
window.rootViewController = mainTab
coordinator.routePendingIfPossible()
let navigationController = mainTab.selectedViewController as? UINavigationController
XCTAssertTrue(navigationController?.topViewController is PaymentCollectionDetailsViewController)
coordinator.handle(destination: .messageCenter, requestIdentifier: "cold-request")
XCTAssertTrue(navigationController?.topViewController is PaymentCollectionDetailsViewController)
}
private func authenticate(userID: String) {
appStore.session.privacyAgreementAccepted = true
appStore.session.saveToken("token")
appStore.session.userId = userID
appStore.session.accountType = .storeUser
}
private func makeManager(
sdk: PushSDKMock? = nil,
api: PushRegistrationAPIMock? = nil,
router: PushRouterMock? = nil,
badgeSetter: ApplicationIconBadgeSetterMock? = nil
) -> PushNotificationManager {
let resolvedSDK: PushSDKMock
if let sdk {
resolvedSDK = sdk
} else {
resolvedSDK = PushSDKMock(registrationID: nil)
}
let resolvedAPI: PushRegistrationAPIMock
if let api {
resolvedAPI = api
} else {
resolvedAPI = PushRegistrationAPIMock()
}
let resolvedRouter: PushRouterMock
if let router {
resolvedRouter = router
} else {
resolvedRouter = PushRouterMock()
}
return PushNotificationManager(
sdk: resolvedSDK,
api: resolvedAPI,
appStore: appStore,
defaults: defaults,
router: resolvedRouter,
applicationIconBadgeSetter: badgeSetter ?? ApplicationIconBadgeSetterMock()
)
}
private func waitUntil(
timeoutIterations: Int = 100,
_ condition: () -> Bool
) async {
for _ in 0..<timeoutIterations {
if condition() { return }
await Task.yield()
}
XCTFail("等待异步条件超时")
}
}
/// App
@MainActor
private final class ApplicationIconBadgeSetterMock: ApplicationIconBadgeSetting {
private(set) var counts: [Int] = []
func setBadgeCount(_ count: Int) async {
counts.append(count)
}
}
/// SDK
@MainActor
private final class PushSDKMock: PushSDKProviding {
private let registrationID: String?
private(set) var initializeCount = 0
private(set) var authorizationRequestCount = 0
private(set) var deviceTokens: [Data] = []
init(registrationID: String?) {
self.registrationID = registrationID
}
func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?, isProduction: Bool) {
initializeCount += 1
}
func requestAuthorization(delegate: JPUSHRegisterDelegate) {
authorizationRequestCount += 1
}
func registerDeviceToken(_ deviceToken: Data) {
deviceTokens.append(deviceToken)
}
func fetchRegistrationID(completion: @escaping (Int32, String?) -> Void) {
completion(0, registrationID)
}
}
/// Registration ID
@MainActor
private final class PushRegistrationAPIMock: PushRegistrationServing {
private var errors: [Error?]
private(set) var registrationIDs: [String] = []
private(set) var completedCallCount = 0
init(errors: [Error?] = []) {
self.errors = errors
}
func registerJPushID(_ registrationID: String) async throws {
registrationIDs.append(registrationID)
let error = errors.isEmpty ? nil : errors.removeFirst()
completedCallCount += 1
if let error { throw error }
}
}
///
@MainActor
private final class PushRouterMock: PushRouting {
private(set) var destinations: [PushDestination] = []
private(set) var resetCount = 0
func attach(window: UIWindow) {}
func handle(destination: PushDestination, requestIdentifier: String) {
destinations.append(destination)
}
func routePendingIfPossible() {}
func resetPendingRoute() {
resetCount += 1
}
}
/// 使
private enum PushTestError: Error {
case expectedFailure
}