修复合作获客扫码绑定崩溃与 Toast 层级,并接入分成比例修改。
将扫码页提升到列表层弹出并补齐 AppDelegate.window,避免扫码时 UIKit 取主窗口崩溃;Toast 改用独立 UIWindow 显示在 sheet 之上;合作获客员支持短信验证修改分成比例,同时优化冷启动根视图创建时机并补充相关测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -64,15 +64,11 @@
|
||||
};
|
||||
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = suixinkanTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = suixinkanUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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`),失败静默。
|
||||
|
||||
## 初始化分层
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
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 窗口。
|
||||
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)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: toastCenter.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建屏幕中央的黑色半透明圆角 Toast 卡片。
|
||||
private func toastBanner(_ message: String) -> some View {
|
||||
/// 屏幕中央的黑色半透明圆角 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 回调。
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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=`
|
||||
|
||||
### 获客员备注名
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/// 处理绑定错误,识别已绑定状态。
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: {
|
||||
|
||||
133
suixinkanTests/BindAcquirerViewModelTests.swift
Normal file
133
suixinkanTests/BindAcquirerViewModelTests.swift
Normal file
@ -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<ReferralLead> {
|
||||
fatalError("Not used in BindAcquirerViewModelTests")
|
||||
}
|
||||
|
||||
func saleUserAcquisitionOrders(keyword: String?, page: Int, pageSize: Int) async throws -> ListPayload<AcquisitionOrder> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@ -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 请求。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
42
suixinkanTests/RootViewDisplayStateTests.swift
Normal file
42
suixinkanTests/RootViewDisplayStateTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user