Compare commits

7 Commits

Author SHA1 Message Date
413a9603a4 fix: 修复首页位置上报图标渲染 2026-07-29 15:14:05 +08:00
f87b13cc05 feat: 支持模拟器定位与地图 fallback 2026-07-24 10:04:55 +08:00
0e0415eec4 feat: 添加获客员分成记录并重构列表 2026-07-23 17:43:13 +08:00
7ccbc5568d fix: 优化旅拍相册入口与二维码提示布局 2026-07-23 15:40:51 +08:00
787a166263 feat: 品牌化那拉提景区收款页 2026-07-23 15:39:34 +08:00
74f7370aab docs: 补充 type=13 新获客线索推送样本与字段说明。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:56:40 +08:00
1b2637f0ee fix: 推送点击仅按 type 路由到收款页或消息中心。
对齐后端消息类型约定,去掉 route/uri 等兼容字段,并补充联调日志与单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:20:36 +08:00
54 changed files with 6500 additions and 2438 deletions

16
Podfile
View File

@ -1,10 +1,17 @@
platform :ios, '16.0' platform :ios, '16.0'
project 'suixinkan.xcodeproj',
'Debug' => :debug,
'SimulatorDebug' => :debug,
'Release' => :release
target 'suixinkan' do target 'suixinkan' do
use_frameworks! use_frameworks!
pod 'AMapLocation-NO-IDFA' # 高德 SDK 未提供 arm64-simulator slice仅在真机配置中接入。
pod 'AMap3DMap-NO-IDFA' pod 'AMapFoundation-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'AMapSearch-NO-IDFA' pod 'AMapLocation-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'AMap3DMap-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'AMapSearch-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'UMCommon' pod 'UMCommon'
pod 'UMDevice' pod 'UMDevice'
pod 'UMAPM' pod 'UMAPM'
@ -24,6 +31,9 @@ post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
target.build_configurations.each do |config| target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' 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 end
end end

View File

@ -58,6 +58,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- AMap3DMap-NO-IDFA - AMap3DMap-NO-IDFA
- AMapFoundation-NO-IDFA
- AMapLocation-NO-IDFA - AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA - AMapSearch-NO-IDFA
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
@ -109,6 +110,6 @@ SPEC CHECKSUMS:
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19 UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949 WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412 PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

3
Pods/Manifest.lock generated
View File

@ -58,6 +58,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- AMap3DMap-NO-IDFA - AMap3DMap-NO-IDFA
- AMapFoundation-NO-IDFA
- AMapLocation-NO-IDFA - AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA - AMapSearch-NO-IDFA
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
@ -109,6 +110,6 @@ SPEC CHECKSUMS:
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19 UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949 WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412 PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -195,6 +195,16 @@ if [[ "$CONFIGURATION" == "Release" ]]; then
install_framework "${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework" install_framework "${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework" install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework"
fi 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 if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then
wait wait
fi fi

View File

