diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 0644cbd..2252dc6 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,15 +64,11 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanUITests; sourceTree = ""; }; @@ -328,10 +324,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md index 2078c9e..22c7135 100644 --- a/suixinkan/App/App.md +++ b/suixinkan/App/App.md @@ -85,19 +85,19 @@ SwiftUI 的 `Environment` 在本项目中是**依赖注入(DI)通道**,不 1. `suixinkanApp` 处理 UI Test 冷启动状态清理并创建 `RootView`。 2. `RootView` 初始化共享依赖,并把它们注入 Environment。 3. iOS 系统 Launch Screen 展示品牌页(白底、Logo、文案)。 -4. `RootView` 挂载后在 bootstrap 完成前以 `SplashView` 覆盖根视图,避免空白或登录页闪屏。 +4. `RootView` 挂载后在 bootstrap 完成前只创建透明占位,并以 `SplashView` 覆盖根视图,避免空白、登录页闪屏和登录页任务提前启动。 5. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。 6. 如果本地已记录用户同意隐私政策,则初始化高德与友盟 SDK;否则不触发第三方 SDK 初始化。 7. `SessionBootstrapper.restore` 尝试从 UserDefaults 读取正式 token。 -8. 无 token 时保持 `loggedOut`,展示 `LoginView`。 +8. 无 token 时完成 bootstrap,再保持 `loggedOut` 并展示 `LoginView`。 9. 有 token 时进入 `restoring`,先恢复本地账号快照。 10. 阻塞请求 `rolePermissions` 成功后立即 `markLoggedIn` 并展示 `MainTabsView`。 11. `userInfo`、`scenicListAll`、`storeAll` 在后台补充刷新,不阻塞首屏。 12. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。 -13. 明确 token 失效时清空 token 和账号快照,回到登录页。 +13. 明确 token 失效时清空 token 和账号快照,bootstrap 完成后回到登录页。 14. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 15. 冷启动 bootstrap 不使用 Lottie 全局 Loading;`.restoring` 期间展示 `SplashView`。 -16. `LoginView` 首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。 +16. `LoginView` 只有在 bootstrap 判定完成且登录页真实可见后才创建,首屏出现后调用 `/api/app/config` 加载远程配置(如 `enable_register`),失败静默。 ## 初始化分层 diff --git a/suixinkan/App/AppDelegate.swift b/suixinkan/App/AppDelegate.swift index d57b966..0be1134 100644 --- a/suixinkan/App/AppDelegate.swift +++ b/suixinkan/App/AppDelegate.swift @@ -9,16 +9,34 @@ import UIKit /// AppDelegate 承接 APNs 与微信 SDK 系统回调,SwiftUI App 入口通过 adaptor 注入。 final class AppDelegate: NSObject, UIApplicationDelegate { + /// 主窗口引用。SwiftUI 生命周期下系统不会自动注入,但扫码等 UIKit 路径仍会读取 `delegate.window`。 + @objc var window: UIWindow? + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + syncKeyWindow() + NotificationCenter.default.addObserver( + self, + selector: #selector(syncKeyWindow), + name: UIWindow.didBecomeKeyNotification, + object: nil + ) Task { @MainActor in WeChatManager.shared.registerIfNeeded() } return true } + /// 将 `window` 与当前 key window 保持同步,供 UIKit / SDK 通过 AppDelegate 取主窗口。 + @objc private func syncKeyWindow() { + window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow) + } + func application( _ app: UIApplication, open url: URL, diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 79fc5e1..09fb679 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -7,6 +7,30 @@ import SwiftUI +/// 根视图内容展示状态,隔离冷启动判定与登录阶段切换。 +enum RootContentDisplayState: Equatable { + case bootstrapPlaceholder + case login + case restoringPlaceholder + case mainTabs + + /// 根据冷启动 bootstrap 状态和登录阶段计算当前应创建的根内容。 + static func resolve(isColdStartBootstrapPending: Bool, appPhase: AuthPhase) -> RootContentDisplayState { + if isColdStartBootstrapPending { + return .bootstrapPlaceholder + } + + switch appPhase { + case .loggedOut: + return .login + case .restoring: + return .restoringPlaceholder + case .loggedIn: + return .mainTabs + } + } +} + /// App 根视图,负责创建全局依赖并根据登录状态切换登录页和主界面。 struct RootView: View { @Environment(\.scenePhase) private var scenePhase @@ -236,13 +260,13 @@ struct RootView: View { @ViewBuilder private var rootContent: some View { - switch appSession.phase { - case .loggedOut: - LoginView() - case .restoring: + switch rootContentDisplayState { + case .bootstrapPlaceholder, .restoringPlaceholder: Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) - case .loggedIn: + case .login: + LoginView() + case .mainTabs: MainTabsView() .task { #if DEBUG @@ -252,6 +276,14 @@ struct RootView: View { } } + /// 计算根视图当前应创建的内容,避免冷启动期间提前创建登录页。 + private var rootContentDisplayState: RootContentDisplayState { + RootContentDisplayState.resolve( + isColdStartBootstrapPending: isColdStartBootstrapPending, + appPhase: appSession.phase + ) + } + /// 景点加载任务的稳定标识,确保登录状态和当前景区变化都会触发任务。 private var scenicSpotTaskID: String { let phaseText: String diff --git a/suixinkan/App/State/ToastCenter.swift b/suixinkan/App/State/ToastCenter.swift index fcb38e8..dbe8d2f 100644 --- a/suixinkan/App/State/ToastCenter.swift +++ b/suixinkan/App/State/ToastCenter.swift @@ -7,6 +7,7 @@ import Combine import SwiftUI +import UIKit @MainActor /// 全局 Toast 状态中心,负责保存和清除当前提示文案。 @@ -62,26 +63,75 @@ final class ToastCenter: ObservableObject { #endif } -/// 全局 Toast 叠加层修饰器,负责把 Toast 展示到屏幕中央。 -private struct GlobalToastOverlay: ViewModifier { - @EnvironmentObject private var toastCenter: ToastCenter +/// Toast 独立窗口宿主,确保提示浮在 sheet / fullScreenCover 等 present 层之上。 +@MainActor +private final class ToastWindowPresenter { + static let shared = ToastWindowPresenter() - func body(content: Content) -> some View { - content - .overlay { - Group { - if let message = toastCenter.message { - toastBanner(message) - .transition(.opacity) - } - } - .allowsHitTesting(false) - } - .animation(.easeInOut(duration: 0.18), value: toastCenter.message) + private var toastWindow: UIWindow? + + /// 在独立 UIWindow 中展示 Toast。 + func show(message: String) { + guard let windowScene = resolveWindowScene() else { return } + + let window: UIWindow + if let existing = toastWindow, existing.windowScene === windowScene { + window = existing + } else { + let newWindow = PassthroughWindow(windowScene: windowScene) + newWindow.windowLevel = .alert + 1 + newWindow.backgroundColor = .clear + toastWindow = newWindow + window = newWindow + } + + let hosting = UIHostingController( + rootView: ToastBannerHostView(message: message) + ) + hosting.view.backgroundColor = .clear + window.rootViewController = hosting + window.isHidden = false } - /// 构建屏幕中央的黑色半透明圆角 Toast 卡片。 - private func toastBanner(_ message: String) -> some View { + /// 隐藏 Toast 窗口。 + func hide() { + toastWindow?.isHidden = true + toastWindow?.rootViewController = nil + } + + /// 取当前前台 window scene,供独立 Toast 窗口挂载。 + private func resolveWindowScene() -> UIWindowScene? { + let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first + } +} + +/// 透传触摸事件的 Toast 窗口,避免挡住下层弹窗交互。 +private final class PassthroughWindow: UIWindow { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let view = super.hitTest(point, with: event) else { return nil } + return view === rootViewController?.view ? nil : view + } +} + +/// Toast 全屏居中容器。 +private struct ToastBannerHostView: View { + let message: String + + var body: some View { + ZStack { + ToastBannerContent(message: message) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + } +} + +/// 屏幕中央的黑色半透明圆角 Toast 卡片。 +private struct ToastBannerContent: View { + let message: String + + var body: some View { HStack(spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 16, weight: .semibold)) @@ -97,6 +147,31 @@ private struct GlobalToastOverlay: ViewModifier { .padding(.vertical, 14) .background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 40) + .accessibilityIdentifier("global.toast") + } +} + +/// 全局 Toast 展示桥接层,监听 ToastCenter 并驱动独立窗口。 +private struct GlobalToastOverlay: ViewModifier { + @EnvironmentObject private var toastCenter: ToastCenter + + func body(content: Content) -> some View { + content + .onAppear { + syncToastWindow(with: toastCenter.message) + } + .onChange(of: toastCenter.message) { message in + syncToastWindow(with: message) + } + } + + /// 根据当前文案显示或隐藏 Toast 窗口。 + private func syncToastWindow(with message: String?) { + if let message { + ToastWindowPresenter.shared.show(message: message) + } else { + ToastWindowPresenter.shared.hide() + } } } diff --git a/suixinkan/Core/WeChat/WeChatManager.swift b/suixinkan/Core/WeChat/WeChatManager.swift index 6660d6a..5e5da7a 100644 --- a/suixinkan/Core/WeChat/WeChatManager.swift +++ b/suixinkan/Core/WeChat/WeChatManager.swift @@ -29,20 +29,20 @@ final class WeChatManager: NSObject { return } - #if DEBUG - WXApi.startLog(by: WXLogLevel.detail) { log in - print("WeChatSDK: \(log)") - } - #endif +// #if DEBUG +// WXApi.startLog(by: WXLogLevel.detail) { log in +// print("WeChatSDK: \(log)") +// } +// #endif let success = WXApi.registerApp(WeChatConfig.appID, universalLink: WeChatConfig.universalLink) isRegistered = success - #if DEBUG - if success { - checkUniversalLinkReady() - } - #endif +// #if DEBUG +// if success { +// checkUniversalLinkReady() +// } +// #endif } /// 处理 URL Scheme 回调。 diff --git a/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift b/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift index 03de8c9..2b03edb 100644 --- a/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift +++ b/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift @@ -17,7 +17,7 @@ protocol CooperationOrderServing { func saleUserInviteInfo(saleUserId: Int) async throws -> SaleUserInviteInfo /// 接受获客员绑定。 - func saleUserAcceptBind(saleUserId: Int) async throws + func saleUserAcceptBind(saleUserId: Int, commissionRate: Int) async throws /// 获取获客员线索列表。 func saleUserReferralLeads(page: Int, pageSize: Int) async throws -> ListPayload @@ -36,6 +36,12 @@ protocol CooperationOrderServing { /// 更新合作获客员备注名。 func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws + + /// 发送修改分成比例短信验证码。 + func saleUserCommissionRateSendSmsCode(phone: String) async throws + + /// 更新合作获客员分成比例。 + func saleUserUpdateCommissionRate(saleUserId: Int, commissionRate: Int, smsCode: String) async throws -> CooperativeSaler } @MainActor @@ -78,12 +84,15 @@ final class CooperationOrderAPI: CooperationOrderServing { } /// 接受获客员绑定。 - func saleUserAcceptBind(saleUserId: Int) async throws { + func saleUserAcceptBind(saleUserId: Int, commissionRate: Int) async throws { let _: EmptyPayload = try await client.send( APIRequest( method: .post, path: "/api/yf-handset-app/photog/sale-user/accept-bind", - queryItems: [URLQueryItem(name: "sale_user_id", value: "\(saleUserId)")] + queryItems: [ + URLQueryItem(name: "sale_user_id", value: "\(saleUserId)"), + URLQueryItem(name: "commission_rate", value: "\(commissionRate)"), + ] ) ) } @@ -173,4 +182,34 @@ final class CooperationOrderAPI: CooperationOrderServing { ) ) } + + /// 发送修改分成比例短信验证码。 + func saleUserCommissionRateSendSmsCode(phone: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/sale-user/commission-rate/send-sms-code", + body: SaleUserCommissionRateSmsRequest(phone: phone) + ) + ) + } + + /// 更新合作获客员分成比例。 + func saleUserUpdateCommissionRate( + saleUserId: Int, + commissionRate: Int, + smsCode: String + ) async throws -> CooperativeSaler { + try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/sale-user/commission-rate", + body: SaleUserCommissionRateUpdateRequest( + saleUserId: saleUserId, + commissionRate: commissionRate, + smsCode: smsCode + ) + ) + ) + } } diff --git a/suixinkan/Features/CooperationOrder/CooperationOrder.md b/suixinkan/Features/CooperationOrder/CooperationOrder.md index f98e44d..69787e2 100644 --- a/suixinkan/Features/CooperationOrder/CooperationOrder.md +++ b/suixinkan/Features/CooperationOrder/CooperationOrder.md @@ -28,8 +28,9 @@ ### 获客员绑定 1. 列表页右上角「合作获客员」→ `CooperationAcquirerView` -2. 扫码或主 Tab 扫码识别 `{"method":"sale_user_bind","sale_user_id":N}` -3. `BindAcquirerView` 加载邀请信息 → 确认 → `POST .../sale-user/accept-bind` +2. 扫码页由 `CooperationOrderListView` 统一弹出(避免在 `navigationDestination` 子页内嵌 `fullScreenCover` 导致 SwiftUI 栈溢出崩溃) +3. 扫码或主 Tab 扫码识别 `{"method":"sale_user_bind","sale_user_id":N}` +4. `BindAcquirerView` 加载邀请信息 → 填写分账比例(0–100)→ `POST .../sale-user/accept-bind?sale_user_id=&commission_rate=` ### 获客员备注名 diff --git a/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift b/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift index c6f0516..056035b 100644 --- a/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift +++ b/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift @@ -36,6 +36,24 @@ struct SaleUserUpdateRemarkRequest: Encodable { } } +/// 合作获客员分成比例验证码请求体。 +struct SaleUserCommissionRateSmsRequest: Encodable { + let phone: String +} + +/// 合作获客员分成比例更新请求体。 +struct SaleUserCommissionRateUpdateRequest: Encodable { + let saleUserId: Int + let commissionRate: Int + let smsCode: String + + enum CodingKeys: String, CodingKey { + case saleUserId = "sale_user_id" + case commissionRate = "commission_rate" + case smsCode = "sms_code" + } +} + /// 合作获客员列表响应。 struct CooperativeSalerListPayload: Decodable { let total: Int @@ -73,6 +91,9 @@ struct CooperativeSaler: Decodable, Identifiable { let salerPhone: String let shareCode: String let storeId: Int + let photogCommissionRate: Int + let commissionRate: Int + let commissionRateLabel: String let bindTime: String let boundAt: String @@ -88,10 +109,18 @@ struct CooperativeSaler: Decodable, Identifiable { salerPhone.cooperationNonEmptyOrDefault(phone) } + var displayMaskedPhone: String { + phoneMasked.cooperationNonEmptyOrDefault(displayPhone) + } + var displayBindTime: String { bindTime.cooperationNonEmptyOrDefault(boundAt) } + var displayCommissionRate: String { + commissionRateLabel.cooperationNonEmptyOrDefault("\(commissionRate)%") + } + enum CodingKeys: String, CodingKey { case saleUserId = "sale_user_id" case bindingId = "binding_id" @@ -105,6 +134,9 @@ struct CooperativeSaler: Decodable, Identifiable { case salerPhone = "saler_phone" case shareCode = "share_code" case storeId = "store_id" + case photogCommissionRate = "photog_commission_rate" + case commissionRate = "commission_rate" + case commissionRateLabel = "commission_rate_label" case bindTime = "bind_time" case boundAt = "bound_at" } @@ -123,6 +155,9 @@ struct CooperativeSaler: Decodable, Identifiable { salerPhone = try container.decodeLossyString(forKey: .salerPhone) shareCode = try container.decodeLossyString(forKey: .shareCode) storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0 + photogCommissionRate = try container.decodeLossyInt(forKey: .photogCommissionRate) ?? 0 + commissionRate = try container.decodeLossyInt(forKey: .commissionRate) ?? 0 + commissionRateLabel = try container.decodeLossyString(forKey: .commissionRateLabel) bindTime = try container.decodeLossyString(forKey: .bindTime) boundAt = try container.decodeLossyString(forKey: .boundAt) } @@ -142,6 +177,9 @@ struct CooperativeSaler: Decodable, Identifiable { salerPhone: salerPhone, shareCode: shareCode, storeId: storeId, + photogCommissionRate: photogCommissionRate, + commissionRate: commissionRate, + commissionRateLabel: commissionRateLabel, bindTime: bindTime, boundAt: boundAt ) @@ -160,6 +198,9 @@ struct CooperativeSaler: Decodable, Identifiable { salerPhone: String, shareCode: String, storeId: Int, + photogCommissionRate: Int = 0, + commissionRate: Int = 0, + commissionRateLabel: String = "", bindTime: String, boundAt: String ) { @@ -175,6 +216,9 @@ struct CooperativeSaler: Decodable, Identifiable { self.salerPhone = salerPhone self.shareCode = shareCode self.storeId = storeId + self.photogCommissionRate = photogCommissionRate + self.commissionRate = commissionRate + self.commissionRateLabel = commissionRateLabel self.bindTime = bindTime self.boundAt = boundAt } @@ -244,6 +288,10 @@ struct SaleUserInviteInfo: Decodable { struct ReferralLead: Decodable, Identifiable { let id: Int let leadId: Int + let displayName: String + let photogRemarkName: String + let name: String + let salerName: String let userPhone: String let userMobileMasked: String let phone: String @@ -261,6 +309,10 @@ struct ReferralLead: Decodable, Identifiable { userMobileMasked.cooperationNonEmptyOrDefault(userPhone.cooperationNonEmptyOrDefault(phone)) } + var displaySalerName: String { + displayName.cooperationNonEmptyOrDefault(photogRemarkName.saleUserDisplayName(primaryName: name, fallbackName: salerName)) + } + var displayImages: [String] { imageUrls.isEmpty ? images : imageUrls } @@ -268,6 +320,10 @@ struct ReferralLead: Decodable, Identifiable { enum CodingKeys: String, CodingKey { case id case leadId = "lead_id" + case displayName = "display_name" + case photogRemarkName = "photog_remark_name" + case name + case salerName = "saler_name" case userPhone = "user_phone" case userMobileMasked = "user_mobile_masked" case phone @@ -281,6 +337,10 @@ struct ReferralLead: Decodable, Identifiable { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeLossyInt(forKey: .id) ?? 0 leadId = try container.decodeLossyInt(forKey: .leadId) ?? 0 + displayName = try container.decodeLossyString(forKey: .displayName) + photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName) + name = try container.decodeLossyString(forKey: .name) + salerName = try container.decodeLossyString(forKey: .salerName) userPhone = try container.decodeLossyString(forKey: .userPhone) userMobileMasked = try container.decodeLossyString(forKey: .userMobileMasked) phone = try container.decodeLossyString(forKey: .phone) diff --git a/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift index a99c6c0..02c843d 100644 --- a/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift +++ b/suixinkan/Features/CooperationOrder/ViewModels/BindAcquirerViewModel.swift @@ -15,6 +15,7 @@ final class BindAcquirerViewModel: ObservableObject { @Published var isLoading = false @Published var isAlreadyBound = false @Published var errorMessage: String? + @Published var commissionRateDraft = "" private var saleUserId = 0 @@ -27,6 +28,7 @@ final class BindAcquirerViewModel: ObservableObject { if self.saleUserId == saleUserId && (inviteInfo != nil || isAlreadyBound) { return } self.saleUserId = saleUserId isAlreadyBound = false + commissionRateDraft = "" isLoading = true errorMessage = nil defer { isLoading = false } @@ -54,10 +56,21 @@ final class BindAcquirerViewModel: ObservableObject { } } + /// 更新分账比例输入,只保留数字且最多 3 位。 + func updateCommissionRateDraft(_ text: String) { + commissionRateDraft = String(text.filter(\.isNumber).prefix(3)) + } + /// 提交接受绑定。 func acceptBind(api: CooperationOrderServing) async throws { guard saleUserId > 0, !isAlreadyBound else { return } - try await api.saleUserAcceptBind(saleUserId: saleUserId) + guard let commissionRate = Int(commissionRateDraft.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw CooperationOrderCommissionRateValidationError(message: "请输入分账比例") + } + guard (0...100).contains(commissionRate) else { + throw CooperationOrderCommissionRateValidationError(message: "分账比例需在0到100之间") + } + try await api.saleUserAcceptBind(saleUserId: saleUserId, commissionRate: commissionRate) } /// 处理绑定错误,识别已绑定状态。 diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift index 431a50e..ac47343 100644 --- a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift +++ b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift @@ -13,6 +13,10 @@ import Combine final class CooperationAcquirerViewModel: ObservableObject { @Published var acquirers: [CooperativeSaler] = [] @Published private(set) var hasLoadedOnce = false + @Published var commissionEditingAcquirer: CooperativeSaler? + @Published var commissionRateDraft = "" + @Published var commissionSmsCodeDraft = "" + @Published var commissionSmsCountdown = 0 /// 重新加载当前门店下的合作获客员。 func reload(api: CooperationOrderServing, storeId: Int?) async { @@ -45,6 +49,82 @@ final class CooperationAcquirerViewModel: ObservableObject { return acquirer.withPhotogRemarkName(trimmedRemark) } } + + /// 打开分成比例编辑弹窗。 + func startEditCommissionRate(_ acquirer: CooperativeSaler) { + commissionEditingAcquirer = acquirer + commissionRateDraft = "\(acquirer.commissionRate)" + commissionSmsCodeDraft = "" + commissionSmsCountdown = 0 + } + + /// 更新分成比例输入,只保留数字且最多 3 位。 + func updateCommissionRateDraft(_ text: String) { + commissionRateDraft = String(text.filter(\.isNumber).prefix(3)) + } + + /// 更新短信验证码输入,只保留数字且最多 6 位。 + func updateCommissionSmsCodeDraft(_ text: String) { + commissionSmsCodeDraft = String(text.filter(\.isNumber).prefix(6)) + } + + /// 关闭分成比例编辑弹窗并清理状态。 + func dismissCommissionRateDialog() { + commissionEditingAcquirer = nil + commissionRateDraft = "" + commissionSmsCodeDraft = "" + commissionSmsCountdown = 0 + } + + /// 发送分成比例修改验证码,成功后启动本地 60 秒倒计时。 + func sendCommissionRateSms(api: CooperationOrderServing) async throws { + guard commissionSmsCountdown == 0 else { + throw CooperationOrderCommissionRateValidationError(message: "请在倒计时结束后重新获取") + } + guard let acquirer = commissionEditingAcquirer else { return } + let phone = acquirer.salerPhone.cooperationNonEmptyOrDefault(acquirer.displayPhone) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !phone.isEmpty else { + throw CooperationOrderCommissionRateValidationError(message: "获客员手机号为空") + } + try await api.saleUserCommissionRateSendSmsCode(phone: phone) + commissionSmsCountdown = 60 + } + + /// 保存分成比例,成功后用接口返回数据刷新本地列表。 + func saveCommissionRate(api: CooperationOrderServing) async throws { + guard let acquirer = commissionEditingAcquirer else { return } + guard acquirer.saleUserId > 0 else { return } + guard let commissionRate = Int(commissionRateDraft.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw CooperationOrderCommissionRateValidationError(message: "请输入分成比例") + } + guard (0...100).contains(commissionRate) else { + throw CooperationOrderCommissionRateValidationError(message: "分成比例需在0到100之间") + } + let smsCode = commissionSmsCodeDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !smsCode.isEmpty else { + throw CooperationOrderCommissionRateValidationError(message: "请输入验证码") + } + + let updated = try await api.saleUserUpdateCommissionRate( + saleUserId: acquirer.saleUserId, + commissionRate: commissionRate, + smsCode: smsCode + ) + if updated.saleUserId > 0 { + acquirers = acquirers.map { item in + item.saleUserId == updated.saleUserId ? updated : item + } + } + dismissCommissionRateDialog() + } + + /// 倒计时递减,由页面定时器驱动。 + func tickCommissionSmsCountdown() { + if commissionSmsCountdown > 0 { + commissionSmsCountdown -= 1 + } + } } /// 合作获客员备注名校验失败错误。 @@ -53,3 +133,10 @@ struct CooperationOrderRemarkValidationError: LocalizedError { var errorDescription: String? { message } } + +/// 合作获客员分成比例校验失败错误。 +struct CooperationOrderCommissionRateValidationError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} diff --git a/suixinkan/Features/CooperationOrder/Views/BindAcquirerView.swift b/suixinkan/Features/CooperationOrder/Views/BindAcquirerView.swift index c53158a..3dd865e 100644 --- a/suixinkan/Features/CooperationOrder/Views/BindAcquirerView.swift +++ b/suixinkan/Features/CooperationOrder/Views/BindAcquirerView.swift @@ -41,6 +41,15 @@ struct BindAcquirerView: View { infoCard(title: "获客员", value: info.salerName) infoCard(title: "手机号", value: info.salerPhone) + BindCommissionRateInputRow( + value: Binding( + get: { viewModel.commissionRateDraft }, + set: { viewModel.updateCommissionRateDraft($0) } + ), + enabled: !viewModel.isAlreadyBound + ) + .padding(14) + .background(.white, in: RoundedRectangle(cornerRadius: 10)) } .padding(16) diff --git a/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift b/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift index e7b5439..a460a9d 100644 --- a/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift +++ b/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Combine /// 合作获客员列表页,支持查看已绑定获客员与扫码绑定。 struct CooperationAcquirerView: View { @@ -15,11 +16,12 @@ struct CooperationAcquirerView: View { @Environment(\.globalLoading) private var globalLoading @Environment(\.openURL) private var openURL @StateObject private var viewModel = CooperationAcquirerViewModel() - @State private var showScanner = false @State private var showRemarkAlert = false @State private var remarkEditingAcquirer: CooperativeSaler? @State private var remarkDraft = "" - let onBind: (Int) -> Void + @State private var sendingCommissionSms = false + @State private var submittingCommissionRate = false + let onScanBind: () -> Void var body: some View { ScrollView { @@ -38,6 +40,8 @@ struct CooperationAcquirerView: View { remarkEditingAcquirer = acquirer remarkDraft = acquirer.photogRemarkName showRemarkAlert = true + } onEditCommission: { + viewModel.startEditCommissionRate(acquirer) } } } @@ -50,7 +54,7 @@ struct CooperationAcquirerView: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("扫码绑定") { - showScanner = true + onScanBind() } .font(.system(size: 14)) .foregroundStyle(CooperationOrderColors.primary) @@ -60,26 +64,38 @@ struct CooperationAcquirerView: View { .refreshable { await reload(showLoading: false) } - .fullScreenCover(isPresented: $showScanner) { - OrderScannerPage( - onClose: { showScanner = false }, - onSuccess: { raw in - showScanner = false - if let saleUserId = SaleUserQrParser.parseSaleUserId(raw) { - onBind(saleUserId) - } else { - toastCenter.show("请扫描正确的获客员二维码") - } - }, - onFailure: { error in - showScanner = false - toastCenter.show(error.localizedDescription) - } - ) - } .task { await reload(showLoading: true) } + .sheet(item: $viewModel.commissionEditingAcquirer, onDismiss: { + viewModel.dismissCommissionRateDialog() + }) { acquirer in + CommissionRateEditSheet( + acquirer: acquirer, + commissionRate: Binding( + get: { viewModel.commissionRateDraft }, + set: { viewModel.updateCommissionRateDraft($0) } + ), + smsCode: Binding( + get: { viewModel.commissionSmsCodeDraft }, + set: { viewModel.updateCommissionSmsCodeDraft($0) } + ), + smsCountdown: viewModel.commissionSmsCountdown, + sending: sendingCommissionSms, + submitting: submittingCommissionRate, + onSendSmsCode: { + Task { await sendCommissionSms() } + }, + onConfirm: { + Task { await saveCommissionRate() } + }, + onCancel: { + viewModel.dismissCommissionRateDialog() + } + ) + .presentationDetents([.height(430)]) + .presentationDragIndicator(.hidden) + } .alert("修改备注", isPresented: $showRemarkAlert) { TextField("备注名", text: $remarkDraft) Button("取消", role: .cancel) { @@ -105,6 +121,9 @@ struct CooperationAcquirerView: View { Text("为 \(acquirer.salerRealName.cooperationNonEmptyOrDefault("—")) 设置备注名") } } + .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in + viewModel.tickCommissionSmsCountdown() + } } /// 保存当前编辑中的获客员备注名。 @@ -130,6 +149,32 @@ struct CooperationAcquirerView: View { } } + /// 发送分成比例修改验证码。 + private func sendCommissionSms() async { + guard !sendingCommissionSms else { return } + sendingCommissionSms = true + defer { sendingCommissionSms = false } + do { + try await viewModel.sendCommissionRateSms(api: cooperationOrderAPI) + toastCenter.show("验证码已发送") + } catch { + toastCenter.show(error.localizedDescription) + } + } + + /// 保存分成比例修改。 + private func saveCommissionRate() async { + guard !submittingCommissionRate else { return } + submittingCommissionRate = true + defer { submittingCommissionRate = false } + do { + try await viewModel.saveCommissionRate(api: cooperationOrderAPI) + toastCenter.show("分成比例已修改") + } catch { + toastCenter.show(error.localizedDescription) + } + } + private func dialPhone(_ phone: String) { let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return } diff --git a/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift b/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift index d9a153c..8e861eb 100644 --- a/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift +++ b/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift @@ -160,6 +160,10 @@ struct ReferralLeadCard: View { .font(.system(size: 11)) .foregroundStyle(CooperationOrderColors.text999) } + CooperationListInfoRow( + label: "获客员", + value: lead.displaySalerName.isEmpty ? "—" : lead.displaySalerName + ) Text("备注") .font(.system(size: 13)) @@ -302,6 +306,7 @@ struct CooperativeSalerCard: View { let acquirer: CooperativeSaler let onDial: (String) -> Void let onEditRemark: () -> Void + let onEditCommission: () -> Void var body: some View { ZStack(alignment: .topTrailing) { @@ -323,6 +328,10 @@ struct CooperativeSalerCard: View { label: "绑定时间", value: acquirer.displayBindTime.isEmpty ? "—" : acquirer.displayBindTime ) + AcquirerCommissionInfoRow( + commissionRate: acquirer.displayCommissionRate.isEmpty ? "—" : acquirer.displayCommissionRate, + onEdit: onEditCommission + ) } .padding(.leading, 12) .frame(maxWidth: .infinity, alignment: .leading) @@ -344,6 +353,179 @@ struct CooperativeSalerCard: View { } } +struct AcquirerCommissionInfoRow: View { + let commissionRate: String + let onEdit: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Text("分成比例") + .font(.system(size: 13)) + .foregroundStyle(CooperationOrderColors.text999) + Text(commissionRate) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(CooperationOrderColors.text666) + .padding(.leading, 12) + Spacer(minLength: 8) + Button("修改比例") { + onEdit() + } + .font(.system(size: 13)) + .foregroundStyle(CooperationOrderColors.primary) + .buttonStyle(.plain) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct BindCommissionRateInputRow: View { + @Binding var value: String + var enabled: Bool = true + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text("分账比例") + .font(.system(size: 13)) + .foregroundStyle(CooperationOrderColors.text666) + HStack(spacing: 8) { + TextField("请输入0到100", text: $value) + .keyboardType(.numberPad) + .font(.system(size: 14)) + .foregroundStyle(CooperationOrderColors.text333) + .disabled(!enabled) + Text("%") + .font(.system(size: 14)) + .foregroundStyle(CooperationOrderColors.text666) + } + .padding(.horizontal, 12) + .padding(.vertical, 13) + .background(Color(hex: 0xF7F8FA), in: RoundedRectangle(cornerRadius: 8)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct CommissionRateEditSheet: View { + let acquirer: CooperativeSaler + @Binding var commissionRate: String + @Binding var smsCode: String + let smsCountdown: Int + let sending: Bool + let submitting: Bool + let onSendSmsCode: () -> Void + let onConfirm: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Capsule() + .fill(Color.black.opacity(0.16)) + .frame(width: 36, height: 4) + .frame(maxWidth: .infinity) + .padding(.top, 8) + + Text("修改分成比例") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(CooperationOrderColors.text333) + + Text("修改分成比例需验证手机号") + .font(.system(size: 13)) + .foregroundStyle(CooperationOrderColors.primary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(CooperationOrderColors.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 8) { + Text("分成比例") + .font(.system(size: 13)) + .foregroundStyle(CooperationOrderColors.text666) + HStack(spacing: 8) { + TextField("请输入0到100", text: $commissionRate) + .keyboardType(.numberPad) + .font(.system(size: 15)) + Text("%") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(CooperationOrderColors.text666) + } + .padding(.horizontal, 12) + .frame(height: 46) + .background(CooperationOrderColors.searchBg, in: RoundedRectangle(cornerRadius: 8)) + } + + VStack(alignment: .leading, spacing: 8) { + Text("验证码发送手机号") + .font(.system(size: 13)) + .foregroundStyle(CooperationOrderColors.text666) + Text(maskAcquirerPhone(acquirer.displayMaskedPhone)) + .font(.system(size: 15)) + .foregroundStyle(CooperationOrderColors.text333) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, minHeight: 46, alignment: .leading) + .background(CooperationOrderColors.searchBg, in: RoundedRectangle(cornerRadius: 8)) + } + + VStack(alignment: .leading, spacing: 8) { + Text("验证码") + .font(.system(size: 13)) + .foregroundStyle(CooperationOrderColors.text666) + HStack(spacing: 10) { + TextField("请输入验证码", text: $smsCode) + .keyboardType(.numberPad) + .font(.system(size: 15)) + .padding(.horizontal, 12) + .frame(height: 46) + .background(CooperationOrderColors.searchBg, in: RoundedRectangle(cornerRadius: 8)) + Button(smsButtonTitle) { + onSendSmsCode() + } + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 104, height: 46) + .background(smsButtonEnabled ? CooperationOrderColors.primary : CooperationOrderColors.primary.opacity(0.45), in: RoundedRectangle(cornerRadius: 8)) + .disabled(!smsButtonEnabled) + } + } + + HStack(spacing: 12) { + Button("取消") { + onCancel() + } + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(CooperationOrderColors.text666) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color(hex: 0xF1F3F5), in: RoundedRectangle(cornerRadius: 8)) + + Button(submitting ? "提交中..." : "确认修改") { + onConfirm() + } + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(CooperationOrderColors.primary, in: RoundedRectangle(cornerRadius: 8)) + .disabled(submitting) + } + .padding(.top, 4) + } + .padding(.horizontal, 18) + .padding(.bottom, 20) + } + + private var smsButtonTitle: String { + if smsCountdown > 0 { return "\(smsCountdown)秒" } + return sending ? "发送中" : "获取验证码" + } + + private var smsButtonEnabled: Bool { + smsCountdown == 0 && !sending + } +} + struct AcquirerInfoRow: View { let label: String let value: String diff --git a/suixinkan/Features/CooperationOrder/Views/CooperationOrderListView.swift b/suixinkan/Features/CooperationOrder/Views/CooperationOrderListView.swift index d5f718a..89f3227 100644 --- a/suixinkan/Features/CooperationOrder/Views/CooperationOrderListView.swift +++ b/suixinkan/Features/CooperationOrder/Views/CooperationOrderListView.swift @@ -9,10 +9,13 @@ import SwiftUI /// 合作订单主页,展示获客线索与获客订单双 Tab 列表。 struct CooperationOrderListView: View { + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.cooperationOrderAPI) private var cooperationOrderAPI @Environment(\.globalLoading) private var globalLoading @StateObject private var viewModel = CooperationOrderListViewModel() @State private var route: CooperationOrderRoute? + /// 扫码绑定页在列表层弹出,避免在 navigationDestination 子页内嵌 fullScreenCover 触发 SwiftUI 栈溢出。 + @State private var showBindScanner = false var body: some View { VStack(spacing: 0) { @@ -81,11 +84,28 @@ struct CooperationOrderListView: View { } } .background(CooperationOrderColors.pageBg.ignoresSafeArea()) + .fullScreenCover(isPresented: $showBindScanner) { + OrderScannerPage( + onClose: { showBindScanner = false }, + onSuccess: { raw in + showBindScanner = false + guard let saleUserId = SaleUserQrParser.parseSaleUserId(raw), saleUserId > 0 else { + toastCenter.show("请扫描正确的获客员二维码") + return + } + route = .bind(saleUserId: saleUserId) + }, + onFailure: { error in + showBindScanner = false + toastCenter.show(error.localizedDescription) + } + ) + } .appNavigationDestination(item: $route) { destination in switch destination { case .acquirer: - CooperationAcquirerView(onBind: { saleUserId in - route = .bind(saleUserId: saleUserId) + CooperationAcquirerView(onScanBind: { + showBindScanner = true }) case let .bind(saleUserId): BindAcquirerView(saleUserId: saleUserId, onSuccess: { diff --git a/suixinkanTests/BindAcquirerViewModelTests.swift b/suixinkanTests/BindAcquirerViewModelTests.swift new file mode 100644 index 0000000..93ece47 --- /dev/null +++ b/suixinkanTests/BindAcquirerViewModelTests.swift @@ -0,0 +1,133 @@ +// +// BindAcquirerViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/7/3. +// + +import XCTest +@testable import suixinkan + +/// 绑定获客员 ViewModel 分账比例校验测试。 +@MainActor +final class BindAcquirerViewModelTests: XCTestCase { + /// 测试分账比例输入只保留数字且最多 3 位。 + func testUpdateCommissionRateDraftFiltersInput() { + let viewModel = BindAcquirerViewModel() + viewModel.updateCommissionRateDraft("12a3.4") + XCTAssertEqual(viewModel.commissionRateDraft, "123") + viewModel.updateCommissionRateDraft("1234") + XCTAssertEqual(viewModel.commissionRateDraft, "123") + } + + /// 测试未填写分账比例时提交失败。 + func testAcceptBindRequiresCommissionRate() async { + let viewModel = BindAcquirerViewModel() + let api = BindAcquirerMockAPI() + await viewModel.load(api: api, saleUserId: 101) + + do { + try await viewModel.acceptBind(api: api) + XCTFail("Expected validation error") + } catch let error as CooperationOrderCommissionRateValidationError { + XCTAssertEqual(error.message, "请输入分账比例") + } catch { + XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(api.acceptBindCalls.count, 0) + } + + /// 测试分账比例超出范围时提交失败。 + func testAcceptBindRejectsOutOfRangeCommissionRate() async { + let viewModel = BindAcquirerViewModel() + let api = BindAcquirerMockAPI() + await viewModel.load(api: api, saleUserId: 101) + viewModel.updateCommissionRateDraft("101") + + do { + try await viewModel.acceptBind(api: api) + XCTFail("Expected validation error") + } catch let error as CooperationOrderCommissionRateValidationError { + XCTAssertEqual(error.message, "分账比例需在0到100之间") + } catch { + XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(api.acceptBindCalls.count, 0) + } + + /// 测试合法分账比例时调用 accept-bind 接口。 + func testAcceptBindPassesCommissionRateToAPI() async throws { + let viewModel = BindAcquirerViewModel() + let api = BindAcquirerMockAPI() + await viewModel.load(api: api, saleUserId: 101) + viewModel.updateCommissionRateDraft("20") + + try await viewModel.acceptBind(api: api) + + XCTAssertEqual(api.acceptBindCalls.count, 1) + XCTAssertEqual(api.acceptBindCalls.first?.saleUserId, 101) + XCTAssertEqual(api.acceptBindCalls.first?.commissionRate, 20) + } +} + +@MainActor +private final class BindAcquirerMockAPI: CooperationOrderServing { + struct AcceptBindCall { + let saleUserId: Int + let commissionRate: Int + } + + var acceptBindCalls: [AcceptBindCall] = [] + + func cooperativeSalers(storeId: Int?, page: Int, pageSize: Int) async throws -> CooperativeSalerListPayload { + fatalError("Not used in BindAcquirerViewModelTests") + } + + func saleUserInviteInfo(saleUserId: Int) async throws -> SaleUserInviteInfo { + SaleUserInviteInfo( + saleUserId: saleUserId, + salerName: "测试获客员", + salerPhone: "13800138000" + ) + } + + func saleUserAcceptBind(saleUserId: Int, commissionRate: Int) async throws { + acceptBindCalls.append(AcceptBindCall(saleUserId: saleUserId, commissionRate: commissionRate)) + } + + func saleUserReferralLeads(page: Int, pageSize: Int) async throws -> ListPayload { + fatalError("Not used in BindAcquirerViewModelTests") + } + + func saleUserAcquisitionOrders(keyword: String?, page: Int, pageSize: Int) async throws -> ListPayload { + fatalError("Not used in BindAcquirerViewModelTests") + } + + func saleUserReferralOrders(saleUserId: Int, page: Int, pageSize: Int) async throws -> ReferralOrderListPayload { + fatalError("Not used in BindAcquirerViewModelTests") + } + + func saleUserBindReferralOrder(orderNumber: String, referralOrderNo: String) async throws { + fatalError("Not used in BindAcquirerViewModelTests") + } + + func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws { + fatalError("Not used in BindAcquirerViewModelTests") + } + + func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws { + fatalError("Not used in BindAcquirerViewModelTests") + } + + func saleUserCommissionRateSendSmsCode(phone: String) async throws { + fatalError("Not used in BindAcquirerViewModelTests") + } + + func saleUserUpdateCommissionRate( + saleUserId: Int, + commissionRate: Int, + smsCode: String + ) async throws -> CooperativeSaler { + fatalError("Not used in BindAcquirerViewModelTests") + } +} diff --git a/suixinkanTests/CooperationOrderAPITests.swift b/suixinkanTests/CooperationOrderAPITests.swift index 226c141..1c974ce 100644 --- a/suixinkanTests/CooperationOrderAPITests.swift +++ b/suixinkanTests/CooperationOrderAPITests.swift @@ -28,6 +28,9 @@ final class CooperationOrderAPITests: XCTestCase { XCTAssertEqual(response.list.first?.displayPhone, "19281871413") XCTAssertEqual(response.list.first?.bindingId, 5) XCTAssertEqual(response.list.first?.shareCode, "INV-000101") + XCTAssertEqual(response.list.first?.commissionRate, 15) + XCTAssertEqual(response.list.first?.commissionRateLabel, "15%") + XCTAssertEqual(response.list.first?.displayCommissionRate, "15%") let request = try XCTUnwrap(session.requests.last) XCTAssertEqual(request.httpMethod, "GET") @@ -70,6 +73,7 @@ final class CooperationOrderAPITests: XCTestCase { XCTAssertEqual(response.total, 1) XCTAssertEqual(response.list.first?.displayId, "501") XCTAssertEqual(response.list.first?.displayPhone, "13900000002") + XCTAssertEqual(response.list.first?.displaySalerName, "张哥") XCTAssertEqual(response.list.first?.remark, "景区门口获客") XCTAssertEqual(response.list.first?.displayImages.first, "https://example.com/lead.jpg") @@ -130,12 +134,14 @@ final class CooperationOrderAPITests: XCTestCase { let acceptSession = CooperationRecordingURLSession(data: emptyData) let acceptAPI = CooperationOrderAPI(client: APIClient(session: acceptSession)) - try await acceptAPI.saleUserAcceptBind(saleUserId: 101) + try await acceptAPI.saleUserAcceptBind(saleUserId: 101, commissionRate: 20) let acceptRequest = try XCTUnwrap(acceptSession.requests.last) XCTAssertEqual(acceptRequest.httpMethod, "POST") XCTAssertEqual(acceptRequest.url?.path, "/api/yf-handset-app/photog/sale-user/accept-bind") - XCTAssertEqual(cooperationQueryItems(from: acceptRequest)["sale_user_id"], "101") + let acceptQuery = cooperationQueryItems(from: acceptRequest) + XCTAssertEqual(acceptQuery["sale_user_id"], "101") + XCTAssertEqual(acceptQuery["commission_rate"], "20") XCTAssertNil(acceptRequest.httpBody) let bindSession = CooperationRecordingURLSession(data: emptyData) @@ -181,6 +187,72 @@ final class CooperationOrderAPITests: XCTestCase { XCTAssertEqual(json["sale_user_id"] as? Int, 123) XCTAssertEqual(json["photog_remark_name"] as? String, "张哥") } + + /// 测试发送修改分成比例验证码请求体。 + func testSaleUserCommissionRateSendSmsCodeUsesPostBody() async throws { + let emptyData = try TestFixture.data(named: "empty_success") + let session = CooperationRecordingURLSession(data: emptyData) + let api = CooperationOrderAPI(client: APIClient(session: session)) + + try await api.saleUserCommissionRateSendSmsCode(phone: "19281871413") + + let request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/commission-rate/send-sms-code") + let body = try XCTUnwrap(request.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["phone"] as? String, "19281871413") + } + + /// 测试更新合作获客员分成比例请求体与响应解码。 + func testSaleUserUpdateCommissionRateUsesPostBodyAndDecodesResponse() async throws { + let responseData = Data( + """ + { + "code": 100000, + "msg": "success", + "data": { + "binding_id": 5, + "sale_user_id": 101, + "share_code": "INV-000101", + "name": "管铜彬", + "saler_name": "管铜彬", + "phone": "19281871413", + "phone_masked": "192****1413", + "saler_phone": "19281871413", + "photog_commission_rate": 20, + "commission_rate": 20, + "commission_rate_label": "20%", + "bind_time": "2026-06-25 17:11:01", + "bound_at": "2026-06-25 17:11:01", + "store_id": 37 + } + } + """.utf8 + ) + let session = CooperationRecordingURLSession(data: responseData) + let api = CooperationOrderAPI(client: APIClient(session: session)) + + let updated = try await api.saleUserUpdateCommissionRate( + saleUserId: 101, + commissionRate: 20, + smsCode: "889900" + ) + + XCTAssertEqual(updated.saleUserId, 101) + XCTAssertEqual(updated.commissionRate, 20) + XCTAssertEqual(updated.commissionRateLabel, "20%") + XCTAssertEqual(updated.displayCommissionRate, "20%") + + let request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/commission-rate") + let body = try XCTUnwrap(request.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["sale_user_id"] as? Int, 101) + XCTAssertEqual(json["commission_rate"] as? Int, 20) + XCTAssertEqual(json["sms_code"] as? String, "889900") + } } /// URLSession 测试替身,记录合作订单 API 请求。 diff --git a/suixinkanTests/Fixtures/cooperative_salers_success.json b/suixinkanTests/Fixtures/cooperative_salers_success.json index 301f585..df89dd3 100644 --- a/suixinkanTests/Fixtures/cooperative_salers_success.json +++ b/suixinkanTests/Fixtures/cooperative_salers_success.json @@ -18,6 +18,9 @@ "sale_user_id": 101, "saler_name": "管铜彬", "saler_phone": "19281871413", + "photog_commission_rate": 0, + "commission_rate": 15, + "commission_rate_label": "15%", "share_code": "INV-000101", "store_id": 37 } diff --git a/suixinkanTests/Fixtures/sale_user_referral_leads_success.json b/suixinkanTests/Fixtures/sale_user_referral_leads_success.json index f369d8f..86e2ff0 100644 --- a/suixinkanTests/Fixtures/sale_user_referral_leads_success.json +++ b/suixinkanTests/Fixtures/sale_user_referral_leads_success.json @@ -6,6 +6,10 @@ "list": [ { "lead_id": 501, + "display_name": "张哥", + "photog_remark_name": "张哥", + "name": "管铜彬", + "saler_name": "管铜彬", "user_phone": "13900000002", "remark": "景区门口获客", "created_at": "2026-05-21 14:00:00", diff --git a/suixinkanTests/RootViewDisplayStateTests.swift b/suixinkanTests/RootViewDisplayStateTests.swift new file mode 100644 index 0000000..7200eed --- /dev/null +++ b/suixinkanTests/RootViewDisplayStateTests.swift @@ -0,0 +1,42 @@ +// +// RootViewDisplayStateTests.swift +// suixinkanTests +// +// Created by Codex on 2026/7/3. +// + +import XCTest +@testable import suixinkan + +/// 根视图展示状态测试,覆盖冷启动门禁与登录阶段的映射关系。 +final class RootViewDisplayStateTests: XCTestCase { + /// 测试冷启动 bootstrap 未完成时不会提前创建登录页。 + func testBootstrapPendingLoggedOutUsesPlaceholder() { + let state = RootContentDisplayState.resolve( + isColdStartBootstrapPending: true, + appPhase: .loggedOut + ) + + XCTAssertEqual(state, .bootstrapPlaceholder) + } + + /// 测试 bootstrap 完成且未登录时展示登录页。 + func testBootstrapCompletedLoggedOutShowsLogin() { + let state = RootContentDisplayState.resolve( + isColdStartBootstrapPending: false, + appPhase: .loggedOut + ) + + XCTAssertEqual(state, .login) + } + + /// 测试 bootstrap 完成且已登录时展示主 Tab。 + func testBootstrapCompletedLoggedInShowsMainTabs() { + let state = RootContentDisplayState.resolve( + isColdStartBootstrapPending: false, + appPhase: .loggedIn + ) + + XCTAssertEqual(state, .mainTabs) + } +}