Integrate CYLTabBar center scan button and unify UIKit navigation.

Add CYLTabBarController with a global scan entry, promote MainTabBarController to the window root after login, replace RouterPath-based navigation with AppNavigator, and fix Profile diffable cell reconfiguration crashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 16:36:18 +08:00
parent 24a7339b68
commit a1c031c9b7
31 changed files with 838 additions and 517 deletions

View File

@ -7,19 +7,19 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
- 根据登录状态展示登录页、恢复态或主 Tab。
- 创建并注入全局状态和共享服务。
- 管理每个 Tab 独立的导航栈与 `AppRouter` 路径
- 管理每个 Tab 独立的 UIKit 导航栈,并通过 `AppNavigator` 统一执行全局跳转
- 协调登录成功、退出登录、冷启动恢复和本地缓存同步。
## 核心对象
- `AppDelegate` / `SceneDelegate`UIKit 应用入口挂载 `RootViewController`
- `AppDelegate` / `SceneDelegate`UIKit 应用入口`SceneDelegate` 挂载 `RootViewController` 并持有其强引用
- `AppServices`全局依赖容器Composition Root等价于参考工程 SwiftUI `RootView` 中的 Environment 注入;内部按 Session / Context / Network / UI / Runtime 分组,对外仍保留扁平属性访问。
- `RootViewController`根据 `AppSession.phase` 切换登录页、恢复占位或 `MainTabBarController`,并挂载全局 Toast / Loading。
- `RootViewController`监听 `AppSession.phase`,未登录时在自身上展示登录/恢复页,登录后将 `MainTabBarController` 设为 window 根控制器,并挂载全局 Toast / Loading。
- `AppSession`:保存认证阶段和正式 token只负责登录态不承载业务资料。
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径
- `AppNavigator`:应用级导航器,持有主 Tab 宿主弱引用,并以 UIKit 导航栈作为唯一导航事实源
- `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
@ -44,7 +44,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
普通业务 `ViewController`、模块基类(`ModuleTableViewController` / `ModuleCollectionViewController`)通过 `UIViewController.appServices` 扩展访问,**不必**再声明 `private let services` 或新增 `init(services:)`
导航辅助(`UIKitAppNavigation``HomeMenuRouting`)在全局 push 场景可直接使用 `AppServices.shared`
导航辅助(`HomeMenuRouting`)在全局 push 场景通过 `AppServices.shared.appNavigator` 执行跳转
### ViewModel 层边界
@ -69,7 +69,7 @@ ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 `
5. 无 token 时保持 `loggedOut`,展示 `LoginViewController`
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
8. 校验成功后进入 `loggedIn`展示 `MainTabBarController`
8. 校验成功后进入 `loggedIn` `MainTabBarController` 设为 window 根控制器
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
@ -96,20 +96,20 @@ ViewModel **不持有** `AppServices`。View 从容器取出具体依赖,在 `
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
- 清空 Keychain token。
- 清空账号快照。
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
- 重置账号上下文、权限上下文、景点上下文、全部 UIKit 导航栈和 Toast。
- `AppSession` 切换为 `loggedOut`
- 保留上次手机号、协议状态等非敏感偏好。
## Toast
全局 Toast 由 `RootViewController` `viewDidAppear`挂载到 window业务页面通过 `showToast(...)``appServices.toastCenter.show(...)` 发出提示命令。
全局 Toast 由 `RootViewController`切换 window 根控制器后挂载到 window业务页面通过 `showToast(...)``appServices.toastCenter.show(...)` 发出提示命令。
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
## 导航规则
主界面使用 `MainTabBarController`,每个 Tab 内部由独立的 `TabNavigationController``UINavigationController`)包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径
主界面使用 `MainTabBarController` 作为 window 根控制器(登录后),每个 Tab 内部由独立的 `TabNavigationController``UINavigationController`)包裹。`UINavigationController.viewControllers` 是页面路径的唯一事实源,普通 Tab 切换保留该 Tab 的原有栈
Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar。
跨 Tab 跳转通过 `AppRouter.select` / `navigateHome` / `navigateProfile` 处理Tab 内 push 通过 `UIKitAppNavigation` 或当前 Nav 栈完成。新增页面时优先扩展 `AppRoute` / `HomeRoute``AppRouteViewControllerFactory` 注册对应 ViewController。
跨 Tab、推送通知、UI Test 直达和全局扫码都通过 `AppNavigator` 处理。`AppNavigationPolicy.currentStack` 沿当前栈 push`.tabRoot` 切到目标 Tab 并先回到 root`.preserveTab` 只切换 Tab 并保留原栈。新增页面时优先扩展 `AppRoute` / `HomeRoute` / `ProfileRoute` / `OrdersRoute`,并`AppRouteViewControllerFactory` 注册对应 ViewController。

View File

@ -17,7 +17,7 @@ final class AppServices {
let network: NetworkBundle
let ui: UIFeedbackBundle
let runtime: AppRuntimeBundle
let appRouter = AppRouter()
let appNavigator = AppNavigator()
private let tokenStore: SessionTokenStore
private let snapshotStore: AccountSnapshotStore
@ -151,7 +151,7 @@ final class AppServices {
PushNotificationManager.shared.configure(
api: pushAPI,
session: appSession,
router: appRouter
navigator: appNavigator
)
}

View File

@ -39,19 +39,19 @@ enum AppUITestRouteDriver {
return
}
services.appRouter.reset()
services.appNavigator.resetAllStacks()
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
switch resolved {
case .tab(let tab):
services.appRouter.select(tab)
services.appNavigator.selectTab(tab)
case .orders(let entry):
services.appRouter.selectOrders(entry: entry)
services.appNavigator.openOrdersEntry(entry)
case .destination(let homeRoute):
services.appRouter.navigateHome(homeRoute, animated: false)
services.appNavigator.openHome(homeRoute, policy: .tabRoot(.home), animated: false)
case .unsupported(let uri, let title, _):
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
services.appNavigator.openHome(.modulePlaceholder(uri: uri, title: title), policy: .tabRoot(.home), animated: false)
case .placeholder(let uri, let title):
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
services.appNavigator.openHome(.modulePlaceholder(uri: uri, title: title), policy: .tabRoot(.home), animated: false)
}
}
@ -78,8 +78,8 @@ enum AppUITestRouteDriver {
}
guard let route else { return }
services.appRouter.reset()
services.appRouter.navigateProfile(route, animated: false)
services.appNavigator.resetAllStacks()
services.appNavigator.openProfile(route, policy: .tabRoot(.profile), animated: false)
}
/// UI Test

View File

@ -172,12 +172,3 @@ enum AppRouteViewControllerFactory {
return String(describing: route)
}
}
@MainActor
/// ViewController `UIKitAppNavigation`
enum HomeRouteViewControllerFactory {
///
static func make(for route: HomeRoute) -> UIViewController {
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
}
}

View File

@ -6,6 +6,7 @@
//
import Foundation
import UIKit
/// Tab
enum AppRoute: Hashable {
@ -21,90 +22,140 @@ enum AppRoute: Hashable {
}
@MainActor
/// Tab
final class RouterPath {
var onChange: (() -> Void)?
var path: [AppRoute] = [] { didSet { onChange?() } }
/// Tab
func navigate(to route: AppRoute) {
path.append(route)
}
/// Tab
func navigateBack() {
guard !path.isEmpty else { return }
path.removeLast()
}
/// Tab
func reset() {
path = []
}
/// UIKit
enum AppNavigationPolicy: Equatable {
case currentStack
case tabRoot(AppTab)
case preserveTab(AppTab)
}
@MainActor
/// Tab Tab
final class AppRouter {
var onChange: (() -> Void)?
var selectedTab: AppTab = .home { didSet { onChange?() } }
var selectedOrdersEntry: OrdersEntry = .storeOrders { didSet { onChange?() } }
private(set) var pendingOrderScanCode: String? { didSet { onChange?() } }
/// Tab 宿 `MainTabBarController` UIKit
protocol AppNavigationHost: AnyObject {
/// Tab
func selectTab(_ tab: AppTab)
private let routers: [AppTab: RouterPath]
/// Tab
func navigationController(for tab: AppTab) -> TabNavigationController?
///
init() {
var builtRouters: [AppTab: RouterPath] = [:]
for tab in AppTab.allCases {
builtRouters[tab] = RouterPath()
/// Tab
func selectedNavigationController() -> TabNavigationController?
/// Tab
func resetAllStacks()
///
func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?)
}
@MainActor
/// UIKit `UINavigationController.viewControllers`
final class AppNavigator {
private weak var host: AppNavigationHost?
private var pendingActions: [(@MainActor (AppNavigationHost) -> Void)] = []
/// Tab 宿 FIFO
func attach(host: AppNavigationHost) {
self.host = host
let actions = pendingActions
pendingActions.removeAll()
actions.forEach { $0(host) }
}
/// Tab 宿
func detach(host: AppNavigationHost? = nil) {
if let host, self.host !== host {
return
}
routers = builtRouters
routers.values.forEach { router in
router.onChange = { [weak self] in
self?.onChange?()
self.host = nil
pendingActions.removeAll()
}
/// Tab Tab
func selectTab(_ tab: AppTab) {
performOrEnqueue { host in
host.selectTab(tab)
}
}
/// push
func push(_ route: AppRoute, from source: UIViewController?, animated: Bool = true) {
if let navigationController = source?.navigationController as? TabNavigationController {
navigationController.push(route: route, animated: animated)
return
}
performOrEnqueue { host in
guard let navigationController = source?.navigationController as? TabNavigationController
?? host.selectedNavigationController()
else { return }
navigationController.push(route: route, animated: animated)
}
}
///
func openHome(_ route: HomeRoute, policy: AppNavigationPolicy = .currentStack, animated: Bool = true) {
open(.home(route), defaultTab: .home, policy: policy, animated: animated)
}
///
func openProfile(_ route: ProfileRoute, policy: AppNavigationPolicy = .currentStack, animated: Bool = true) {
open(.profile(route), defaultTab: .profile, policy: policy, animated: animated)
}
/// Tab
func openOrdersEntry(_ entry: OrdersEntry, scannedCode: String? = nil) {
performOrEnqueue { host in
host.selectTab(.orders)
host.navigationController(for: .orders)?.popToRootViewController(animated: false)
host.applyOrdersEntry(entry, scannedCode: scannedCode)
}
}
/// Tab
func resetAllStacks() {
pendingActions.removeAll()
host?.resetAllStacks()
}
///
private func open(
_ route: AppRoute,
defaultTab: AppTab,
policy: AppNavigationPolicy,
animated: Bool
) {
performOrEnqueue { host in
let targetTab: AppTab
switch policy {
case .currentStack:
guard let navigationController = host.selectedNavigationController() else { return }
navigationController.push(route: route, animated: animated)
return
case .tabRoot(let tab):
targetTab = tab
host.selectTab(tab)
guard let navigationController = host.navigationController(for: tab) else { return }
navigationController.popToRootViewController(animated: false)
navigationController.push(route: route, animated: animated)
case .preserveTab(let tab):
targetTab = tab
host.selectTab(tab)
host.navigationController(for: tab)?.push(route: route, animated: animated)
}
if targetTab != defaultTab {
assertionFailure("Route \(route) opened on unexpected tab \(targetTab)")
}
}
}
/// Tab
func router(for tab: AppTab) -> RouterPath {
guard let router = routers[tab] else {
assertionFailure("Missing router path for tab: \(tab)")
return RouterPath()
/// Tab
private func performOrEnqueue(_ action: @escaping @MainActor (AppNavigationHost) -> Void) {
if let host {
action(host)
} else {
pendingActions.append(action)
}
return router
}
/// Tab
func select(_ tab: AppTab) {
selectedTab = tab
}
/// Tab
func selectOrders(entry: OrdersEntry) {
selectedOrdersEntry = entry
selectedTab = .orders
}
///
func routeToOrderVerification(scannedCode: String) {
pendingOrderScanCode = scannedCode
selectOrders(entry: .verificationOrders)
}
///
func consumePendingOrderScanCode() -> String? {
let code = pendingOrderScanCode
pendingOrderScanCode = nil
return code
}
/// Tab Tab
func reset() {
selectedTab = .home
selectedOrdersEntry = .storeOrders
pendingOrderScanCode = nil
routers.values.forEach { $0.reset() }
}
}

View File

@ -6,25 +6,19 @@
import UIKit
@MainActor
/// Tab `AppRouter` push TabBar
final class TabNavigationController: UINavigationController, UINavigationControllerDelegate {
/// Tab UIKit Tab
final class TabNavigationController: UINavigationController {
let appTab: AppTab
private let services: AppServices
private var isSyncingStack = false
///
init(tab: AppTab, services: AppServices) {
self.appTab = tab
self.services = services
super.init(nibName: nil, bundle: nil)
delegate = self
navigationBar.prefersLargeTitles = false
setViewControllers([tab.makeRootViewController(services: services)], animated: false)
registerNavigationBridge()
services.appRouter.router(for: appTab).onChange = { [weak self] in
self?.syncFromRouterPath()
}
}
@available(*, unavailable)
@ -32,76 +26,10 @@ final class TabNavigationController: UINavigationController, UINavigationControl
fatalError("init(coder:) has not been implemented")
}
/// Push RouterPath
func pushRoute(_ route: AppRoute, animated: Bool = true) {
/// Push TabBar
func push(route: AppRoute, animated: Bool = true) {
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
isSyncingStack = true
services.appRouter.router(for: appTab).navigate(to: route)
isSyncingStack = false
pushViewController(viewController, animated: animated)
}
/// navigationController
func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool
) {
syncRouterPathFromStack()
}
/// NavigationBridge
private func registerNavigationBridge() {
switch appTab {
case .home:
UIKitAppNavigation.homeNavigationController = self
case .profile:
UIKitAppNavigation.profileNavigationController = self
default:
break
}
}
/// FromRouterPath
private func syncFromRouterPath() {
guard !isSyncingStack else { return }
let routerPath = services.appRouter.router(for: appTab)
let targetDepth = routerPath.path.count
let currentDepth = max(viewControllers.count - 1, 0)
if targetDepth > currentDepth {
// Router push
for index in currentDepth..<targetDepth {
let route = routerPath.path[index]
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
pushViewController(viewController, animated: true)
}
return
}
if targetDepth < currentDepth {
// Router pop
if targetDepth == 0 {
popToRootViewController(animated: true)
} else if targetDepth < viewControllers.count {
popToViewController(viewControllers[targetDepth], animated: true)
}
}
}
/// RouterPathFromStack
private func syncRouterPathFromStack() {
guard !isSyncingStack else { return }
let routerPath = services.appRouter.router(for: appTab)
let depth = max(viewControllers.count - 1, 0)
if depth < routerPath.path.count {
isSyncingStack = true
routerPath.path = Array(routerPath.path.prefix(depth))
isSyncingStack = false
}
}
}

View File

@ -1,46 +0,0 @@
//
// UIKitAppNavigation.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
@MainActor
/// UIKit AppRouter UI Test
enum UIKitAppNavigation {
weak static var homeNavigationController: UINavigationController?
weak static var profileNavigationController: UINavigationController?
/// Push
static func pushHomeRoute(_ route: HomeRoute, animated: Bool = true) {
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
homeNavigationController?.pushViewController(viewController, animated: animated)
}
/// Push
static func pushProfileRoute(_ route: ProfileRoute, animated: Bool = true) {
let viewController = AppRouteViewControllerFactory.makeViewController(
for: .profile(route),
services: AppServices.shared
)
profileNavigationController?.pushViewController(viewController, animated: animated)
}
}
extension AppRouter {
/// UIKit push
func navigateHome(_ route: HomeRoute, animated: Bool = true) {
select(.home)
router(for: .home).navigate(to: .home(route))
UIKitAppNavigation.pushHomeRoute(route, animated: animated)
}
/// UIKit push
func navigateProfile(_ route: ProfileRoute, animated: Bool = true) {
select(.profile)
router(for: .profile).navigate(to: .profile(route))
UIKitAppNavigation.pushProfileRoute(route, animated: animated)
}
}

View File

@ -124,7 +124,7 @@ final class AuthSessionCoordinator {
accountContext: AccountContext,
permissionContext: PermissionContext,
scenicSpotContext: ScenicSpotContext,
appRouter: AppRouter,
appNavigator: AppNavigator,
toastCenter: ToastCenter
) {
try? tokenStore.clear()
@ -132,7 +132,8 @@ final class AuthSessionCoordinator {
accountContext.reset()
permissionContext.reset()
scenicSpotContext.reset()
appRouter.reset()
appNavigator.resetAllStacks()
appNavigator.detach()
toastCenter.dismiss()
appSession.logout()
}

View File

@ -7,12 +7,11 @@ import SnapKit
import UIKit
@MainActor
/// ViewController Overlay
/// window `MainTabBarController`
final class RootViewController: UIViewController {
private let services: AppServices
private var currentChild: UIViewController?
private let restoringView = UIView()
private var currentAuthChild: UIViewController?
///
init(services: AppServices = .shared) {
@ -29,21 +28,14 @@ final class RootViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
restoringView.backgroundColor = .systemBackground
services.appSession.onChange = { [weak self] in
self?.handleSessionPhaseChange()
}
mountChild(for: services.appSession.phase)
applyRoot(for: services.appSession.phase)
bootstrapSession()
configurePushNotifications()
}
/// Toast Loading window
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
attachGlobalOverlays()
services.configurePushNotifications()
}
///
@ -66,21 +58,9 @@ final class RootViewController: UIViewController {
}
}
///
private func configurePushNotifications() {
services.configurePushNotifications()
}
/// Toast Loading window
private func attachGlobalOverlays() {
guard let window = view.window else { return }
services.toastCenter.attachToWindow(window)
services.globalLoading.attachToWindow(window)
}
///
/// window
private func handleSessionPhaseChange() {
mountChild(for: services.appSession.phase)
applyRoot(for: services.appSession.phase)
switch services.appSession.phase {
case .loggedIn:
@ -93,7 +73,8 @@ final class RootViewController: UIViewController {
services.accountContext.reset()
services.permissionContext.reset()
services.scenicSpotContext.reset()
services.appRouter.reset()
services.appNavigator.resetAllStacks()
services.appNavigator.detach()
services.toastCenter.dismiss()
services.scenicQueueRuntime.stop()
case .restoring:
@ -113,31 +94,67 @@ final class RootViewController: UIViewController {
)
}
/// Tab
private func mountChild(for phase: AuthPhase) {
let nextChild: UIViewController
/// window
private func applyRoot(for phase: AuthPhase) {
switch phase {
case .loggedOut:
nextChild = LoginViewController(services: services)
case .restoring:
nextChild = restoringViewController()
case .loggedIn:
nextChild = MainTabBarController(services: services)
showMainTabBarAsWindowRoot()
case .loggedOut:
showAuthRoot(with: LoginViewController(services: services))
case .restoring:
showAuthRoot(with: restoringViewController())
}
}
/// `MainTabBarController` window CYLTabBarController
private func showMainTabBarAsWindowRoot() {
removeCurrentAuthChild()
guard let window = activeWindow else { return }
let mainTabBarController = MainTabBarController(services: services)
window.rootViewController = mainTabBarController
attachGlobalOverlays(to: window)
}
/// /
private func showAuthRoot(with child: UIViewController) {
guard let window = activeWindow else {
mountAuthChild(child)
return
}
guard currentChild !== nextChild else { return }
if window.rootViewController !== self {
window.rootViewController = self
}
currentChild?.willMove(toParent: nil)
currentChild?.view.removeFromSuperview()
currentChild?.removeFromParent()
mountAuthChild(child)
attachGlobalOverlays(to: window)
}
addChild(nextChild)
view.addSubview(nextChild.view)
nextChild.view.snp.makeConstraints { make in
///
private func mountAuthChild(_ child: UIViewController) {
guard currentAuthChild !== child else { return }
currentAuthChild?.willMove(toParent: nil)
currentAuthChild?.view.removeFromSuperview()
currentAuthChild?.removeFromParent()
addChild(child)
view.addSubview(child.view)
child.view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
nextChild.didMove(toParent: self)
currentChild = nextChild
child.didMove(toParent: self)
currentAuthChild = child
}
///
private func removeCurrentAuthChild() {
currentAuthChild?.willMove(toParent: nil)
currentAuthChild?.view.removeFromSuperview()
currentAuthChild?.removeFromParent()
currentAuthChild = nil
}
///
@ -146,4 +163,18 @@ final class RootViewController: UIViewController {
controller.view.backgroundColor = .systemBackground
return controller
}
/// Toast Loading window
private func attachGlobalOverlays(to window: UIWindow) {
services.toastCenter.attachToWindow(window)
services.globalLoading.attachToWindow(window)
}
/// key window
private var activeWindow: UIWindow? {
view.window ?? UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.first(where: \.isKeyWindow)
}
}

View File

@ -15,7 +15,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
private var api: PushAPI?
private weak var session: AppSession?
private weak var router: AppRouter?
private weak var navigator: AppNavigator?
private var latestDeviceToken: String?
private var latestAuthorizationStatus: UNAuthorizationStatus = .notDetermined
private let tokenDefaultsKey = "apns_device_token"
@ -27,10 +27,10 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
}
///
func configure(api: PushAPI, session: AppSession, router: AppRouter) {
func configure(api: PushAPI, session: AppSession, navigator: AppNavigator) {
self.api = api
self.session = session
self.router = router
self.navigator = navigator
UNUserNotificationCenter.current().delegate = self
latestDeviceToken = UserDefaults.standard.string(forKey: tokenDefaultsKey)
}
@ -128,29 +128,21 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
///
private func route(from payload: PushPayload) {
guard let router else { return }
guard let navigator else { return }
switch payload.route {
case .payment:
navigateHomeRoute(.paymentCollection)
navigator.openHome(.paymentCollection, policy: .tabRoot(.home))
case .order:
router.selectOrders(entry: .storeOrders)
navigator.openOrdersEntry(.storeOrders)
case .verificationOrder:
router.selectOrders(entry: .verificationOrders)
navigator.openOrdersEntry(.verificationOrders)
case .task:
navigateHomeRoute(.taskManagement)
navigator.openHome(.taskManagement, policy: .tabRoot(.home))
case .queue:
navigateHomeRoute(.queueManagement)
navigator.openHome(.queueManagement, policy: .tabRoot(.home))
case .messageCenter:
navigateHomeRoute(.messageCenter)
navigator.openHome(.messageCenter, policy: .tabRoot(.home))
}
}
/// HomeRoute
private func navigateHomeRoute(_ route: HomeRoute) {
guard let router else { return }
router.select(.home)
router.router(for: .home).navigate(to: .home(route))
UIKitAppNavigation.pushHomeRoute(route, animated: true)
}
}

View File

@ -29,7 +29,7 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
- `unsupported`:已知 iOS 不支持的入口,进入占位说明。
- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。
`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转
`HomeMenuRouting` 只作为首页菜单到 `AppNavigator` 的适配层:本地业务页面沿当前 UIKit 导航栈 push订单和数据等一级入口交给导航器切换 Tab未迁移入口进入占位页
## 后续迁移