@ -0,0 +1 @@
${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh

View 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

View 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

View File

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

View File

@ -36,6 +36,8 @@
93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; 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; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -59,11 +61,15 @@
}; };
9A01B8E22FFB84950033C402 /* Vendor */ = { 9A01B8E22FFB84950033C402 /* Vendor */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Vendor; path = Vendor;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A1B2C3D42FFB84950033C305 /* suixinkanTests */ = { A1B2C3D42FFB84950033C305 /* suixinkanTests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanTests; path = suixinkanTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -111,6 +117,8 @@
1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */, 1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */,
83445E7B4CCCF42C6FD481D3 /* Pods-suixinkanTests.debug.xcconfig */, 83445E7B4CCCF42C6FD481D3 /* Pods-suixinkanTests.debug.xcconfig */,
6CB3E1DA4E97AA4961B25733 /* Pods-suixinkanTests.release.xcconfig */, 6CB3E1DA4E97AA4961B25733 /* Pods-suixinkanTests.release.xcconfig */,
ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */,
DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */,
); );
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
@ -361,14 +369,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks.sh\"\n";
@ -382,14 +386,10 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
@ -728,6 +728,150 @@
}; };
name = Debug; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@ -735,6 +879,7 @@
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
93FF17DB2FFB84950033C304 /* Debug */, 93FF17DB2FFB84950033C304 /* Debug */,
D1A2B3C52FFB84950033C402 /* SimulatorDebug */,
93FF17DC2FFB84950033C304 /* Release */, 93FF17DC2FFB84950033C304 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
@ -744,6 +889,7 @@
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
93FF17D92FFB84950033C304 /* Debug */, 93FF17D92FFB84950033C304 /* Debug */,
D1A2B3C42FFB84950033C401 /* SimulatorDebug */,
93FF17DA2FFB84950033C304 /* Release */, 93FF17DA2FFB84950033C304 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
@ -753,6 +899,7 @@
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
A1B2C3DC2FFB84950033C305 /* Debug */, A1B2C3DC2FFB84950033C305 /* Debug */,
D1A2B3C62FFB84950033C403 /* SimulatorDebug */,
A1B2C3DA2FFB84950033C305 /* Release */, A1B2C3DA2FFB84950033C305 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -2,129 +2,85 @@ import Foundation
/// ///
enum PushDestination: Sendable, Equatable { enum PushDestination: Sendable, Equatable {
case payment case paymentRecord
case orders case paymentDetails
case verificationOrders
case task
case queue
case messageCenter case messageCenter
///
var requiresScenicContext: Bool {
switch self {
case .payment, .task, .queue:
true
case .orders, .verificationOrders, .messageCenter:
false
}
}
} }
/// /APNs payload JSON /// /APNs payload
struct PushPayload: Sendable, Equatable { struct PushPayload: Sendable, Equatable {
private let values: [String: String] private let type: String
/// userInfo /// userInfo
nonisolated init(userInfo: [AnyHashable: Any]) { nonisolated init(userInfo: [AnyHashable: Any]) {
var flattened: [String: String] = [:] type = Self.extractType(from: userInfo)
Self.mergeDictionary(userInfo, into: &flattened)
values = flattened
} }
/// payload Actor 使 /// payload
nonisolated init(values: [String: String]) { nonisolated init(values: [String: String]) {
self.values = values type = Self.normalizedType(values["type"])
} }
/// ///
nonisolated var normalizedValues: [String: String] { nonisolated var normalizedValues: [String: String] {
values type.isEmpty ? [:] : ["type": type]
} }
/// ///
nonisolated var destination: PushDestination { nonisolated var destination: PushDestination {
let type = value(for: "type") switch type {
let route = value(for: "route") case "1":
let uri = value(for: "uri") return .paymentRecord
let action = value(for: "action") case "6":
let merged = [route, uri, action].joined(separator: " ").lowercased() return .paymentDetails
default:
if type == "1" || type == "6"
|| merged.contains("payment") || merged.contains("payment_qr")
|| merged.contains("收款") {
return .payment
}
if merged.contains("queue") || merged.contains("scenic-queue")
|| merged.contains("排队") || merged.contains("叫号") {
return .queue
}
if merged.contains("verification") || merged.contains("writeoff")
|| merged.contains("write_off") || merged.contains("核销") {
return .verificationOrders
}
if merged.contains("order") || merged.contains("订单") {
return .orders
}
if merged.contains("task") || merged.contains("任务") {
return .task
}
return .messageCenter return .messageCenter
} }
private nonisolated func value(for key: String) -> String {
values[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
} }
private nonisolated static func mergeDictionary( private nonisolated static func extractType(
_ dictionary: [AnyHashable: Any], from dictionary: [AnyHashable: Any],
into result: inout [String: String] depth: Int = 0
) { ) -> String {
for (rawKey, value) in dictionary { guard depth <= 4 else { return "" }
guard let key = rawKey as? String else { continue } let directType = normalizedType(dictionary["type"])
merge(value, key: key, into: &result) guard directType.isEmpty else { return directType }
}
for key in nestedTypeContainerKeys {
guard let value = dictionary[key] else { continue }
if let nested = value as? [AnyHashable: Any] {
let nestedType = extractType(from: nested, depth: depth + 1)
if !nestedType.isEmpty { return nestedType }
continue
} }
private nonisolated static func merge( guard let text = value as? String,
_ value: Any,
key: String,
into result: inout [String: String]
) {
if let dictionary = value as? [String: Any] {
result[key] = stringValue(value)
mergeDictionary(dictionary, into: &result)
return
}
if let dictionary = value as? [AnyHashable: Any] {
result[key] = stringValue(value)
mergeDictionary(dictionary, into: &result)
return
}
let text = stringValue(value)
result[key] = text
guard Self.nestedJSONKeys.contains(key),
let data = text.data(using: .utf8), let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) let object = try? JSONSerialization.jsonObject(with: data),
else { return } let nested = object as? [String: Any]
else { continue }
if let dictionary = object as? [String: Any] { let bridged = Dictionary(uniqueKeysWithValues: nested.map {
mergeDictionary(dictionary, into: &result) (AnyHashable($0.key), $0.value)
})
let nestedType = extractType(from: bridged, depth: depth + 1)
if !nestedType.isEmpty { return nestedType }
} }
return ""
} }
private nonisolated static func stringValue(_ value: Any) -> String { private nonisolated static func normalizedType(_ value: Any?) -> String {
switch value { switch value {
case let string as String: case let string as String:
string return string.trimmingCharacters(in: .whitespacesAndNewlines)
case let number as NSNumber: case let number as NSNumber:
number.stringValue return number.stringValue
default: default:
String(describing: value) return ""
} }
} }
private nonisolated static let nestedJSONKeys: Set<String> = [ private nonisolated static let nestedTypeContainerKeys = [
"extras", "extra", "data", "JMessageExtra", "n_extras", "extras", "extra", "JMessageExtra", "n_extras",
] ]
} }

View File

