Compare commits
4 Commits
74f7370aab
...
codex/push
| Author | SHA1 | Date | |
|---|---|---|---|
| f87b13cc05 | |||
| 0e0415eec4 | |||
| 7ccbc5568d | |||
| 787a166263 |
16
Podfile
16
Podfile
@ -1,10 +1,17 @@
|
||||
platform :ios, '16.0'
|
||||
|
||||
project 'suixinkan.xcodeproj',
|
||||
'Debug' => :debug,
|
||||
'SimulatorDebug' => :debug,
|
||||
'Release' => :release
|
||||
|
||||
target 'suixinkan' do
|
||||
use_frameworks!
|
||||
pod 'AMapLocation-NO-IDFA'
|
||||
pod 'AMap3DMap-NO-IDFA'
|
||||
pod 'AMapSearch-NO-IDFA'
|
||||
# 高德 SDK 未提供 arm64-simulator slice,仅在真机配置中接入。
|
||||
pod 'AMapFoundation-NO-IDFA', :configurations => ['Debug', 'Release']
|
||||
pod 'AMapLocation-NO-IDFA', :configurations => ['Debug', 'Release']
|
||||
pod 'AMap3DMap-NO-IDFA', :configurations => ['Debug', 'Release']
|
||||
pod 'AMapSearch-NO-IDFA', :configurations => ['Debug', 'Release']
|
||||
pod 'UMCommon'
|
||||
pod 'UMDevice'
|
||||
pod 'UMAPM'
|
||||
@ -24,6 +31,9 @@ post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0'
|
||||
if target.name == 'Pods-suixinkanTests' && config.name == 'SimulatorDebug'
|
||||
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = ''
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@ -58,6 +58,7 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- AMap3DMap-NO-IDFA
|
||||
- AMapFoundation-NO-IDFA
|
||||
- AMapLocation-NO-IDFA
|
||||
- AMapSearch-NO-IDFA
|
||||
- IQKeyboardManagerSwift
|
||||
@ -109,6 +110,6 @@ SPEC CHECKSUMS:
|
||||
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
|
||||
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
|
||||
|
||||
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412
|
||||
PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
3
Pods/Manifest.lock
generated
3
Pods/Manifest.lock
generated
@ -58,6 +58,7 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- AMap3DMap-NO-IDFA
|
||||
- AMapFoundation-NO-IDFA
|
||||
- AMapLocation-NO-IDFA
|
||||
- AMapSearch-NO-IDFA
|
||||
- IQKeyboardManagerSwift
|
||||
@ -109,6 +110,6 @@ SPEC CHECKSUMS:
|
||||
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
|
||||
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
|
||||
|
||||
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412
|
||||
PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
5067
Pods/Pods.xcodeproj/project.pbxproj
generated
5067
Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,9 @@
|
||||
${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks.sh
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework
|
||||
@ -0,0 +1,8 @@
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardCore.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardManagerSwift.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardNotification.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardReturnManager.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardToolbar.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardToolbarManager.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQTextInputViewNotification.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQTextView.framework
|
||||
@ -195,6 +195,16 @@ if [[ "$CONFIGURATION" == "Release" ]]; then
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework"
|
||||
fi
|
||||
if [[ "$CONFIGURATION" == "SimulatorDebug" ]]; then
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework"
|
||||
fi
|
||||
if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then
|
||||
wait
|
||||
fi
|
||||
|
||||
@ -0,0 +1 @@
|
||||
${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh
|
||||
18
Pods/Target Support Files/Pods-suixinkan/Pods-suixinkan.simulatordebug.xcconfig
generated
Normal file
18
Pods/Target Support Files/Pods-suixinkan/Pods-suixinkan.simulatordebug.xcconfig
generated
Normal file
@ -0,0 +1,18 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO
|
||||
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}/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
|
||||
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'
|
||||
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"JCore" -l"JPush" -l"c++" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreTelephony" -framework "Foundation" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -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}/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
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
|
||||
PODS_ROOT = ${SRCROOT}/Pods
|
||||
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
|
||||
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
|
||||
VALID_ARCHS = arm64 x86_64
|
||||
15
Pods/Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.simulatordebug.xcconfig
generated
Normal file
15
Pods/Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.simulatordebug.xcconfig
generated
Normal file
@ -0,0 +1,15 @@
|
||||
ARCHS = $(ARCHS_STANDARD)
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO
|
||||
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}/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
|
||||
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"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreTelephony" -framework "Foundation" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -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_PODFILE_DIR_PATH = ${SRCROOT}/.
|
||||
PODS_ROOT = ${SRCROOT}/Pods
|
||||
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
|
||||
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
|
||||
VALID_ARCHS = arm64 x86_64
|
||||
@ -36,6 +36,8 @@
|
||||
93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A1B2C3D02FFB84950033C305 /* suixinkanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C69C08A144C2D5D4A448B7D2 /* Pods_suixinkan.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkan.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkanTests.simulatordebug.xcconfig"; path = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.simulatordebug.xcconfig"; sourceTree = "<group>"; };
|
||||
ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan.simulatordebug.xcconfig"; path = "Target Support Files/Pods-suixinkan/Pods-suixinkan.simulatordebug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -59,11 +61,15 @@
|
||||
};
|
||||
9A01B8E22FFB84950033C402 /* Vendor */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Vendor;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A1B2C3D42FFB84950033C305 /* suixinkanTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = suixinkanTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -111,6 +117,8 @@
|
||||
1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */,
|
||||
83445E7B4CCCF42C6FD481D3 /* Pods-suixinkanTests.debug.xcconfig */,
|
||||
6CB3E1DA4E97AA4961B25733 /* Pods-suixinkanTests.release.xcconfig */,
|
||||
ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */,
|
||||
DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@ -361,14 +369,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks.sh\"\n";
|
||||
@ -382,14 +386,10 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
||||
@ -728,6 +728,150 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D1A2B3C42FFB84950033C401 /* SimulatorDebug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */;
|
||||
buildSettings = {
|
||||
APS_ENVIRONMENT = development;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1010402;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = suixinkan/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以拍摄任务素材、扫描订单核销二维码,并连接有线相机进行旅拍相册传输";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "需要访问麦克风以在拍摄素材视频时录制声音";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码、排队打卡点小程序码和云盘图片视频到相册";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证、银行卡照片和云盘上传素材";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.4;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkan;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphonesimulator;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = iphonesimulator;
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "suixinkan/suixinkan-Bridging-Header.h";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = SimulatorDebug;
|
||||
};
|
||||
D1A2B3C52FFB84950033C402 /* SimulatorDebug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphonesimulator;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = SimulatorDebug;
|
||||
};
|
||||
D1A2B3C62FFB84950033C403 /* SimulatorDebug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphonesimulator;
|
||||
SUPPORTED_PLATFORMS = iphonesimulator;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/suixinkan.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan";
|
||||
};
|
||||
name = SimulatorDebug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@ -735,6 +879,7 @@
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
93FF17DB2FFB84950033C304 /* Debug */,
|
||||
D1A2B3C52FFB84950033C402 /* SimulatorDebug */,
|
||||
93FF17DC2FFB84950033C304 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
@ -744,6 +889,7 @@
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
93FF17D92FFB84950033C304 /* Debug */,
|
||||
D1A2B3C42FFB84950033C401 /* SimulatorDebug */,
|
||||
93FF17DA2FFB84950033C304 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
@ -753,6 +899,7 @@
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A1B2C3DC2FFB84950033C305 /* Debug */,
|
||||
D1A2B3C62FFB84950033C403 /* SimulatorDebug */,
|
||||
A1B2C3DA2FFB84950033C305 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
|
||||
@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "SimulatorDebug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A1B2C3D32FFB84950033C305"
|
||||
BuildableName = "suixinkanTests.xctest"
|
||||
BlueprintName = "suixinkanTests"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "SimulatorDebug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "SimulatorDebug"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "SimulatorDebug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
6
suixinkan/Assets.xcassets/Payment/Contents.json
Normal file
6
suixinkan/Assets.xcassets/Payment/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
21
suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/payment_nalati_background.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/Payment/payment_nalati_background.imageset/payment_nalati_background.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
21
suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/payment_nalati_logo.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/Payment/payment_nalati_logo.imageset/payment_nalati_logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@ -0,0 +1,94 @@
|
||||
//
|
||||
// CommissionRateLogViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 获客员分成比例修改记录 ViewModel,负责刷新、分页与重复项合并。
|
||||
final class CommissionRateLogViewModel {
|
||||
private enum Constants {
|
||||
static let pageSize = 10
|
||||
}
|
||||
|
||||
private(set) var items: [CommissionRateLogEntity] = []
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isLoadingMore = false
|
||||
private(set) var initialLoading = true
|
||||
private(set) var canLoadMore = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private let saleUserId: Int
|
||||
private var total = 0
|
||||
private var lastLoadedPage = 0
|
||||
private var isLoading = false
|
||||
|
||||
init(saleUserId: Int) {
|
||||
self.saleUserId = saleUserId
|
||||
}
|
||||
|
||||
/// 重新加载第一页修改记录。
|
||||
func refresh(api: OrderAPI) async {
|
||||
await load(api: api, page: 1, append: false)
|
||||
}
|
||||
|
||||
/// 在存在下一页时加载更多修改记录。
|
||||
func loadMore(api: OrderAPI) async {
|
||||
guard canLoadMore, !isRefreshing, !isLoadingMore else { return }
|
||||
await load(api: api, page: lastLoadedPage + 1, append: true)
|
||||
}
|
||||
|
||||
private func load(api: OrderAPI, page: Int, append: Bool) async {
|
||||
guard saleUserId > 0 else {
|
||||
initialLoading = false
|
||||
onShowMessage?("获客员信息无效")
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
if append {
|
||||
isLoadingMore = true
|
||||
} else {
|
||||
isRefreshing = true
|
||||
}
|
||||
notifyStateChange()
|
||||
|
||||
defer {
|
||||
isLoading = false
|
||||
initialLoading = false
|
||||
if append {
|
||||
isLoadingMore = false
|
||||
} else {
|
||||
isRefreshing = false
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.saleUserCommissionRateLogs(
|
||||
saleUserId: saleUserId,
|
||||
page: page,
|
||||
pageSize: Constants.pageSize
|
||||
)
|
||||
if append {
|
||||
let existingIDs = Set(items.map(\.id))
|
||||
items.append(contentsOf: response.list.filter { !existingIDs.contains($0.id) })
|
||||
} else {
|
||||
var seenIDs = Set<Int>()
|
||||
items = response.list.filter { seenIDs.insert($0.id).inserted }
|
||||
}
|
||||
total = max(0, response.total)
|
||||
lastLoadedPage = page
|
||||
canLoadMore = items.count < total && !response.list.isEmpty
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@ -9,10 +9,40 @@ import Foundation
|
||||
struct RolePermissionResponse: Codable, Equatable, Sendable {
|
||||
let role: RoleInfo
|
||||
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.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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ enum AMapBootstrap {
|
||||
defer { lock.unlock() }
|
||||
guard !isConfigured else { return true }
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
|
||||
AMapLocationManager.updatePrivacyAgree(.didAgree)
|
||||
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
|
||||
@ -29,6 +30,7 @@ enum AMapBootstrap {
|
||||
|
||||
AMapServices.shared().apiKey = AMapConfig.apiKey
|
||||
AMapServices.shared().enableHTTPS = true
|
||||
#endif
|
||||
isConfigured = true
|
||||
return true
|
||||
}
|
||||
|
||||
@ -3,16 +3,28 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// 高德逆地理编码封装,对齐 Android `GeocodeSearch`。
|
||||
/// 地址逆地理编码能力,供平台定位实现注入。
|
||||
@MainActor
|
||||
final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
protocol LocationAddressGeocoding: AnyObject {
|
||||
/// 将坐标解析为可读地址,失败时返回空字符串。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String
|
||||
}
|
||||
|
||||
/// 平台逆地理编码封装;真机使用高德,模拟器使用 Core Location。
|
||||
@MainActor
|
||||
final class LocationGeocoder: NSObject, LocationAddressGeocoding {
|
||||
|
||||
nonisolated static let shared = LocationGeocoder()
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
private let geocoder = CLGeocoder()
|
||||
#else
|
||||
private var searchAPI: AMapSearchAPI?
|
||||
private var continuation: CheckedContinuation<String, Never>?
|
||||
#endif
|
||||
|
||||
nonisolated private override init() {
|
||||
super.init()
|
||||
@ -20,6 +32,26 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
|
||||
/// 将坐标解析为可读地址。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
#if targetEnvironment(simulator)
|
||||
let location = CLLocation(latitude: latitude, longitude: longitude)
|
||||
guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else {
|
||||
return ""
|
||||
}
|
||||
if let name = placemark.name?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty {
|
||||
return name
|
||||
}
|
||||
return [
|
||||
placemark.administrativeArea,
|
||||
placemark.locality,
|
||||
placemark.subLocality,
|
||||
placemark.thoroughfare,
|
||||
placemark.subThoroughfare,
|
||||
]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined()
|
||||
#else
|
||||
do {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
} catch {
|
||||
@ -40,8 +72,10 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
request.radius = 200
|
||||
api.aMapReGoecodeSearch(request)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
|
||||
let address = response?.regeocode?.formattedAddress ?? ""
|
||||
continuation?.resume(returning: address)
|
||||
@ -62,4 +96,9 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
searchAPI = api
|
||||
return api
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
extension LocationGeocoder: AMapSearchDelegate {}
|
||||
#endif
|
||||
|
||||
@ -6,7 +6,165 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// 统一定位服务,封装高德 `AMapLocationManager`,对齐 Android `LocationProvider`。
|
||||
/// Core Location 管理器最小接口,便于模拟器定位实现注入测试替身。
|
||||
@MainActor
|
||||
protocol CoreLocationManaging: AnyObject {
|
||||
var authorizationStatus: CLAuthorizationStatus { get }
|
||||
var desiredAccuracy: CLLocationAccuracy { get set }
|
||||
var delegate: CLLocationManagerDelegate? { get set }
|
||||
|
||||
/// 请求使用期间定位权限。
|
||||
func requestWhenInUseAuthorization()
|
||||
/// 请求一次当前位置。
|
||||
func requestLocation()
|
||||
}
|
||||
|
||||
extension CLLocationManager: CoreLocationManaging {}
|
||||
|
||||
/// Core Location 定位实现,供模拟器运行并支持依赖注入测试。
|
||||
@MainActor
|
||||
final class CoreLocationProvider: NSObject, LocationProviding, CLLocationManagerDelegate {
|
||||
|
||||
nonisolated static let shared = CoreLocationProvider()
|
||||
|
||||
private var appStore: AppStore = .shared
|
||||
private var manager: any CoreLocationManaging = CLLocationManager()
|
||||
private var geocoder: any LocationAddressGeocoding = LocationGeocoder.shared
|
||||
private var permissionContinuation: CheckedContinuation<Void, Error>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocationCoordinate2D, Error>?
|
||||
|
||||
nonisolated override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 使用可控依赖创建 Core Location 定位服务。
|
||||
init(
|
||||
appStore: AppStore,
|
||||
manager: any CoreLocationManaging,
|
||||
geocoder: any LocationAddressGeocoding
|
||||
) {
|
||||
self.appStore = appStore
|
||||
self.manager = manager
|
||||
self.geocoder = geocoder
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
}
|
||||
|
||||
/// 请求坐标及逆地理编码地址。
|
||||
func requestSnapshot(
|
||||
desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
|
||||
) async throws -> HomeLocationSnapshot {
|
||||
let coordinate = try await requestCoordinate(desiredAccuracy: desiredAccuracy)
|
||||
let address = await geocoder.reverseGeocode(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude
|
||||
)
|
||||
return HomeLocationSnapshot(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
address: address
|
||||
)
|
||||
}
|
||||
|
||||
/// 请求一次当前位置坐标。
|
||||
func requestCoordinate(
|
||||
desiredAccuracy: CLLocationAccuracy
|
||||
) async throws -> CLLocationCoordinate2D {
|
||||
try requirePrivacyAgreement()
|
||||
try await ensureLocationPermission()
|
||||
guard locationContinuation == nil else {
|
||||
throw LocationProviderError.locationFailed
|
||||
}
|
||||
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = desiredAccuracy
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
locationContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用系统服务将坐标解析为地址。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
guard appStore.session.privacyAgreementAccepted else { return "" }
|
||||
return await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
|
||||
/// 请求系统定位权限,等待用户完成首次授权。
|
||||
func ensureLocationPermission() async throws {
|
||||
manager.delegate = self
|
||||
switch manager.authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return
|
||||
case .denied, .restricted:
|
||||
throw LocationProviderError.permissionDenied
|
||||
case .notDetermined:
|
||||
guard permissionContinuation == nil else {
|
||||
throw LocationProviderError.locationFailed
|
||||
}
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
permissionContinuation = continuation
|
||||
manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
@unknown default:
|
||||
throw LocationProviderError.permissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
resolvePermission(status: self.manager.authorizationStatus)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let continuation = locationContinuation else { return }
|
||||
locationContinuation = nil
|
||||
guard let coordinate = locations.last?.coordinate,
|
||||
CLLocationCoordinate2DIsValid(coordinate),
|
||||
coordinate.latitude != 0 || coordinate.longitude != 0 else {
|
||||
continuation.resume(throwing: LocationProviderError.locationFailed)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: coordinate)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
guard let continuation = locationContinuation else { return }
|
||||
locationContinuation = nil
|
||||
continuation.resume(throwing: LocationProviderError.locationFailed)
|
||||
}
|
||||
|
||||
private func requirePrivacyAgreement() throws {
|
||||
guard appStore.session.privacyAgreementAccepted else {
|
||||
throw LocationProviderError.privacyNotAccepted
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvePermission(status: CLAuthorizationStatus) {
|
||||
guard let continuation = permissionContinuation else { return }
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
permissionContinuation = nil
|
||||
continuation.resume()
|
||||
case .denied, .restricted:
|
||||
permissionContinuation = nil
|
||||
continuation.resume(throwing: LocationProviderError.permissionDenied)
|
||||
case .notDetermined:
|
||||
break
|
||||
@unknown default:
|
||||
permissionContinuation = nil
|
||||
continuation.resume(throwing: LocationProviderError.permissionDenied)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
|
||||
/// 模拟器默认定位服务,使用 Core Location 与系统逆地理编码。
|
||||
typealias LocationProvider = CoreLocationProvider
|
||||
|
||||
#else
|
||||
|
||||
/// 真机统一定位服务,封装高德 `AMapLocationManager`,对齐 Android `LocationProvider`。
|
||||
@MainActor
|
||||
final class LocationProvider: NSObject, LocationProviding {
|
||||
|
||||
@ -19,7 +177,7 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
super.init()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 请求坐标及高德逆地理编码地址。
|
||||
func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
try await ensureLocationPermission()
|
||||
@ -51,7 +209,7 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 请求一次当前位置坐标。
|
||||
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
try await ensureLocationPermission()
|
||||
@ -78,7 +236,7 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 使用高德服务将坐标解析为地址。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
@ -110,3 +268,5 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
return manager
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -16,20 +16,25 @@ struct HomeLocationSnapshot: Sendable, Equatable {
|
||||
/// 定位服务协议,便于 ViewModel 注入与单元测试 mock。
|
||||
protocol LocationProviding: Sendable {
|
||||
/// 按指定精度请求当前坐标与地址。
|
||||
@MainActor
|
||||
func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot
|
||||
/// 按指定精度请求当前坐标。
|
||||
@MainActor
|
||||
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D
|
||||
/// 逆地理编码。
|
||||
@MainActor
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String
|
||||
}
|
||||
|
||||
extension LocationProviding {
|
||||
/// 请求当前坐标与地址,默认使用最高精度以保持既有业务行为。
|
||||
@MainActor
|
||||
func requestSnapshot() async throws -> HomeLocationSnapshot {
|
||||
try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest)
|
||||
}
|
||||
|
||||
/// 请求当前坐标,默认使用最高精度以保持既有业务行为。
|
||||
@MainActor
|
||||
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest)
|
||||
}
|
||||
|
||||
@ -293,6 +293,28 @@ final class OrderAPI {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取指定获客员的分成比例修改记录。
|
||||
func saleUserCommissionRateLogs(
|
||||
saleUserId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
) async throws -> CommissionRateLogListResponse {
|
||||
guard saleUserId > 0 else {
|
||||
throw APIError.networkFailed("获客员信息无效")
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/sale-user/commission-rate/logs",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "sale_user_id", value: String(saleUserId)),
|
||||
URLQueryItem(name: "page", value: String(max(1, page))),
|
||||
URLQueryItem(name: "page_size", value: String(min(max(1, pageSize), 50))),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
|
||||
@ -875,6 +875,93 @@ struct CooperativeSalerEntity: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获客员分成比例修改记录分页响应。
|
||||
struct CommissionRateLogListResponse: Decodable, Equatable {
|
||||
let list: [CommissionRateLogEntity]
|
||||
let total: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case list
|
||||
case total
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条获客员分成比例修改记录。
|
||||
struct CommissionRateLogEntity: Decodable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let bindingId: Int
|
||||
let saleUserId: Int
|
||||
let storeUserId: Int
|
||||
let beforeRate: Int
|
||||
let beforeRateLabel: String
|
||||
let afterRate: Int
|
||||
let afterRateLabel: String
|
||||
let operatorType: String
|
||||
let operatorId: Int
|
||||
let operatorName: String
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bindingId = "binding_id"
|
||||
case saleUserId = "sale_user_id"
|
||||
case storeUserId = "store_user_id"
|
||||
case beforeRate = "before_rate"
|
||||
case beforeRateLabel = "before_rate_label"
|
||||
case afterRate = "after_rate"
|
||||
case afterRateLabel = "after_rate_label"
|
||||
case operatorType = "operator_type"
|
||||
case operatorId = "operator_id"
|
||||
case operatorName = "operator_name"
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 修改前比例展示文本,优先使用服务端 label。
|
||||
var displayBeforeRate: String {
|
||||
let label = beforeRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return label.isEmpty ? "\(beforeRate)%" : label
|
||||
}
|
||||
|
||||
/// 修改后比例展示文本,优先使用服务端 label。
|
||||
var displayAfterRate: String {
|
||||
let label = afterRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return label.isEmpty ? "\(afterRate)%" : label
|
||||
}
|
||||
|
||||
/// 修改时间展示文本。
|
||||
var metadataLine: String {
|
||||
formattedCreatedAt
|
||||
}
|
||||
|
||||
/// 去除首尾空白后的备注;空备注不展示。
|
||||
var displayRemark: String? {
|
||||
let value = remark.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
private var formattedCreatedAt: String {
|
||||
let value = createdAt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return "—" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: value) {
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
struct ReferralOrderListResponse: Decodable {
|
||||
let total: Int
|
||||
let list: [ReferralOrderEntity]
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import UIKit
|
||||
|
||||
/// 主 Tab 中间扫码按钮结果处理,对齐 Android `MainTabViewModel.signIn`。
|
||||
|
||||
@ -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 天收款记录。
|
||||
func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse {
|
||||
try await client.send(
|
||||
|
||||
@ -5,6 +5,83 @@
|
||||
|
||||
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。
|
||||
struct PayCodeResponse: Decodable, Sendable, Equatable {
|
||||
let staticPayURL: String
|
||||
@ -101,3 +178,10 @@ struct PaymentMerchantInfo: Sendable, Equatable {
|
||||
company: "扬州元智享网络科技有限公司"
|
||||
)
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
|
||||
51
suixinkan/Features/Payment/Services/PayPageConfigCache.swift
Normal file
51
suixinkan/Features/Payment/Services/PayPageConfigCache.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,9 @@ final class PaymentCollectionDetailsViewModel {
|
||||
private(set) var showAmountDialog = false
|
||||
private(set) var amount = ""
|
||||
private(set) var remark = ""
|
||||
private(set) var scenicId = 0
|
||||
private(set) var payPageConfig = PayPageConfig.nalatiDefault
|
||||
private(set) var brandingRefreshVersion = 0
|
||||
|
||||
let merchantInfo = PaymentMerchantInfo.defaultInfo
|
||||
|
||||
@ -26,9 +29,21 @@ final class PaymentCollectionDetailsViewModel {
|
||||
private var staticPayURL = ""
|
||||
private var dynamicPayURL = ""
|
||||
private let appStore: AppStore
|
||||
private let payPageConfigCache: PayPageConfigCaching
|
||||
|
||||
init(appStore: AppStore = .shared) {
|
||||
init(
|
||||
appStore: AppStore = .shared,
|
||||
payPageConfigCache: PayPageConfigCaching = PayPageConfigCache()
|
||||
) {
|
||||
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 {
|
||||
@ -39,10 +54,32 @@ final class PaymentCollectionDetailsViewModel {
|
||||
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() {
|
||||
scenicId = appStore.session.currentScenicId
|
||||
scenicName = appStore.session.currentScenicName
|
||||
staffId = appStore.session.userId
|
||||
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
|
||||
if usesNalatiBranding {
|
||||
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
|
||||
} else {
|
||||
payPageConfig = .nalatiDefault
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
@ -56,7 +93,7 @@ final class PaymentCollectionDetailsViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let scenicId = appStore.session.currentScenicId
|
||||
let scenicId = self.scenicId
|
||||
guard scenicId > 0 else {
|
||||
clearPayCode()
|
||||
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 {
|
||||
await loadPayCode(api: api)
|
||||
async let payCode: Void = loadPayCode(api: api)
|
||||
async let pageConfig: Void = loadPayPageConfig(api: api)
|
||||
_ = await (payCode, pageConfig)
|
||||
}
|
||||
|
||||
func showSetAmountDialog() {
|
||||
|
||||
@ -0,0 +1,284 @@
|
||||
//
|
||||
// CommissionRateLogViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 获客员分成比例修改记录页,以时间轴形式展示历史变更。
|
||||
final class CommissionRateLogViewController: BaseViewController {
|
||||
private let viewModel: CommissionRateLogViewModel
|
||||
private let orderAPI: OrderAPI
|
||||
private let navigationTitle: String
|
||||
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyLabel = UILabel()
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let footerLabel = UILabel()
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, CommissionRateLogEntity>!
|
||||
|
||||
init(saleUserId: Int, acquirerName: String, orderAPI: OrderAPI? = nil) {
|
||||
viewModel = CommissionRateLogViewModel(saleUserId: saleUserId)
|
||||
let trimmedName = acquirerName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
navigationTitle = trimmedName.isEmpty ? "修改记录" : "给\(trimmedName)的分成记录"
|
||||
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.refresh(api: orderAPI) }
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = navigationTitle
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
|
||||
tableView.backgroundColor = AppColor.pageBackground
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 132
|
||||
tableView.delegate = self
|
||||
tableView.contentInset.top = AppSpacing.sm
|
||||
tableView.register(
|
||||
CommissionRateLogTimelineCell.self,
|
||||
forCellReuseIdentifier: CommissionRateLogTimelineCell.reuseIdentifier
|
||||
)
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Int, CommissionRateLogEntity>(
|
||||
tableView: tableView
|
||||
) { tableView, indexPath, item in
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: CommissionRateLogTimelineCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? CommissionRateLogTimelineCell else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
cell.configure(with: item, isLatest: indexPath.item == 0)
|
||||
return cell
|
||||
}
|
||||
|
||||
emptyLabel.text = "暂无修改记录"
|
||||
emptyLabel.font = .systemFont(ofSize: 14)
|
||||
emptyLabel.textColor = AppColor.textTertiary
|
||||
emptyLabel.textAlignment = .center
|
||||
emptyLabel.isHidden = true
|
||||
|
||||
loadingIndicator.color = AppColor.primary
|
||||
|
||||
footerLabel.font = .systemFont(ofSize: 13)
|
||||
footerLabel.textColor = AppColor.textTertiary
|
||||
footerLabel.textAlignment = .center
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyLabel)
|
||||
view.addSubview(loadingIndicator)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
}
|
||||
loadingIndicator.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyState() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
private func applyState() {
|
||||
if !viewModel.isRefreshing {
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
}
|
||||
if viewModel.initialLoading && viewModel.items.isEmpty {
|
||||
loadingIndicator.startAnimating()
|
||||
} else {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, CommissionRateLogEntity>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.items)
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
|
||||
guard !viewModel.items.isEmpty else {
|
||||
tableView.tableFooterView = UIView()
|
||||
return
|
||||
}
|
||||
footerLabel.text = viewModel.isLoadingMore ? "加载中…" : (viewModel.canLoadMore ? "" : "没有更多")
|
||||
footerLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableFooterView = footerLabel
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task { await viewModel.refresh(api: orderAPI) }
|
||||
}
|
||||
}
|
||||
|
||||
extension CommissionRateLogViewController: UITableViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard !viewModel.items.isEmpty,
|
||||
scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else {
|
||||
return
|
||||
}
|
||||
Task { await viewModel.loadMore(api: orderAPI) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 分成比例修改记录的时间轴 Cell。
|
||||
final class CommissionRateLogTimelineCell: UITableViewCell {
|
||||
static let reuseIdentifier = "CommissionRateLogTimelineCell"
|
||||
|
||||
private let railView = UIView()
|
||||
private let dotView = UIView()
|
||||
private let cardView = UIView()
|
||||
private let metadataLabel = UILabel()
|
||||
private let latestBadgeLabel = UILabel()
|
||||
private let beforeRateLabel = UILabel()
|
||||
private let arrowImageView = UIImageView(image: UIImage(systemName: "arrow.right"))
|
||||
private let afterRateLabel = UILabel()
|
||||
private let remarkLabel = UILabel()
|
||||
private let contentStack = UIStackView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = AppColor.pageBackground
|
||||
contentView.backgroundColor = AppColor.pageBackground
|
||||
|
||||
railView.backgroundColor = AppColor.primary.withAlphaComponent(0.16)
|
||||
dotView.backgroundColor = AppColor.primary
|
||||
dotView.layer.cornerRadius = 4
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = AppRadius.md
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.04
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
|
||||
cardView.layer.shadowRadius = 2
|
||||
|
||||
metadataLabel.font = .systemFont(ofSize: 12)
|
||||
metadataLabel.textColor = AppColor.textSecondary
|
||||
metadataLabel.numberOfLines = 2
|
||||
|
||||
latestBadgeLabel.text = "最新"
|
||||
latestBadgeLabel.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
latestBadgeLabel.textColor = AppColor.primary
|
||||
latestBadgeLabel.textAlignment = .center
|
||||
latestBadgeLabel.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
|
||||
latestBadgeLabel.layer.cornerRadius = 9
|
||||
latestBadgeLabel.clipsToBounds = true
|
||||
latestBadgeLabel.isHidden = true
|
||||
latestBadgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
latestBadgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
latestBadgeLabel.snp.makeConstraints { make in
|
||||
make.width.equalTo(38)
|
||||
make.height.equalTo(18)
|
||||
}
|
||||
|
||||
let headerStack = UIStackView(arrangedSubviews: [metadataLabel, latestBadgeLabel])
|
||||
headerStack.axis = .horizontal
|
||||
headerStack.alignment = .top
|
||||
headerStack.spacing = AppSpacing.sm
|
||||
|
||||
[beforeRateLabel, afterRateLabel].forEach { label in
|
||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
label.textColor = AppColor.textPrimary
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
}
|
||||
afterRateLabel.textColor = AppColor.primary
|
||||
arrowImageView.tintColor = AppColor.textTertiary
|
||||
arrowImageView.contentMode = .scaleAspectFit
|
||||
|
||||
let rateStack = UIStackView(arrangedSubviews: [beforeRateLabel, arrowImageView, afterRateLabel, UIView()])
|
||||
rateStack.axis = .horizontal
|
||||
rateStack.alignment = .center
|
||||
rateStack.spacing = AppSpacing.sm
|
||||
arrowImageView.snp.makeConstraints { make in
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
|
||||
remarkLabel.font = .systemFont(ofSize: 13)
|
||||
remarkLabel.textColor = AppColor.textSecondary
|
||||
remarkLabel.numberOfLines = 0
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = AppSpacing.sm
|
||||
contentStack.addArrangedSubview(headerStack)
|
||||
contentStack.addArrangedSubview(rateStack)
|
||||
contentStack.addArrangedSubview(remarkLabel)
|
||||
|
||||
contentView.addSubview(railView)
|
||||
contentView.addSubview(dotView)
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(contentStack)
|
||||
|
||||
railView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(24)
|
||||
make.top.bottom.equalToSuperview()
|
||||
make.width.equalTo(1.5)
|
||||
}
|
||||
dotView.snp.makeConstraints { make in
|
||||
make.centerX.equalTo(railView)
|
||||
make.top.equalToSuperview().offset(20)
|
||||
make.size.equalTo(8)
|
||||
}
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.leading.equalTo(railView.snp.trailing).offset(16)
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.top.equalToSuperview().offset(4)
|
||||
make.bottom.equalToSuperview().inset(8)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(15)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 绑定比例变化、时间及可选备注。
|
||||
func configure(with item: CommissionRateLogEntity, isLatest: Bool) {
|
||||
metadataLabel.text = item.metadataLine
|
||||
latestBadgeLabel.isHidden = !isLatest
|
||||
beforeRateLabel.text = item.displayBeforeRate
|
||||
afterRateLabel.text = item.displayAfterRate
|
||||
if let remark = item.displayRemark {
|
||||
remarkLabel.text = "备注:\(remark)"
|
||||
remarkLabel.isHidden = false
|
||||
} else {
|
||||
remarkLabel.text = nil
|
||||
remarkLabel.isHidden = true
|
||||
}
|
||||
accessibilityLabel = [
|
||||
isLatest ? "最新记录" : nil,
|
||||
item.metadataLine,
|
||||
"\(item.displayBeforeRate)修改为\(item.displayAfterRate)",
|
||||
item.displayRemark.map { "备注\($0)" },
|
||||
].compactMap { $0 }.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
@ -31,12 +31,13 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF6F7FA)
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 142
|
||||
tableView.contentInset.top = AppSpacing.sm
|
||||
tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm
|
||||
tableView.estimatedRowHeight = 224
|
||||
tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
|
||||
tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier)
|
||||
@ -245,6 +246,17 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
|
||||
cell.onEditCommission = { [weak self] in
|
||||
self?.presentCommissionDialog(for: acquirer)
|
||||
}
|
||||
cell.onViewCommissionLogs = { [weak self] in
|
||||
guard acquirer.saleUserId > 0 else {
|
||||
self?.showToast("获客员信息无效")
|
||||
return
|
||||
}
|
||||
let controller = CommissionRateLogViewController(
|
||||
saleUserId: acquirer.saleUserId,
|
||||
acquirerName: acquirer.displayName
|
||||
)
|
||||
self?.navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
cell.onCall = {
|
||||
let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return }
|
||||
|
||||
@ -445,22 +445,25 @@ final class CooperationAcquirerCell: UITableViewCell {
|
||||
|
||||
var onEditRemark: (() -> Void)?
|
||||
var onEditCommission: (() -> Void)?
|
||||
var onViewCommissionLogs: (() -> Void)?
|
||||
var onCall: (() -> Void)?
|
||||
|
||||
private let cardView = UIView()
|
||||
private let avatarContainer = UIView()
|
||||
private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill"))
|
||||
private let nameTitleLabel = UILabel()
|
||||
private let nameValueLabel = UILabel()
|
||||
private let editRemarkButton = UIButton(type: .system)
|
||||
private let phoneTitleLabel = UILabel()
|
||||
private let phoneValueLabel = UILabel()
|
||||
private let callButton = UIButton(type: .system)
|
||||
private let bindTimeTitleLabel = UILabel()
|
||||
private let bindTimeValueLabel = UILabel()
|
||||
private let commissionContainer = UIView()
|
||||
private let commissionTitleLabel = UILabel()
|
||||
private let commissionValueLabel = UILabel()
|
||||
private let bindTimeIconView = UIImageView(image: UIImage(systemName: "calendar"))
|
||||
private let bindTimeValueLabel = UILabel()
|
||||
private let dividerView = UIView()
|
||||
private let editRemarkButton = UIButton(type: .system)
|
||||
private let editCommissionButton = UIButton(type: .system)
|
||||
private let commissionLogButton = UIButton(type: .system)
|
||||
private let actionStack = UIStackView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
@ -469,135 +472,180 @@ final class CooperationAcquirerCell: UITableViewCell {
|
||||
contentView.backgroundColor = .clear
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = AppRadius.md
|
||||
cardView.layer.cornerRadius = AppRadius.xl
|
||||
cardView.layer.borderWidth = 0.5
|
||||
cardView.layer.borderColor = AppColor.cardOutline.cgColor
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.04
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
|
||||
cardView.layer.shadowRadius = 2
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
cardView.layer.shadowRadius = 8
|
||||
cardView.accessibilityIdentifier = "cooperation_acquirer_card"
|
||||
|
||||
avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
|
||||
avatarContainer.layer.cornerRadius = AppRadius.sm
|
||||
avatarContainer.layer.cornerRadius = 24
|
||||
avatarContainer.clipsToBounds = true
|
||||
|
||||
avatarIconView.tintColor = AppColor.primary
|
||||
avatarIconView.contentMode = .scaleAspectFit
|
||||
|
||||
configureTitleLabel(nameTitleLabel, text: "获客员名称")
|
||||
configureTitleLabel(phoneTitleLabel, text: "手机号")
|
||||
configureTitleLabel(bindTimeTitleLabel, text: "绑定时间")
|
||||
configureTitleLabel(commissionTitleLabel, text: "分成比例")
|
||||
|
||||
nameValueLabel.font = .systemFont(ofSize: 15, weight: .bold)
|
||||
nameValueLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
nameValueLabel.textColor = AppColor.textPrimary
|
||||
nameValueLabel.numberOfLines = 1
|
||||
nameValueLabel.lineBreakMode = .byTruncatingTail
|
||||
nameValueLabel.accessibilityIdentifier = "cooperation_acquirer_name"
|
||||
|
||||
[phoneValueLabel, bindTimeValueLabel, commissionValueLabel].forEach { label in
|
||||
label.font = .systemFont(ofSize: 13)
|
||||
label.textColor = AppColor.textSecondary
|
||||
label.numberOfLines = 1
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
}
|
||||
commissionValueLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
|
||||
editRemarkButton.setTitle("修改备注", for: .normal)
|
||||
editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
editRemarkButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
editRemarkButton.setConfigurationContentInsets(
|
||||
NSDirectionalEdgeInsets(top: 16, leading: 14, bottom: 16, trailing: 14)
|
||||
)
|
||||
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
|
||||
phoneValueLabel.font = .systemFont(ofSize: 14)
|
||||
phoneValueLabel.textColor = AppColor.textTertiary
|
||||
phoneValueLabel.numberOfLines = 1
|
||||
phoneValueLabel.lineBreakMode = .byTruncatingTail
|
||||
phoneValueLabel.accessibilityIdentifier = "cooperation_acquirer_phone"
|
||||
|
||||
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||
callButton.tintColor = AppColor.primary
|
||||
callButton.backgroundColor = AppColor.primaryLight
|
||||
callButton.layer.cornerRadius = 15
|
||||
callButton.layer.cornerRadius = AppSpacing.minTouchTarget / 2
|
||||
callButton.layer.borderWidth = 0.5
|
||||
callButton.layer.borderColor = AppColor.primary.withAlphaComponent(0.12).cgColor
|
||||
callButton.imageView?.contentMode = .scaleAspectFit
|
||||
callButton.accessibilityLabel = "拨打获客员电话"
|
||||
callButton.accessibilityIdentifier = "cooperation_acquirer_call"
|
||||
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
||||
|
||||
editCommissionButton.setTitle("修改比例", for: .normal)
|
||||
editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
editCommissionButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
editCommissionButton.setConfigurationContentInsets(
|
||||
NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)
|
||||
commissionContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
|
||||
commissionContainer.layer.cornerRadius = AppRadius.lg
|
||||
|
||||
commissionTitleLabel.text = "分成比例"
|
||||
commissionTitleLabel.font = .systemFont(ofSize: 12)
|
||||
commissionTitleLabel.textColor = AppColor.textTertiary
|
||||
|
||||
commissionValueLabel.font = .systemFont(ofSize: 24, weight: .bold)
|
||||
commissionValueLabel.textColor = AppColor.primary
|
||||
commissionValueLabel.numberOfLines = 1
|
||||
commissionValueLabel.adjustsFontSizeToFitWidth = true
|
||||
commissionValueLabel.minimumScaleFactor = 0.75
|
||||
commissionValueLabel.accessibilityIdentifier = "cooperation_acquirer_commission_rate"
|
||||
|
||||
bindTimeIconView.tintColor = AppColor.textTertiary
|
||||
bindTimeIconView.contentMode = .scaleAspectFit
|
||||
|
||||
bindTimeValueLabel.font = .systemFont(ofSize: 13)
|
||||
bindTimeValueLabel.textColor = AppColor.textTertiary
|
||||
bindTimeValueLabel.numberOfLines = 2
|
||||
bindTimeValueLabel.lineBreakMode = .byTruncatingTail
|
||||
bindTimeValueLabel.accessibilityIdentifier = "cooperation_acquirer_bind_time"
|
||||
|
||||
dividerView.backgroundColor = AppColor.cardOutline
|
||||
|
||||
configureActionButton(
|
||||
editRemarkButton,
|
||||
title: "修改备注",
|
||||
isPrimary: false,
|
||||
accessibilityIdentifier: "cooperation_acquirer_edit_remark"
|
||||
)
|
||||
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
|
||||
|
||||
configureActionButton(
|
||||
commissionLogButton,
|
||||
title: "修改记录",
|
||||
isPrimary: false,
|
||||
accessibilityIdentifier: "cooperation_acquirer_commission_logs"
|
||||
)
|
||||
commissionLogButton.accessibilityLabel = "查看分成比例修改记录"
|
||||
commissionLogButton.addTarget(self, action: #selector(commissionLogTapped), for: .touchUpInside)
|
||||
|
||||
configureActionButton(
|
||||
editCommissionButton,
|
||||
title: "修改比例",
|
||||
isPrimary: true,
|
||||
accessibilityIdentifier: "cooperation_acquirer_edit_commission"
|
||||
)
|
||||
editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside)
|
||||
|
||||
actionStack.axis = .horizontal
|
||||
actionStack.alignment = .fill
|
||||
actionStack.distribution = .fillEqually
|
||||
actionStack.spacing = AppSpacing.xs
|
||||
actionStack.addArrangedSubview(editRemarkButton)
|
||||
actionStack.addArrangedSubview(commissionLogButton)
|
||||
actionStack.addArrangedSubview(editCommissionButton)
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(avatarContainer)
|
||||
avatarContainer.addSubview(avatarIconView)
|
||||
cardView.addSubview(nameTitleLabel)
|
||||
cardView.addSubview(nameValueLabel)
|
||||
cardView.addSubview(editRemarkButton)
|
||||
cardView.addSubview(phoneTitleLabel)
|
||||
cardView.addSubview(phoneValueLabel)
|
||||
cardView.addSubview(callButton)
|
||||
cardView.addSubview(bindTimeTitleLabel)
|
||||
cardView.addSubview(commissionContainer)
|
||||
commissionContainer.addSubview(commissionTitleLabel)
|
||||
commissionContainer.addSubview(commissionValueLabel)
|
||||
cardView.addSubview(bindTimeIconView)
|
||||
cardView.addSubview(bindTimeValueLabel)
|
||||
cardView.addSubview(commissionTitleLabel)
|
||||
cardView.addSubview(commissionValueLabel)
|
||||
cardView.addSubview(editCommissionButton)
|
||||
cardView.addSubview(dividerView)
|
||||
cardView.addSubview(actionStack)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 14, bottom: AppSpacing.sm, right: 14))
|
||||
make.edges.equalToSuperview().inset(
|
||||
UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)
|
||||
)
|
||||
}
|
||||
avatarContainer.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(14)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(44)
|
||||
make.top.leading.equalToSuperview().inset(AppSpacing.md)
|
||||
make.size.equalTo(48)
|
||||
}
|
||||
avatarIconView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(22)
|
||||
}
|
||||
nameTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(AppSpacing.md)
|
||||
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
nameValueLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(nameTitleLabel)
|
||||
make.leading.equalTo(nameTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualTo(editRemarkButton.snp.leading).offset(-AppSpacing.xs)
|
||||
}
|
||||
editRemarkButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview()
|
||||
}
|
||||
phoneTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.equalTo(nameTitleLabel)
|
||||
make.top.equalTo(avatarContainer).offset(2)
|
||||
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
|
||||
}
|
||||
phoneValueLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(phoneTitleLabel)
|
||||
make.leading.equalTo(phoneTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.xs)
|
||||
make.top.equalTo(nameValueLabel.snp.bottom).offset(AppSpacing.xxs)
|
||||
make.leading.equalTo(nameValueLabel)
|
||||
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
|
||||
}
|
||||
callButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.centerY.equalTo(phoneTitleLabel)
|
||||
make.size.equalTo(30)
|
||||
make.top.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.size.equalTo(AppSpacing.minTouchTarget)
|
||||
}
|
||||
bindTimeTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(phoneTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.equalTo(nameTitleLabel)
|
||||
}
|
||||
bindTimeValueLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(bindTimeTitleLabel)
|
||||
make.leading.equalTo(bindTimeTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||
commissionContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(avatarContainer.snp.bottom).offset(14)
|
||||
make.leading.equalToSuperview().inset(AppSpacing.md)
|
||||
make.width.equalTo(120)
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
commissionTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(bindTimeTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.equalTo(nameTitleLabel)
|
||||
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.top.equalToSuperview().offset(AppSpacing.xs)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
commissionValueLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(commissionTitleLabel)
|
||||
make.leading.equalTo(commissionTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualTo(editCommissionButton.snp.leading).offset(-AppSpacing.xs)
|
||||
make.top.equalTo(commissionTitleLabel.snp.bottom).offset(2)
|
||||
make.centerX.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualToSuperview().offset(AppSpacing.xs)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.xs)
|
||||
}
|
||||
editCommissionButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(commissionTitleLabel)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.xs)
|
||||
bindTimeIconView.snp.makeConstraints { make in
|
||||
make.leading.equalTo(commissionContainer.snp.trailing).offset(AppSpacing.md)
|
||||
make.centerY.equalTo(commissionContainer)
|
||||
make.size.equalTo(18)
|
||||
}
|
||||
bindTimeValueLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(bindTimeIconView.snp.trailing).offset(AppSpacing.xs)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.centerY.equalTo(bindTimeIconView)
|
||||
}
|
||||
dividerView.snp.makeConstraints { make in
|
||||
make.top.equalTo(commissionContainer.snp.bottom).offset(14)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
actionStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(dividerView.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(AppSpacing.minTouchTarget)
|
||||
make.bottom.equalToSuperview().inset(14)
|
||||
}
|
||||
}
|
||||
|
||||
@ -610,26 +658,52 @@ final class CooperationAcquirerCell: UITableViewCell {
|
||||
super.prepareForReuse()
|
||||
onEditRemark = nil
|
||||
onEditCommission = nil
|
||||
onViewCommissionLogs = nil
|
||||
onCall = nil
|
||||
}
|
||||
|
||||
func configure(with acquirer: CooperativeSalerEntity) {
|
||||
nameValueLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName
|
||||
phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "—" : CooperationOrderPhoneMask.mask(acquirer.displayPhone)
|
||||
bindTimeValueLabel.text = acquirer.displayBindTime.isEmpty ? "—" : acquirer.displayBindTime
|
||||
let bindTime = acquirer.displayBindTime.isEmpty ? "—" : Self.minutePrecisionTime(acquirer.displayBindTime)
|
||||
bindTimeValueLabel.text = "绑定于 \(bindTime)"
|
||||
commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "—" : acquirer.displayCommissionRate
|
||||
}
|
||||
|
||||
private func configureTitleLabel(_ label: UILabel, text: String) {
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 13)
|
||||
label.textColor = AppColor.textTertiary
|
||||
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
private func configureActionButton(
|
||||
_ button: UIButton,
|
||||
title: String,
|
||||
isPrimary: Bool,
|
||||
accessibilityIdentifier: String
|
||||
) {
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.title = title
|
||||
configuration.baseForegroundColor = isPrimary ? AppColor.primary : UIColor(hex: 0x5F6673)
|
||||
configuration.background.backgroundColor = isPrimary ? AppColor.primaryLight : .white
|
||||
configuration.background.strokeColor = isPrimary
|
||||
? AppColor.primary.withAlphaComponent(0.35)
|
||||
: UIColor(hex: 0xD9DEE7)
|
||||
configuration.background.strokeWidth = 1
|
||||
configuration.background.cornerRadius = AppRadius.md
|
||||
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)
|
||||
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||
var updated = attributes
|
||||
updated.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
return updated
|
||||
}
|
||||
button.configuration = configuration
|
||||
button.accessibilityIdentifier = accessibilityIdentifier
|
||||
}
|
||||
|
||||
private static func minutePrecisionTime(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count == 19, trimmed.dropFirst(16).first == ":" else { return trimmed }
|
||||
return String(trimmed.prefix(16))
|
||||
}
|
||||
|
||||
@objc private func editRemarkTapped() { onEditRemark?() }
|
||||
@objc private func editCommissionTapped() { onEditCommission?() }
|
||||
@objc private func commissionLogTapped() { onViewCommissionLogs?() }
|
||||
@objc private func callTapped() { onCall?() }
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,10 @@ import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
import MapKit
|
||||
#endif
|
||||
|
||||
/// 地图缩放/定位控件。
|
||||
final class LocationReportMapControlStack: UIView {
|
||||
|
||||
@ -295,7 +299,112 @@ final class LocationReportBottomActionView: UIControl {
|
||||
}
|
||||
}
|
||||
|
||||
/// 地图容器,封装高德 `MAMapView`。
|
||||
/// 位置上报地图容器,模拟器使用 MapKit。
|
||||
#if targetEnvironment(simulator)
|
||||
final class LocationReportMapView: UIView, MKMapViewDelegate {
|
||||
|
||||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||||
|
||||
let mapView: MKMapView
|
||||
private var markerAnnotation: MKPointAnnotation?
|
||||
private var shouldCenterOnNextUserLocation = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
mapView = MKMapView(frame: .zero)
|
||||
super.init(frame: frame)
|
||||
mapView.delegate = self
|
||||
mapView.showsUserLocation = true
|
||||
mapView.showsCompass = false
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
|
||||
tapGesture.cancelsTouchesInView = false
|
||||
mapView.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 进入页面后,在首次拿到用户位置时将地图居中。
|
||||
func enableInitialUserLocationCentering() {
|
||||
shouldCenterOnNextUserLocation = true
|
||||
centerOnUserLocationIfNeeded()
|
||||
}
|
||||
|
||||
func centerOnUserLocation() {
|
||||
guard let coordinate = validUserCoordinate else { return }
|
||||
setCenter(coordinate)
|
||||
}
|
||||
|
||||
func setCenter(_ coordinate: CLLocationCoordinate2D, animated: Bool = true) {
|
||||
let region = MKCoordinateRegion(
|
||||
center: coordinate,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.012)
|
||||
)
|
||||
mapView.setRegion(region, animated: animated)
|
||||
}
|
||||
|
||||
func zoomIn() {
|
||||
updateZoom(scale: 0.5)
|
||||
}
|
||||
|
||||
func zoomOut() {
|
||||
updateZoom(scale: 2)
|
||||
}
|
||||
|
||||
func updateMarker(latitude: Double?, longitude: Double?) {
|
||||
if let markerAnnotation {
|
||||
mapView.removeAnnotation(markerAnnotation)
|
||||
self.markerAnnotation = nil
|
||||
}
|
||||
guard let latitude, let longitude else { return }
|
||||
let annotation = MKPointAnnotation()
|
||||
annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
markerAnnotation = annotation
|
||||
mapView.addAnnotation(annotation)
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||||
centerOnUserLocationIfNeeded()
|
||||
}
|
||||
|
||||
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard gesture.state == .ended else { return }
|
||||
let point = gesture.location(in: mapView)
|
||||
onMapTap?(mapView.convert(point, toCoordinateFrom: mapView))
|
||||
}
|
||||
|
||||
private var validUserCoordinate: CLLocationCoordinate2D? {
|
||||
guard let coordinate = mapView.userLocation.location?.coordinate,
|
||||
CLLocationCoordinate2DIsValid(coordinate),
|
||||
coordinate.latitude != 0 || coordinate.longitude != 0 else {
|
||||
return nil
|
||||
}
|
||||
return coordinate
|
||||
}
|
||||
|
||||
private func centerOnUserLocationIfNeeded() {
|
||||
guard shouldCenterOnNextUserLocation, let coordinate = validUserCoordinate else { return }
|
||||
setCenter(coordinate, animated: false)
|
||||
shouldCenterOnNextUserLocation = false
|
||||
}
|
||||
|
||||
private func updateZoom(scale: Double) {
|
||||
let current = mapView.region
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: min(max(current.span.latitudeDelta * scale, 0.0005), 120),
|
||||
longitudeDelta: min(max(current.span.longitudeDelta * scale, 0.0005), 120)
|
||||
)
|
||||
mapView.setRegion(MKCoordinateRegion(center: current.center, span: span), animated: true)
|
||||
}
|
||||
}
|
||||
#else
|
||||
/// 位置上报地图容器,真机封装高德 `MAMapView`。
|
||||
final class LocationReportMapView: UIView, MAMapViewDelegate {
|
||||
|
||||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||||
@ -381,3 +490,4 @@ final class LocationReportMapView: UIView, MAMapViewDelegate {
|
||||
shouldCenterOnNextUserLocation = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@ -13,10 +14,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
private let paymentAPI = NetworkServices.shared.paymentAPI
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentContainerView = UIView()
|
||||
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 scenicNameLabel = UILabel()
|
||||
private let qrContainerView = UIView()
|
||||
private let qrImageView = UIImageView()
|
||||
private let qrPlaceholderView = UIView()
|
||||
private let qrErrorLabel = UILabel()
|
||||
@ -29,26 +38,75 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
private let scenicRow = PaymentInfoRowView(title: "景区名称")
|
||||
private let merchantRow = PaymentInfoRowView(title: "收款方")
|
||||
private let staffRow = PaymentInfoRowView(title: "员工 ID")
|
||||
private let photographerRow = PaymentInfoRowView(title: "摄影师名称")
|
||||
private let storeRow = PaymentInfoRowView(title: "店铺名称")
|
||||
|
||||
private let recordRow = PaymentNavigationRowView(title: "收款记录")
|
||||
|
||||
private let settingsCardView = UIView()
|
||||
private let voiceCardView = UIView()
|
||||
private let voiceTitleLabel = UILabel()
|
||||
private let voiceSwitch = UISwitch()
|
||||
|
||||
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() {
|
||||
title = "收款详情"
|
||||
title = viewModel.usesNalatiBranding ? nil : "收款详情"
|
||||
}
|
||||
|
||||
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.spacing = AppSpacing.md
|
||||
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
|
||||
|
||||
qrCardView.backgroundColor = .white
|
||||
qrCardView.backgroundColor = usesBranding ? .clear : .white
|
||||
qrCardView.layer.cornerRadius = AppRadius.lg
|
||||
|
||||
scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
@ -57,9 +115,16 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
|
||||
qrImageView.contentMode = .scaleAspectFit
|
||||
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.layer.cornerRadius = 24
|
||||
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
|
||||
qrPlaceholderView.isHidden = true
|
||||
|
||||
qrErrorLabel.text = "未选景区或网络问题"
|
||||
@ -73,19 +138,29 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
|
||||
actionRow.axis = .horizontal
|
||||
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(saveQRButton, title: "保存二维码")
|
||||
|
||||
infoCardView.backgroundColor = .white
|
||||
infoCardView.layer.cornerRadius = AppRadius.lg
|
||||
configureCard(infoCardView, branded: usesBranding)
|
||||
|
||||
recordRow.backgroundColor = .white
|
||||
recordRow.layer.cornerRadius = AppRadius.lg
|
||||
recordRow.backgroundColor = usesBranding ? .clear : .white
|
||||
recordRow.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
|
||||
|
||||
voiceCardView.backgroundColor = .white
|
||||
voiceCardView.layer.cornerRadius = AppRadius.lg
|
||||
voiceCardView.backgroundColor = usesBranding ? .clear : .white
|
||||
voiceCardView.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
|
||||
|
||||
voiceTitleLabel.text = "收款到账语音提醒"
|
||||
voiceTitleLabel.font = .systemFont(ofSize: 14)
|
||||
@ -93,49 +168,112 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
|
||||
voiceSwitch.onTintColor = AppColor.primary
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
if usesBranding {
|
||||
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(infoCardView)
|
||||
if usesBranding {
|
||||
configureCard(settingsCardView, branded: true)
|
||||
contentStack.addArrangedSubview(settingsCardView)
|
||||
} else {
|
||||
contentStack.addArrangedSubview(recordRow)
|
||||
contentStack.addArrangedSubview(voiceCardView)
|
||||
}
|
||||
|
||||
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
|
||||
let qrContentStack = UIStackView(arrangedSubviews: [
|
||||
scenicNameLabel,
|
||||
qrImageView,
|
||||
qrDisplayView,
|
||||
qrPlaceholderView,
|
||||
actionRow,
|
||||
])
|
||||
qrContentStack.axis = .vertical
|
||||
qrContentStack.alignment = .center
|
||||
qrContentStack.spacing = AppSpacing.lg
|
||||
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
|
||||
qrCardView.addSubview(qrContentStack)
|
||||
|
||||
qrPlaceholderView.addSubview(qrErrorLabel)
|
||||
qrPlaceholderView.addSubview(refreshButton)
|
||||
|
||||
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)
|
||||
|
||||
let infoStack = UIStackView(arrangedSubviews: [scenicRow, merchantRow, staffRow])
|
||||
let infoRows = usesBranding
|
||||
? [photographerRow, storeRow]
|
||||
: [scenicRow, merchantRow, staffRow]
|
||||
let infoStack = UIStackView(arrangedSubviews: infoRows)
|
||||
infoStack.axis = .vertical
|
||||
infoCardView.addSubview(infoStack)
|
||||
|
||||
voiceCardView.addSubview(voiceTitleLabel)
|
||||
voiceCardView.addSubview(voiceSwitch)
|
||||
|
||||
qrContentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 32, left: 16, bottom: 32, right: 16))
|
||||
if usesBranding {
|
||||
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
|
||||
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
|
||||
make.width.height.equalTo(182)
|
||||
}
|
||||
}
|
||||
qrPlaceholderView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(182)
|
||||
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
|
||||
}
|
||||
qrErrorLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(56)
|
||||
@ -162,12 +300,36 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
if viewModel.usesNalatiBranding {
|
||||
backgroundImageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
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() {
|
||||
super.viewDidLoad()
|
||||
applyViewModel()
|
||||
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 {
|
||||
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
|
||||
showLoading()
|
||||
await viewModel.loadPayCode(api: paymentAPI)
|
||||
hideLoading()
|
||||
await pageConfig
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -203,15 +382,20 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
scenicRow.setValue(viewModel.displayScenicName)
|
||||
merchantRow.setValue(viewModel.merchantInfo.company)
|
||||
staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId)
|
||||
photographerRow.setValue(viewModel.displayPhotographerName)
|
||||
storeRow.setValue(viewModel.displayStoreNames)
|
||||
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
|
||||
applyBrandConfigIfNeeded()
|
||||
|
||||
if let image = viewModel.qrImage {
|
||||
qrImageView.image = image
|
||||
qrImageView.isHidden = false
|
||||
qrContainerView.isHidden = !viewModel.usesNalatiBranding
|
||||
qrPlaceholderView.isHidden = true
|
||||
actionRow.isHidden = false
|
||||
} else {
|
||||
qrImageView.isHidden = true
|
||||
qrContainerView.isHidden = true
|
||||
qrPlaceholderView.isHidden = false
|
||||
actionRow.isHidden = true
|
||||
}
|
||||
@ -254,6 +438,181 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
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() {
|
||||
viewModel.showSetAmountDialog()
|
||||
}
|
||||
@ -310,7 +669,7 @@ private final class PaymentInfoRowView: UIView {
|
||||
valueLabel.font = .systemFont(ofSize: 14)
|
||||
valueLabel.textColor = AppColor.textPrimary
|
||||
valueLabel.textAlignment = .right
|
||||
valueLabel.numberOfLines = 2
|
||||
valueLabel.numberOfLines = 0
|
||||
|
||||
divider.backgroundColor = UIColor(hex: 0xE5E7EB)
|
||||
|
||||
|
||||
@ -3,11 +3,16 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Kingfisher
|
||||
import Photos
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
import MapKit
|
||||
#endif
|
||||
|
||||
/// 打卡点详情页,对齐 Android `PunchPointDetailScreen`。
|
||||
final class PunchPointDetailViewController: BaseViewController {
|
||||
private let viewModel: PunchPointDetailViewModel
|
||||
@ -447,7 +452,192 @@ final class PunchPointDetailViewController: BaseViewController {
|
||||
}
|
||||
|
||||
/// 高德地图容器,供打卡点详情与表单复用。
|
||||
/// 打卡点地图视图,封装高德地图 marker、缩放、定位与点击选点行为。
|
||||
/// 打卡点地图视图,模拟器使用 MapKit 提供 marker、缩放、定位与点击选点行为。
|
||||
#if targetEnvironment(simulator)
|
||||
final class PunchPointMapView: UIView, MKMapViewDelegate {
|
||||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||||
|
||||
private let mapView: MKMapView
|
||||
private var punchPointAnnotation: MKPointAnnotation?
|
||||
private var selectedAnnotation: MKPointAnnotation?
|
||||
private var punchPointImageURL: URL?
|
||||
private var fitWithUserLocation = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
mapView = MKMapView(frame: .zero)
|
||||
super.init(frame: frame)
|
||||
mapView.delegate = self
|
||||
mapView.showsUserLocation = true
|
||||
mapView.showsCompass = false
|
||||
mapView.showsScale = false
|
||||
mapView.pointOfInterestFilter = .includingAll
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
|
||||
tapGesture.cancelsTouchesInView = false
|
||||
mapView.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新原打卡点标记,可使用首张图片作为圆角 Marker。
|
||||
func updatePunchPointMarker(
|
||||
coordinate: CLLocationCoordinate2D?,
|
||||
imageURL: String? = nil,
|
||||
fitWithUserLocation: Bool = false
|
||||
) {
|
||||
if let punchPointAnnotation {
|
||||
mapView.removeAnnotation(punchPointAnnotation)
|
||||
self.punchPointAnnotation = nil
|
||||
}
|
||||
punchPointImageURL = imageURL.flatMap(URL.init(string:))
|
||||
self.fitWithUserLocation = fitWithUserLocation
|
||||
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||||
let annotation = MKPointAnnotation()
|
||||
annotation.coordinate = coordinate
|
||||
annotation.title = "punch_point_original"
|
||||
punchPointAnnotation = annotation
|
||||
mapView.addAnnotation(annotation)
|
||||
if fitWithUserLocation {
|
||||
fitPunchPointAndUserLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容仅需更新普通 Marker 的调用。
|
||||
func updateMarker(coordinate: CLLocationCoordinate2D?) {
|
||||
updatePunchPointMarker(coordinate: coordinate)
|
||||
}
|
||||
|
||||
/// 更新地图点击产生的新位置 Marker。
|
||||
func updateSelectedMarker(coordinate: CLLocationCoordinate2D?) {
|
||||
if let selectedAnnotation {
|
||||
mapView.removeAnnotation(selectedAnnotation)
|
||||
self.selectedAnnotation = nil
|
||||
}
|
||||
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||||
let annotation = MKPointAnnotation()
|
||||
annotation.coordinate = coordinate
|
||||
annotation.title = "punch_point_selected"
|
||||
selectedAnnotation = annotation
|
||||
mapView.addAnnotation(annotation)
|
||||
}
|
||||
|
||||
/// 设置地图中心点。
|
||||
func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double = 15, animated: Bool = true) {
|
||||
let delta = min(max(0.012 * pow(2, 15 - zoomLevel), 0.0005), 120)
|
||||
let region = MKCoordinateRegion(
|
||||
center: coordinate,
|
||||
span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta)
|
||||
)
|
||||
mapView.setRegion(region, animated: animated)
|
||||
}
|
||||
|
||||
/// 放大地图。
|
||||
func zoomIn() {
|
||||
updateZoom(scale: 0.5)
|
||||
}
|
||||
|
||||
/// 缩小地图。
|
||||
func zoomOut() {
|
||||
updateZoom(scale: 2)
|
||||
}
|
||||
|
||||
/// 居中到用户当前位置。
|
||||
func centerOnUserLocation() {
|
||||
guard let coordinate = mapView.userLocation.location?.coordinate,
|
||||
CLLocationCoordinate2DIsValid(coordinate),
|
||||
coordinate.latitude != 0 || coordinate.longitude != 0 else { return }
|
||||
setCenter(coordinate)
|
||||
}
|
||||
|
||||
/// 同时展示原打卡点、用户位置和新选位置。
|
||||
func fitAllMarkers(edgePadding: UIEdgeInsets = UIEdgeInsets(top: 80, left: 56, bottom: 80, right: 56)) {
|
||||
var annotations: [any MKAnnotation] = []
|
||||
if let punchPointAnnotation { annotations.append(punchPointAnnotation) }
|
||||
if let selectedAnnotation { annotations.append(selectedAnnotation) }
|
||||
if mapView.userLocation.location != nil { annotations.append(mapView.userLocation) }
|
||||
guard !annotations.isEmpty else { return }
|
||||
if annotations.count == 1, let annotation = annotations.first {
|
||||
setCenter(annotation.coordinate, zoomLevel: 16)
|
||||
} else {
|
||||
var mapRect = MKMapRect.null
|
||||
for annotation in annotations {
|
||||
let point = MKMapPoint(annotation.coordinate)
|
||||
mapRect = mapRect.union(MKMapRect(
|
||||
origin: point,
|
||||
size: MKMapSize(width: 1, height: 1)
|
||||
))
|
||||
}
|
||||
mapView.setVisibleMapRect(mapRect, edgePadding: edgePadding, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? {
|
||||
if annotation is MKUserLocation { return nil }
|
||||
let isOriginal = (annotation as AnyObject) === punchPointAnnotation
|
||||
let reuseIdentifier = isOriginal ? "PunchPointImageMarker" : "PunchPointSelectedMarker"
|
||||
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
|
||||
?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
||||
annotationView.annotation = annotation
|
||||
annotationView.canShowCallout = false
|
||||
annotationView.centerOffset = CGPoint(x: 0, y: -12)
|
||||
|
||||
if isOriginal, let punchPointImageURL {
|
||||
annotationView.image = UIImage(named: "punch_point_marker")
|
||||
KingfisherManager.shared.retrieveImage(with: punchPointImageURL) { result in
|
||||
guard case let .success(value) = result else { return }
|
||||
Task { @MainActor in
|
||||
guard annotationView.annotation === annotation else { return }
|
||||
annotationView.image = Self.roundedMarkerImage(value.image)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
annotationView.image = UIImage(named: "punch_point_marker")
|
||||
}
|
||||
return annotationView
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||||
guard userLocation.location != nil else { return }
|
||||
fitPunchPointAndUserLocation()
|
||||
}
|
||||
|
||||
private func fitPunchPointAndUserLocation() {
|
||||
guard fitWithUserLocation else { return }
|
||||
fitAllMarkers(edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: 80, right: 50))
|
||||
}
|
||||
|
||||
private static func roundedMarkerImage(_ source: UIImage) -> UIImage {
|
||||
let size = CGSize(width: 40, height: 40)
|
||||
return UIGraphicsImageRenderer(size: size).image { _ in
|
||||
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 6).addClip()
|
||||
source.draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard gesture.state == .ended else { return }
|
||||
let point = gesture.location(in: mapView)
|
||||
onMapTap?(mapView.convert(point, toCoordinateFrom: mapView))
|
||||
}
|
||||
|
||||
private func updateZoom(scale: Double) {
|
||||
let current = mapView.region
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: min(max(current.span.latitudeDelta * scale, 0.0005), 120),
|
||||
longitudeDelta: min(max(current.span.longitudeDelta * scale, 0.0005), 120)
|
||||
)
|
||||
mapView.setRegion(MKCoordinateRegion(center: current.center, span: span), animated: true)
|
||||
}
|
||||
}
|
||||
#else
|
||||
/// 打卡点地图视图,真机封装高德地图 marker、缩放、定位与点击选点行为。
|
||||
final class PunchPointMapView: UIView, MAMapViewDelegate {
|
||||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||||
|
||||
@ -614,6 +804,7 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
|
||||
onMapTap?(poi.coordinate)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 打卡点地图控制按钮。
|
||||
/// 地图悬浮控制按钮。
|
||||
|
||||
@ -20,7 +20,9 @@ final class TravelAlbumCodeDialogViewController: UIViewController {
|
||||
private let albumNameLabel = UILabel()
|
||||
private let qrImageView = UIImageView()
|
||||
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)
|
||||
|
||||
init(state: TravelAlbumEntryViewModel.AlbumCodeState) {
|
||||
@ -117,8 +119,8 @@ final class TravelAlbumCodeDialogViewController: UIViewController {
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(qrImageView.snp.bottom).offset(18)
|
||||
make.centerX.equalToSuperview()
|
||||
make.height.equalTo(34)
|
||||
make.leading.greaterThanOrEqualToSuperview().offset(20)
|
||||
make.trailing.lessThanOrEqualToSuperview().offset(-20)
|
||||
}
|
||||
downloadButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(hintLabel.snp.bottom).offset(20)
|
||||
@ -160,3 +162,30 @@ final class TravelAlbumCodeDialogViewController: UIViewController {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -184,7 +184,8 @@ extension TravelAlbumEntryViewController: UITableViewDelegate {
|
||||
/// 新建相册任务蓝色入口卡片。
|
||||
private final class TravelAlbumHeroCard: UIControl {
|
||||
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 subtitleLabel = UILabel()
|
||||
private let arrowLabel = UILabel()
|
||||
@ -202,10 +203,11 @@ private final class TravelAlbumHeroCard: UIControl {
|
||||
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||||
layer.insertSublayer(gradientLayer, at: 0)
|
||||
|
||||
plusView.tintColor = AppColor.primary
|
||||
plusView.contentMode = .scaleAspectFit
|
||||
plusView.backgroundColor = .white
|
||||
plusView.layer.cornerRadius = 17
|
||||
plusView.clipsToBounds = true
|
||||
plusImageView.tintColor = AppColor.primary
|
||||
plusImageView.contentMode = .scaleAspectFit
|
||||
|
||||
titleLabel.text = "新建相册任务"
|
||||
titleLabel.font = .systemFont(ofSize: 19, weight: .medium)
|
||||
@ -218,6 +220,7 @@ private final class TravelAlbumHeroCard: UIControl {
|
||||
arrowLabel.font = .systemFont(ofSize: 36, weight: .light)
|
||||
|
||||
addSubview(plusView)
|
||||
plusView.addSubview(plusImageView)
|
||||
addSubview(titleLabel)
|
||||
addSubview(subtitleLabel)
|
||||
addSubview(arrowLabel)
|
||||
@ -226,6 +229,10 @@ private final class TravelAlbumHeroCard: UIControl {
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(34)
|
||||
}
|
||||
plusImageView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(26)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(30)
|
||||
make.leading.equalTo(plusView.snp.trailing).offset(16)
|
||||
|
||||
@ -4,10 +4,15 @@
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import MAMapKit
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
import MapKit
|
||||
#else
|
||||
import MAMapKit
|
||||
#endif
|
||||
|
||||
/// 我的举报列表页,支持按状态筛选并进入举报详情。
|
||||
final class WildPhotographerReportListViewController: BaseViewController {
|
||||
private let viewModel: WildPhotographerReportListViewModel
|
||||
@ -1797,6 +1802,49 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
||||
}
|
||||
|
||||
/// 举报详情页地图预览,绘制简化道路、路径和定位点。
|
||||
#if targetEnvironment(simulator)
|
||||
private final class WildReportDetailMapPreviewView: UIView {
|
||||
private let mapView: MKMapView
|
||||
|
||||
init(scenicName: String, detailAddress: String, coordinate: CLLocationCoordinate2D?) {
|
||||
mapView = MKMapView(frame: .zero)
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = AppColor.pageBackgroundSoft
|
||||
layer.cornerRadius = 10
|
||||
clipsToBounds = true
|
||||
|
||||
mapView.showsCompass = false
|
||||
mapView.showsScale = false
|
||||
mapView.isScrollEnabled = false
|
||||
mapView.isZoomEnabled = false
|
||||
mapView.isRotateEnabled = false
|
||||
mapView.isPitchEnabled = false
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
guard let coordinate else { return }
|
||||
let annotation = MKPointAnnotation()
|
||||
annotation.coordinate = coordinate
|
||||
annotation.title = scenicName
|
||||
annotation.subtitle = detailAddress
|
||||
mapView.addAnnotation(annotation)
|
||||
mapView.setRegion(
|
||||
MKCoordinateRegion(
|
||||
center: coordinate,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.006, longitudeDelta: 0.006)
|
||||
),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
#else
|
||||
private final class WildReportDetailMapPreviewView: UIView {
|
||||
private let mapView: MAMapView
|
||||
|
||||
@ -1834,6 +1882,7 @@ private final class WildReportDetailMapPreviewView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 举报详情页处理进度时间线单行。
|
||||
private final class WildReportDetailTimelineRowView: UIView {
|
||||
|
||||
@ -3,19 +3,19 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
#if __has_include(<AMapFoundationKit/AMapFoundationKit.h>)
|
||||
#if !TARGET_OS_SIMULATOR && __has_include(<AMapFoundationKit/AMapFoundationKit.h>)
|
||||
#import <AMapFoundationKit/AMapFoundationKit.h>
|
||||
#endif
|
||||
|
||||
#if __has_include(<AMapLocationKit/AMapLocationKit.h>)
|
||||
#if !TARGET_OS_SIMULATOR && __has_include(<AMapLocationKit/AMapLocationKit.h>)
|
||||
#import <AMapLocationKit/AMapLocationKit.h>
|
||||
#endif
|
||||
|
||||
#if __has_include(<MAMapKit/MAMapKit.h>)
|
||||
#if !TARGET_OS_SIMULATOR && __has_include(<MAMapKit/MAMapKit.h>)
|
||||
#import <MAMapKit/MAMapKit.h>
|
||||
#endif
|
||||
|
||||
#if __has_include(<AMapSearchKit/AMapSearchKit.h>)
|
||||
#if !TARGET_OS_SIMULATOR && __has_include(<AMapSearchKit/AMapSearchKit.h>)
|
||||
#import <AMapSearchKit/AMapSearchKit.h>
|
||||
#endif
|
||||
|
||||
|
||||
277
suixinkanTests/CommissionRateLogTests.swift
Normal file
277
suixinkanTests/CommissionRateLogTests.swift
Normal file
@ -0,0 +1,277 @@
|
||||
//
|
||||
// CommissionRateLogTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 获客员分成比例修改记录模型、接口与分页行为测试。
|
||||
@MainActor
|
||||
final class CommissionRateLogTests: XCTestCase {
|
||||
func testModelDecodesCompleteRecordAndBuildsBusinessDisplayText() throws {
|
||||
let data = """
|
||||
{
|
||||
"list": [{
|
||||
"id": 1,
|
||||
"binding_id": 10,
|
||||
"sale_user_id": 456,
|
||||
"store_user_id": 732,
|
||||
"before_rate": 10,
|
||||
"before_rate_label": "10%(旧)",
|
||||
"after_rate": 20,
|
||||
"after_rate_label": "20%(新)",
|
||||
"operator_type": "store_user",
|
||||
"operator_id": 732,
|
||||
"operator_name": "李师傅",
|
||||
"remark": " 调整合作比例 ",
|
||||
"created_at": "2026-07-23 11:00:00"
|
||||
}],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 10
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let response = try JSONDecoder().decode(CommissionRateLogListResponse.self, from: data)
|
||||
let item = try XCTUnwrap(response.list.first)
|
||||
|
||||
XCTAssertEqual(response.pageSize, 10)
|
||||
XCTAssertEqual(item.bindingId, 10)
|
||||
XCTAssertEqual(item.saleUserId, 456)
|
||||
XCTAssertEqual(item.storeUserId, 732)
|
||||
XCTAssertEqual(item.operatorType, "store_user")
|
||||
XCTAssertEqual(item.operatorId, 732)
|
||||
XCTAssertEqual(item.displayBeforeRate, "10%(旧)")
|
||||
XCTAssertEqual(item.displayAfterRate, "20%(新)")
|
||||
XCTAssertEqual(item.metadataLine, "2026-07-23 11:00")
|
||||
XCTAssertEqual(item.displayRemark, "调整合作比例")
|
||||
}
|
||||
|
||||
func testDisplayFallsBackToNumericRateUnknownOperatorAndRawTime() throws {
|
||||
let data = """
|
||||
{
|
||||
"list": [{
|
||||
"id": 2,
|
||||
"binding_id": 0,
|
||||
"sale_user_id": 456,
|
||||
"store_user_id": 0,
|
||||
"before_rate": 5,
|
||||
"before_rate_label": "",
|
||||
"after_rate": 15,
|
||||
"after_rate_label": " ",
|
||||
"operator_type": "",
|
||||
"operator_id": 0,
|
||||
"operator_name": "",
|
||||
"remark": " ",
|
||||
"created_at": "服务端时间"
|
||||
}],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 10
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let item = try JSONDecoder().decode(CommissionRateLogListResponse.self, from: data).list[0]
|
||||
|
||||
XCTAssertEqual(item.displayBeforeRate, "5%")
|
||||
XCTAssertEqual(item.displayAfterRate, "15%")
|
||||
XCTAssertEqual(item.metadataLine, "服务端时间")
|
||||
XCTAssertNil(item.displayRemark)
|
||||
}
|
||||
|
||||
func testAPIBuildsPathAndClampsPaginationParameters() async throws {
|
||||
let session = MockURLSession(responses: [envelope(list: [], total: 0, page: 1)])
|
||||
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
_ = try await api.saleUserCommissionRateLogs(saleUserId: 456, page: 0, pageSize: 99)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/commission-rate/logs")
|
||||
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(query?.first { $0.name == "sale_user_id" }?.value, "456")
|
||||
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "1")
|
||||
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "50")
|
||||
}
|
||||
|
||||
func testViewModelRetriesFailedPageAndDeduplicatesItems() async throws {
|
||||
let page1 = envelope(
|
||||
list: [
|
||||
record(id: 1, before: 10, after: 20),
|
||||
record(id: 1, before: 10, after: 20),
|
||||
record(id: 2, before: 20, after: 30),
|
||||
],
|
||||
total: 3,
|
||||
page: 1
|
||||
)
|
||||
let page2 = envelope(
|
||||
list: [
|
||||
record(id: 2, before: 20, after: 30),
|
||||
record(id: 3, before: 30, after: 40),
|
||||
],
|
||||
total: 3,
|
||||
page: 2
|
||||
)
|
||||
let response = HTTPURLResponse(
|
||||
url: URL(string: "https://api-test.zhifly.cn/mock")!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
let session = MockURLSession(results: [
|
||||
.success((page1, response)),
|
||||
.failure(URLError(.timedOut)),
|
||||
.success((page2, response)),
|
||||
])
|
||||
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = CommissionRateLogViewModel(saleUserId: 456)
|
||||
|
||||
await viewModel.refresh(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
|
||||
XCTAssertTrue(viewModel.canLoadMore)
|
||||
XCTAssertFalse(viewModel.isLoadingMore)
|
||||
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2, 3])
|
||||
XCTAssertFalse(viewModel.canLoadMore)
|
||||
let requestedPages = session.requests.compactMap { request in
|
||||
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.first { $0.name == "page" }?
|
||||
.value
|
||||
}
|
||||
XCTAssertEqual(requestedPages, ["1", "2", "2"])
|
||||
}
|
||||
|
||||
func testTimelineCellOnlyMarksLatestRecord() {
|
||||
let item = CommissionRateLogEntity(
|
||||
id: 1,
|
||||
bindingId: 10,
|
||||
saleUserId: 456,
|
||||
storeUserId: 732,
|
||||
beforeRate: 10,
|
||||
beforeRateLabel: "10%",
|
||||
afterRate: 20,
|
||||
afterRateLabel: "20%",
|
||||
operatorType: "store_user",
|
||||
operatorId: 732,
|
||||
operatorName: "李师傅",
|
||||
remark: "",
|
||||
createdAt: "2026-07-23 11:00:00"
|
||||
)
|
||||
let cell = CommissionRateLogTimelineCell(style: .default, reuseIdentifier: nil)
|
||||
|
||||
cell.configure(with: item, isLatest: true)
|
||||
XCTAssertTrue(cell.accessibilityLabel?.contains("最新记录") == true)
|
||||
XCTAssertFalse(cell.accessibilityLabel?.contains("李师傅") == true)
|
||||
|
||||
cell.configure(with: item, isLatest: false)
|
||||
XCTAssertFalse(cell.accessibilityLabel?.contains("最新记录") == true)
|
||||
}
|
||||
|
||||
func testPageTitleUsesAcquirerNameAndFallsBackWhenEmpty() {
|
||||
let namedController = CommissionRateLogViewController(
|
||||
saleUserId: 456,
|
||||
acquirerName: " 获客员小王 "
|
||||
)
|
||||
namedController.setupNavigationBar()
|
||||
XCTAssertEqual(namedController.title, "给获客员小王的分成记录")
|
||||
|
||||
let fallbackController = CommissionRateLogViewController(
|
||||
saleUserId: 456,
|
||||
acquirerName: " "
|
||||
)
|
||||
fallbackController.setupNavigationBar()
|
||||
XCTAssertEqual(fallbackController.title, "修改记录")
|
||||
}
|
||||
|
||||
func testAcquirerCardUsesUnifiedActionsAndBusinessHierarchy() throws {
|
||||
let cell = CooperationAcquirerCell(style: .default, reuseIdentifier: nil)
|
||||
cell.configure(
|
||||
with: CooperativeSalerEntity(
|
||||
saleUserId: 456,
|
||||
name: "获客员小王",
|
||||
salerPhone: "13800138000",
|
||||
commissionRate: 20,
|
||||
commissionRateLabel: "20%",
|
||||
bindTime: "2026-07-23 11:00:00"
|
||||
)
|
||||
)
|
||||
let fittingSize = cell.contentView.systemLayoutSizeFitting(
|
||||
CGSize(width: 390, height: UIView.layoutFittingCompressedSize.height),
|
||||
withHorizontalFittingPriority: .required,
|
||||
verticalFittingPriority: .fittingSizeLevel
|
||||
)
|
||||
cell.frame = CGRect(x: 0, y: 0, width: 390, height: fittingSize.height)
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
let remarkButton = try XCTUnwrap(
|
||||
subview(in: cell, identifier: "cooperation_acquirer_edit_remark") as? UIButton
|
||||
)
|
||||
let logButton = try XCTUnwrap(
|
||||
subview(in: cell, identifier: "cooperation_acquirer_commission_logs") as? UIButton
|
||||
)
|
||||
let commissionButton = try XCTUnwrap(
|
||||
subview(in: cell, identifier: "cooperation_acquirer_edit_commission") as? UIButton
|
||||
)
|
||||
let bindTimeLabel = try XCTUnwrap(
|
||||
subview(in: cell, identifier: "cooperation_acquirer_bind_time") as? UILabel
|
||||
)
|
||||
|
||||
XCTAssertEqual(remarkButton.bounds.height, AppSpacing.minTouchTarget, accuracy: 0.5)
|
||||
XCTAssertEqual(logButton.bounds.height, remarkButton.bounds.height, accuracy: 0.5)
|
||||
XCTAssertEqual(commissionButton.bounds.height, remarkButton.bounds.height, accuracy: 0.5)
|
||||
XCTAssertEqual(logButton.bounds.width, remarkButton.bounds.width, accuracy: 0.5)
|
||||
XCTAssertEqual(commissionButton.bounds.width, remarkButton.bounds.width, accuracy: 0.5)
|
||||
XCTAssertEqual(bindTimeLabel.text, "绑定于 2026-07-23 11:00")
|
||||
}
|
||||
|
||||
private func envelope(list: [[String: Any]], total: Int, page: Int) -> Data {
|
||||
let object: [String: Any] = [
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": 10,
|
||||
],
|
||||
]
|
||||
return try! JSONSerialization.data(withJSONObject: object)
|
||||
}
|
||||
|
||||
private func record(id: Int, before: Int, after: Int) -> [String: Any] {
|
||||
[
|
||||
"id": id,
|
||||
"binding_id": 10,
|
||||
"sale_user_id": 456,
|
||||
"store_user_id": 732,
|
||||
"before_rate": before,
|
||||
"before_rate_label": "\(before)%",
|
||||
"after_rate": after,
|
||||
"after_rate_label": "\(after)%",
|
||||
"operator_type": "store_user",
|
||||
"operator_id": 732,
|
||||
"operator_name": "李师傅",
|
||||
"remark": "",
|
||||
"created_at": "2026-07-23 11:00:00",
|
||||
]
|
||||
}
|
||||
|
||||
private func subview(in root: UIView, identifier: String) -> UIView? {
|
||||
if root.accessibilityIdentifier == identifier {
|
||||
return root
|
||||
}
|
||||
for child in root.subviews {
|
||||
if let match = subview(in: child, identifier: identifier) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
204
suixinkanTests/CoreLocationProviderTests.swift
Normal file
204
suixinkanTests/CoreLocationProviderTests.swift
Normal file
@ -0,0 +1,204 @@
|
||||
//
|
||||
// CoreLocationProviderTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// Core Location fallback 的授权、定位与逆地理编码测试。
|
||||
@MainActor
|
||||
final class CoreLocationProviderTests: XCTestCase {
|
||||
|
||||
func testPrivacyAgreementIsRequiredBeforeLocation() async {
|
||||
let context = makeContext(privacyAccepted: false)
|
||||
|
||||
await assertLocationError(.privacyNotAccepted) {
|
||||
_ = try await context.provider.requestCoordinate()
|
||||
}
|
||||
XCTAssertEqual(context.manager.requestLocationCallCount, 0)
|
||||
}
|
||||
|
||||
func testDeniedAuthorizationReturnsPermissionError() async {
|
||||
let context = makeContext(authorizationStatus: .denied)
|
||||
|
||||
await assertLocationError(.permissionDenied) {
|
||||
_ = try await context.provider.requestCoordinate()
|
||||
}
|
||||
XCTAssertEqual(context.manager.requestLocationCallCount, 0)
|
||||
}
|
||||
|
||||
func testNotDeterminedAuthorizationContinuesAfterGrant() async throws {
|
||||
let coordinate = CLLocationCoordinate2D(latitude: 30.2741, longitude: 120.1551)
|
||||
let context = makeContext(
|
||||
authorizationStatus: .notDetermined,
|
||||
requestedAuthorizationStatus: .authorizedWhenInUse,
|
||||
locationResult: .success(CLLocation(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude
|
||||
))
|
||||
)
|
||||
|
||||
let result = try await context.provider.requestCoordinate(
|
||||
desiredAccuracy: kCLLocationAccuracyHundredMeters
|
||||
)
|
||||
|
||||
XCTAssertEqual(context.manager.requestAuthorizationCallCount, 1)
|
||||
XCTAssertEqual(context.manager.requestLocationCallCount, 1)
|
||||
XCTAssertEqual(context.manager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
|
||||
XCTAssertEqual(result.latitude, coordinate.latitude, accuracy: 0.000_001)
|
||||
XCTAssertEqual(result.longitude, coordinate.longitude, accuracy: 0.000_001)
|
||||
}
|
||||
|
||||
func testLocationFailureUsesExistingBusinessError() async {
|
||||
let context = makeContext(locationResult: .failure)
|
||||
|
||||
await assertLocationError(.locationFailed) {
|
||||
_ = try await context.provider.requestCoordinate()
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshotContainsReverseGeocodedAddress() async throws {
|
||||
let context = makeContext(
|
||||
locationResult: .success(CLLocation(latitude: 31.2304, longitude: 121.4737)),
|
||||
geocodedAddress: "上海市黄浦区"
|
||||
)
|
||||
|
||||
let snapshot = try await context.provider.requestSnapshot()
|
||||
|
||||
XCTAssertEqual(snapshot.latitude, 31.2304, accuracy: 0.000_001)
|
||||
XCTAssertEqual(snapshot.longitude, 121.4737, accuracy: 0.000_001)
|
||||
XCTAssertEqual(snapshot.address, "上海市黄浦区")
|
||||
XCTAssertEqual(context.geocoder.requestedCoordinates.count, 1)
|
||||
}
|
||||
|
||||
func testSnapshotFallsBackToEmptyAddressWhenGeocoderHasNoResult() async throws {
|
||||
let context = makeContext(
|
||||
locationResult: .success(CLLocation(latitude: 39.9042, longitude: 116.4074)),
|
||||
geocodedAddress: ""
|
||||
)
|
||||
|
||||
let snapshot = try await context.provider.requestSnapshot()
|
||||
|
||||
XCTAssertEqual(snapshot.address, "")
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
privacyAccepted: Bool = true,
|
||||
authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse,
|
||||
requestedAuthorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse,
|
||||
locationResult: FakeCoreLocationManager.LocationResult = .success(
|
||||
CLLocation(latitude: 39.9042, longitude: 116.4074)
|
||||
),
|
||||
geocodedAddress: String = "北京市东城区"
|
||||
) -> TestContext {
|
||||
let suiteName = "CoreLocationProviderTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
let appStore = AppStore(defaults: defaults)
|
||||
appStore.session.privacyAgreementAccepted = privacyAccepted
|
||||
|
||||
let manager = FakeCoreLocationManager(
|
||||
authorizationStatus: authorizationStatus,
|
||||
requestedAuthorizationStatus: requestedAuthorizationStatus,
|
||||
locationResult: locationResult
|
||||
)
|
||||
let geocoder = FakeLocationAddressGeocoder(address: geocodedAddress)
|
||||
let provider = CoreLocationProvider(
|
||||
appStore: appStore,
|
||||
manager: manager,
|
||||
geocoder: geocoder
|
||||
)
|
||||
return TestContext(provider: provider, manager: manager, geocoder: geocoder)
|
||||
}
|
||||
|
||||
private func assertLocationError(
|
||||
_ expected: LocationProviderError,
|
||||
operation: () async throws -> Void
|
||||
) async {
|
||||
do {
|
||||
try await operation()
|
||||
XCTFail("Expected \(expected)")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? LocationProviderError, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Core Location fallback 测试依赖集合。
|
||||
@MainActor
|
||||
private struct TestContext {
|
||||
let provider: CoreLocationProvider
|
||||
let manager: FakeCoreLocationManager
|
||||
let geocoder: FakeLocationAddressGeocoder
|
||||
}
|
||||
|
||||
/// 可控的 `CLLocationManager` 替身。
|
||||
@MainActor
|
||||
private final class FakeCoreLocationManager: CoreLocationManaging {
|
||||
|
||||
/// 单次定位请求的模拟结果。
|
||||
enum LocationResult {
|
||||
case success(CLLocation)
|
||||
case failure
|
||||
}
|
||||
|
||||
var authorizationStatus: CLAuthorizationStatus
|
||||
var desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
|
||||
weak var delegate: CLLocationManagerDelegate?
|
||||
private(set) var requestAuthorizationCallCount = 0
|
||||
private(set) var requestLocationCallCount = 0
|
||||
|
||||
private let requestedAuthorizationStatus: CLAuthorizationStatus
|
||||
private let locationResult: LocationResult
|
||||
|
||||
init(
|
||||
authorizationStatus: CLAuthorizationStatus,
|
||||
requestedAuthorizationStatus: CLAuthorizationStatus,
|
||||
locationResult: LocationResult
|
||||
) {
|
||||
self.authorizationStatus = authorizationStatus
|
||||
self.requestedAuthorizationStatus = requestedAuthorizationStatus
|
||||
self.locationResult = locationResult
|
||||
}
|
||||
|
||||
func requestWhenInUseAuthorization() {
|
||||
requestAuthorizationCallCount += 1
|
||||
authorizationStatus = requestedAuthorizationStatus
|
||||
delegate?.locationManagerDidChangeAuthorization?(CLLocationManager())
|
||||
}
|
||||
|
||||
func requestLocation() {
|
||||
requestLocationCallCount += 1
|
||||
let callbackManager = CLLocationManager()
|
||||
switch locationResult {
|
||||
case let .success(location):
|
||||
delegate?.locationManager?(callbackManager, didUpdateLocations: [location])
|
||||
case .failure:
|
||||
delegate?.locationManager?(
|
||||
callbackManager,
|
||||
didFailWithError: CLError(.locationUnknown)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 可控的逆地理编码替身。
|
||||
@MainActor
|
||||
private final class FakeLocationAddressGeocoder: LocationAddressGeocoding {
|
||||
private(set) var requestedCoordinates: [CLLocationCoordinate2D] = []
|
||||
private let address: String
|
||||
|
||||
init(address: String) {
|
||||
self.address = address
|
||||
}
|
||||
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
requestedCoordinates.append(CLLocationCoordinate2D(
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
))
|
||||
return address
|
||||
}
|
||||
}
|
||||
@ -79,6 +79,24 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
||||
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 {
|
||||
appStore.session.currentScenicId = 0
|
||||
let session = MockURLSession(responses: [])
|
||||
@ -90,4 +108,74 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
||||
XCTAssertFalse(viewModel.hasPayCode)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,88 @@ import XCTest
|
||||
|
||||
/// 收款模型解码测试。
|
||||
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 {
|
||||
let json = """
|
||||
{
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
|
||||
Reference in New Issue
Block a user