View File

@ -19,42 +19,16 @@ enum HomeMenuRouting {
let services = AppServices.shared
switch route {
case .tab(let tab):
services.appRouter.select(tab)
services.appNavigator.selectTab(tab)
case .orders(let entry):
services.appRouter.selectOrders(entry: entry)
services.appNavigator.openOrdersEntry(entry)
case .destination(let homeRoute):
push(homeRoute, from: viewController)
services.appNavigator.push(.home(homeRoute), from: viewController)
case .unsupported(let uri, let title, _):
pushPlaceholder(title: title, uri: uri, from: viewController)
services.appNavigator.push(.home(.modulePlaceholder(uri: uri, title: title)), from: viewController)
case .placeholder(let uri, let title):
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
pushPlaceholder(title: title, uri: uri, from: viewController)
services.appNavigator.push(.home(.modulePlaceholder(uri: uri, title: title)), from: viewController)
}
}
/// Push
static func push(_ route: HomeRoute, from viewController: UIViewController) {
let target = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
viewController.navigationController?.pushViewController(target, animated: true)
}
/// Push
static func pushOrders(_ route: OrdersRoute, from viewController: UIViewController) {
let target = AppRouteViewControllerFactory.makeViewController(for: .orders(route), services: AppServices.shared)
viewController.navigationController?.pushViewController(target, animated: true)
}
/// Push
static func pushProfile(_ route: ProfileRoute, from viewController: UIViewController) {
let target = AppRouteViewControllerFactory.makeViewController(for: .profile(route), services: AppServices.shared)
viewController.navigationController?.pushViewController(target, animated: true)
}
/// Push Placeholder
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
viewController.navigationController?.pushViewController(
FeaturePlaceholderViewController(title: title, uri: uri),
animated: true
)
}
}

