修复合作获客扫码绑定崩溃与 Toast 层级,并接入分成比例修改。

将扫码页提升到列表层弹出并补齐 AppDelegate.window,避免扫码时 UIKit 取主窗口崩溃;Toast 改用独立 UIWindow 显示在 sheet 之上;合作获客员支持短信验证修改分成比例,同时优化冷启动根视图创建时机并补充相关测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-03 20:37:24 +08:00
parent 00c3cd0a93
commit 49e997ddba
20 changed files with 905 additions and 70 deletions

View File

@ -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`),失败静默。
## 初始化分层

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ReferralLead>
@ -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
)
)
)
}
}

View File

@ -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` 加载邀请信息 → 填写分账比例0100`POST .../sale-user/accept-bind?sale_user_id=&commission_rate=`
### 获客员备注名

View File

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

View File

@ -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)
}
///

View File

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

View File

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

View File

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

View File

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

View File

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