@ -229,6 +229,7 @@ final class PushNotificationManager: NSObject {
/// Scene /// Scene
func handleNotificationResponse(_ response: UNNotificationResponse) { func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo let userInfo = response.notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "tap(cold-start)")
JPUSHService.handleRemoteNotification(userInfo) JPUSHService.handleRemoteNotification(userInfo)
handleNotificationTap( handleNotificationTap(
payload: PushPayload(userInfo: userInfo), payload: PushPayload(userInfo: userInfo),
@ -238,10 +239,35 @@ final class PushNotificationManager: NSObject {
/// ///
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) { func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
Self.logReceivedPush(userInfo, source: "background")
JPUSHService.handleRemoteNotification(userInfo) JPUSHService.handleRemoteNotification(userInfo)
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil) NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
} }
/// payload 便
private nonisolated static func logReceivedPush(
_ userInfo: [AnyHashable: Any],
source: String
) {
#if DEBUG
let payloadText: String
let normalized = Dictionary(uniqueKeysWithValues: userInfo.compactMap { key, value in
(key as? String).map { ($0, value) }
})
if JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(
withJSONObject: normalized,
options: [.prettyPrinted, .sortedKeys]
),
let text = String(data: data, encoding: .utf8) {
payloadText = text
} else {
payloadText = String(describing: userInfo)
}
print("[Push] received (\(source)):\n\(payloadText)")
#endif
}
private func observeJPushNetworkLogin() { private func observeJPushNetworkLogin() {
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
@ -352,7 +378,9 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
willPresent notification: UNNotification, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (Int) -> Void withCompletionHandler completionHandler: @escaping (Int) -> Void
) { ) {
JPUSHService.handleRemoteNotification(notification.request.content.userInfo) let userInfo = notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "foreground")
JPUSHService.handleRemoteNotification(userInfo)
Task { @MainActor in Task { @MainActor in
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil) NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
} }
@ -367,6 +395,7 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
withCompletionHandler completionHandler: @escaping () -> Void withCompletionHandler completionHandler: @escaping () -> Void
) { ) {
let userInfo = response.notification.request.content.userInfo let userInfo = response.notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "tap")
JPUSHService.handleRemoteNotification(userInfo) JPUSHService.handleRemoteNotification(userInfo)
let payload = PushPayload(userInfo: userInfo) let payload = PushPayload(userInfo: userInfo)
let requestIdentifier = response.notification.request.identifier let requestIdentifier = response.notification.request.identifier

View File

@ -84,16 +84,8 @@ final class PushRouteCoordinator: PushRouting {
return return
} }
let destination: PushDestination
if route.destination.requiresScenicContext,
appStore.session.currentScenicId <= 0 {
destination = .messageCenter
} else {
destination = route.destination
}
mainTabController.dismiss(animated: false) mainTabController.dismiss(animated: false)
mainTabController.openPushDestination(destination) mainTabController.openPushDestination(route.destination)
} }
private func markHandled(_ identifier: String) { private func markHandled(_ identifier: String) {

View File

@ -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?()
}
}

View File

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

View File

@ -16,21 +16,24 @@ enum HomeMenuIconFactory {
private static let imageCache = NSCache<NSString, UIImage>() private static let imageCache = NSCache<NSString, UIImage>()
/// Assets SF Symbol /// Assets SF Symbol
static func image(named iconName: String) -> UIImage? { static func image(named iconName: String) -> UIImage? {
let cacheKey = iconName as NSString let cacheKey = iconName as NSString
if let cachedImage = imageCache.object(forKey: cacheKey) { if let cachedImage = imageCache.object(forKey: cacheKey) {
return cachedImage return cachedImage
} }
guard let sourceImage = UIImage(named: iconName) let image: UIImage?
?? UIImage(systemName: iconName, withConfiguration: symbolConfiguration) else { if let assetImage = UIImage(named: iconName) {
return nil image = renderOnFixedCanvas(assetImage)
} else {
image = UIImage(systemName: iconName, withConfiguration: symbolConfiguration)
} }
let normalizedImage = renderOnFixedCanvas(sourceImage) if let image {
imageCache.setObject(normalizedImage, forKey: cacheKey) imageCache.setObject(image, forKey: cacheKey)
return normalizedImage }
return image
} }
private static func renderOnFixedCanvas(_ image: UIImage) -> UIImage { private static func renderOnFixedCanvas(_ image: UIImage) -> UIImage {

View File

@ -20,6 +20,7 @@ enum AMapBootstrap {
defer { lock.unlock() } defer { lock.unlock() }
guard !isConfigured else { return true } guard !isConfigured else { return true }
#if !targetEnvironment(simulator)
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain) AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapLocationManager.updatePrivacyAgree(.didAgree) AMapLocationManager.updatePrivacyAgree(.didAgree)
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain) MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
@ -29,6 +30,7 @@ enum AMapBootstrap {
AMapServices.shared().apiKey = AMapConfig.apiKey AMapServices.shared().apiKey = AMapConfig.apiKey
AMapServices.shared().enableHTTPS = true AMapServices.shared().enableHTTPS = true
#endif
isConfigured = true isConfigured = true
return true return true
} }

View File

@ -3,16 +3,28 @@
// suixinkan // suixinkan
// //
import CoreLocation
import Foundation import Foundation
/// Android `GeocodeSearch` ///
@MainActor @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() nonisolated static let shared = LocationGeocoder()
#if targetEnvironment(simulator)
private let geocoder = CLGeocoder()
#else
private var searchAPI: AMapSearchAPI? private var searchAPI: AMapSearchAPI?
private var continuation: CheckedContinuation<String, Never>? private var continuation: CheckedContinuation<String, Never>?
#endif
nonisolated private override init() { nonisolated private override init() {
super.init() super.init()
@ -20,6 +32,26 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
/// ///
func reverseGeocode(latitude: Double, longitude: Double) async -> String { 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 { do {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
} catch { } catch {
@ -40,8 +72,10 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
request.radius = 200 request.radius = 200
api.aMapReGoecodeSearch(request) api.aMapReGoecodeSearch(request)
} }
#endif
} }
#if !targetEnvironment(simulator)
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) { func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
let address = response?.regeocode?.formattedAddress ?? "" let address = response?.regeocode?.formattedAddress ?? ""
continuation?.resume(returning: address) continuation?.resume(returning: address)
@ -62,4 +96,9 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
searchAPI = api searchAPI = api
return api return api
} }
#endif
} }
#if !targetEnvironment(simulator)
extension LocationGeocoder: AMapSearchDelegate {}
#endif

View File

