Compare commits
3
Commits
10249776ad
...
dev_9_8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d12dbc6bb0 | ||
|
|
3823d10423 | ||
|
|
c4b0e7798b |
+3
-3
@@ -295,7 +295,7 @@ iOS 通过 APNs/极光收到 `userInfo` 字典。与 Android 相比:
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 标题示例 | `付款成功通知` |
|
| 标题示例 | `付款成功通知` |
|
||||||
| 正文示例 | `您的订单 #260722128006 已支付成功,支付金额 ¥0.01` |
|
| 正文示例 | `您的订单 #260722128006 已支付成功,支付金额 ¥0.01` |
|
||||||
| 客户端点击行为 | Android、iOS 均进入收款记录页 |
|
| 客户端点击行为 | iOS 进入订单 Tab,恢复默认筛选并刷新;Android 行为以 Android 实现为准 |
|
||||||
|
|
||||||
`data` 字段:
|
`data` 字段:
|
||||||
|
|
||||||
@@ -410,9 +410,9 @@ iOS 通过 APNs/极光收到 `userInfo` 字典。与 Android 相比:
|
|||||||
## 7. 客户端解析约定
|
## 7. 客户端解析约定
|
||||||
|
|
||||||
1. Android、iOS 的通知点击路由只根据业务字段 `type` 判断,不读取标题、正文、`data`、`route`、`uri`、`action` 或极光内部字段决定页面。
|
1. Android、iOS 的通知点击路由只根据业务字段 `type` 判断,不读取标题、正文、`data`、`route`、`uri`、`action` 或极光内部字段决定页面。
|
||||||
2. `type = 1`:进入收款记录页。
|
2. iOS `type = 1`、`type = 5`:进入订单 Tab 根页面,清空搜索、日期与状态筛选并刷新列表;不进入订单详情。Android 行为以 Android 实现为准。
|
||||||
3. `type = 6`:进入收款详情页。
|
3. `type = 6`:进入收款详情页。
|
||||||
4. 其他任意 `type`、缺少 `type`、类型无法解析或 payload 损坏:进入消息中心。
|
4. iOS `type = 14`:有效 `ai_retouch_batch_id` 进入 AI 修图任务详情,否则进入任务列表。其余未单独处理的 `type`、缺少 `type` 或类型无法解析:进入消息中心。
|
||||||
5. Android 从 `notificationExtras` 或厂商通道包装字段中提取 `type`;iOS 优先读取 payload 顶层 `type`,并兼容常见推送包装层中的 `type`。
|
5. Android 从 `notificationExtras` 或厂商通道包装字段中提取 `type`;iOS 优先读取 payload 顶层 `type`,并兼容常见推送包装层中的 `type`。
|
||||||
6. `data` 仍可按消息类型用于页面内容刷新,但不参与通知点击路由。新增字段应保持向后兼容,客户端应忽略当前版本不认识的额外字段。
|
6. `data` 仍可按消息类型用于页面内容刷新,但不参与通知点击路由。新增字段应保持向后兼容,客户端应忽略当前版本不认识的额外字段。
|
||||||
7. 金额继续使用字符串;时间戳建议统一为 Unix 秒级整数,并在协议中固定单位。
|
7. 金额继续使用字符串;时间戳建议统一为 Unix 秒级整数,并在协议中固定单位。
|
||||||
|
|||||||
@@ -436,7 +436,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1040101;
|
CURRENT_PROJECT_VERSION = 2010101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 2.1.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -502,7 +502,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1040101;
|
CURRENT_PROJECT_VERSION = 2010101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -526,7 +526,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 2.1.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -741,7 +741,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1040101;
|
CURRENT_PROJECT_VERSION = 2010101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -761,7 +761,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 2.1.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "payment_sailimu_background.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
suixinkan/Assets.xcassets/Payment/payment_sailimu_background.imageset/payment_sailimu_background.png
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
+21
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "payment_sailimu_logo.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom": "universal",
|
||||||
|
"scale": "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "xcode",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
/// 推送点击后可到达的业务页面。
|
/// 推送点击后可到达的业务页面。
|
||||||
enum PushDestination: Sendable, Equatable {
|
enum PushDestination: Sendable, Equatable {
|
||||||
case paymentRecord
|
case orders
|
||||||
case paymentDetails
|
case paymentDetails
|
||||||
case messageCenter
|
case messageCenter
|
||||||
case aiRetouchTaskList
|
case aiRetouchTaskList
|
||||||
@@ -36,8 +36,8 @@ struct PushPayload: Sendable, Equatable {
|
|||||||
/// 仅根据后端业务消息类型解析目标页面。
|
/// 仅根据后端业务消息类型解析目标页面。
|
||||||
nonisolated var destination: PushDestination {
|
nonisolated var destination: PushDestination {
|
||||||
switch type {
|
switch type {
|
||||||
case "1":
|
case "1", "5":
|
||||||
return .paymentRecord
|
return .orders
|
||||||
case "6":
|
case "6":
|
||||||
return .paymentDetails
|
return .paymentDetails
|
||||||
case "14":
|
case "14":
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import Foundation
|
|||||||
/// 店铺管理员押金单列表 ViewModel,对齐 Android `DepositOrderListViewModel`。
|
/// 店铺管理员押金单列表 ViewModel,对齐 Android `DepositOrderListViewModel`。
|
||||||
final class DepositOrderListViewModel {
|
final class DepositOrderListViewModel {
|
||||||
|
|
||||||
|
private let appStore: AppStore
|
||||||
|
|
||||||
|
/// 注入账号上下文,使订单查询与测试使用各自独立的会话。
|
||||||
|
init(appStore: AppStore = .shared) {
|
||||||
|
self.appStore = appStore
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private(set) var searchText = ""
|
private(set) var searchText = ""
|
||||||
private(set) var filterLabel = "全部"
|
private(set) var filterLabel = "全部"
|
||||||
private(set) var tabIndex = 0
|
private(set) var tabIndex = 0
|
||||||
@@ -22,6 +30,7 @@ final class DepositOrderListViewModel {
|
|||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
private var listRequestID = UUID()
|
||||||
private var currentPage = 1
|
private var currentPage = 1
|
||||||
private var totalCount = 0
|
private var totalCount = 0
|
||||||
private var searchDebounceTask: Task<Void, Never>?
|
private var searchDebounceTask: Task<Void, Never>?
|
||||||
@@ -34,6 +43,18 @@ final class DepositOrderListViewModel {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// 清除通知进入前的查询条件,由页面随后发起第一页刷新。
|
||||||
|
func resetFiltersForOrderNotification() {
|
||||||
|
searchDebounceTask?.cancel()
|
||||||
|
searchDebounceTask = nil
|
||||||
|
searchText = ""
|
||||||
|
filterLabel = "全部"
|
||||||
|
tabIndex = 0
|
||||||
|
startTime = nil
|
||||||
|
endTime = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
func refreshOrderList(api: OrderAPI) async {
|
func refreshOrderList(api: OrderAPI) async {
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
@@ -102,7 +123,7 @@ final class DepositOrderListViewModel {
|
|||||||
onShowMessage?("核销码内容不完整")
|
onShowMessage?("核销码内容不完整")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let storeId = AppStore.shared.session.currentStoreId
|
let storeId = appStore.session.currentStoreId
|
||||||
guard storeId > 0 else {
|
guard storeId > 0 else {
|
||||||
onShowMessage?("店铺信息异常,请先选择店铺")
|
onShowMessage?("店铺信息异常,请先选择店铺")
|
||||||
return
|
return
|
||||||
@@ -125,7 +146,7 @@ final class DepositOrderListViewModel {
|
|||||||
refundReason: String,
|
refundReason: String,
|
||||||
api: OrderAPI
|
api: OrderAPI
|
||||||
) async {
|
) async {
|
||||||
let storeId = AppStore.shared.session.currentStoreId
|
let storeId = appStore.session.currentStoreId
|
||||||
guard storeId > 0 else {
|
guard storeId > 0 else {
|
||||||
onShowMessage?("店铺信息异常")
|
onShowMessage?("店铺信息异常")
|
||||||
return
|
return
|
||||||
@@ -163,10 +184,12 @@ final class DepositOrderListViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadOrderList(refresh: Bool, api: OrderAPI) async {
|
private func loadOrderList(refresh: Bool, api: OrderAPI) async {
|
||||||
|
let requestID = UUID()
|
||||||
|
listRequestID = requestID
|
||||||
let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let isPhoneSearch = !phone.isEmpty && Self.isValidPhone(phone)
|
let isPhoneSearch = !phone.isEmpty && Self.isValidPhone(phone)
|
||||||
|
|
||||||
let storeId = AppStore.shared.session.currentStoreId
|
let storeId = appStore.session.currentStoreId
|
||||||
if storeId <= 0 {
|
if storeId <= 0 {
|
||||||
finishLoading()
|
finishLoading()
|
||||||
orderList = []
|
orderList = []
|
||||||
@@ -201,6 +224,7 @@ final class DepositOrderListViewModel {
|
|||||||
startTime: start,
|
startTime: start,
|
||||||
endTime: end
|
endTime: end
|
||||||
)
|
)
|
||||||
|
guard listRequestID == requestID else { return }
|
||||||
totalCount = response.total
|
totalCount = response.total
|
||||||
if refresh || currentPage == 1 {
|
if refresh || currentPage == 1 {
|
||||||
orderList = response.list
|
orderList = response.list
|
||||||
@@ -213,6 +237,7 @@ final class DepositOrderListViewModel {
|
|||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
guard listRequestID == requestID else { return }
|
||||||
onShowMessage?(error.localizedDescription)
|
onShowMessage?(error.localizedDescription)
|
||||||
}
|
}
|
||||||
finishLoading()
|
finishLoading()
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ struct OrderSourceSelection: Equatable {
|
|||||||
/// 摄影师/景区管理员订单列表 ViewModel,对齐 Android `OrderListViewModel`。
|
/// 摄影师/景区管理员订单列表 ViewModel,对齐 Android `OrderListViewModel`。
|
||||||
final class OrderListViewModel {
|
final class OrderListViewModel {
|
||||||
|
|
||||||
|
private let appStore: AppStore
|
||||||
|
|
||||||
|
/// 注入账号上下文,使订单查询与测试使用各自独立的会话。
|
||||||
|
init(appStore: AppStore = .shared) {
|
||||||
|
self.appStore = appStore
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private(set) var orderList: [OrderEntity] = []
|
private(set) var orderList: [OrderEntity] = []
|
||||||
private(set) var searchText = ""
|
private(set) var searchText = ""
|
||||||
private(set) var filterLabel = "全部"
|
private(set) var filterLabel = "全部"
|
||||||
@@ -110,6 +118,7 @@ final class OrderListViewModel {
|
|||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
var onNavigateBindAcquirer: ((Int) -> Void)?
|
var onNavigateBindAcquirer: ((Int) -> Void)?
|
||||||
|
|
||||||
|
private var listRequestID = UUID()
|
||||||
private var currentPage = 1
|
private var currentPage = 1
|
||||||
private var totalCount = 0
|
private var totalCount = 0
|
||||||
|
|
||||||
@@ -122,7 +131,7 @@ final class OrderListViewModel {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
var isScenicAdminReadOnly: Bool {
|
var isScenicAdminReadOnly: Bool {
|
||||||
AppStore.shared.session.currentAppRole == .scenicAdmin
|
appStore.session.currentAppRole == .scenicAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
var listAPIPath: String {
|
var listAPIPath: String {
|
||||||
@@ -131,6 +140,17 @@ final class OrderListViewModel {
|
|||||||
: "/api/yf-handset-app/photog/order/listv2"
|
: "/api/yf-handset-app/photog/order/listv2"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清除通知进入前的查询条件,由页面随后发起第一页刷新。
|
||||||
|
func resetFiltersForOrderNotification() {
|
||||||
|
searchText = ""
|
||||||
|
filterLabel = "全部"
|
||||||
|
tabIndex = 0
|
||||||
|
rTabIndex = 0
|
||||||
|
startTime = nil
|
||||||
|
endTime = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
func refreshOrderList(api: OrderAPI) async {
|
func refreshOrderList(api: OrderAPI) async {
|
||||||
currentPage = 1
|
currentPage = 1
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
@@ -359,7 +379,7 @@ final class OrderListViewModel {
|
|||||||
isLoadingOrderSourcePeople = false
|
isLoadingOrderSourcePeople = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
let storeId = AppStore.shared.session.currentStoreId
|
let storeId = appStore.session.currentStoreId
|
||||||
let storeIdParam = storeId > 0 ? String(storeId) : nil
|
let storeIdParam = storeId > 0 ? String(storeId) : nil
|
||||||
do {
|
do {
|
||||||
let response = try await api.cooperativeSalers(storeId: storeIdParam)
|
let response = try await api.cooperativeSalers(storeId: storeIdParam)
|
||||||
@@ -402,7 +422,9 @@ final class OrderListViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadOrderList(api: OrderAPI, append: Bool) async {
|
private func loadOrderList(api: OrderAPI, append: Bool) async {
|
||||||
let scenicId = AppStore.shared.session.currentScenicId
|
let requestID = UUID()
|
||||||
|
listRequestID = requestID
|
||||||
|
let scenicId = appStore.session.currentScenicId
|
||||||
guard scenicId > 0 else {
|
guard scenicId > 0 else {
|
||||||
finishLoading()
|
finishLoading()
|
||||||
onShowMessage?("当前景区信息缺失")
|
onShowMessage?("当前景区信息缺失")
|
||||||
@@ -436,6 +458,7 @@ final class OrderListViewModel {
|
|||||||
isRefined: rTabIndex
|
isRefined: rTabIndex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
guard listRequestID == requestID else { return }
|
||||||
totalCount = response.total
|
totalCount = response.total
|
||||||
if append {
|
if append {
|
||||||
orderList.append(contentsOf: response.list)
|
orderList.append(contentsOf: response.list)
|
||||||
@@ -447,6 +470,7 @@ final class OrderListViewModel {
|
|||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
guard listRequestID == requestID else { return }
|
||||||
onShowMessage?(error.localizedDescription)
|
onShowMessage?(error.localizedDescription)
|
||||||
}
|
}
|
||||||
finishLoading()
|
finishLoading()
|
||||||
@@ -479,7 +503,7 @@ final class OrderListViewModel {
|
|||||||
var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
json["scenic_id"] = String(AppStore.shared.session.currentScenicId)
|
json["scenic_id"] = String(appStore.session.currentScenicId)
|
||||||
guard let updated = try? JSONSerialization.data(withJSONObject: json),
|
guard let updated = try? JSONSerialization.data(withJSONObject: json),
|
||||||
let string = String(data: updated, encoding: .utf8) else {
|
let string = String(data: updated, encoding: .utf8) else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ struct PayPageConfig: Codable, Sendable, Equatable {
|
|||||||
subtitle: "那拉提景区"
|
subtitle: "那拉提景区"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/// 赛里木湖景区随包默认文案,图片由展示层选择本地资源。
|
||||||
|
static let sailimuDefault = PayPageConfig(
|
||||||
|
background: "",
|
||||||
|
logo: "",
|
||||||
|
title: "赛里木湖旅拍管理\n收费平台",
|
||||||
|
subtitle: "赛里木湖景区"
|
||||||
|
)
|
||||||
|
|
||||||
/// 将服务端非空有效字段合并到当前可用配置。
|
/// 将服务端非空有效字段合并到当前可用配置。
|
||||||
func merging(_ response: PayPageConfigResponse) -> PayPageConfig {
|
func merging(_ response: PayPageConfigResponse) -> PayPageConfig {
|
||||||
PayPageConfig(
|
PayPageConfig(
|
||||||
@@ -61,10 +69,22 @@ enum PayPageBrandingPolicy {
|
|||||||
/// 那拉提景区正式 ID。
|
/// 那拉提景区正式 ID。
|
||||||
static let nalatiScenicId = 128
|
static let nalatiScenicId = 128
|
||||||
|
|
||||||
/// 判断当前景区是否启用品牌收款页。
|
/// 赛里木湖景区正式 ID。
|
||||||
|
static let sailimuScenicId = 125
|
||||||
|
|
||||||
|
/// 判断当前景区是否为那拉提。
|
||||||
static func isNalati(scenicId: Int) -> Bool {
|
static func isNalati(scenicId: Int) -> Bool {
|
||||||
scenicId == nalatiScenicId
|
scenicId == nalatiScenicId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 返回支持品牌收款页的景区默认配置;其他景区继续使用普通页面。
|
||||||
|
static func defaultConfig(scenicId: Int) -> PayPageConfig? {
|
||||||
|
switch scenicId {
|
||||||
|
case nalatiScenicId: return .nalatiDefault
|
||||||
|
case sailimuScenicId: return .sailimuDefault
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收款页当前账号信息的展示文本格式化规则。
|
/// 收款页当前账号信息的展示文本格式化规则。
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
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 PayPageBrandingPolicy.isNalati(scenicId: scenicId) {
|
restorePayPageConfig()
|
||||||
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasPayCode: Bool {
|
var hasPayCode: Bool {
|
||||||
@@ -60,8 +58,26 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
scenicName.isEmpty ? "暂无景区" : scenicName
|
scenicName.isEmpty ? "暂无景区" : scenicName
|
||||||
}
|
}
|
||||||
|
|
||||||
var usesNalatiBranding: Bool {
|
/// 是否启用品牌收款页,包含那拉提与赛里木湖。
|
||||||
PayPageBrandingPolicy.isNalati(scenicId: scenicId)
|
var usesBranding: Bool {
|
||||||
|
PayPageBrandingPolicy.defaultConfig(scenicId: scenicId) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前景区的随包背景资源名称。
|
||||||
|
var brandBackgroundAssetName: String {
|
||||||
|
scenicId == PayPageBrandingPolicy.sailimuScenicId
|
||||||
|
? "payment_sailimu_background" : "payment_nalati_background"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前景区的随包 Logo 资源名称。
|
||||||
|
var brandLogoAssetName: String {
|
||||||
|
scenicId == PayPageBrandingPolicy.sailimuScenicId
|
||||||
|
? "payment_sailimu_logo" : "payment_nalati_logo"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 品牌主标题的最大行数,赛里木湖允许两行展示。
|
||||||
|
var brandTitleNumberOfLines: Int {
|
||||||
|
scenicId == PayPageBrandingPolicy.sailimuScenicId ? 2 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayPhotographerName: String {
|
var displayPhotographerName: String {
|
||||||
@@ -80,14 +96,18 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
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 {
|
restorePayPageConfig()
|
||||||
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
|
|
||||||
} else {
|
|
||||||
payPageConfig = .nalatiDefault
|
|
||||||
}
|
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func restorePayPageConfig() {
|
||||||
|
guard let defaults = PayPageBrandingPolicy.defaultConfig(scenicId: scenicId) else {
|
||||||
|
payPageConfig = .nalatiDefault
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? defaults
|
||||||
|
}
|
||||||
|
|
||||||
func loadPayCode(api: PaymentAPI) async {
|
func loadPayCode(api: PaymentAPI) async {
|
||||||
guard !loading else { return }
|
guard !loading else { return }
|
||||||
loading = true
|
loading = true
|
||||||
@@ -116,12 +136,14 @@ final class PaymentCollectionDetailsViewModel {
|
|||||||
|
|
||||||
func loadPayPageConfig(api: PaymentAPI) async {
|
func loadPayPageConfig(api: PaymentAPI) async {
|
||||||
let requestedScenicId = appStore.session.currentScenicId
|
let requestedScenicId = appStore.session.currentScenicId
|
||||||
guard PayPageBrandingPolicy.isNalati(scenicId: requestedScenicId) else { return }
|
guard let defaults = PayPageBrandingPolicy.defaultConfig(scenicId: requestedScenicId) else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await api.payPageConfig(scenicId: requestedScenicId)
|
let response = try await api.payPageConfig(scenicId: requestedScenicId)
|
||||||
guard appStore.session.currentScenicId == requestedScenicId else { return }
|
guard appStore.session.currentScenicId == requestedScenicId,
|
||||||
let resolved = payPageConfig.merging(response)
|
scenicId == requestedScenicId else { return }
|
||||||
|
let cached = payPageConfigCache.config(scenicId: requestedScenicId) ?? defaults
|
||||||
|
let resolved = cached.merging(response)
|
||||||
payPageConfigCache.save(resolved, scenicId: requestedScenicId)
|
payPageConfigCache.save(resolved, scenicId: requestedScenicId)
|
||||||
payPageConfig = resolved
|
payPageConfig = resolved
|
||||||
brandingRefreshVersion += 1
|
brandingRefreshVersion += 1
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>UIUserInterfaceStyle</key>
|
||||||
|
<string>Light</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -67,16 +67,20 @@ final class MainTabBarController: UITabBarController {
|
|||||||
|
|
||||||
/// 响应推送点击,切换到目标 Tab 并打开对应业务页面。
|
/// 响应推送点击,切换到目标 Tab 并打开对应业务页面。
|
||||||
func openPushDestination(_ destination: PushDestination) {
|
func openPushDestination(_ destination: PushDestination) {
|
||||||
let targetTab = AppTab.home
|
loadViewIfNeeded()
|
||||||
|
let targetTab: AppTab = destination == .orders ? .orders : .home
|
||||||
|
|
||||||
|
let wasAlreadyOnOrders = targetTab == .orders && selectedViewController === tabNavigationControllers[.orders]
|
||||||
selectTab(targetTab)
|
selectTab(targetTab)
|
||||||
guard let navigationController = tabNavigationControllers[targetTab] else { return }
|
guard let navigationController = tabNavigationControllers[targetTab] else { return }
|
||||||
_ = navigationController.popToRootViewController(animated: false)
|
_ = navigationController.popToRootViewController(animated: false)
|
||||||
|
|
||||||
let controller: UIViewController?
|
let controller: UIViewController?
|
||||||
switch destination {
|
switch destination {
|
||||||
case .paymentRecord:
|
case .orders:
|
||||||
controller = PaymentCollectionRecordViewController()
|
(navigationController.viewControllers.first as? OrdersViewController)?.openFromOrderNotification()
|
||||||
|
if wasAlreadyOnOrders { badgeViewModel.refreshPendingWriteOffCount() }
|
||||||
|
controller = nil
|
||||||
case .paymentDetails:
|
case .paymentDetails:
|
||||||
controller = PaymentCollectionDetailsViewController()
|
controller = PaymentCollectionDetailsViewController()
|
||||||
case .messageCenter:
|
case .messageCenter:
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ final class OfflineCollectionRegistrationViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func quickAmountTapped(_ sender: UIButton) {
|
@objc private func quickAmountTapped(_ sender: UIButton) {
|
||||||
viewModel.updateAmount(OfflineCollectionMoney.apiAmount(sender.tag))
|
viewModel.updateAmount(OfflineCollectionMoney.displayAmount(sender.tag))
|
||||||
amountField.becomeFirstResponder()
|
amountField.becomeFirstResponder()
|
||||||
moveAmountCursorToEnd()
|
moveAmountCursorToEnd()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,64 @@ import SnapKit
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 订单 Tab 根页面,按角色分流摄影师/景区管理员/店铺管理员。
|
/// 订单 Tab 根页面,按角色分流摄影师/景区管理员/店铺管理员。
|
||||||
final class OrdersViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate {
|
final class OrdersViewController: BaseViewController, UITableViewDelegate {
|
||||||
|
|
||||||
private let orderAPI = NetworkServices.shared.orderAPI
|
private let appStore: AppStore
|
||||||
private let orderListViewModel = OrderListViewModel()
|
private let orderAPI: OrderAPI
|
||||||
private let depositViewModel = DepositOrderListViewModel()
|
private let orderListViewModel: OrderListViewModel
|
||||||
|
private let depositViewModel: DepositOrderListViewModel
|
||||||
|
|
||||||
|
/// 表格快照持有完整行数据,避免刷新清空 ViewModel 后仍按旧行号取值。
|
||||||
|
private struct OrderRow: Hashable {
|
||||||
|
/// 同一快照内按位置区分行,兼容接口返回重复订单号。
|
||||||
|
let position: Int
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
/// 摄影师和店铺订单的不可变展示数据。
|
||||||
|
enum Content: Equatable {
|
||||||
|
case photographer(OrderEntity, source: OrderSourceSelection?, readOnly: Bool, showSource: Bool)
|
||||||
|
case deposit(StoreOrderItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(position)
|
||||||
|
switch content {
|
||||||
|
case .photographer(let order, _, _, _):
|
||||||
|
hasher.combine(0)
|
||||||
|
hasher.combine(order.orderNumber)
|
||||||
|
case .deposit(let order):
|
||||||
|
hasher.combine(1)
|
||||||
|
hasher.combine(order.orderNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Int, OrderRow>?
|
||||||
|
|
||||||
|
/// 注入订单数据依赖;默认使用应用共享 API 和独立的页面 ViewModel。
|
||||||
|
init(
|
||||||
|
orderAPI: OrderAPI? = nil,
|
||||||
|
orderListViewModel: OrderListViewModel? = nil,
|
||||||
|
depositViewModel: DepositOrderListViewModel? = nil,
|
||||||
|
appStore: AppStore = .shared
|
||||||
|
) {
|
||||||
|
self.appStore = appStore
|
||||||
|
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
|
||||||
|
self.orderListViewModel = orderListViewModel ?? OrderListViewModel(appStore: appStore)
|
||||||
|
self.depositViewModel = depositViewModel ?? DepositOrderListViewModel(appStore: appStore)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
private let filterBar = OrderFilterBarView()
|
private let filterBar = OrderFilterBarView()
|
||||||
private let listView = OrderListView()
|
private let listView = OrderListView()
|
||||||
|
|
||||||
private var hasInitialized = false
|
private var hasInitialized = false
|
||||||
private var isStoreMode: Bool { AppStore.shared.session.currentAppRole == .storeAdmin }
|
private var isStoreMode: Bool { appStore.session.currentAppRole == .storeAdmin }
|
||||||
private var pendingOrderSourceNumber: String?
|
private var pendingOrderSourceNumber: String?
|
||||||
private weak var orderSourcePicker: OrderSourcePickerViewController?
|
private weak var orderSourcePicker: OrderSourcePickerViewController?
|
||||||
private var pendingGiftOrderNumber: String?
|
private var pendingGiftOrderNumber: String?
|
||||||
@@ -32,10 +79,10 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.addSubview(filterBar)
|
view.addSubview(filterBar)
|
||||||
view.addSubview(listView)
|
view.addSubview(listView)
|
||||||
listView.tableView.dataSource = self
|
|
||||||
listView.tableView.delegate = self
|
listView.tableView.delegate = self
|
||||||
listView.tableView.register(OrderCardCell.self, forCellReuseIdentifier: OrderCardCell.reuseIdentifier)
|
listView.tableView.register(OrderCardCell.self, forCellReuseIdentifier: OrderCardCell.reuseIdentifier)
|
||||||
listView.tableView.register(DepositOrderCardCell.self, forCellReuseIdentifier: DepositOrderCardCell.reuseIdentifier)
|
listView.tableView.register(DepositOrderCardCell.self, forCellReuseIdentifier: DepositOrderCardCell.reuseIdentifier)
|
||||||
|
configureDataSource()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
@@ -133,6 +180,21 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
Task { await initializeList() }
|
Task { await initializeList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从订单通知进入时恢复默认查询,并刷新已有或首次打开的列表。
|
||||||
|
func openFromOrderNotification() {
|
||||||
|
loadViewIfNeeded()
|
||||||
|
hasInitialized = true
|
||||||
|
if isStoreMode {
|
||||||
|
depositViewModel.resetFiltersForOrderNotification()
|
||||||
|
} else {
|
||||||
|
orderListViewModel.resetFiltersForOrderNotification()
|
||||||
|
}
|
||||||
|
applyViewModel()
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
await self?.initializeList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func initializeList() async {
|
private func initializeList() async {
|
||||||
if isStoreMode {
|
if isStoreMode {
|
||||||
await depositViewModel.refreshOrderList(api: orderAPI)
|
await depositViewModel.refreshOrderList(api: orderAPI)
|
||||||
@@ -142,6 +204,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
|
applyOrderSnapshot()
|
||||||
if isStoreMode {
|
if isStoreMode {
|
||||||
filterBar.configure(
|
filterBar.configure(
|
||||||
isStoreMode: true,
|
isStoreMode: true,
|
||||||
@@ -191,7 +254,6 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
presentMultiVerifySheet()
|
presentMultiVerifySheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
listView.tableView.reloadData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dateRangeText(start: Date?, end: Date?) -> String {
|
private func dateRangeText(start: Date?, end: Date?) -> String {
|
||||||
@@ -314,32 +376,61 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
|
|
||||||
// MARK: - UITableView
|
// MARK: - UITableView
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
private func configureDataSource() {
|
||||||
isStoreMode ? depositViewModel.orderList.count : orderListViewModel.orderList.count
|
dataSource = UITableViewDiffableDataSource<Int, OrderRow>(tableView: listView.tableView) { [weak self] tableView, _, row in
|
||||||
}
|
guard let self else { return nil }
|
||||||
|
switch row.content {
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
case .deposit(let item):
|
||||||
if isStoreMode {
|
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCardCell.reuseIdentifier) as! DepositOrderCardCell
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCardCell.reuseIdentifier, for: indexPath) as! DepositOrderCardCell
|
cell.configure(item: item, actions: self.makeDepositActions(for: item))
|
||||||
let item = depositViewModel.orderList[indexPath.row]
|
|
||||||
cell.configure(item: item, actions: makeDepositActions(for: item))
|
|
||||||
listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: depositViewModel.shouldLoadMore(at: indexPath.row))
|
|
||||||
return cell
|
return cell
|
||||||
}
|
case .photographer(let order, let source, let readOnly, let showSource):
|
||||||
|
let cell = tableView.dequeueReusableCell(withIdentifier: OrderCardCell.reuseIdentifier) as! OrderCardCell
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: OrderCardCell.reuseIdentifier, for: indexPath) as! OrderCardCell
|
|
||||||
let order = orderListViewModel.orderList[indexPath.row]
|
|
||||||
let source = orderListViewModel.orderSourceSelection(for: order)
|
|
||||||
cell.configure(
|
cell.configure(
|
||||||
order: order,
|
order: order,
|
||||||
hideWriteActions: orderListViewModel.isScenicAdminReadOnly,
|
hideWriteActions: readOnly,
|
||||||
actions: makeOrderActions(for: order),
|
actions: self.makeOrderActions(for: order),
|
||||||
showOrderSource: orderListViewModel.shouldShowOrderSource(for: order),
|
showOrderSource: showSource,
|
||||||
sourceSelection: source
|
sourceSelection: source
|
||||||
)
|
)
|
||||||
listView.notifyLoadMoreIfNeeded(at: indexPath.row, shouldLoad: orderListViewModel.shouldLoadMore(at: indexPath.row))
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyOrderSnapshot() {
|
||||||
|
let rows: [OrderRow]
|
||||||
|
if isStoreMode {
|
||||||
|
rows = depositViewModel.orderList.enumerated().map {
|
||||||
|
OrderRow(position: $0.offset, content: .deposit($0.element))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows = orderListViewModel.orderList.enumerated().map { index, order in
|
||||||
|
OrderRow(position: index, content: .photographer(
|
||||||
|
order,
|
||||||
|
source: orderListViewModel.orderSourceSelection(for: order),
|
||||||
|
readOnly: orderListViewModel.isScenicAdminReadOnly,
|
||||||
|
showSource: orderListViewModel.shouldShowOrderSource(for: order)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Int, OrderRow>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
snapshot.appendItems(rows)
|
||||||
|
dataSource?.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
guard let row = dataSource?.itemIdentifier(for: indexPath) else { return }
|
||||||
|
let shouldLoad: Bool
|
||||||
|
switch row.content {
|
||||||
|
case .deposit:
|
||||||
|
shouldLoad = isStoreMode && depositViewModel.shouldLoadMore(at: row.position)
|
||||||
|
case .photographer:
|
||||||
|
shouldLoad = !isStoreMode && orderListViewModel.shouldLoadMore(at: row.position)
|
||||||
|
}
|
||||||
|
listView.notifyLoadMoreIfNeeded(at: row.position, shouldLoad: shouldLoad)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeOrderActions(for order: OrderEntity) -> OrderCardActions {
|
private func makeOrderActions(for order: OrderEntity) -> OrderCardActions {
|
||||||
var actions = OrderCardActions()
|
var actions = OrderCardActions()
|
||||||
|
|||||||
@@ -101,15 +101,16 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = viewModel.usesNalatiBranding ? nil : "收款详情"
|
title = viewModel.usesBranding ? nil : "收款详情"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
let usesBranding = viewModel.usesNalatiBranding
|
let usesBranding = viewModel.usesBranding
|
||||||
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
|
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
|
||||||
scrollView.showsVerticalScrollIndicator = !usesBranding
|
scrollView.showsVerticalScrollIndicator = !usesBranding
|
||||||
scrollView.isScrollEnabled = true
|
scrollView.isScrollEnabled = true
|
||||||
scrollView.alwaysBounceVertical = true
|
scrollView.alwaysBounceVertical = true
|
||||||
|
scrollView.contentInsetAdjustmentBehavior = .always
|
||||||
|
|
||||||
contentStack.axis = .vertical
|
contentStack.axis = .vertical
|
||||||
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
|
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
|
||||||
@@ -187,7 +188,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
configureBrandHeader()
|
configureBrandHeader()
|
||||||
backgroundImageView.contentMode = .scaleAspectFill
|
backgroundImageView.contentMode = .scaleAspectFill
|
||||||
backgroundImageView.clipsToBounds = true
|
backgroundImageView.clipsToBounds = true
|
||||||
backgroundImageView.image = UIImage(named: "payment_nalati_background")
|
backgroundImageView.image = UIImage(named: viewModel.brandBackgroundAssetName)
|
||||||
view.addSubview(backgroundImageView)
|
view.addSubview(backgroundImageView)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,30 +321,24 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
if viewModel.usesNalatiBranding {
|
if viewModel.usesBranding {
|
||||||
backgroundImageView.snp.makeConstraints { make in
|
backgroundImageView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scrollView.snp.makeConstraints { make in
|
scrollView.snp.makeConstraints { make in
|
||||||
if viewModel.usesNalatiBranding {
|
make.edges.equalToSuperview()
|
||||||
make.leading.trailing.equalToSuperview()
|
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(-44)
|
|
||||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
|
||||||
} else {
|
|
||||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
contentContainerView.snp.makeConstraints { make in
|
contentContainerView.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview()
|
make.edges.equalTo(scrollView.contentLayoutGuide)
|
||||||
make.width.equalTo(scrollView.snp.width)
|
make.width.equalTo(scrollView.frameLayoutGuide)
|
||||||
if viewModel.usesNalatiBranding {
|
if viewModel.usesBranding {
|
||||||
make.height.greaterThanOrEqualTo(scrollView.snp.height)
|
make.height.greaterThanOrEqualTo(scrollView.snp.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentStack.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
||||||
if viewModel.usesNalatiBranding {
|
if viewModel.usesBranding {
|
||||||
make.centerX.equalToSuperview()
|
make.centerX.equalToSuperview()
|
||||||
make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
} else {
|
} else {
|
||||||
@@ -403,7 +398,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
viewModel.usesNalatiBranding ? .darkContent : .default
|
viewModel.usesBranding ? .darkContent : .default
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadData() async {
|
private func loadData() async {
|
||||||
@@ -426,7 +421,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
if viewModel.isPayCodeLoading {
|
if viewModel.isPayCodeLoading {
|
||||||
qrImageView.isHidden = true
|
qrImageView.isHidden = true
|
||||||
qrContainerView.isHidden = true
|
qrContainerView.isHidden = true
|
||||||
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
|
qrPlaceholderView.backgroundColor = viewModel.usesBranding
|
||||||
? .white
|
? .white
|
||||||
: AppColor.inputBackground
|
: AppColor.inputBackground
|
||||||
qrPlaceholderView.isHidden = false
|
qrPlaceholderView.isHidden = false
|
||||||
@@ -437,7 +432,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
} else if let image = viewModel.qrImage {
|
} else if let image = viewModel.qrImage {
|
||||||
qrImageView.image = image
|
qrImageView.image = image
|
||||||
qrImageView.isHidden = false
|
qrImageView.isHidden = false
|
||||||
qrContainerView.isHidden = !viewModel.usesNalatiBranding
|
qrContainerView.isHidden = !viewModel.usesBranding
|
||||||
qrPlaceholderView.isHidden = true
|
qrPlaceholderView.isHidden = true
|
||||||
qrLoadingIndicator.stopAnimating()
|
qrLoadingIndicator.stopAnimating()
|
||||||
setActionRowAvailable(true)
|
setActionRowAvailable(true)
|
||||||
@@ -527,7 +522,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
brandHeaderStack.alignment = .center
|
brandHeaderStack.alignment = .center
|
||||||
brandHeaderStack.spacing = brandScreenHeight <= 700 ? 6 : 8
|
brandHeaderStack.spacing = brandScreenHeight <= 700 ? 6 : 8
|
||||||
|
|
||||||
brandLogoImageView.image = UIImage(named: "payment_nalati_logo")
|
brandLogoImageView.image = UIImage(named: viewModel.brandLogoAssetName)
|
||||||
brandLogoImageView.contentMode = .scaleAspectFit
|
brandLogoImageView.contentMode = .scaleAspectFit
|
||||||
brandLogoImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
brandLogoImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
@@ -537,7 +532,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
)
|
)
|
||||||
brandTitleLabel.textColor = UIColor(hex: 0x102D54)
|
brandTitleLabel.textColor = UIColor(hex: 0x102D54)
|
||||||
brandTitleLabel.textAlignment = .center
|
brandTitleLabel.textAlignment = .center
|
||||||
brandTitleLabel.numberOfLines = 1
|
brandTitleLabel.numberOfLines = viewModel.brandTitleNumberOfLines
|
||||||
brandTitleLabel.adjustsFontSizeToFitWidth = true
|
brandTitleLabel.adjustsFontSizeToFitWidth = true
|
||||||
brandTitleLabel.minimumScaleFactor = 0.8
|
brandTitleLabel.minimumScaleFactor = 0.8
|
||||||
|
|
||||||
@@ -604,7 +599,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func applyBrandConfigIfNeeded() {
|
private func applyBrandConfigIfNeeded() {
|
||||||
guard viewModel.usesNalatiBranding else { return }
|
guard viewModel.usesBranding else { return }
|
||||||
let config = viewModel.payPageConfig
|
let config = viewModel.payPageConfig
|
||||||
brandTitleLabel.text = config.title
|
brandTitleLabel.text = config.title
|
||||||
brandSubtitleLabel.text = config.subtitle
|
brandSubtitleLabel.text = config.subtitle
|
||||||
@@ -615,13 +610,13 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
setBrandImage(
|
setBrandImage(
|
||||||
backgroundImageView,
|
backgroundImageView,
|
||||||
urlString: config.background,
|
urlString: config.background,
|
||||||
fallbackName: "payment_nalati_background",
|
fallbackName: viewModel.brandBackgroundAssetName,
|
||||||
forceRefresh: forceRefresh
|
forceRefresh: forceRefresh
|
||||||
)
|
)
|
||||||
setBrandImage(
|
setBrandImage(
|
||||||
brandLogoImageView,
|
brandLogoImageView,
|
||||||
urlString: config.logo,
|
urlString: config.logo,
|
||||||
fallbackName: "payment_nalati_logo",
|
fallbackName: viewModel.brandLogoAssetName,
|
||||||
forceRefresh: forceRefresh
|
forceRefresh: forceRefresh
|
||||||
)
|
)
|
||||||
appliedBrandConfig = config
|
appliedBrandConfig = config
|
||||||
@@ -639,6 +634,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
guard let url = URL(string: trimmed),
|
guard let url = URL(string: trimmed),
|
||||||
url.scheme?.lowercased() == "https",
|
url.scheme?.lowercased() == "https",
|
||||||
url.host != nil else {
|
url.host != nil else {
|
||||||
|
imageView.kf.cancelDownloadTask()
|
||||||
imageView.image = fallback
|
imageView.image = fallback
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -655,7 +651,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyBrandNavigationAppearanceIfNeeded() {
|
private func applyBrandNavigationAppearanceIfNeeded() {
|
||||||
guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return }
|
guard viewModel.usesBranding, let navigationBar = navigationController?.navigationBar else { return }
|
||||||
previousStandardAppearance = navigationBar.standardAppearance
|
previousStandardAppearance = navigationBar.standardAppearance
|
||||||
previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance
|
previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance
|
||||||
previousCompactAppearance = navigationBar.compactAppearance
|
previousCompactAppearance = navigationBar.compactAppearance
|
||||||
@@ -677,7 +673,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func restoreNavigationAppearanceIfNeeded() {
|
private func restoreNavigationAppearanceIfNeeded() {
|
||||||
guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return }
|
guard viewModel.usesBranding, let navigationBar = navigationController?.navigationBar else { return }
|
||||||
if let previousStandardAppearance {
|
if let previousStandardAppearance {
|
||||||
navigationBar.standardAppearance = previousStandardAppearance
|
navigationBar.standardAppearance = previousStandardAppearance
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
/// 扫码采集会话的配置与运行接口,便于独立验证生命周期顺序。
|
||||||
|
protocol QRCodeCaptureSession: AnyObject {
|
||||||
|
/// 当前是否正在采集。
|
||||||
|
var isRunning: Bool { get }
|
||||||
|
/// 开始批量配置。
|
||||||
|
func beginConfiguration()
|
||||||
|
/// 提交批量配置。
|
||||||
|
func commitConfiguration()
|
||||||
|
/// 启动采集。
|
||||||
|
func startRunning()
|
||||||
|
/// 停止采集。
|
||||||
|
func stopRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AVCaptureSession: QRCodeCaptureSession {}
|
||||||
|
|
||||||
|
/// 在同一串行队列中配置和启停相机,避免配置提交与启动交叉执行。
|
||||||
|
final class QRCodeCaptureSessionController {
|
||||||
|
private let session: QRCodeCaptureSession
|
||||||
|
private let queue: DispatchQueue
|
||||||
|
private var isConfigured = false
|
||||||
|
|
||||||
|
/// 创建会话生命周期控制器;队列必须为串行队列。
|
||||||
|
init(session: QRCodeCaptureSession,
|
||||||
|
queue: DispatchQueue = DispatchQueue(label: "com.zhifly.suixinkan.qr-capture")) {
|
||||||
|
self.session = session
|
||||||
|
self.queue = queue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在配置事务内添加输入输出,提交完成后在主线程通知结果。
|
||||||
|
func configure(_ configuration: @escaping () -> Bool, completion: @escaping (Bool) -> Void) {
|
||||||
|
queue.async { [self] in
|
||||||
|
if !isConfigured {
|
||||||
|
session.beginConfiguration()
|
||||||
|
isConfigured = configuration()
|
||||||
|
session.commitConfiguration()
|
||||||
|
}
|
||||||
|
let succeeded = isConfigured
|
||||||
|
DispatchQueue.main.async { completion(succeeded) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按提交顺序更新运行状态,运行状态的读取也限定在会话队列内。
|
||||||
|
func setRunning(_ shouldRun: Bool) {
|
||||||
|
queue.async { [self] in
|
||||||
|
if shouldRun {
|
||||||
|
guard isConfigured, !session.isRunning else { return }
|
||||||
|
session.startRunning()
|
||||||
|
} else if session.isRunning {
|
||||||
|
session.stopRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,15 @@ import UIKit
|
|||||||
/// AVFoundation 二维码扫描页。
|
/// AVFoundation 二维码扫描页。
|
||||||
final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOutputObjectsDelegate {
|
final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
|
||||||
|
/// 识别到有效二维码后返回内容,每次展示仅回调一次。
|
||||||
var onScanResult: ((String) -> Void)?
|
var onScanResult: ((String) -> Void)?
|
||||||
|
|
||||||
private let captureSession = AVCaptureSession()
|
private let captureSession = AVCaptureSession()
|
||||||
|
private lazy var captureController = QRCodeCaptureSessionController(session: captureSession)
|
||||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
private var isCameraConfigured = false
|
private var isCameraConfigured = false
|
||||||
|
private var isCameraConfiguring = false
|
||||||
|
private var isScannerVisible = false
|
||||||
private var hasHandledResult = false
|
private var hasHandledResult = false
|
||||||
|
|
||||||
private let maskView = UIView()
|
private let maskView = UIView()
|
||||||
@@ -63,6 +67,7 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
|
|||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
isScannerVisible = true
|
||||||
updatePreviewLayout()
|
updatePreviewLayout()
|
||||||
startCaptureSessionIfNeeded()
|
startCaptureSessionIfNeeded()
|
||||||
}
|
}
|
||||||
@@ -75,6 +80,7 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
|
|||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
isScannerVisible = false
|
||||||
stopCaptureSession()
|
stopCaptureSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,32 +111,44 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let device = AVCaptureDevice.default(for: .video),
|
guard !isCameraConfiguring else { return }
|
||||||
let input = try? AVCaptureDeviceInput(device: device) else {
|
isCameraConfiguring = true
|
||||||
showToast("无法打开相机")
|
captureController.configure({ [weak self, captureSession] in
|
||||||
return
|
guard let self,
|
||||||
}
|
let device = AVCaptureDevice.default(for: .video),
|
||||||
|
let input = try? AVCaptureDeviceInput(device: device),
|
||||||
captureSession.beginConfiguration()
|
captureSession.canAddInput(input) else { return false }
|
||||||
defer { captureSession.commitConfiguration() }
|
|
||||||
|
|
||||||
guard captureSession.canAddInput(input) else { return }
|
|
||||||
captureSession.addInput(input)
|
captureSession.addInput(input)
|
||||||
|
|
||||||
let output = AVCaptureMetadataOutput()
|
let output = AVCaptureMetadataOutput()
|
||||||
guard captureSession.canAddOutput(output) else { return }
|
guard captureSession.canAddOutput(output) else {
|
||||||
|
captureSession.removeInput(input)
|
||||||
|
return false
|
||||||
|
}
|
||||||
captureSession.addOutput(output)
|
captureSession.addOutput(output)
|
||||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
guard output.availableMetadataObjectTypes.contains(.qr) else {
|
||||||
|
captureSession.removeOutput(output)
|
||||||
|
captureSession.removeInput(input)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||||
output.metadataObjectTypes = [.qr]
|
output.metadataObjectTypes = [.qr]
|
||||||
|
return true
|
||||||
let layer = AVCaptureVideoPreviewLayer(session: captureSession)
|
}, completion: { [weak self] succeeded in
|
||||||
|
guard let self else { return }
|
||||||
|
self.isCameraConfiguring = false
|
||||||
|
guard succeeded else {
|
||||||
|
self.showToast("无法打开相机")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let layer = AVCaptureVideoPreviewLayer(session: self.captureSession)
|
||||||
layer.videoGravity = .resizeAspectFill
|
layer.videoGravity = .resizeAspectFill
|
||||||
view.layer.insertSublayer(layer, at: 0)
|
self.view.layer.insertSublayer(layer, at: 0)
|
||||||
previewLayer = layer
|
self.previewLayer = layer
|
||||||
isCameraConfigured = true
|
self.isCameraConfigured = true
|
||||||
|
self.updatePreviewLayout()
|
||||||
updatePreviewLayout()
|
self.startCaptureSessionIfNeeded()
|
||||||
startCaptureSessionIfNeeded()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePreviewLayout() {
|
private func updatePreviewLayout() {
|
||||||
@@ -138,17 +156,12 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startCaptureSessionIfNeeded() {
|
private func startCaptureSessionIfNeeded() {
|
||||||
guard isCameraConfigured, !captureSession.isRunning else { return }
|
guard isScannerVisible, isCameraConfigured, !hasHandledResult else { return }
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
captureController.setRunning(true)
|
||||||
self?.captureSession.startRunning()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopCaptureSession() {
|
private func stopCaptureSession() {
|
||||||
guard captureSession.isRunning else { return }
|
captureController.setRunning(false)
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
||||||
self?.captureSession.stopRunning()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMask() {
|
private func updateMask() {
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ final class DepositOrderListViewModelTests: XCTestCase {
|
|||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOrderNotificationResetsFilters() {
|
||||||
|
let viewModel = DepositOrderListViewModel()
|
||||||
|
viewModel.onSearchTextChange("123", api: OrderAPI(client: APIClient(environment: .testing, session: MockURLSession(results: []))))
|
||||||
|
viewModel.onFilterChange(tab: 18)
|
||||||
|
viewModel.onDateRangeSelected(start: Date(), end: Date())
|
||||||
|
viewModel.resetFiltersForOrderNotification()
|
||||||
|
XCTAssertEqual(viewModel.searchText, "")
|
||||||
|
XCTAssertEqual(viewModel.tabIndex, 0)
|
||||||
|
XCTAssertEqual(viewModel.filterLabel, "全部")
|
||||||
|
XCTAssertNil(viewModel.startTime)
|
||||||
|
XCTAssertNil(viewModel.endTime)
|
||||||
|
}
|
||||||
|
|
||||||
func testStoreFilterMapsPaidStatusToAPI20() {
|
func testStoreFilterMapsPaidStatusToAPI20() {
|
||||||
XCTAssertEqual(DepositOrderListViewModel.apiOrderStatus(forTabIndex: 18), 20)
|
XCTAssertEqual(DepositOrderListViewModel.apiOrderStatus(forTabIndex: 18), 20)
|
||||||
XCTAssertEqual(DepositOrderListViewModel.apiOrderStatus(forTabIndex: 30), 30)
|
XCTAssertEqual(DepositOrderListViewModel.apiOrderStatus(forTabIndex: 30), 30)
|
||||||
|
|||||||
@@ -72,3 +72,88 @@ private extension UIColor {
|
|||||||
return UInt(r << 16 | g << 8 | b)
|
return UInt(r << 16 | g << 8 | b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 回归通知刷新时表格仍请求旧行的场景,使用 mock 数据覆盖两种订单页面。
|
||||||
|
@MainActor
|
||||||
|
final class OrderNotificationListSnapshotTests: XCTestCase {
|
||||||
|
private var appStore: AppStore!
|
||||||
|
private var suiteName: String!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
suiteName = "OrderNotificationListSnapshotTests.\(UUID().uuidString)"
|
||||||
|
appStore = AppStore(defaults: UserDefaults(suiteName: suiteName)!)
|
||||||
|
appStore.session.configureTestAccount(userId: "push-list-test", accountType: .storeUser, scenicId: 10)
|
||||||
|
appStore.session.currentStoreId = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
UserDefaults.standard.removePersistentDomain(forName: suiteName)
|
||||||
|
appStore = nil
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPhotographerRowsRemainReadableWhileNotificationRefreshClearsModel() async throws {
|
||||||
|
try await verifyRefreshUsesSnapshot(storeMode: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDepositRowsRemainReadableWhileNotificationRefreshClearsModel() async throws {
|
||||||
|
try await verifyRefreshUsesSnapshot(storeMode: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verifyRefreshUsesSnapshot(storeMode: Bool) async throws {
|
||||||
|
appStore.session.roleCode = storeMode ? AppRoleCode.storeAdmin.rawValue : AppRoleCode.photographer.rawValue
|
||||||
|
let initial = Data(#"{"code":100000,"msg":"success","data":{"list":[{"order_number":"push-order-a","order_status":30},{"order_number":"push-order-b","order_status":30}],"total":2}}"#.utf8)
|
||||||
|
let empty = Data(#"{"code":100000,"msg":"success","data":{"list":[],"total":0}}"#.utf8)
|
||||||
|
let session = MockURLSession(responses: [initial, empty, initial])
|
||||||
|
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
let orders = OrderListViewModel(appStore: appStore)
|
||||||
|
let deposits = DepositOrderListViewModel(appStore: appStore)
|
||||||
|
let controller = OrdersViewController(orderAPI: api, orderListViewModel: orders, depositViewModel: deposits, appStore: appStore)
|
||||||
|
controller.openFromOrderNotification()
|
||||||
|
let list = try XCTUnwrap(controller.view.subviews.first { $0 is OrderListView } as? OrderListView)
|
||||||
|
let table = list.tableView
|
||||||
|
await waitForRows(2, in: table)
|
||||||
|
let source = try XCTUnwrap(table.dataSource)
|
||||||
|
|
||||||
|
// 保持表格的旧快照,精确模拟模型已清空、主线程 UI 回调尚未执行的窗口。
|
||||||
|
let notify = storeMode ? deposits.onStateChange : orders.onStateChange
|
||||||
|
if storeMode {
|
||||||
|
deposits.onStateChange = nil
|
||||||
|
deposits.resetFiltersForOrderNotification()
|
||||||
|
await deposits.refreshOrderList(api: api)
|
||||||
|
XCTAssertTrue(deposits.orderList.isEmpty)
|
||||||
|
} else {
|
||||||
|
orders.onStateChange = nil
|
||||||
|
orders.resetFiltersForOrderNotification()
|
||||||
|
await orders.refreshOrderList(api: api)
|
||||||
|
XCTAssertTrue(orders.orderList.isEmpty)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(source.tableView(table, numberOfRowsInSection: 0), 2)
|
||||||
|
for index in 0..<2 {
|
||||||
|
let cell = source.tableView(table, cellForRowAt: IndexPath(row: index, section: 0))
|
||||||
|
if storeMode {
|
||||||
|
XCTAssertTrue(cell is DepositOrderCardCell)
|
||||||
|
} else {
|
||||||
|
XCTAssertTrue(cell is OrderCardCell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if storeMode { deposits.onStateChange = notify } else { orders.onStateChange = notify }
|
||||||
|
notify?()
|
||||||
|
await waitForRows(0, in: table)
|
||||||
|
|
||||||
|
// 已打开的订单页再次点击通知,仍能加载完整列表。
|
||||||
|
controller.openFromOrderNotification()
|
||||||
|
await waitForRows(2, in: table)
|
||||||
|
XCTAssertEqual(session.requests.count, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForRows(_ count: Int, in table: UITableView) async {
|
||||||
|
let applied = expectation(for: NSPredicate { _, _ in
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
table.numberOfRows(inSection: 0) == count
|
||||||
|
}
|
||||||
|
}, evaluatedWith: nil)
|
||||||
|
await fulfillment(of: [applied], timeout: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ final class OrderListViewModelTests: XCTestCase {
|
|||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOrderNotificationResetsFilters() {
|
||||||
|
let viewModel = OrderListViewModel()
|
||||||
|
viewModel.onSearchTextChange("13800138000")
|
||||||
|
viewModel.onFilterChange(orderStatus: 18, isRefined: 1)
|
||||||
|
viewModel.onDateRangeSelected(start: Date(), end: Date())
|
||||||
|
viewModel.resetFiltersForOrderNotification()
|
||||||
|
XCTAssertEqual(viewModel.searchText, "")
|
||||||
|
XCTAssertEqual(viewModel.tabIndex, 0)
|
||||||
|
XCTAssertEqual(viewModel.filterLabel, "全部")
|
||||||
|
XCTAssertNil(viewModel.startTime)
|
||||||
|
XCTAssertNil(viewModel.endTime)
|
||||||
|
XCTAssertEqual(viewModel.rTabIndex, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func testOrderStatusQueryMapping() {
|
func testOrderStatusQueryMapping() {
|
||||||
XCTAssertNil(OrderListQueryBuilder.orderStatusQuery(tabIndex: 0))
|
XCTAssertNil(OrderListQueryBuilder.orderStatusQuery(tabIndex: 0))
|
||||||
XCTAssertEqual(OrderListQueryBuilder.orderStatusQuery(tabIndex: 18), "18")
|
XCTAssertEqual(OrderListQueryBuilder.orderStatusQuery(tabIndex: 18), "18")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
import UIKit
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
/// 收款详情 ViewModel 测试。
|
/// 收款详情 ViewModel 测试。
|
||||||
@@ -187,14 +188,14 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNonNalatiDoesNotRequestPayPageConfig() async {
|
func testUnbrandedScenicDoesNotRequestPayPageConfig() async {
|
||||||
let session = MockURLSession(responses: [])
|
let session = MockURLSession(responses: [])
|
||||||
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
||||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||||
|
|
||||||
await viewModel.loadPayPageConfig(api: api)
|
await viewModel.loadPayPageConfig(api: api)
|
||||||
|
|
||||||
XCTAssertFalse(viewModel.usesNalatiBranding)
|
XCTAssertFalse(viewModel.usesBranding)
|
||||||
XCTAssertEqual(session.requests.count, 0)
|
XCTAssertEqual(session.requests.count, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,4 +210,96 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(production.config(scenicId: 128), .nalatiDefault)
|
XCTAssertEqual(production.config(scenicId: 128), .nalatiDefault)
|
||||||
XCTAssertNil(testing.config(scenicId: 128))
|
XCTAssertNil(testing.config(scenicId: 128))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSailimuRefreshPersistsOnlyItsOwnConfigAndReusesBrandUI() async throws {
|
||||||
|
let suite = "PaymentCollectionDetailsViewModelTests.Sailimu.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let cache = PayPageConfigCache(defaults: defaults, environment: .testing)
|
||||||
|
cache.save(.nalatiDefault, scenicId: 128)
|
||||||
|
let cached = PayPageConfig(
|
||||||
|
background: "https://cache.example.com/sailimu.png",
|
||||||
|
logo: "https://cache.example.com/sailimu-logo.png",
|
||||||
|
title: "缓存赛里木湖标题",
|
||||||
|
subtitle: "赛里木湖景区"
|
||||||
|
)
|
||||||
|
cache.save(cached, scenicId: 125)
|
||||||
|
appStore.session.currentScenicId = 125
|
||||||
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore, payPageConfigCache: cache)
|
||||||
|
XCTAssertTrue(viewModel.usesBranding)
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig, cached)
|
||||||
|
|
||||||
|
let response = """
|
||||||
|
{"code":100000,"msg":"success","data":{
|
||||||
|
"background":"https://new.example.com/sailimu.png",
|
||||||
|
"logo":"", "title":"最新赛里木湖标题", "subtitle":""
|
||||||
|
}}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let session = MockURLSession(responses: [response])
|
||||||
|
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
await viewModel.loadPayPageConfig(api: api)
|
||||||
|
|
||||||
|
let url = try XCTUnwrap(session.requests.first?.url)
|
||||||
|
XCTAssertEqual(url.path, "/api/yf-handset-app/photog/pay-page-config")
|
||||||
|
XCTAssertEqual(URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||||
|
.queryItems?.first(where: { $0.name == "scenic_id" })?.value, "125")
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig.title, "最新赛里木湖标题")
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig.background, "https://new.example.com/sailimu.png")
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig.logo, cached.logo)
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig.subtitle, cached.subtitle)
|
||||||
|
XCTAssertEqual(viewModel.brandingRefreshVersion, 1)
|
||||||
|
XCTAssertEqual(cache.config(scenicId: 128), .nalatiDefault)
|
||||||
|
XCTAssertEqual(cache.config(scenicId: 125), viewModel.payPageConfig)
|
||||||
|
let reopened = PaymentCollectionDetailsViewModel(appStore: appStore, payPageConfigCache: cache)
|
||||||
|
XCTAssertEqual(reopened.payPageConfig, viewModel.payPageConfig)
|
||||||
|
|
||||||
|
appStore.session.currentScenicId = 128
|
||||||
|
viewModel.refreshLocalState()
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig, .nalatiDefault)
|
||||||
|
appStore.session.currentScenicId = 125
|
||||||
|
viewModel.refreshLocalState()
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig, reopened.payPageConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSailimuRequestFailurePreservesDefaultsOrItsOwnCache() async {
|
||||||
|
let suite = "PaymentCollectionDetailsViewModelTests.SailimuFailure.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let cache = PayPageConfigCache(defaults: defaults, environment: .testing)
|
||||||
|
cache.save(.nalatiDefault, scenicId: 128)
|
||||||
|
appStore.session.currentScenicId = 125
|
||||||
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore, payPageConfigCache: cache)
|
||||||
|
let session = MockURLSession(responses: [])
|
||||||
|
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig, .sailimuDefault)
|
||||||
|
await viewModel.loadPayPageConfig(api: api)
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig, .sailimuDefault)
|
||||||
|
XCTAssertNil(cache.config(scenicId: 125))
|
||||||
|
|
||||||
|
let cached = PayPageConfig(background: "https://cache.example.com/lake.png",
|
||||||
|
logo: "", title: "缓存标题", subtitle: "赛里木湖")
|
||||||
|
cache.save(cached, scenicId: 125)
|
||||||
|
viewModel.refreshLocalState()
|
||||||
|
await viewModel.loadPayPageConfig(api: api)
|
||||||
|
XCTAssertEqual(viewModel.payPageConfig, cached)
|
||||||
|
XCTAssertEqual(cache.config(scenicId: 125), cached)
|
||||||
|
XCTAssertEqual(cache.config(scenicId: 128), .nalatiDefault)
|
||||||
|
XCTAssertEqual(viewModel.brandingRefreshVersion, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func testBrandPresentationUsesMatchingBundledAssetsAndTitleLines() {
|
||||||
|
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||||
|
for (scenicId, prefix, lines) in [(125, "payment_sailimu", 2), (128, "payment_nalati", 1)] {
|
||||||
|
appStore.session.currentScenicId = scenicId
|
||||||
|
viewModel.refreshLocalState()
|
||||||
|
XCTAssertTrue(viewModel.usesBranding)
|
||||||
|
XCTAssertEqual(viewModel.brandBackgroundAssetName, "\(prefix)_background")
|
||||||
|
XCTAssertEqual(viewModel.brandLogoAssetName, "\(prefix)_logo")
|
||||||
|
XCTAssertEqual(viewModel.brandTitleNumberOfLines, lines)
|
||||||
|
XCTAssertNotNil(UIImage(named: viewModel.brandBackgroundAssetName))
|
||||||
|
XCTAssertNotNil(UIImage(named: viewModel.brandLogoAssetName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ final class PaymentModelsTests: XCTestCase {
|
|||||||
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
|
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testBrandDefaultsAreScopedToSupportedScenicIds() {
|
||||||
|
XCTAssertEqual(PayPageBrandingPolicy.defaultConfig(scenicId: 128), .nalatiDefault)
|
||||||
|
XCTAssertEqual(PayPageBrandingPolicy.defaultConfig(scenicId: 125), .sailimuDefault)
|
||||||
|
XCTAssertEqual(PayPageConfig.sailimuDefault.title, "赛里木湖旅拍管理\n收费平台")
|
||||||
|
XCTAssertEqual(PayPageConfig.sailimuDefault.subtitle, "赛里木湖景区")
|
||||||
|
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 125))
|
||||||
|
XCTAssertNil(PayPageBrandingPolicy.defaultConfig(scenicId: 127))
|
||||||
|
XCTAssertNil(PayPageBrandingPolicy.defaultConfig(scenicId: 0))
|
||||||
|
}
|
||||||
|
|
||||||
func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
|
func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
|
PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ final class PushNotificationTests: XCTestCase {
|
|||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPayloadRoutesPaymentTypesToTheirPages() {
|
func testPayloadRoutesOrderAndScanTypesToTheirPages() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PushPayload(userInfo: ["type": 1, "route": "message_center"]).destination,
|
PushPayload(userInfo: ["type": 1, "route": "message_center"]).destination,
|
||||||
.paymentRecord
|
.orders
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
PushPayload(userInfo: ["type": "6", "action": "task_management"]).destination,
|
PushPayload(userInfo: ["type": "6", "action": "task_management"]).destination,
|
||||||
@@ -38,6 +38,36 @@ final class PushNotificationTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOrderTypesSupportNumbersStringsAndVendorWrappers() {
|
||||||
|
for type in [1, 5] {
|
||||||
|
XCTAssertEqual(PushPayload(userInfo: ["type": type]).destination, .orders)
|
||||||
|
XCTAssertEqual(PushPayload(userInfo: ["type": "\(type)"]).destination, .orders)
|
||||||
|
XCTAssertEqual(PushPayload(userInfo: ["extras": "{\"type\":\(type)}"]).destination, .orders)
|
||||||
|
XCTAssertEqual(PushPayload(values: ["type": "\(type)"]).destination, .orders)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOrderNotificationReturnsToOrdersRootAndDeduplicatesPendingRoute() {
|
||||||
|
let coordinator = PushRouteCoordinator(appStore: appStore)
|
||||||
|
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||||
|
coordinator.attach(window: window)
|
||||||
|
coordinator.handle(destination: .orders, requestIdentifier: "order-request")
|
||||||
|
authenticate(userID: "100")
|
||||||
|
let mainTab = MainTabBarController()
|
||||||
|
mainTab.loadViewIfNeeded()
|
||||||
|
mainTab.selectTab(.orders)
|
||||||
|
let ordersNavigation = mainTab.selectedViewController as? TabNavigationController
|
||||||
|
ordersNavigation?.pushViewController(UIViewController(), animated: false)
|
||||||
|
mainTab.selectTab(.home)
|
||||||
|
window.rootViewController = mainTab
|
||||||
|
coordinator.routePendingIfPossible()
|
||||||
|
XCTAssertTrue(mainTab.selectedViewController === ordersNavigation)
|
||||||
|
XCTAssertEqual(ordersNavigation?.viewControllers.count, 1)
|
||||||
|
XCTAssertTrue(ordersNavigation?.topViewController is OrdersViewController)
|
||||||
|
coordinator.handle(destination: .messageCenter, requestIdentifier: "order-request")
|
||||||
|
XCTAssertTrue(mainTab.selectedViewController === ordersNavigation)
|
||||||
|
}
|
||||||
|
|
||||||
func testPayloadIgnoresNonTypeRoutingFieldsAndOtherTypesFallBackToMessageCenter() {
|
func testPayloadIgnoresNonTypeRoutingFieldsAndOtherTypesFallBackToMessageCenter() {
|
||||||
let conflictingFields: [AnyHashable: Any] = [
|
let conflictingFields: [AnyHashable: Any] = [
|
||||||
"type": 2,
|
"type": 2,
|
||||||
@@ -46,7 +76,7 @@ final class PushNotificationTests: XCTestCase {
|
|||||||
"action": "收款",
|
"action": "收款",
|
||||||
]
|
]
|
||||||
|
|
||||||
[0, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 100, 999].forEach { type in
|
[0, 2, 3, 4, 7, 8, 9, 10, 11, 12, 100, 999].forEach { type in
|
||||||
XCTAssertEqual(PushPayload(userInfo: ["type": type]).destination, .messageCenter)
|
XCTAssertEqual(PushPayload(userInfo: ["type": type]).destination, .messageCenter)
|
||||||
}
|
}
|
||||||
XCTAssertEqual(PushPayload(userInfo: conflictingFields).destination, .messageCenter)
|
XCTAssertEqual(PushPayload(userInfo: conflictingFields).destination, .messageCenter)
|
||||||
@@ -220,7 +250,7 @@ final class PushNotificationTests: XCTestCase {
|
|||||||
payload: PushPayload(userInfo: ["type": 1, "route": "task_management"]),
|
payload: PushPayload(userInfo: ["type": 1, "route": "task_management"]),
|
||||||
requestIdentifier: "request-1"
|
requestIdentifier: "request-1"
|
||||||
)
|
)
|
||||||
XCTAssertEqual(router.destinations, [.paymentRecord])
|
XCTAssertEqual(router.destinations, [.orders])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRemoteNotificationNotifiesUnreadMessageStateChange() async {
|
func testRemoteNotificationNotifiesUnreadMessageStateChange() async {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 回归验证相机配置期间启动、快速关闭和重复启动的顺序。
|
||||||
|
final class QRCodeCaptureSessionControllerTests: XCTestCase {
|
||||||
|
func testStartWaitsForConfigurationCommit() {
|
||||||
|
let session = RecordingCaptureSession()
|
||||||
|
let queue = DispatchQueue(label: "qr-capture-test")
|
||||||
|
let controller = QRCodeCaptureSessionController(session: session, queue: queue)
|
||||||
|
let entered = DispatchSemaphore(value: 0)
|
||||||
|
let release = DispatchSemaphore(value: 0)
|
||||||
|
let completed = expectation(description: "configuration committed")
|
||||||
|
|
||||||
|
controller.configure({
|
||||||
|
entered.signal()
|
||||||
|
_ = release.wait(timeout: .now() + 5)
|
||||||
|
return true
|
||||||
|
}, completion: { succeeded in
|
||||||
|
XCTAssertTrue(Thread.isMainThread)
|
||||||
|
XCTAssertTrue(succeeded)
|
||||||
|
completed.fulfill()
|
||||||
|
})
|
||||||
|
XCTAssertEqual(entered.wait(timeout: .now() + 5), .success)
|
||||||
|
controller.setRunning(true)
|
||||||
|
release.signal()
|
||||||
|
queue.sync {
|
||||||
|
XCTAssertEqual(session.events, ["begin", "commit", "start"])
|
||||||
|
XCTAssertFalse(session.startedDuringConfiguration)
|
||||||
|
}
|
||||||
|
wait(for: [completed], timeout: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testImmediateCloseStopsPendingStart() {
|
||||||
|
let session = RecordingCaptureSession()
|
||||||
|
let queue = DispatchQueue(label: "qr-capture-test")
|
||||||
|
let controller = QRCodeCaptureSessionController(session: session, queue: queue)
|
||||||
|
controller.configure({ true }, completion: { _ in })
|
||||||
|
controller.setRunning(true)
|
||||||
|
controller.setRunning(false)
|
||||||
|
queue.sync {
|
||||||
|
XCTAssertEqual(session.events, ["begin", "commit", "start", "stop"])
|
||||||
|
XCTAssertFalse(session.isRunning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRepeatedStartAndStopAreIdempotent() {
|
||||||
|
let session = RecordingCaptureSession()
|
||||||
|
let queue = DispatchQueue(label: "qr-capture-test")
|
||||||
|
let controller = QRCodeCaptureSessionController(session: session, queue: queue)
|
||||||
|
controller.configure({ true }, completion: { _ in })
|
||||||
|
controller.setRunning(true)
|
||||||
|
controller.setRunning(true)
|
||||||
|
controller.setRunning(false)
|
||||||
|
controller.setRunning(false)
|
||||||
|
queue.sync {
|
||||||
|
XCTAssertEqual(session.events, ["begin", "commit", "start", "stop"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailedConfigurationCommitsButDoesNotStart() {
|
||||||
|
let session = RecordingCaptureSession()
|
||||||
|
let queue = DispatchQueue(label: "qr-capture-test")
|
||||||
|
let controller = QRCodeCaptureSessionController(session: session, queue: queue)
|
||||||
|
let completed = expectation(description: "configuration failed")
|
||||||
|
controller.configure({ false }, completion: { succeeded in
|
||||||
|
XCTAssertFalse(succeeded)
|
||||||
|
completed.fulfill()
|
||||||
|
})
|
||||||
|
controller.setRunning(true)
|
||||||
|
queue.sync {
|
||||||
|
XCTAssertEqual(session.events, ["begin", "commit"])
|
||||||
|
XCTAssertFalse(session.isRunning)
|
||||||
|
}
|
||||||
|
wait(for: [completed], timeout: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录调用顺序并检测配置事务内启动的会话替身,仅在测试串行队列读写。
|
||||||
|
private final class RecordingCaptureSession: QRCodeCaptureSession {
|
||||||
|
var isRunning = false
|
||||||
|
var events: [String] = []
|
||||||
|
var startedDuringConfiguration = false
|
||||||
|
private var configuring = false
|
||||||
|
|
||||||
|
func beginConfiguration() {
|
||||||
|
configuring = true
|
||||||
|
events.append("begin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitConfiguration() {
|
||||||
|
configuring = false
|
||||||
|
events.append("commit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func startRunning() {
|
||||||
|
startedDuringConfiguration = configuring
|
||||||
|
isRunning = true
|
||||||
|
events.append("start")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRunning() {
|
||||||
|
isRunning = false
|
||||||
|
events.append("stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user