修复合作获客扫码绑定崩溃与 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()
}
}
}