@ -6,7 +6,165 @@
import CoreLocation import CoreLocation
import Foundation 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 @MainActor
final class LocationProvider: NSObject, LocationProviding { final class LocationProvider: NSObject, LocationProviding {
@ -19,7 +177,7 @@ final class LocationProvider: NSObject, LocationProviding {
super.init() super.init()
} }
@MainActor ///
func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot { func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
try await ensureLocationPermission() try await ensureLocationPermission()
@ -51,7 +209,7 @@ final class LocationProvider: NSObject, LocationProviding {
} }
} }
@MainActor ///
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D { func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
try await ensureLocationPermission() try await ensureLocationPermission()
@ -78,7 +236,7 @@ final class LocationProvider: NSObject, LocationProviding {
} }
} }
@MainActor /// 使
func reverseGeocode(latitude: Double, longitude: Double) async -> String { func reverseGeocode(latitude: Double, longitude: Double) async -> String {
await geocoder.reverseGeocode(latitude: latitude, longitude: longitude) await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
} }
@ -110,3 +268,5 @@ final class LocationProvider: NSObject, LocationProviding {
return manager return manager
} }
} }
#endif

View File

@ -16,20 +16,25 @@ struct HomeLocationSnapshot: Sendable, Equatable {
/// 便 ViewModel mock /// 便 ViewModel mock
protocol LocationProviding: Sendable { protocol LocationProviding: Sendable {
/// ///
@MainActor
func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot
/// ///
@MainActor
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D
/// ///
@MainActor
func reverseGeocode(latitude: Double, longitude: Double) async -> String func reverseGeocode(latitude: Double, longitude: Double) async -> String
} }
extension LocationProviding { extension LocationProviding {
/// 使 /// 使
@MainActor
func requestSnapshot() async throws -> HomeLocationSnapshot { func requestSnapshot() async throws -> HomeLocationSnapshot {
try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest) try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest)
} }
/// 使 /// 使
@MainActor
func requestCoordinate() async throws -> CLLocationCoordinate2D { func requestCoordinate() async throws -> CLLocationCoordinate2D {
try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest) try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest)
} }

View File