View File

@ -368,7 +368,7 @@ final class HomeViewController: UIViewController {
///
@objc private func scenicTapped() {
HomeMenuRouting.push(.scenicSelection, from: self)
appServices.appNavigator.push(.home(.scenicSelection), from: self)
}
/// 线 / 线

View File

@ -4,7 +4,7 @@
Main 模块负责登录后的主界面 Tab 容器。
主界面由 `MainTabBarController` 承载,使用系统 `UITabBarController` 展示首页、订单、数据、我的四个 Tab。每个 Tab 内部包裹独立的 `TabNavigationController`导航路径由 `AppRouter` 单独保存
主界面由 `MainTabBarController` 承载,使用 **CYLTabBarController** 展示首页、订单、数据、我的四个 Tab,并在中间提供扫码核销按钮。每个 Tab 内部包裹独立的 `TabNavigationController`页面路径以 UIKit 导航栈为唯一事实源
## Tab 结构
@ -18,11 +18,11 @@ Main 模块负责登录后的主界面 Tab 容器。
## 导航流程
1. `MainTabBarController` 读取 `AppServices.appRouter` 作为选中 Tab 状态源
2. 用户点击 TabBar 或通过 `AppRouter.select` 切换 Tab 时,两边状态保持同步
1. `MainTabBarController` 在登录后注册为 `AppNavigator` 的导航宿主
2. 用户点击 TabBar 只改变 UIKit 选中态,并保留各 Tab 原有导航栈
3. 每个 Tab 由 `TabNavigationController` 创建独立 `UINavigationController` 栈。
4. Tab 内 push 子页面时,通过 `AppRoute.hidesTabBarWhenPushed` 控制是否隐藏系统 TabBar默认隐藏。
5. 跨 Tab 跳转通过 `AppRouter.select``AppRouter.selectOrders(entry:)` 处理
5. 跨 Tab、推送通知、UI Test 直达和全局扫码统一通过 `AppNavigator` 执行
## 订单角标
@ -32,15 +32,21 @@ Main 模块负责登录后的主界面 Tab 容器。
## 扫码核销
系统 TabBar 不展示中间扫码按钮;订单页内部的扫码核销入口仍然可用。如需全局扫码入口,可通过首页或订单页进入。
TabBar 中间通过 `MainScanPlusButton`CYLTabBarController PlusButton展示全局扫码入口点击后全屏打开 `OrderCodeScannerViewController`
1. 扫码成功 → `AppNavigator.openOrdersEntry(.verificationOrders, scannedCode:)` 切换至订单核销入口并传入扫码结果。
2. `OrdersViewController` 本地保存并消费扫码结果后进入确认核销流程。
3. 扫码失败 → 通过 `ToastCenter` 提示错误。
订单页内部的扫码核销入口仍然可用,与 TabBar 中间按钮为双入口。
## 后续迁移规则
迁移新页面时:
- 优先替换对应 Tab 的根 ViewController。
- 保持每个 Tab 自己的 `TabNavigationController` 导航栈。
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)``AppRouter.routeToOrderVerification(scannedCode:)`
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
- 跨 Tab 跳转通过 `AppNavigator.selectTab` 或对应业务入口方法执行
- 需要从首页或全局入口进入订单核销时,优先使用 `AppNavigator.openOrdersEntry(_:scannedCode:)`
- Tab 内跳转通过 `AppNavigator.push(_:from:)` 或扩展后的 `AppRoute` 处理。
- 普通业务子页面默认隐藏 TabBar只有确有产品需求时才在 `AppRoute` 策略中单独放开。
- 真实业务页面接入后,应同步补充该模块文档和单元测试。

View File

@ -3,11 +3,12 @@
// suixinkan
//
import CYLTabBarController
import UIKit
@MainActor
/// Tab 使 `UITabBarController`
final class MainTabBarController: UITabBarController, UITabBarControllerDelegate {
/// Tab 使 CYLTabBarController
final class MainTabBarController: CYLTabBarController {
private let services: AppServices
private let badgeViewModel = MainTabBadgeViewModel()
@ -17,7 +18,11 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
///
init(services: AppServices) {
self.services = services
MainScanPlusButton.register()
super.init(nibName: nil, bundle: nil)
MainScanPlusButton.onScanTap = { [weak self] in
self?.presentGlobalScanner()
}
delegate = self
}
@ -31,36 +36,72 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
super.viewDidLoad()
configureTabBarAppearance()
configureTabs()
bindRouter()
services.appNavigator.attach(host: self)
bindBadgeViewModel()
bindAccountContext()
syncSelectedTabFromRouter(animated: false)
Task { await refreshOrderBadge() }
#if DEBUG
Task { await AppUITestRouteDriver.applyIfNeeded(services: services) }
#endif
}
/// TabBar
private func configureTabBarAppearance() {
tabBar.tintColor = AppDesign.primary
tabBar.backgroundColor = .white
}
/// Tab TabBarItem
private func configureTabs() {
viewControllers = AppTab.allCases.map { tab in
let navigationController = TabNavigationController(tab: tab, services: services)
navigationController.tabBarItem = tab.makeTabBarItem()
tabNavigationControllers[tab] = navigationController
return navigationController
///
deinit {
Task { @MainActor in
MainScanPlusButton.onScanTap = nil
}
}
/// Router Tab
private func bindRouter() {
services.appRouter.onChange = { [weak self] in
self?.handleRouterChange()
/// TabBar CustomMainTabBar
private func configureTabBarAppearance() {
tabBar.tintColor = AppDesign.primary
tabBar.backgroundColor = .white
tabBar.shadowImage = UIImage()
let normalAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor(hex: 0x7D8DA3),
.font: UIFont.systemFont(ofSize: 12, weight: .regular)
]
let selectedAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: AppDesign.primary,
.font: UIFont.systemFont(ofSize: 12, weight: .medium)
]
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.shadowColor = .clear
appearance.stackedLayoutAppearance.normal.titleTextAttributes = normalAttributes
appearance.stackedLayoutAppearance.selected.titleTextAttributes = selectedAttributes
tabBar.standardAppearance = appearance
tabBar.scrollEdgeAppearance = appearance
}
/// Tab CYLTabBarController Tab
private func configureTabs() {
let tabs = AppTab.allCases
tabBarItemsAttributes = tabs.map { makeTabBarItemAttributes(for: $0) }
viewControllers = tabs.map { tab in
let navigationController = TabNavigationController(tab: tab, services: services)
tabNavigationControllers[tab] = navigationController
return navigationController
}
applyTabAccessibilityIdentifiers()
}
/// CYLTabBarController TabBarItem
private func makeTabBarItemAttributes(for tab: AppTab) -> [String: Any] {
[
CYLTabBarItemTitle: tab.title,
CYLTabBarItemImage: tab.unselectedImageName,
CYLTabBarItemSelectedImage: tab.selectedImageName
]
}
/// CYL TabBarItem
private func applyTabAccessibilityIdentifiers() {
for tab in AppTab.allCases {
tabNavigationControllers[tab]?.tabBarItem.accessibilityIdentifier = "main.tab.\(tab.rawValue)"
}
}
@ -78,24 +119,6 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
}
}
/// Router Tab `AppRouter`
private func handleRouterChange() {
syncSelectedTabFromRouter(animated: false)
if services.appRouter.selectedTab == .orders {
Task { await refreshOrderBadge() }
}
}
/// `AppRouter.selectedTab` TabBar
private func syncSelectedTabFromRouter(animated: Bool) {
guard let index = AppTab.allCases.firstIndex(of: services.appRouter.selectedTab) else { return }
guard selectedIndex != index else { return }
isSyncingTabSelection = true
selectedIndex = index
isSyncingTabSelection = false
}
/// Tab
private func updateOrderBadge() {
let badgeValue: String?
@ -116,14 +139,71 @@ final class MainTabBarController: UITabBarController, UITabBarControllerDelegate
)
}
/// TabBar `AppRouter`
func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
/// TabBar
private func presentGlobalScanner() {
let scanner = OrderCodeScannerViewController()
scanner.onScanResult = { [weak self, weak scanner] result in
scanner?.dismiss(animated: true)
guard let self else { return }
switch result {
case .success(let code):
self.services.appNavigator.openOrdersEntry(.verificationOrders, scannedCode: code)
case .failure(let error):
self.services.toastCenter.show(error.localizedDescription)
}
}
let navigationController = UINavigationController(rootViewController: scanner)
navigationController.modalPresentationStyle = .fullScreen
present(navigationController, animated: true)
}
/// TabBar
override func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) {
guard !isSyncingTabSelection,
let navigationController = viewController as? TabNavigationController else { return }
services.appRouter.select(navigationController.appTab)
if navigationController.appTab == .orders {
Task { await refreshOrderBadge() }
}
}
}
extension MainTabBarController: AppNavigationHost {
/// Tab
func selectTab(_ tab: AppTab) {
guard let index = AppTab.allCases.firstIndex(of: tab) else { return }
guard selectedIndex != index else { return }
isSyncingTabSelection = true
selectedIndex = index
isSyncingTabSelection = false
if tab == .orders {
Task { await refreshOrderBadge() }
}
}
/// Tab
func navigationController(for tab: AppTab) -> TabNavigationController? {
tabNavigationControllers[tab]
}
/// Tab
func selectedNavigationController() -> TabNavigationController? {
selectedViewController as? TabNavigationController
}
/// Tab
func resetAllStacks() {
for navigationController in tabNavigationControllers.values {
navigationController.popToRootViewController(animated: false)
}
selectTab(.home)
}
///
func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) {
guard let ordersController = tabNavigationControllers[.orders]?.viewControllers.first as? OrdersViewController else {
return
}
ordersController.applyOrdersEntry(entry, scannedCode: scannedCode)
}
}

View File

@ -0,0 +1,77 @@
//
// MainScanPlusButton.swift
// suixinkan
//
import CYLTabBarController
import UIKit
@MainActor
@objcMembers
/// TabBar CYLTabBarController PlusButton
final class MainScanPlusButton: CYLPlusButton, CYLPlusButtonSubclassing {
/// `MainTabBarController`
static var onScanTap: (() -> Void)?
/// CYLTabBarController 使
static func plusButton() -> Any {
let button = MainScanPlusButton(type: .custom)
button.frame = CGRect(x: 0, y: 0, width: 70, height: 64)
button.configureAppearance()
button.addTarget(button, action: #selector(handleScanTap), for: .touchUpInside)
return button
}
/// TabBar
static func indexOfPlusButtonInTabBar() -> UInt {
2
}
/// 使 TabBar
static func constantOfPlusButtonCenterYOffset(forTabBarHeight tabBarHeight: CGFloat) -> CGFloat {
-6
}
/// CustomMainTabBarCenterAction
private func configureAppearance() {
backgroundColor = .clear
let circleSize: CGFloat = 58
let circleView = UIView()
circleView.isUserInteractionEnabled = false
circleView.backgroundColor = AppDesign.primary
circleView.layer.cornerRadius = circleSize / 2
addSubview(circleView)
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 30, weight: .bold)
let iconView = UIImageView(
image: UIImage(systemName: "qrcode.viewfinder", withConfiguration: symbolConfig)
)
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
iconView.isUserInteractionEnabled = false
addSubview(iconView)
circleView.translatesAutoresizingMaskIntoConstraints = false
iconView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
circleView.centerXAnchor.constraint(equalTo: centerXAnchor),
circleView.centerYAnchor.constraint(equalTo: centerYAnchor),
circleView.widthAnchor.constraint(equalToConstant: circleSize),
circleView.heightAnchor.constraint(equalToConstant: circleSize),
iconView.centerXAnchor.constraint(equalTo: circleView.centerXAnchor),
iconView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 30),
iconView.heightAnchor.constraint(equalToConstant: 30)
])
accessibilityLabel = "扫码核销"
accessibilityIdentifier = "main.scan"
}
///
@objc private func handleScanTap() {
Self.onScanTap?()
}
}

View File

@ -8,7 +8,7 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销
## 核心对象
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext``PermissionContext``AppRouter``OrdersAPI`
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext``PermissionContext``OrdersAPI`,并接收 `AppNavigator` 传入的订单入口与扫码结果
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
@ -44,6 +44,6 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销
## 路由边界
首页 `photographer_orders``/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail``deposit_order``deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由push 后继续隐藏 TabBar。
首页 `photographer_orders``/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail``deposit_order``deposit_order_shooting_info` 会进入押金订单页。全局扫码和推送通知通过 `AppNavigator.openOrdersEntry` 切到订单 Tab root再由 `OrdersViewController` 应用入口状态;订单卡片、核销卡片、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由push 后继续隐藏 TabBar。
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。

