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:
@ -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。
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 用例菜单名映射为调试目录标题。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
||||
- `unsupported`:已知 iOS 不支持的入口,进入占位说明。
|
||||
- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。
|
||||
|
||||
`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转。
|
||||
`HomeMenuRouting` 只作为首页菜单到 `AppNavigator` 的适配层:本地业务页面沿当前 UIKit 导航栈 push,订单和数据等一级入口交给导航器切换 Tab,未迁移入口进入占位页。
|
||||
|
||||
## 后续迁移
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -368,7 +368,7 @@ final class HomeViewController: UIViewController {
|
||||
|
||||
/// 点击景区名称,跳转景区选择页。
|
||||
@objc private func scenicTapped() {
|
||||
HomeMenuRouting.push(.scenicSelection, from: self)
|
||||
appServices.appNavigator.push(.home(.scenicSelection), from: self)
|
||||
}
|
||||
|
||||
/// 切换在线 / 离线状态。
|
||||
|
||||
@ -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` 策略中单独放开。
|
||||
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
77
suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift
Normal file
77
suixinkan_ios/Features/Main/Views/MainScanPlusButton.swift
Normal 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?()
|
||||
}
|
||||
}
|
||||
@ -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。
|
||||
|
||||
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/// 刷新。
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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. 上次手机号和协议状态等非敏感偏好保留。
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user