@ -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 { func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
try await client.send( try await client.send(
APIRequest( APIRequest(

View File

@ -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 { struct ReferralOrderListResponse: Decodable {
let total: Int let total: Int
let list: [ReferralOrderEntity] let list: [ReferralOrderEntity]

View File

@ -3,6 +3,7 @@
// suixinkan // suixinkan
// //
import CoreLocation
import UIKit import UIKit
/// Tab Android `MainTabViewModel.signIn` /// Tab Android `MainTabViewModel.signIn`

View File

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

View File

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

View File

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

View File

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

View File

@ -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: "")
}
}

View File

@ -31,12 +31,13 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
} }
override func setupUI() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF6F7FA)
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 142 tableView.estimatedRowHeight = 224
tableView.contentInset.top = AppSpacing.sm tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
tableView.dataSource = self tableView.dataSource = self
tableView.delegate = self tableView.delegate = self
tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier) tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier)
@ -245,6 +246,17 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
cell.onEditCommission = { [weak self] in cell.onEditCommission = { [weak self] in
self?.presentCommissionDialog(for: acquirer) 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 = { cell.onCall = {
let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines) let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return } guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return }

View File

@ -445,22 +445,25 @@ final class CooperationAcquirerCell: UITableViewCell {
var onEditRemark: (() -> Void)? var onEditRemark: (() -> Void)?
var onEditCommission: (() -> Void)? var onEditCommission: (() -> Void)?
var onViewCommissionLogs: (() -> Void)?
var onCall: (() -> Void)? var onCall: (() -> Void)?
private let cardView = UIView() private let cardView = UIView()
private let avatarContainer = UIView() private let avatarContainer = UIView()
private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill")) private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill"))
private let nameTitleLabel = UILabel()
private let nameValueLabel = UILabel() private let nameValueLabel = UILabel()
private let editRemarkButton = UIButton(type: .system)
private let phoneTitleLabel = UILabel()
private let phoneValueLabel = UILabel() private let phoneValueLabel = UILabel()
private let callButton = UIButton(type: .system) private let callButton = UIButton(type: .system)
private let bindTimeTitleLabel = UILabel() private let commissionContainer = UIView()
private let bindTimeValueLabel = UILabel()
private let commissionTitleLabel = UILabel() private let commissionTitleLabel = UILabel()
private let commissionValueLabel = 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 editCommissionButton = UIButton(type: .system)
private let commissionLogButton = UIButton(type: .system)
private let actionStack = UIStackView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
@ -469,135 +472,180 @@ final class CooperationAcquirerCell: UITableViewCell {
contentView.backgroundColor = .clear contentView.backgroundColor = .clear
cardView.backgroundColor = .white 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.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.04 cardView.layer.shadowOpacity = 0.04
cardView.layer.shadowOffset = CGSize(width: 0, height: 1) cardView.layer.shadowOffset = CGSize(width: 0, height: 3)
cardView.layer.shadowRadius = 2 cardView.layer.shadowRadius = 8
cardView.accessibilityIdentifier = "cooperation_acquirer_card"
avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08) avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
avatarContainer.layer.cornerRadius = AppRadius.sm avatarContainer.layer.cornerRadius = 24
avatarContainer.clipsToBounds = true avatarContainer.clipsToBounds = true
avatarIconView.tintColor = AppColor.primary avatarIconView.tintColor = AppColor.primary
avatarIconView.contentMode = .scaleAspectFit avatarIconView.contentMode = .scaleAspectFit
configureTitleLabel(nameTitleLabel, text: "获客员名称") nameValueLabel.font = .systemFont(ofSize: 17, weight: .semibold)
configureTitleLabel(phoneTitleLabel, text: "手机号")
configureTitleLabel(bindTimeTitleLabel, text: "绑定时间")
configureTitleLabel(commissionTitleLabel, text: "分成比例")
nameValueLabel.font = .systemFont(ofSize: 15, weight: .bold)
nameValueLabel.textColor = AppColor.textPrimary nameValueLabel.textColor = AppColor.textPrimary
nameValueLabel.numberOfLines = 1 nameValueLabel.numberOfLines = 1
nameValueLabel.lineBreakMode = .byTruncatingTail nameValueLabel.lineBreakMode = .byTruncatingTail
nameValueLabel.accessibilityIdentifier = "cooperation_acquirer_name"
[phoneValueLabel, bindTimeValueLabel, commissionValueLabel].forEach { label in phoneValueLabel.font = .systemFont(ofSize: 14)
label.font = .systemFont(ofSize: 13) phoneValueLabel.textColor = AppColor.textTertiary
label.textColor = AppColor.textSecondary phoneValueLabel.numberOfLines = 1
label.numberOfLines = 1 phoneValueLabel.lineBreakMode = .byTruncatingTail
label.lineBreakMode = .byTruncatingTail phoneValueLabel.accessibilityIdentifier = "cooperation_acquirer_phone"
}
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)
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary callButton.tintColor = AppColor.primary
callButton.backgroundColor = AppColor.primaryLight 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.imageView?.contentMode = .scaleAspectFit
callButton.accessibilityLabel = "拨打获客员电话"
callButton.accessibilityIdentifier = "cooperation_acquirer_call"
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
editCommissionButton.setTitle("修改比例", for: .normal) commissionContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13) commissionContainer.layer.cornerRadius = AppRadius.lg
editCommissionButton.setTitleColor(AppColor.primary, for: .normal)
editCommissionButton.setConfigurationContentInsets( commissionTitleLabel.text = "分成比例"
NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) 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) 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) contentView.addSubview(cardView)
cardView.addSubview(avatarContainer) cardView.addSubview(avatarContainer)
avatarContainer.addSubview(avatarIconView) avatarContainer.addSubview(avatarIconView)
cardView.addSubview(nameTitleLabel)
cardView.addSubview(nameValueLabel) cardView.addSubview(nameValueLabel)
cardView.addSubview(editRemarkButton)
cardView.addSubview(phoneTitleLabel)
cardView.addSubview(phoneValueLabel) cardView.addSubview(phoneValueLabel)
cardView.addSubview(callButton) cardView.addSubview(callButton)
cardView.addSubview(bindTimeTitleLabel) cardView.addSubview(commissionContainer)
commissionContainer.addSubview(commissionTitleLabel)
commissionContainer.addSubview(commissionValueLabel)
cardView.addSubview(bindTimeIconView)
cardView.addSubview(bindTimeValueLabel) cardView.addSubview(bindTimeValueLabel)
cardView.addSubview(commissionTitleLabel) cardView.addSubview(dividerView)
cardView.addSubview(commissionValueLabel) cardView.addSubview(actionStack)
cardView.addSubview(editCommissionButton)
cardView.snp.makeConstraints { make in 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 avatarContainer.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14) make.top.leading.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview() make.size.equalTo(48)
make.size.equalTo(44)
} }
avatarIconView.snp.makeConstraints { make in avatarIconView.snp.makeConstraints { make in
make.center.equalToSuperview() make.center.equalToSuperview()
make.size.equalTo(22) make.size.equalTo(24)
}
nameTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.md)
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
} }
nameValueLabel.snp.makeConstraints { make in nameValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(nameTitleLabel) make.top.equalTo(avatarContainer).offset(2)
make.leading.equalTo(nameTitleLabel.snp.trailing).offset(AppSpacing.sm) make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualTo(editRemarkButton.snp.leading).offset(-AppSpacing.xs) make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
}
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)
} }
phoneValueLabel.snp.makeConstraints { make in phoneValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(phoneTitleLabel) make.top.equalTo(nameValueLabel.snp.bottom).offset(AppSpacing.xxs)
make.leading.equalTo(phoneTitleLabel.snp.trailing).offset(AppSpacing.sm) make.leading.equalTo(nameValueLabel)
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.xs) make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
} }
callButton.snp.makeConstraints { make in callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(14) make.top.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalTo(phoneTitleLabel) make.size.equalTo(AppSpacing.minTouchTarget)
make.size.equalTo(30)
} }
bindTimeTitleLabel.snp.makeConstraints { make in commissionContainer.snp.makeConstraints { make in
make.top.equalTo(phoneTitleLabel.snp.bottom).offset(AppSpacing.xs) make.top.equalTo(avatarContainer.snp.bottom).offset(14)
make.leading.equalTo(nameTitleLabel) make.leading.equalToSuperview().inset(AppSpacing.md)
} make.width.equalTo(120)
bindTimeValueLabel.snp.makeConstraints { make in make.height.equalTo(64)
make.centerY.equalTo(bindTimeTitleLabel)
make.leading.equalTo(bindTimeTitleLabel.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualToSuperview().inset(14)
} }
commissionTitleLabel.snp.makeConstraints { make in commissionTitleLabel.snp.makeConstraints { make in
make.top.equalTo(bindTimeTitleLabel.snp.bottom).offset(AppSpacing.xs) make.top.equalToSuperview().offset(AppSpacing.xs)
make.leading.equalTo(nameTitleLabel) make.centerX.equalToSuperview()
make.bottom.equalToSuperview().inset(AppSpacing.md)
} }
commissionValueLabel.snp.makeConstraints { make in commissionValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(commissionTitleLabel) make.top.equalTo(commissionTitleLabel.snp.bottom).offset(2)
make.leading.equalTo(commissionTitleLabel.snp.trailing).offset(AppSpacing.sm) make.centerX.equalToSuperview()
make.trailing.lessThanOrEqualTo(editCommissionButton.snp.leading).offset(-AppSpacing.xs) make.leading.greaterThanOrEqualToSuperview().offset(AppSpacing.xs)
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.xs)
} }
editCommissionButton.snp.makeConstraints { make in bindTimeIconView.snp.makeConstraints { make in
make.centerY.equalTo(commissionTitleLabel) make.leading.equalTo(commissionContainer.snp.trailing).offset(AppSpacing.md)
make.trailing.equalToSuperview().inset(AppSpacing.xs) 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() super.prepareForReuse()
onEditRemark = nil onEditRemark = nil
onEditCommission = nil onEditCommission = nil
onViewCommissionLogs = nil
onCall = nil onCall = nil
} }
func configure(with acquirer: CooperativeSalerEntity) { func configure(with acquirer: CooperativeSalerEntity) {
nameValueLabel.text = acquirer.displayName.isEmpty ? "" : acquirer.displayName nameValueLabel.text = acquirer.displayName.isEmpty ? "" : acquirer.displayName
phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "" : CooperationOrderPhoneMask.mask(acquirer.displayPhone) 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 commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "" : acquirer.displayCommissionRate
} }
private func configureTitleLabel(_ label: UILabel, text: String) { private func configureActionButton(
label.text = text _ button: UIButton,
label.font = .systemFont(ofSize: 13) title: String,
label.textColor = AppColor.textTertiary isPrimary: Bool,
label.setContentHuggingPriority(.required, for: .horizontal) accessibilityIdentifier: String
label.setContentCompressionResistancePriority(.required, for: .horizontal) ) {
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 editRemarkTapped() { onEditRemark?() }
@objc private func editCommissionTapped() { onEditCommission?() } @objc private func editCommissionTapped() { onEditCommission?() }
@objc private func commissionLogTapped() { onViewCommissionLogs?() }
@objc private func callTapped() { onCall?() } @objc private func callTapped() { onCall?() }
} }