View File

@ -96,7 +96,7 @@ final class DepositOrderListViewController: UIViewController, UITableViewDelegat
let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell
cell.configure(item: orderItem, isOperating: self.viewModel.operatingOrderNumber == orderItem.orderNumber)
cell.onDetail = { [weak self] in
self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: orderItem.orderNumber), from: $0) }
self.flatMap { $0.appServices.appNavigator.push(.orders(.depositDetail(orderNumber: orderItem.orderNumber)), from: $0) }
}
cell.onWriteOff = { [weak self] in self?.writeOff(orderItem) }
cell.onRefund = { [weak self] in self?.refund(orderItem) }
@ -154,7 +154,7 @@ final class DepositOrderListViewController: UIViewController, UITableViewDelegat
@objc private func openDetail() {
let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !text.isEmpty else { return }
HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self)
appServices.appNavigator.push(.orders(.depositDetail(orderNumber: text)), from: self)
}
///

View File

@ -289,13 +289,13 @@ final class StoreOrderDetailViewController: UIViewController, UITableViewDelegat
let orderNumber = viewModel.display.orderNumber
switch index {
case 0:
HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self)
appServices.appNavigator.push(.orders(.historicalShooting(orderNumber: orderNumber)), from: self)
case 1 where item.orderType == 19:
HomeMenuRouting.pushOrders(.multiTravelTaskUpload(orderNumber: orderNumber), from: self)
appServices.appNavigator.push(.orders(.multiTravelTaskUpload(orderNumber: orderNumber)), from: self)
case 2:
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "视频预告"), from: self)
appServices.appNavigator.push(.orders(.orderTrailer(orderNumber: orderNumber, title: "视频预告")), from: self)
case 3:
HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self)
appServices.appNavigator.push(.orders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传")), from: self)
case 4:
presentRefund()
default:

View File

@ -37,6 +37,7 @@ final class OrdersViewController: UIViewController {
private let viewModel = OrdersViewModel()
private var manualOrderNumber = ""
private var scanHintMessage: String?
private var pendingScanCode: String?
private lazy var collectionView: UICollectionView = {
let layout = makeCollectionLayout()
@ -77,25 +78,34 @@ final class OrdersViewController: UIViewController {
self?.applySnapshot()
}
appServices.appRouter.onChange = { [weak self] in
guard let self else { return }
if self.viewModel.selectedEntry != self.appServices.appRouter.selectedOrdersEntry {
self.viewModel.selectedEntry = self.appServices.appRouter.selectedOrdersEntry
Task { await self.reload(showLoading: true) }
}
Task { await self.consumePendingScanCodeIfNeeded() }
}
appServices.accountContext.onChange = { [weak self] in
Task { await self?.reload(showLoading: true) }
}
viewModel.selectedEntry = appServices.appRouter.selectedOrdersEntry
Task {
await reload(showLoading: true)
await consumePendingScanCodeIfNeeded()
}
}
///
func applyOrdersEntry(_ entry: OrdersEntry, scannedCode: String?) {
let didChangeEntry = viewModel.selectedEntry != entry
viewModel.selectedEntry = entry
if let scannedCode {
pendingScanCode = scannedCode
}
guard isViewLoaded else { return }
Task {
if didChangeEntry {
await reload(showLoading: true)
}
await consumePendingScanCodeIfNeeded()
}
}
/// Compositional Layout section
private func makeCollectionLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
@ -345,7 +355,6 @@ final class OrdersViewController: UIViewController {
private func switchEntry(_ entry: OrdersEntry) {
guard viewModel.selectedEntry != entry else { return }
viewModel.selectedEntry = entry
appServices.appRouter.selectedOrdersEntry = entry
Task { await reload(showLoading: true) }
}
@ -370,7 +379,8 @@ final class OrdersViewController: UIViewController {
///
private func consumePendingScanCodeIfNeeded() async {
guard viewModel.selectedEntry == .verificationOrders,
let code = appServices.appRouter.consumePendingOrderScanCode() else { return }
let code = pendingScanCode else { return }
pendingScanCode = nil
handleScanResult(code)
}
@ -459,11 +469,11 @@ extension OrdersViewController: UICollectionViewDelegate {
if item.hasPrefix("orders:store:") {
let orderNumber = String(item.dropFirst("orders:store:".count))
guard let order = viewModel.storeOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
HomeMenuRouting.pushOrders(.storeDetail(order), from: self)
appServices.appNavigator.push(.orders(.storeDetail(order)), from: self)
} else if item.hasPrefix("orders:writeoff:") {
let orderNumber = String(item.dropFirst("orders:writeoff:".count))
guard let order = viewModel.writeOffOrders.first(where: { $0.orderNumber == orderNumber }) else { return }
HomeMenuRouting.pushOrders(.writeOffDetail(order), from: self)
appServices.appNavigator.push(.orders(.writeOffDetail(order)), from: self)
}
}

View File

@ -76,7 +76,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
3. 页面展示景区账号和门店账号,优先选中后端标记的当前账号,否则选中第一项。
4. 用户确认后调用 `AuthAPI.setUser`,使用 `AccountSwitchAccount.toSetUserRequest()` 生成请求体。
5. 切换成功后调用 `AuthSessionCoordinator.completeLogin`,重新写入正式 token、账号快照、权限、景区和门店上下文。
6. `AppRouter.reset()` 清空旧路由栈并回到首页。
6. `AppNavigator.resetAllStacks()` 清空旧 UIKit 导航栈并回到首页。
## 实名认证流程
@ -105,7 +105,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
1. 用户点击退出登录。
2. `ProfileView` 展示确认弹窗。
3. 用户确认后调用 `AuthSessionCoordinator.logout`
4. 协调器清空正式 token、账号快照、账号上下文、路由栈和 Toast。
4. 协调器清空正式 token、账号快照、账号上下文、全部 UIKit 导航栈和 Toast。
5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。
6. 上次手机号和协议状态等非敏感偏好保留。

View File

@ -187,7 +187,7 @@ final class AccountSwitchViewController: UIViewController, UITableViewDelegate {
profileAPI: appServices.profileAPI,
accountContextAPI: appServices.accountContextAPI
)
appServices.appRouter.reset()
appServices.appNavigator.resetAllStacks()
showToast("账号已切换")
navigationController?.popToRootViewController(animated: true)
} catch {

View File

@ -28,6 +28,24 @@ private enum ProfileRowID {
static let logout = "profile:logout"
}
/// value1 Cell Diffable reconfigure
private final class ProfileValueTableViewCell: UITableViewCell {
/// value1 Cell
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .value1, reuseIdentifier: reuseIdentifier)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private enum ProfileCellReuseID {
static let value = "profile.value"
static let logout = "profile.logout"
}
///
final class ProfileViewController: UIViewController, UITableViewDelegate {
@ -76,43 +94,57 @@ final class ProfileViewController: UIViewController, UITableViewDelegate {
/// Diffable
private func configureTableDataSource() {
tableView.register(ProfileValueTableViewCell.self, forCellReuseIdentifier: ProfileCellReuseID.value)
tableView.register(UITableViewCell.self, forCellReuseIdentifier: ProfileCellReuseID.logout)
tableDataSource = UITableViewDiffableDataSource<ProfileTableSection, ProfileTableRow>(
tableView: tableView
) { [weak self] (_: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in
) { [weak self] (tableView: UITableView, indexPath: IndexPath, row: ProfileTableRow) -> UITableViewCell? in
guard let self else { return UITableViewCell() }
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
switch row {
case ProfileRowID.logout:
let cell = tableView.dequeueReusableCell(withIdentifier: ProfileCellReuseID.logout, for: indexPath)
cell.textLabel?.text = "退出登录"
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
cell.textLabel?.textAlignment = .center
cell.selectionStyle = .default
case ProfileRowID.name:
cell.textLabel?.text = "姓名"
cell.detailTextLabel?.text = self.viewModel.displayRealName
cell.selectionStyle = .none
case ProfileRowID.account:
cell.textLabel?.text = "当前账号"
cell.detailTextLabel?.text = self.appServices.accountContext.profile?.displayName ?? "--"
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
case ProfileRowID.phone:
cell.textLabel?.text = "手机号"
cell.detailTextLabel?.text = self.viewModel.displayPhone
cell.selectionStyle = .none
case ProfileRowID.realName:
cell.textLabel?.text = "实名认证"
cell.detailTextLabel?.text = self.viewModel.realNameStatusText
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
case ProfileRowID.settings:
cell.textLabel?.text = "设置"
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
cell.accessoryType = .none
return cell
default:
break
let cell = tableView.dequeueReusableCell(
withIdentifier: ProfileCellReuseID.value,
for: indexPath
)
cell.textLabel?.textAlignment = .natural
cell.textLabel?.textColor = .label
cell.accessoryType = .none
cell.selectionStyle = .none
switch row {
case ProfileRowID.name:
cell.textLabel?.text = "姓名"
cell.detailTextLabel?.text = self.viewModel.displayRealName
case ProfileRowID.account:
cell.textLabel?.text = "当前账号"
cell.detailTextLabel?.text = self.appServices.accountContext.profile?.displayName ?? "--"
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
case ProfileRowID.phone:
cell.textLabel?.text = "手机号"
cell.detailTextLabel?.text = self.viewModel.displayPhone
case ProfileRowID.realName:
cell.textLabel?.text = "实名认证"
cell.detailTextLabel?.text = self.viewModel.realNameStatusText
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
case ProfileRowID.settings:
cell.textLabel?.text = "设置"
cell.accessoryType = .disclosureIndicator
cell.selectionStyle = .default
default:
break
}
return cell
}
return cell
}
applyTableSnapshot(animated: false)
}
@ -296,7 +328,7 @@ final class ProfileViewController: UIViewController, UITableViewDelegate {
accountContext: self.appServices.accountContext,
permissionContext: self.appServices.permissionContext,
scenicSpotContext: self.appServices.scenicSpotContext,
appRouter: self.appServices.appRouter,
appNavigator: self.appServices.appNavigator,
toastCenter: self.appServices.toastCenter
)
})
@ -311,11 +343,11 @@ final class ProfileViewController: UIViewController, UITableViewDelegate {
case ProfileRowID.logout:
logoutTapped()
case ProfileRowID.account:
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
appServices.appNavigator.push(.profile(.accountSwitch), from: self)
case ProfileRowID.realName:
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
appServices.appNavigator.push(.profile(.realNameAuth), from: self)
case ProfileRowID.settings:
HomeMenuRouting.pushProfile(.settings, from: self)
appServices.appNavigator.push(.profile(.settings), from: self)
default:
break
}

View File

@ -98,7 +98,7 @@ final class SettingsViewController: SimpleTableDiffableViewController {
guard let action = rowActions[row] else { return }
switch action {
case .agreement(let page):
HomeMenuRouting.pushProfile(.agreement(page), from: self)
appServices.appNavigator.push(.profile(.agreement(page)), from: self)
case .download:
copyDownloadLink()
case .version:

View File

@ -7,11 +7,14 @@
import UIKit
/// Scene `RootViewController`
/// Scene
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
/// window
private var sessionRootController: RootViewController?
/// Scene UI
func scene(
_ scene: UIScene,
@ -22,8 +25,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
AppUITestLaunchState.resetIfNeeded()
let rootViewController = RootViewController()
sessionRootController = rootViewController
let window = UIWindow(windowScene: windowScene)
window.rootViewController = RootViewController()
window.rootViewController = rootViewController
window.makeKeyAndVisible()
self.window = window
}