View File

@ -7,6 +7,10 @@ import CoreLocation
import SnapKit import SnapKit
import UIKit import UIKit
#if targetEnvironment(simulator)
import MapKit
#endif
/// / /// /
final class LocationReportMapControlStack: UIView { 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 { final class LocationReportMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)? var onMapTap: ((CLLocationCoordinate2D) -> Void)?
@ -381,3 +490,4 @@ final class LocationReportMapView: UIView, MAMapViewDelegate {
shouldCenterOnNextUserLocation = false shouldCenterOnNextUserLocation = false
} }
} }
#endif

View File

@ -67,12 +67,7 @@ final class MainTabBarController: UITabBarController {
/// Tab /// Tab
func openPushDestination(_ destination: PushDestination) { func openPushDestination(_ destination: PushDestination) {
let targetTab: AppTab = switch destination { let targetTab = AppTab.home
case .orders, .verificationOrders:
.orders
case .payment, .task, .queue, .messageCenter:
.home
}
selectTab(targetTab) selectTab(targetTab)
guard let navigationController = tabNavigationControllers[targetTab] else { return } guard let navigationController = tabNavigationControllers[targetTab] else { return }
@ -80,16 +75,12 @@ final class MainTabBarController: UITabBarController {
let controller: UIViewController? let controller: UIViewController?
switch destination { switch destination {
case .payment: case .paymentRecord:
controller = PaymentCollectionRecordViewController()
case .paymentDetails:
controller = PaymentCollectionDetailsViewController() controller = PaymentCollectionDetailsViewController()
case .task:
controller = TaskAddViewController()
case .queue:
controller = ScenicQueueViewController()
case .messageCenter: case .messageCenter:
controller = MessageCenterViewController() controller = MessageCenterViewController()
case .orders, .verificationOrders:
controller = nil
} }
if let controller { if let controller {

View File

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

View File

@ -3,11 +3,16 @@
// suixinkan // suixinkan
// //
import CoreLocation
import Kingfisher import Kingfisher
import Photos import Photos
import SnapKit import SnapKit
import UIKit import UIKit
#if targetEnvironment(simulator)
import MapKit
#endif
/// Android `PunchPointDetailScreen` /// Android `PunchPointDetailScreen`
final class PunchPointDetailViewController: BaseViewController { final class PunchPointDetailViewController: BaseViewController {
private let viewModel: PunchPointDetailViewModel 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 { final class PunchPointMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)? var onMapTap: ((CLLocationCoordinate2D) -> Void)?
@ -614,6 +804,7 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
onMapTap?(poi.coordinate) onMapTap?(poi.coordinate)
} }
} }
#endif
/// ///
/// ///

View File

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

View File

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

View File

@ -4,10 +4,15 @@
// //
import Kingfisher import Kingfisher
import MAMapKit
import SnapKit import SnapKit
import UIKit import UIKit
#if targetEnvironment(simulator)
import MapKit
#else
import MAMapKit
#endif
/// ///
final class WildPhotographerReportListViewController: BaseViewController { final class WildPhotographerReportListViewController: BaseViewController {
private let viewModel: WildPhotographerReportListViewModel 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 final class WildReportDetailMapPreviewView: UIView {
private let mapView: MAMapView private let mapView: MAMapView
@ -1834,6 +1882,7 @@ private final class WildReportDetailMapPreviewView: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
} }
#endif
/// 线 /// 线
private final class WildReportDetailTimelineRowView: UIView { private final class WildReportDetailTimelineRowView: UIView {

View File

@ -3,19 +3,19 @@
// suixinkan // suixinkan
// //
#if __has_include(<AMapFoundationKit/AMapFoundationKit.h>) #if !TARGET_OS_SIMULATOR && __has_include(<AMapFoundationKit/AMapFoundationKit.h>)
#import <AMapFoundationKit/AMapFoundationKit.h> #import <AMapFoundationKit/AMapFoundationKit.h>
#endif #endif
#if __has_include(<AMapLocationKit/AMapLocationKit.h>) #if !TARGET_OS_SIMULATOR && __has_include(<AMapLocationKit/AMapLocationKit.h>)
#import <AMapLocationKit/AMapLocationKit.h> #import <AMapLocationKit/AMapLocationKit.h>
#endif #endif
#if __has_include(<MAMapKit/MAMapKit.h>) #if !TARGET_OS_SIMULATOR && __has_include(<MAMapKit/MAMapKit.h>)
#import <MAMapKit/MAMapKit.h> #import <MAMapKit/MAMapKit.h>
#endif #endif
#if __has_include(<AMapSearchKit/AMapSearchKit.h>) #if !TARGET_OS_SIMULATOR && __has_include(<AMapSearchKit/AMapSearchKit.h>)
#import <AMapSearchKit/AMapSearchKit.h> #import <AMapSearchKit/AMapSearchKit.h>
#endif #endif

View 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
}
}

View 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
}
}

View File

@ -18,20 +18,20 @@ final class HomeMenuIconFactoryTests: XCTestCase {
XCTAssertNil(UIImage(systemName: "home_menu_space")) XCTAssertNil(UIImage(systemName: "home_menu_space"))
} }
func testSystemSymbolsUseFixedCanvas() { func testSystemSymbolsPreserveSymbolRepresentation() {
let iconNames = [ let iconNames = [
"ellipsis", "ellipsis",
"dot.radiowaves.left.and.right", "dot.radiowaves.left.and.right",
"rectangle.stack.badge.plus", "rectangle.stack.badge.plus",
"mappin.circle.fill",
] ]
for iconName in iconNames { for iconName in iconNames {
let image = HomeMenuIconFactory.image(named: iconName) let image = HomeMenuIconFactory.image(named: iconName)
XCTAssertNotNil(image) XCTAssertNotNil(image)
XCTAssertEqual(image?.size, HomeMenuIconFactory.canvasSize) XCTAssertTrue(image?.isSymbolImage ?? false)
XCTAssertEqual(image?.renderingMode, .alwaysTemplate) XCTAssertNotNil(image?.symbolConfiguration)
XCTAssertFalse(image?.isSymbolImage ?? true)
} }
} }
@ -39,6 +39,13 @@ final class HomeMenuIconFactoryTests: XCTestCase {
XCTAssertNotNil(HomeMenuIconFactory.image(named: "dot.radiowaves.left.and.right")) XCTAssertNotNil(HomeMenuIconFactory.image(named: "dot.radiowaves.left.and.right"))
XCTAssertNotNil(HomeMenuIconFactory.image(named: "rectangle.stack.badge.plus")) XCTAssertNotNil(HomeMenuIconFactory.image(named: "rectangle.stack.badge.plus"))
} }
func testLocationReportIconKeepsSystemSymbolLayers() {
let image = HomeMenuIconFactory.image(named: "mappin.circle.fill")
XCTAssertNotNil(image)
XCTAssertTrue(image?.isSymbolImage ?? false)
}
} }
/// cell /// cell
@ -50,6 +57,7 @@ final class HomeMenuCellTests: XCTestCase {
let menus = [ let menus = [
HomeMenuItem(uri: "space", title: "空间设置", iconName: "home_menu_space"), HomeMenuItem(uri: "space", title: "空间设置", iconName: "home_menu_space"),
HomeMenuItem(uri: "live", title: "直播管理", iconName: "dot.radiowaves.left.and.right"), HomeMenuItem(uri: "live", title: "直播管理", iconName: "dot.radiowaves.left.and.right"),
HomeMenuItem(uri: "location", title: "位置上报", iconName: "mappin.circle.fill"),
HomeMenuItem(uri: "more", title: "更多功能", iconName: "ellipsis"), HomeMenuItem(uri: "more", title: "更多功能", iconName: "ellipsis"),
] ]
@ -60,11 +68,19 @@ final class HomeMenuCellTests: XCTestCase {
cell.layoutIfNeeded() cell.layoutIfNeeded()
let iconView = try XCTUnwrap(findImageView(in: cell, identifier: menu.iconName)) let iconView = try XCTUnwrap(findImageView(in: cell, identifier: menu.iconName))
XCTAssertEqual(iconView.bounds.size, CGSize(width: 24, height: 24)) let alignmentSize = iconView.alignmentRect(forFrame: iconView.frame).size
XCTAssertEqual(alignmentSize.width, 24, accuracy: 0.001)
XCTAssertEqual(alignmentSize.height, 24, accuracy: 0.001)
XCTAssertEqual(iconView.superview?.bounds.size, CGSize(width: 40, height: 40)) XCTAssertEqual(iconView.superview?.bounds.size, CGSize(width: 40, height: 40))
XCTAssertEqual(iconView.image?.size, HomeMenuIconFactory.canvasSize)
XCTAssertEqual(iconView.contentMode, .center) XCTAssertEqual(iconView.contentMode, .center)
XCTAssertNil(iconView.preferredSymbolConfiguration) XCTAssertNil(iconView.preferredSymbolConfiguration)
if menu.iconName == "home_menu_space" {
XCTAssertEqual(iconView.image?.size, HomeMenuIconFactory.canvasSize)
XCTAssertFalse(iconView.image?.isSymbolImage ?? true)
} else {
XCTAssertTrue(iconView.image?.isSymbolImage ?? false)
}
} }
} }

View File

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

View File

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

View File

@ -3,6 +3,7 @@
// suixinkanTests // suixinkanTests
// //
import CoreLocation
import XCTest import XCTest
@testable import suixinkan @testable import suixinkan

View File

@ -27,22 +27,40 @@ final class PushNotificationTests: XCTestCase {
super.tearDown() super.tearDown()
} }
func testPayloadRoutesTopLevelAndKnownDestinations() { func testPayloadRoutesPaymentTypesToTheirPages() {
XCTAssertEqual(PushPayload(userInfo: ["type": "1"]).destination, .payment) XCTAssertEqual(
XCTAssertEqual(PushPayload(userInfo: ["route": "photographer_orders"]).destination, .orders) PushPayload(userInfo: ["type": 1, "route": "message_center"]).destination,
XCTAssertEqual(PushPayload(userInfo: ["route": "verification_order"]).destination, .verificationOrders) .paymentRecord
XCTAssertEqual(PushPayload(userInfo: ["action": "task_management"]).destination, .task) )
XCTAssertEqual(PushPayload(userInfo: ["uri": "/scenic-queue"]).destination, .queue) XCTAssertEqual(
XCTAssertEqual(PushPayload(userInfo: ["route": "message_center"]).destination, .messageCenter) PushPayload(userInfo: ["type": "6", "action": "task_management"]).destination,
.paymentDetails
)
} }
func testPayloadRoutesNestedJSONStringAndUnknownFallsBackToMessageCenter() { func testPayloadIgnoresNonTypeRoutingFieldsAndOtherTypesFallBackToMessageCenter() {
let conflictingFields: [AnyHashable: Any] = [
"type": 2,
"route": "payment",
"uri": "/payment_qr",
"action": "收款",
]
[0, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 100, 999].forEach { type in
XCTAssertEqual(PushPayload(userInfo: ["type": type]).destination, .messageCenter)
}
XCTAssertEqual(PushPayload(userInfo: conflictingFields).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["route": "payment"]).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["data": ["type": 6]]).destination, .messageCenter)
XCTAssertEqual(PushPayload(userInfo: ["type": 999]).destination, .messageCenter)
}
func testPayloadReadsTypeFromNestedJSONStringWithoutUsingOtherFields() {
let nested = PushPayload(userInfo: [ let nested = PushPayload(userInfo: [
"extras": #"{"data":{"action":""}}"#, "extras": #"{"type":6,"route":"message_center"}"#,
]) ])
XCTAssertEqual(nested.destination, .queue) XCTAssertEqual(nested.destination, .paymentDetails)
XCTAssertEqual(PushPayload(userInfo: ["title": "未知消息"]).destination, .messageCenter)
} }
func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws { func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws {
@ -152,10 +170,10 @@ final class PushNotificationTests: XCTestCase {
XCTAssertTrue(router.destinations.isEmpty) XCTAssertTrue(router.destinations.isEmpty)
manager.handleNotificationTap( manager.handleNotificationTap(
payload: PushPayload(userInfo: ["route": "task_management"]), payload: PushPayload(userInfo: ["type": 1, "route": "task_management"]),
requestIdentifier: "request-1" requestIdentifier: "request-1"
) )
XCTAssertEqual(router.destinations, [.task]) XCTAssertEqual(router.destinations, [.paymentRecord])
} }
func testRemoteNotificationNotifiesUnreadMessageStateChange() async { func testRemoteNotificationNotifiesUnreadMessageStateChange() async {
@ -186,19 +204,18 @@ final class PushNotificationTests: XCTestCase {
window.rootViewController = UINavigationController(rootViewController: UIViewController()) window.rootViewController = UINavigationController(rootViewController: UIViewController())
coordinator.attach(window: window) coordinator.attach(window: window)
coordinator.handle(destination: .queue, requestIdentifier: "cold-request") coordinator.handle(destination: .paymentDetails, requestIdentifier: "cold-request")
authenticate(userID: "100") authenticate(userID: "100")
appStore.session.currentScenicId = 9
let mainTab = MainTabBarController() let mainTab = MainTabBarController()
mainTab.loadViewIfNeeded() mainTab.loadViewIfNeeded()
window.rootViewController = mainTab window.rootViewController = mainTab
coordinator.routePendingIfPossible() coordinator.routePendingIfPossible()
let navigationController = mainTab.selectedViewController as? UINavigationController let navigationController = mainTab.selectedViewController as? UINavigationController
XCTAssertTrue(navigationController?.topViewController is ScenicQueueViewController) XCTAssertTrue(navigationController?.topViewController is PaymentCollectionDetailsViewController)
coordinator.handle(destination: .messageCenter, requestIdentifier: "cold-request") coordinator.handle(destination: .messageCenter, requestIdentifier: "cold-request")
XCTAssertTrue(navigationController?.topViewController is ScenicQueueViewController) XCTAssertTrue(navigationController?.topViewController is PaymentCollectionDetailsViewController)
} }
private func authenticate(userID: String) { private func authenticate(userID: String) {