Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
36
suixinkan_ios/App/AMapBootstrap.swift
Normal file
36
suixinkan_ios/App/AMapBootstrap.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// AMapBootstrap.swift
|
||||
// suixinkan
|
||||
//
|
||||
// 高德 SDK 初始化入口。真机构建时 AMAP_ENABLED 生效;模拟器构建时不链接 AMap。
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if AMAP_ENABLED
|
||||
import AMapFoundationKit
|
||||
import AMapLocationKit
|
||||
import AMapSearchKit
|
||||
import MAMapKit
|
||||
|
||||
enum AMapBootstrap {
|
||||
/// 在 App 启动时调用一次。将 `apiKey` 替换为高德控制台申请的 Key。
|
||||
static func configure(apiKey: String) {
|
||||
guard !apiKey.isEmpty else { return }
|
||||
|
||||
AMapServices.shared().apiKey = apiKey
|
||||
AMapServices.shared().enableHTTPS = true
|
||||
|
||||
AMapSearchAPI.updatePrivacyShow(.didShow, privacyInfo: .didContain)
|
||||
AMapSearchAPI.updatePrivacyAgree(.didAgree)
|
||||
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
|
||||
MAMapView.updatePrivacyAgree(.didAgree)
|
||||
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
|
||||
AMapLocationManager.updatePrivacyAgree(.didAgree)
|
||||
}
|
||||
}
|
||||
#else
|
||||
enum AMapBootstrap {
|
||||
static func configure(apiKey: String) {}
|
||||
}
|
||||
#endif
|
||||
79
suixinkan_ios/App/App.md
Normal file
79
suixinkan_ios/App/App.md
Normal file
@ -0,0 +1,79 @@
|
||||
# App 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast。
|
||||
|
||||
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
|
||||
- 根据登录状态展示登录页、恢复态或主 Tab。
|
||||
- 创建并注入全局状态和共享服务。
|
||||
- 管理每个 Tab 独立的 `NavigationStack` 路径。
|
||||
- 协调登录成功、退出登录、冷启动恢复和本地缓存同步。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `suixinkanApp`:SwiftUI 应用入口,挂载 `RootView`。
|
||||
- `RootView`:创建 `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter`、`APIClient` 和业务 API,并注入 SwiftUI Environment。
|
||||
- `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。
|
||||
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
|
||||
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
|
||||
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
|
||||
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
|
||||
- `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。
|
||||
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
|
||||
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
|
||||
- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。
|
||||
|
||||
## 启动流程
|
||||
|
||||
1. `suixinkanApp` 创建 `RootView`。
|
||||
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
|
||||
3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
|
||||
4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。
|
||||
5. 无 token 时保持 `loggedOut`,展示 `LoginView`。
|
||||
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
|
||||
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
|
||||
8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`。
|
||||
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
|
||||
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
|
||||
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
|
||||
|
||||
## UI Test 启动约定
|
||||
|
||||
- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。
|
||||
- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。
|
||||
- `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。
|
||||
- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings`、`realNameAuth`)。
|
||||
- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。
|
||||
- 详细运行方式见 `suixinkanUITests/README.md`。
|
||||
|
||||
## 登录和退出
|
||||
|
||||
登录完成由 `AuthSessionCoordinator.completeLogin` 统一处理:
|
||||
- 正式 token 写入 `SessionTokenStore`。
|
||||
- 上次手机号和协议状态写入 `AppPreferencesStore`。
|
||||
- 账号资料、景区列表、门店列表写入 `AccountContext`。
|
||||
- 角色权限和当前角色写入 `PermissionContext`。
|
||||
- 非敏感账号快照写入 `AccountSnapshotStore`。
|
||||
- `AppSession` 切换为 `loggedIn`。
|
||||
|
||||
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
|
||||
- 清空 Keychain token。
|
||||
- 清空账号快照。
|
||||
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
|
||||
- `AppSession` 切换为 `loggedOut`。
|
||||
- 保留上次手机号、协议状态等非敏感偏好。
|
||||
|
||||
## Toast
|
||||
|
||||
全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。
|
||||
|
||||
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
|
||||
|
||||
## 导航规则
|
||||
|
||||
主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。
|
||||
|
||||
Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,`MainTabsView` 会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar,避免每个业务页面重复处理。
|
||||
|
||||
当前路由枚举 `AppRoute` 仍以占位详情页为主,后续新增真实页面时应优先扩展 `AppRoute`,再由对应 Tab 的 `NavigationStack` 处理跳转。
|
||||
123
suixinkan_ios/App/AppServices.swift
Normal file
123
suixinkan_ios/App/AppServices.swift
Normal file
@ -0,0 +1,123 @@
|
||||
//
|
||||
// AppServices.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 全局依赖容器,集中提供 ViewModel 与 ViewController 所需的 API 和上下文。
|
||||
final class AppServices {
|
||||
static let shared = AppServices()
|
||||
|
||||
let appSession = AppSession()
|
||||
let accountContext = AccountContext()
|
||||
let permissionContext = PermissionContext()
|
||||
let scenicSpotContext = ScenicSpotContext()
|
||||
let appRouter = AppRouter()
|
||||
let toastCenter = ToastCenter()
|
||||
let globalLoading = GlobalLoadingCenter()
|
||||
let scenicQueueRuntime = ScenicQueueRuntime()
|
||||
|
||||
let apiClient: APIClient
|
||||
let authAPI: AuthAPI
|
||||
let profileAPI: ProfileAPI
|
||||
let uploadAPI: UploadAPI
|
||||
let ossUploadService: OSSUploadService
|
||||
let accountContextAPI: AccountContextAPI
|
||||
let ordersAPI: OrdersAPI
|
||||
let statisticsAPI: StatisticsAPI
|
||||
let paymentAPI: PaymentAPI
|
||||
let walletAPI: WalletAPI
|
||||
let pushAPI: PushAPI
|
||||
let scenicPermissionAPI: ScenicPermissionAPI
|
||||
let scenicSettlementAPI: ScenicSettlementAPI
|
||||
let messageCenterAPI: MessageCenterAPI
|
||||
let scenicQueueAPI: ScenicQueueAPI
|
||||
let liveAPI: LiveAPI
|
||||
let operatingAreaAPI: OperatingAreaAPI
|
||||
let pilotCertificationAPI: PilotCertificationAPI
|
||||
let taskAPI: TaskAPI
|
||||
let projectAPI: ProjectAPI
|
||||
let scheduleAPI: ScheduleAPI
|
||||
let inviteAPI: InviteAPI
|
||||
let assetsAPI: AssetsAPI
|
||||
let punchPointAPI: PunchPointAPI
|
||||
let locationReportAPI: LocationReportAPI
|
||||
|
||||
let authSessionCoordinator: AuthSessionCoordinator
|
||||
let sessionBootstrapper: SessionBootstrapper
|
||||
private let tokenStore: SessionTokenStore
|
||||
private let snapshotStore: AccountSnapshotStore
|
||||
|
||||
/// 当前景区 ID。
|
||||
var currentScenicId: Int? {
|
||||
accountContext.currentScenic?.id
|
||||
}
|
||||
|
||||
/// 当前用户 staffId,用于钱包等接口。
|
||||
var staffId: Int? {
|
||||
Int(accountContext.profile?.userId ?? "")
|
||||
}
|
||||
|
||||
/// 当前用户 ID 字符串。
|
||||
var userId: String? {
|
||||
accountContext.profile?.userId
|
||||
}
|
||||
|
||||
private init() {
|
||||
let client = APIClient()
|
||||
let tokenStore = SessionTokenStore()
|
||||
let snapshotStore = AccountSnapshotStore()
|
||||
self.tokenStore = tokenStore
|
||||
self.snapshotStore = snapshotStore
|
||||
apiClient = client
|
||||
authAPI = AuthAPI(client: client)
|
||||
profileAPI = ProfileAPI(client: client)
|
||||
uploadAPI = UploadAPI(client: client)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
accountContextAPI = AccountContextAPI(client: client)
|
||||
ordersAPI = OrdersAPI(client: client)
|
||||
statisticsAPI = StatisticsAPI(client: client)
|
||||
paymentAPI = PaymentAPI(client: client)
|
||||
walletAPI = WalletAPI(client: client)
|
||||
pushAPI = PushAPI(client: client)
|
||||
scenicPermissionAPI = ScenicPermissionAPI(client: client)
|
||||
scenicSettlementAPI = ScenicSettlementAPI(client: client)
|
||||
messageCenterAPI = MessageCenterAPI(client: client)
|
||||
scenicQueueAPI = ScenicQueueAPI(client: client)
|
||||
liveAPI = LiveAPI(client: client)
|
||||
operatingAreaAPI = OperatingAreaAPI(client: client)
|
||||
pilotCertificationAPI = PilotCertificationAPI(client: client)
|
||||
taskAPI = TaskAPI(client: client)
|
||||
projectAPI = ProjectAPI(client: client)
|
||||
scheduleAPI = ScheduleAPI(client: client)
|
||||
inviteAPI = InviteAPI(client: client)
|
||||
assetsAPI = AssetsAPI(client: client)
|
||||
punchPointAPI = PunchPointAPI(client: client)
|
||||
locationReportAPI = LocationReportAPI(client: client)
|
||||
authSessionCoordinator = AuthSessionCoordinator(
|
||||
tokenStore: tokenStore,
|
||||
snapshotStore: snapshotStore,
|
||||
preferencesStore: AppPreferencesStore()
|
||||
)
|
||||
sessionBootstrapper = SessionBootstrapper(
|
||||
tokenStore: tokenStore,
|
||||
snapshotStore: snapshotStore
|
||||
)
|
||||
apiClient.bindAuthTokenProvider { [unowned self] in
|
||||
appSession.token
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定推送与 UIKit 导航。
|
||||
func configurePushNotifications() {
|
||||
PushNotificationManager.shared.configure(
|
||||
api: pushAPI,
|
||||
session: appSession,
|
||||
router: appRouter
|
||||
)
|
||||
}
|
||||
}
|
||||
55
suixinkan_ios/App/AppUITestLaunchState.swift
Normal file
55
suixinkan_ios/App/AppUITestLaunchState.swift
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// AppUITestLaunchState.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// UI 测试启动状态,仅在测试进程显式传入启动参数时生效。
|
||||
enum AppUITestLaunchState {
|
||||
/// UI Test 主开关,测试进程统一传入。
|
||||
static let uiTestsArgument = "-suixinkan-ui-tests"
|
||||
/// 冷启动前清理本地登录缓存。
|
||||
static let resetArgument = "-suixinkan-ui-tests-reset-state"
|
||||
/// 登录后直达首页调试菜单项(下一参数为菜单标题)。
|
||||
static let openMenuArgument = "-suixinkan-ui-tests-open-menu"
|
||||
/// 登录后直达个人中心二级页(下一参数为路由名)。
|
||||
static let openProfileArgument = "-suixinkan-ui-tests-open-profile"
|
||||
|
||||
/// 当前进程是否由 XCUITest 启动。
|
||||
static var isRunningUITests: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains(uiTestsArgument)
|
||||
}
|
||||
|
||||
/// 待打开的首页调试菜单标题。
|
||||
static var pendingMenuTitle: String? {
|
||||
argumentValue(following: openMenuArgument)
|
||||
}
|
||||
|
||||
/// 待打开的个人中心路由标识。
|
||||
static var pendingProfileRouteRawValue: String? {
|
||||
argumentValue(following: openProfileArgument)
|
||||
}
|
||||
|
||||
/// 读取启动参数中紧跟在指定 flag 后的值。
|
||||
private static func argumentValue(following flag: String) -> String? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else {
|
||||
return nil
|
||||
}
|
||||
let value = arguments[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
/// 按需在冷启动时清理 token、账号快照和偏好设置。
|
||||
static func resetIfNeeded(arguments: [String] = ProcessInfo.processInfo.arguments) {
|
||||
guard arguments.contains(resetArgument) else { return }
|
||||
|
||||
try? SessionTokenStore().clear()
|
||||
AccountSnapshotStore().clear()
|
||||
let preferences = AppPreferencesStore()
|
||||
preferences.clear()
|
||||
}
|
||||
}
|
||||
95
suixinkan_ios/App/AppUITestRouteDriver.swift
Normal file
95
suixinkan_ios/App/AppUITestRouteDriver.swift
Normal file
@ -0,0 +1,95 @@
|
||||
//
|
||||
// AppUITestRouteDriver.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
import UIKit
|
||||
|
||||
/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开自定义 TabBar 下 XCUITest 难以触发的 push。
|
||||
@MainActor
|
||||
enum AppUITestRouteDriver {
|
||||
private static var didApply = false
|
||||
|
||||
/// 根据启动参数跳转到指定首页菜单或个人中心页面。
|
||||
static func applyIfNeeded(services: AppServices = .shared) async {
|
||||
guard AppUITestLaunchState.isRunningUITests, !didApply else { return }
|
||||
guard AppUITestLaunchState.pendingMenuTitle != nil
|
||||
|| AppUITestLaunchState.pendingProfileRouteRawValue != nil else { return }
|
||||
|
||||
didApply = true
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
|
||||
if let menuTitle = AppUITestLaunchState.pendingMenuTitle {
|
||||
openHomeMenu(title: menuTitle, services: services)
|
||||
return
|
||||
}
|
||||
|
||||
if let rawValue = AppUITestLaunchState.pendingProfileRouteRawValue {
|
||||
openProfileRoute(rawValue: rawValue, services: services)
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开首页调试目录中的指定菜单。
|
||||
private static func openHomeMenu(title: String, services: AppServices) {
|
||||
let resolvedTitle = resolveMenuTitle(title)
|
||||
guard let item = HomeMenuRouter.debugAllMenuItems().first(where: { $0.title == resolvedTitle }) else {
|
||||
return
|
||||
}
|
||||
|
||||
services.appRouter.reset()
|
||||
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
|
||||
switch resolved {
|
||||
case .tab(let tab):
|
||||
services.appRouter.select(tab)
|
||||
case .orders(let entry):
|
||||
services.appRouter.selectOrders(entry: entry)
|
||||
case .destination(let homeRoute):
|
||||
services.appRouter.navigateHome(homeRoute, animated: false)
|
||||
case .unsupported(let uri, let title, _):
|
||||
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
|
||||
case .placeholder(let uri, let title):
|
||||
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开个人中心二级页面。
|
||||
private static func openProfileRoute(rawValue: String, services: AppServices) {
|
||||
let route: ProfileRoute?
|
||||
switch rawValue {
|
||||
case "settings":
|
||||
route = .settings
|
||||
case "realNameAuth":
|
||||
route = .realNameAuth
|
||||
case "accountSwitch":
|
||||
route = .accountSwitch
|
||||
case "debugHomeMenus":
|
||||
route = .debugHomeMenus
|
||||
case "agreement.about":
|
||||
route = .agreement(.about)
|
||||
case "agreement.userAgreement":
|
||||
route = .agreement(.userAgreement)
|
||||
case "agreement.privacyPolicy":
|
||||
route = .agreement(.privacyPolicy)
|
||||
default:
|
||||
route = nil
|
||||
}
|
||||
|
||||
guard let route else { return }
|
||||
services.appRouter.reset()
|
||||
services.appRouter.navigateProfile(route, animated: false)
|
||||
}
|
||||
|
||||
/// 将 UI Test 用例菜单名映射为调试目录标题。
|
||||
private static func resolveMenuTitle(_ title: String) -> String {
|
||||
switch title {
|
||||
case "系统设置":
|
||||
return "设置中心"
|
||||
default:
|
||||
return title
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
181
suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift
Normal file
181
suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift
Normal file
@ -0,0 +1,181 @@
|
||||
//
|
||||
// AppRouteViewControllerFactory.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 应用路由 ViewController 工厂,将 `AppRoute` 及各模块路由映射为 UIKit 页面。
|
||||
enum AppRouteViewControllerFactory {
|
||||
|
||||
/// 根据路由创建目标 ViewController;未迁移页面使用占位页。
|
||||
static func makeViewController(for route: AppRoute, services: AppServices) -> UIViewController {
|
||||
switch route {
|
||||
case .placeholder(let title):
|
||||
return PlaceholderViewController(title: title)
|
||||
case .home(let homeRoute):
|
||||
return makeViewController(for: homeRoute, services: services)
|
||||
case .profile(let profileRoute):
|
||||
return makeViewController(for: profileRoute, services: services)
|
||||
case .orders(let ordersRoute):
|
||||
return makeViewController(for: ordersRoute, services: services)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据首页二级路由创建 ViewController。
|
||||
static func makeViewController(for route: HomeRoute, services: AppServices) -> UIViewController {
|
||||
switch route {
|
||||
case .profileSpace:
|
||||
return ProfileViewController()
|
||||
case .scenicSelection:
|
||||
return ScenicSelectionViewController()
|
||||
case .permissionApply:
|
||||
return PermissionApplyViewController()
|
||||
case .permissionApplyStatus:
|
||||
return PermissionApplyStatusViewController()
|
||||
case .scenicApplication:
|
||||
return ScenicApplicationViewController()
|
||||
case .moreFunctions:
|
||||
return HomeMoreFunctionsViewController()
|
||||
case .settings:
|
||||
return SettingsViewController()
|
||||
case .paymentCollection:
|
||||
return PaymentCollectionViewController()
|
||||
case .wallet:
|
||||
return WalletViewController()
|
||||
case .taskManagement:
|
||||
return TaskManagementViewController()
|
||||
case .taskCreate:
|
||||
return TaskCreateViewController()
|
||||
case .taskDetail(let id, let summary):
|
||||
return TaskDetailViewController(taskId: id, summary: summary)
|
||||
case .projectManagement:
|
||||
return ProjectManagementViewController()
|
||||
case .pmProjectManagement:
|
||||
return StoreProjectManagementViewController()
|
||||
case .projectDetail(let id, let storeMode):
|
||||
return ProjectDetailViewController(projectId: id, storeMode: storeMode)
|
||||
case .projectEditor(let id, let storeMode):
|
||||
if storeMode {
|
||||
return StoreProjectEditorViewController(projectId: id)
|
||||
}
|
||||
return ProjectEditorViewController(projectId: id)
|
||||
case .scheduleManagement:
|
||||
return ScheduleManagementViewController()
|
||||
case .scheduleAdd:
|
||||
return ScheduleAddViewController()
|
||||
case .photographerInvite:
|
||||
return PhotographerInviteViewController()
|
||||
case .inviteRecord:
|
||||
return InviteRecordViewController()
|
||||
case .cloudStorage:
|
||||
return CloudStorageViewController()
|
||||
case .cloudStorageTransit:
|
||||
return CloudStorageTransitViewController()
|
||||
case .materialLibrary:
|
||||
return MediaLibraryViewController(kind: .material)
|
||||
case .materialUpload:
|
||||
return MediaLibraryUploadViewController(kind: .material)
|
||||
case .sampleLibrary:
|
||||
return MediaLibraryViewController(kind: .sample)
|
||||
case .sampleUpload:
|
||||
return MediaLibraryUploadViewController(kind: .sample)
|
||||
case .albumList:
|
||||
return AlbumListViewController()
|
||||
case .albumTrailer:
|
||||
return AlbumTrailerViewController()
|
||||
case .punchPointList:
|
||||
return PunchPointListViewController()
|
||||
case .punchPointDetail(let id, let summary):
|
||||
return PunchPointDetailViewController(punchPointId: id, summary: summary)
|
||||
case .punchPointEditor(let id):
|
||||
return PunchPointEditorViewController(punchPointId: id)
|
||||
case .punchPointQR(_, let title, let qrURL):
|
||||
return PunchPointQRViewController(title: title, qrURL: qrURL)
|
||||
case .locationReport:
|
||||
return LocationReportViewController()
|
||||
case .locationReportHistory:
|
||||
return LocationReportHistoryViewController()
|
||||
case .depositOrders:
|
||||
return DepositOrderListViewController()
|
||||
case .withdrawalAudit:
|
||||
return WithdrawalAuditViewController()
|
||||
case .scenicSettlement:
|
||||
return ScenicSettlementViewController()
|
||||
case .scenicSettlementReview:
|
||||
return ScenicSettlementReviewViewController()
|
||||
case .messageCenter:
|
||||
return MessageCenterViewController()
|
||||
case .queueManagement:
|
||||
return QueueManagementViewController()
|
||||
case .liveManagement:
|
||||
return LiveManagementViewController()
|
||||
case .liveAlbum:
|
||||
return LiveAlbumViewController()
|
||||
case .operatingArea:
|
||||
return OperatingAreaViewController()
|
||||
case .pilotCertification:
|
||||
return PilotCertificationViewController()
|
||||
case .modulePlaceholder(_, let title):
|
||||
return FeaturePlaceholderViewController(title: title, uri: homeRouteURI(route))
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据个人中心二级路由创建 ViewController。
|
||||
static func makeViewController(for route: ProfileRoute, services: AppServices) -> UIViewController {
|
||||
switch route {
|
||||
case .accountSwitch:
|
||||
return AccountSwitchViewController()
|
||||
case .realNameAuth:
|
||||
return RealNameAuthViewController()
|
||||
case .settings:
|
||||
return SettingsViewController()
|
||||
case .agreement(let page):
|
||||
return AgreementViewController(page: page)
|
||||
#if DEBUG
|
||||
case .debugHomeMenus:
|
||||
return HomeMoreFunctionsViewController()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据订单模块二级路由创建 ViewController。
|
||||
static func makeViewController(for route: OrdersRoute, services: AppServices) -> UIViewController {
|
||||
switch route {
|
||||
case .storeDetail(let item):
|
||||
return StoreOrderDetailViewController(item: item)
|
||||
case .writeOffDetail(let item):
|
||||
return WriteOffOrderDetailViewController(item: item)
|
||||
case .depositDetail(let orderNumber):
|
||||
return DepositOrderDetailViewController(orderNumber: orderNumber)
|
||||
case .depositShootingInfo(let orderNumber, let scenicSpotId, let photogUid):
|
||||
return DepositOrderShootingInfoViewController(
|
||||
orderNumber: orderNumber,
|
||||
scenicSpotId: scenicSpotId,
|
||||
photogUid: photogUid
|
||||
)
|
||||
case .historicalShooting(let orderNumber):
|
||||
return HistoricalShootingInfoViewController(orderNumber: orderNumber)
|
||||
case .multiTravelTaskUpload(let orderNumber):
|
||||
return MultiTravelTaskUploadViewController(initialOrderNumber: orderNumber)
|
||||
case .orderTrailer(let orderNumber, let title):
|
||||
return FeaturePlaceholderViewController(title: title, uri: "order_trailer:\(orderNumber)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func homeRouteURI(_ route: HomeRoute) -> String {
|
||||
if case let .modulePlaceholder(uri, _) = route {
|
||||
return uri
|
||||
}
|
||||
return String(describing: route)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。
|
||||
enum HomeRouteViewControllerFactory {
|
||||
static func make(for route: HomeRoute) -> UIViewController {
|
||||
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
|
||||
}
|
||||
}
|
||||
58
suixinkan_ios/App/Navigation/AppTab.swift
Normal file
58
suixinkan_ios/App/Navigation/AppTab.swift
Normal file
@ -0,0 +1,58 @@
|
||||
//
|
||||
// AppTab.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 主 Tab 实体,描述底部导航中的每一个一级入口。
|
||||
enum AppTab: String, CaseIterable, Identifiable, Hashable {
|
||||
case home
|
||||
case orders
|
||||
case statistics
|
||||
case profile
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home:
|
||||
"首页"
|
||||
case .orders:
|
||||
"订单"
|
||||
case .statistics:
|
||||
"数据"
|
||||
case .profile:
|
||||
"我的"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .home:
|
||||
"house"
|
||||
case .orders:
|
||||
"doc.text"
|
||||
case .statistics:
|
||||
"chart.bar"
|
||||
case .profile:
|
||||
"person"
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 Tab 根页面 ViewController。
|
||||
func makeRootViewController(services: AppServices) -> UIViewController {
|
||||
switch self {
|
||||
case .home:
|
||||
return HomeViewController()
|
||||
case .orders:
|
||||
return OrdersViewController()
|
||||
case .statistics:
|
||||
return StatisticsViewController()
|
||||
case .profile:
|
||||
return ProfileViewController()
|
||||
}
|
||||
}
|
||||
}
|
||||
109
suixinkan_ios/App/Navigation/NavigationRouter.swift
Normal file
109
suixinkan_ios/App/Navigation/NavigationRouter.swift
Normal file
@ -0,0 +1,109 @@
|
||||
//
|
||||
// NavigationRouter.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 应用内可导航目标,承载各 Tab 内部的路由目的地。
|
||||
enum AppRoute: Hashable {
|
||||
case placeholder(title: String)
|
||||
case home(HomeRoute)
|
||||
case profile(ProfileRoute)
|
||||
case orders(OrdersRoute)
|
||||
|
||||
/// 返回该路由通过 push 后是否隐藏底部 TabBar。
|
||||
var hidesTabBarWhenPushed: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@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 = []
|
||||
}
|
||||
}
|
||||
|
||||
@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?() } }
|
||||
|
||||
private let routers: [AppTab: RouterPath]
|
||||
|
||||
init() {
|
||||
var builtRouters: [AppTab: RouterPath] = [:]
|
||||
for tab in AppTab.allCases {
|
||||
builtRouters[tab] = RouterPath()
|
||||
}
|
||||
routers = builtRouters
|
||||
routers.values.forEach { router in
|
||||
router.onChange = { [weak self] in
|
||||
self?.onChange?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定 Tab 对应的路由路径容器。
|
||||
func router(for tab: AppTab) -> RouterPath {
|
||||
guard let router = routers[tab] else {
|
||||
assertionFailure("Missing router path for tab: \(tab)")
|
||||
return RouterPath()
|
||||
}
|
||||
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() }
|
||||
}
|
||||
}
|
||||
111
suixinkan_ios/App/Navigation/TabNavigationController.swift
Normal file
111
suixinkan_ios/App/Navigation/TabNavigationController.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// TabNavigationController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 单个 Tab 的导航栈,与 `AppRouter` 路径同步并在 push 时隐藏底部 TabBar。
|
||||
final class TabNavigationController: UINavigationController, UINavigationControllerDelegate {
|
||||
|
||||
let appTab: AppTab
|
||||
private let services: AppServices
|
||||
private var isSyncingStack = false
|
||||
|
||||
weak var tabBarHost: MainTabBarController?
|
||||
|
||||
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)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// Push 指定应用路由,并同步写入 RouterPath。
|
||||
func pushRoute(_ 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)
|
||||
}
|
||||
|
||||
func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
willShow viewController: UIViewController,
|
||||
animated: Bool
|
||||
) {
|
||||
let isRoot = viewController === viewControllers.first
|
||||
tabBarHost?.setCustomTabBarHidden(!isRoot, animated: animated)
|
||||
}
|
||||
|
||||
func navigationController(
|
||||
_ navigationController: UINavigationController,
|
||||
didShow viewController: UIViewController,
|
||||
animated: Bool
|
||||
) {
|
||||
syncRouterPathFromStack()
|
||||
}
|
||||
|
||||
private func registerNavigationBridge() {
|
||||
switch appTab {
|
||||
case .home:
|
||||
UIKitAppNavigation.homeNavigationController = self
|
||||
case .profile:
|
||||
UIKitAppNavigation.profileNavigationController = self
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
if targetDepth == 0 {
|
||||
popToRootViewController(animated: true)
|
||||
} else if targetDepth < viewControllers.count {
|
||||
popToViewController(viewControllers[targetDepth], animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
46
suixinkan_ios/App/Navigation/UIKitAppNavigation.swift
Normal file
46
suixinkan_ios/App/Navigation/UIKitAppNavigation.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
150
suixinkan_ios/App/State/AccountContext.swift
Normal file
150
suixinkan_ios/App/State/AccountContext.swift
Normal file
@ -0,0 +1,150 @@
|
||||
//
|
||||
// AccountContext.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 当前账号的基础资料实体,用于跨页面展示昵称、手机号和头像。
|
||||
struct AccountProfile: Codable, Equatable {
|
||||
var userId: String
|
||||
var displayName: String
|
||||
var phone: String?
|
||||
var avatarURL: String?
|
||||
}
|
||||
|
||||
/// 当前业务作用域实体,表示景区或门店维度的上下文。
|
||||
struct BusinessScope: Codable, Equatable, Identifiable {
|
||||
/// 业务作用域类型,用于区分景区和门店。
|
||||
enum Kind: Codable, Equatable {
|
||||
case scenic
|
||||
case store
|
||||
}
|
||||
|
||||
var id: Int
|
||||
var name: String
|
||||
var kind: Kind
|
||||
var parentScenicId: Int?
|
||||
var status: Int?
|
||||
var address: String?
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var coverURLString: String?
|
||||
|
||||
/// 创建业务作用域,门店可通过 parentScenicId 标记所属景区。
|
||||
init(
|
||||
id: Int,
|
||||
name: String,
|
||||
kind: Kind,
|
||||
parentScenicId: Int? = nil,
|
||||
status: Int? = nil,
|
||||
address: String? = nil,
|
||||
latitude: Double? = nil,
|
||||
longitude: Double? = nil,
|
||||
coverURLString: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.parentScenicId = parentScenicId
|
||||
self.status = status
|
||||
self.address = address
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.coverURLString = coverURLString
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文状态中心,保存当前账号资料和景区/门店作用域。
|
||||
final class AccountContext {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var profile: AccountProfile? { didSet { onChange?() } }
|
||||
private(set) var scenicScopes: [BusinessScope] = [] { didSet { onChange?() } }
|
||||
private(set) var storeScopes: [BusinessScope] = [] { didSet { onChange?() } }
|
||||
var currentScenic: BusinessScope? { didSet { onChange?() } }
|
||||
var currentStore: BusinessScope? { didSet { onChange?() } }
|
||||
|
||||
/// 应用登录成功后写入账号资料。
|
||||
func applyLogin(profile: AccountProfile? = nil) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
/// 替换当前账号资料,通常用于用户信息刷新后同步全局展示。
|
||||
func replaceProfile(_ profile: AccountProfile?) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
/// 替换景区和门店作用域,并尽量保持当前选择不变。
|
||||
func replaceScopes(
|
||||
scenic: [BusinessScope],
|
||||
stores: [BusinessScope],
|
||||
currentScenicId: Int? = nil,
|
||||
currentStoreId: Int? = nil
|
||||
) {
|
||||
scenicScopes = scenic
|
||||
storeScopes = stores
|
||||
currentScenic = currentScenicId.flatMap { id in
|
||||
scenic.first { $0.id == id }
|
||||
} ?? currentScenic.flatMap { current in
|
||||
scenic.first { $0.id == current.id }
|
||||
} ?? scenic.first
|
||||
currentStore = resolvedStore(
|
||||
in: stores,
|
||||
currentScenic: currentScenic,
|
||||
preferredStoreId: currentStoreId ?? currentStore?.id
|
||||
)
|
||||
}
|
||||
|
||||
/// 切换当前景区,并按景区重新校准当前门店。
|
||||
func selectScenic(id scenicId: Int) {
|
||||
guard let scenic = scenicScopes.first(where: { $0.id == scenicId }) else { return }
|
||||
currentScenic = scenic
|
||||
currentStore = resolvedStore(
|
||||
in: storeScopes,
|
||||
currentScenic: scenic,
|
||||
preferredStoreId: currentStore?.id
|
||||
)
|
||||
}
|
||||
|
||||
/// 切换当前门店,并在门店有关联景区时同步当前景区。
|
||||
func selectStore(id storeId: Int) {
|
||||
guard let store = storeScopes.first(where: { $0.id == storeId }) else { return }
|
||||
currentStore = store
|
||||
if let scenicId = store.parentScenicId,
|
||||
currentScenic?.id != scenicId,
|
||||
let scenic = scenicScopes.first(where: { $0.id == scenicId }) {
|
||||
currentScenic = scenic
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空账号上下文,通常在退出登录时调用。
|
||||
func reset() {
|
||||
profile = nil
|
||||
scenicScopes = []
|
||||
storeScopes = []
|
||||
currentScenic = nil
|
||||
currentStore = nil
|
||||
}
|
||||
|
||||
/// 按当前景区和期望门店 ID 选择有效门店。
|
||||
private func resolvedStore(
|
||||
in stores: [BusinessScope],
|
||||
currentScenic: BusinessScope?,
|
||||
preferredStoreId: Int?
|
||||
) -> BusinessScope? {
|
||||
let scenicId = currentScenic?.id
|
||||
let storesForScenic = stores.filter { store in
|
||||
guard let scenicId else { return true }
|
||||
return store.parentScenicId == nil || store.parentScenicId == scenicId
|
||||
}
|
||||
if let preferredStoreId,
|
||||
let store = storesForScenic.first(where: { $0.id == preferredStoreId }) {
|
||||
return store
|
||||
}
|
||||
return storesForScenic.first
|
||||
}
|
||||
}
|
||||
115
suixinkan_ios/App/State/AccountContextLoader.swift
Normal file
115
suixinkan_ios/App/State/AccountContextLoader.swift
Normal file
@ -0,0 +1,115 @@
|
||||
//
|
||||
// AccountContextLoader.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 用户资料服务协议,抽象用户信息读取能力以便测试替换。
|
||||
protocol UserProfileServing {
|
||||
/// 获取当前登录账号的用户资料。
|
||||
func userInfo() async throws -> UserInfoResponse
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文加载器,统一装配用户资料、角色权限、景区和门店上下文。
|
||||
struct AccountContextLoader {
|
||||
/// 刷新账号上下文,权限失败会向外抛错,景区和门店按旧工程规则兜底。
|
||||
func refresh(
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
profileAPI: UserProfileServing,
|
||||
accountContextAPI: AccountContextServing,
|
||||
cachedCurrentRoleId: Int? = nil,
|
||||
cachedCurrentScenicId: Int? = nil,
|
||||
cachedCurrentStoreId: Int? = nil
|
||||
) async throws {
|
||||
async let userData = profileAPI.userInfo()
|
||||
async let roleData = accountContextAPI.rolePermissions()
|
||||
let (userInfo, rolePermissions) = try await (userData, roleData)
|
||||
|
||||
permissionContext.replaceRolePermissions(rolePermissions, currentRoleId: cachedCurrentRoleId)
|
||||
|
||||
let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions)
|
||||
let storesResponse = await loadStores(accountContextAPI: accountContextAPI)
|
||||
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
|
||||
let scenicScopes = scopedScenics(
|
||||
permissionContext: permissionContext,
|
||||
scenicResponse: scenicResponse
|
||||
)
|
||||
let storeScopes = storesResponse?.list.map { store in
|
||||
BusinessScope(id: store.id, name: store.name, kind: .store, parentScenicId: store.scenicId)
|
||||
} ?? []
|
||||
|
||||
accountContext.replaceProfile(profile)
|
||||
accountContext.replaceScopes(
|
||||
scenic: scenicScopes,
|
||||
stores: storeScopes,
|
||||
currentScenicId: cachedCurrentScenicId,
|
||||
currentStoreId: cachedCurrentStoreId
|
||||
)
|
||||
}
|
||||
|
||||
/// 景区列表接口失败时,从角色权限中的景区数据去重构造兜底列表。
|
||||
private func loadScenicList(
|
||||
accountContextAPI: AccountContextServing,
|
||||
rolePermissions: [RolePermissionResponse]
|
||||
) async -> ScenicListAllResponse {
|
||||
do {
|
||||
return try await accountContextAPI.scenicListAll()
|
||||
} catch {
|
||||
let fallback = uniqueScenics(from: rolePermissions)
|
||||
return ScenicListAllResponse(total: fallback.count, list: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
/// 门店列表接口失败时返回 nil,不阻断登录和恢复流程。
|
||||
private func loadStores(accountContextAPI: AccountContextServing) async -> ListPayload<StoreItem>? {
|
||||
try? await accountContextAPI.storeAll()
|
||||
}
|
||||
|
||||
/// 根据当前角色优先返回角色可访问景区,角色无景区时使用景区列表接口结果。
|
||||
private func scopedScenics(
|
||||
permissionContext: PermissionContext,
|
||||
scenicResponse: ScenicListAllResponse
|
||||
) -> [BusinessScope] {
|
||||
let roleScenics = permissionContext.currentRoleScenicScopes()
|
||||
if !roleScenics.isEmpty {
|
||||
return roleScenics
|
||||
}
|
||||
return scenicResponse.list.map {
|
||||
BusinessScope(id: $0.id, name: $0.name, kind: .scenic)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从角色权限中去重生成景区列表。
|
||||
private func uniqueScenics(from rolePermissions: [RolePermissionResponse]) -> [ScenicListItem] {
|
||||
var seen = Set<Int>()
|
||||
var result: [ScenicListItem] = []
|
||||
for permission in rolePermissions {
|
||||
for scenic in permission.scenic where seen.insert(scenic.id).inserted {
|
||||
result.append(ScenicListItem(id: scenic.id, name: scenic.name))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// 将用户资料接口响应转换为全局账号资料。
|
||||
private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile {
|
||||
AccountProfile(
|
||||
userId: fallback?.userId ?? "",
|
||||
displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称",
|
||||
phone: nonEmpty(userInfo.phone) ?? fallback?.phone,
|
||||
avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL
|
||||
)
|
||||
}
|
||||
|
||||
/// 去除空白字符后返回非空字符串。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
46
suixinkan_ios/App/State/AppSession.swift
Normal file
46
suixinkan_ios/App/State/AppSession.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// AppSession.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 登录阶段实体,表示根视图当前应该展示的认证状态。
|
||||
enum AuthPhase: Equatable {
|
||||
case loggedOut
|
||||
case restoring
|
||||
case loggedIn
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 登录会话状态中心,保存 token 和当前认证阶段。
|
||||
final class AppSession {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var phase: AuthPhase = .loggedOut { didSet { onChange?() } }
|
||||
private(set) var token: String? { didSet { onChange?() } }
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
phase == .loggedIn
|
||||
}
|
||||
|
||||
/// 将会话切换到恢复中状态,用于冷启动恢复登录态。
|
||||
func beginRestoring(token: String? = nil) {
|
||||
self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
phase = .restoring
|
||||
}
|
||||
|
||||
/// 标记登录成功并保存正式 token。
|
||||
func markLoggedIn(token: String? = nil) {
|
||||
self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
phase = .loggedIn
|
||||
}
|
||||
|
||||
/// 清空 token 并回到未登录状态。
|
||||
func logout() {
|
||||
token = nil
|
||||
phase = .loggedOut
|
||||
}
|
||||
}
|
||||
209
suixinkan_ios/App/State/AuthSessionCoordinator.swift
Normal file
209
suixinkan_ios/App/State/AuthSessionCoordinator.swift
Normal file
@ -0,0 +1,209 @@
|
||||
//
|
||||
// AuthSessionCoordinator.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 登录会话协调器,统一处理登录完成、退出登录和缓存同步。
|
||||
final class AuthSessionCoordinator {
|
||||
private let tokenStore: SessionTokenStore
|
||||
private let snapshotStore: AccountSnapshotStore
|
||||
private let preferencesStore: AppPreferencesStore
|
||||
|
||||
/// 初始化登录会话协调器,并注入 token、账号快照和偏好存储。
|
||||
init(
|
||||
tokenStore: SessionTokenStore,
|
||||
snapshotStore: AccountSnapshotStore,
|
||||
preferencesStore: AppPreferencesStore
|
||||
) {
|
||||
self.tokenStore = tokenStore
|
||||
self.snapshotStore = snapshotStore
|
||||
self.preferencesStore = preferencesStore
|
||||
}
|
||||
|
||||
/// 创建使用默认存储服务的登录会话协调器。
|
||||
convenience init() {
|
||||
self.init(
|
||||
tokenStore: SessionTokenStore(),
|
||||
snapshotStore: AccountSnapshotStore(),
|
||||
preferencesStore: AppPreferencesStore()
|
||||
)
|
||||
}
|
||||
|
||||
/// 读取登录页需要的本地偏好。
|
||||
func loginPreferences() -> LoginPreferences {
|
||||
LoginPreferences(
|
||||
lastUsername: preferencesStore.loadLastLoginUsername(),
|
||||
privacyAgreementAccepted: preferencesStore.loadPrivacyAgreementAccepted()
|
||||
)
|
||||
}
|
||||
|
||||
/// 完成正式登录,写入内存状态、Keychain token 和账号快照。
|
||||
func completeLogin(
|
||||
with response: V9AuthResponse,
|
||||
username: String,
|
||||
privacyAgreementAccepted: Bool,
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
profileAPI: UserProfileServing,
|
||||
accountContextAPI: AccountContextServing
|
||||
) async throws {
|
||||
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try tokenStore.save(token)
|
||||
preferencesStore.saveLastLoginUsername(username)
|
||||
preferencesStore.savePrivacyAgreementAccepted(privacyAgreementAccepted)
|
||||
|
||||
let profile = response.primaryProfile
|
||||
let scenicScopes = response.scenicScopes
|
||||
let storeScopes = response.storeScopes
|
||||
let identity = response.currentAccountIdentity
|
||||
accountContext.applyLogin(profile: profile)
|
||||
accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes)
|
||||
appSession.beginRestoring(token: token)
|
||||
|
||||
do {
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentScenicId: accountContext.currentScenic?.id,
|
||||
cachedCurrentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
} catch {
|
||||
if APIError.isAuthenticationExpired(error) {
|
||||
try? tokenStore.clear()
|
||||
snapshotStore.clear()
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
appSession.logout()
|
||||
throw error
|
||||
}
|
||||
appSession.markLoggedIn(token: token)
|
||||
saveSnapshot(
|
||||
profile: accountContext.profile ?? profile,
|
||||
accountType: identity?.accountType,
|
||||
businessUserId: identity?.businessUserId,
|
||||
permissionContext: permissionContext,
|
||||
accountContext: accountContext
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
saveSnapshot(
|
||||
profile: accountContext.profile ?? profile,
|
||||
accountType: identity?.accountType,
|
||||
businessUserId: identity?.businessUserId,
|
||||
permissionContext: permissionContext,
|
||||
accountContext: accountContext
|
||||
)
|
||||
appSession.markLoggedIn(token: token)
|
||||
}
|
||||
|
||||
/// 使用最新用户资料刷新账号快照中的展示信息。
|
||||
func refreshCachedProfile(from userInfo: UserInfoResponse, accountContext: AccountContext) {
|
||||
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
|
||||
accountContext.replaceProfile(profile)
|
||||
saveSnapshot(
|
||||
profile: profile,
|
||||
accountType: snapshotStore.load()?.accountType,
|
||||
businessUserId: snapshotStore.load()?.businessUserId,
|
||||
currentRoleId: snapshotStore.load()?.currentRoleId,
|
||||
accountContext: accountContext
|
||||
)
|
||||
}
|
||||
|
||||
/// 退出登录并清空敏感缓存,保留手机号和协议状态等偏好。
|
||||
func logout(
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
scenicSpotContext: ScenicSpotContext,
|
||||
appRouter: AppRouter,
|
||||
toastCenter: ToastCenter
|
||||
) {
|
||||
try? tokenStore.clear()
|
||||
snapshotStore.clear()
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
scenicSpotContext.reset()
|
||||
appRouter.reset()
|
||||
toastCenter.dismiss()
|
||||
appSession.logout()
|
||||
}
|
||||
|
||||
/// 保存当前账号上下文的快照。
|
||||
private func saveSnapshot(
|
||||
profile: AccountProfile?,
|
||||
accountType: String?,
|
||||
businessUserId: Int?,
|
||||
permissionContext: PermissionContext? = nil,
|
||||
currentRoleId: Int? = nil,
|
||||
accountContext: AccountContext
|
||||
) {
|
||||
snapshotStore.save(
|
||||
AccountSnapshot(
|
||||
profile: profile,
|
||||
accountType: accountType,
|
||||
businessUserId: businessUserId,
|
||||
currentRoleId: permissionContext?.currentRole?.id ?? currentRoleId,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
storeScopes: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
currentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 将用户资料接口响应转换为全局账号资料。
|
||||
private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile {
|
||||
AccountProfile(
|
||||
userId: fallback?.userId ?? "",
|
||||
displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称",
|
||||
phone: nonEmpty(userInfo.phone) ?? fallback?.phone,
|
||||
avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL
|
||||
)
|
||||
}
|
||||
|
||||
/// 去除空白字符后返回非空字符串。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录页偏好实体,表示可安全恢复到表单的非敏感信息。
|
||||
struct LoginPreferences: Equatable {
|
||||
let lastUsername: String?
|
||||
let privacyAgreementAccepted: Bool
|
||||
}
|
||||
|
||||
private extension V9AuthResponse {
|
||||
/// 当前账号身份实体,表示登录后实际选中的业务账号。
|
||||
struct CurrentAccountIdentity {
|
||||
let accountType: String
|
||||
let businessUserId: Int
|
||||
}
|
||||
|
||||
/// 提取当前账号身份,优先使用后端标记的 isCurrent 账号。
|
||||
var currentAccountIdentity: CurrentAccountIdentity? {
|
||||
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first {
|
||||
return CurrentAccountIdentity(
|
||||
accountType: storeUser.accountType,
|
||||
businessUserId: storeUser.businessUserId
|
||||
)
|
||||
}
|
||||
if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first {
|
||||
return CurrentAccountIdentity(
|
||||
accountType: scenicUser.accountType,
|
||||
businessUserId: scenicUser.businessUserId
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
92
suixinkan_ios/App/State/PermissionContext.swift
Normal file
92
suixinkan_ios/App/State/PermissionContext.swift
Normal file
@ -0,0 +1,92 @@
|
||||
//
|
||||
// PermissionContext.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 权限上下文状态中心,保存角色权限、当前角色和扁平化权限 URI。
|
||||
final class PermissionContext {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var rolePermissions: [RolePermissionResponse] = [] { didSet { onChange?() } }
|
||||
private(set) var permissionURIs: Set<String> = [] { didSet { onChange?() } }
|
||||
var currentRole: RoleInfo? { didSet { onChange?() } }
|
||||
|
||||
/// 替换角色权限列表,并尽量按缓存角色 ID 保持当前角色。
|
||||
func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) {
|
||||
self.rolePermissions = rolePermissions
|
||||
currentRole = currentRoleId.flatMap { id in
|
||||
rolePermissions.first { $0.role.id == id }?.role
|
||||
} ?? currentRole.flatMap { role in
|
||||
rolePermissions.first { $0.role.id == role.id }?.role
|
||||
} ?? rolePermissions.first?.role
|
||||
permissionURIs = Set(Self.flattenPermissions(currentRole?.permission ?? []))
|
||||
}
|
||||
|
||||
/// 切换当前角色,并同步账号上下文中的景区和门店选择。
|
||||
func selectRole(id roleId: Int, accountContext: AccountContext) {
|
||||
guard let rolePermission = rolePermissions.first(where: { $0.role.id == roleId }) else { return }
|
||||
currentRole = rolePermission.role
|
||||
permissionURIs = Set(Self.flattenPermissions(rolePermission.role.permission))
|
||||
|
||||
let scenicScopes = Self.uniqueScenicScopes(from: rolePermission.scenic)
|
||||
accountContext.replaceScopes(
|
||||
scenic: scenicScopes,
|
||||
stores: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
currentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
}
|
||||
|
||||
/// 判断当前角色是否拥有指定 URI 权限。
|
||||
func canAccess(_ uri: String) -> Bool {
|
||||
permissionURIs.contains(uri.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
/// 返回当前角色关联的景区作用域。
|
||||
func currentRoleScenicScopes() -> [BusinessScope] {
|
||||
guard let currentRole else { return [] }
|
||||
guard let rolePermission = rolePermissions.first(where: { $0.role.id == currentRole.id }) else {
|
||||
return []
|
||||
}
|
||||
return Self.uniqueScenicScopes(from: rolePermission.scenic)
|
||||
}
|
||||
|
||||
/// 清空权限上下文,通常在退出登录时调用。
|
||||
func reset() {
|
||||
rolePermissions = []
|
||||
permissionURIs = []
|
||||
currentRole = nil
|
||||
}
|
||||
|
||||
/// 递归提取权限树里的非空 URI。
|
||||
private static func flattenPermissions(_ permissions: [PermissionItem]) -> [String] {
|
||||
permissions.flatMap { item -> [String] in
|
||||
let current = item.uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let children = flattenPermissions(item.children)
|
||||
return current.isEmpty ? children : [current] + children
|
||||
}
|
||||
}
|
||||
|
||||
/// 将角色关联景区去重并转换为业务作用域。
|
||||
private static func uniqueScenicScopes(from scenics: [ScenicInfo]) -> [BusinessScope] {
|
||||
var seen = Set<Int>()
|
||||
return scenics.compactMap { scenic in
|
||||
guard seen.insert(scenic.id).inserted else { return nil }
|
||||
return BusinessScope(
|
||||
id: scenic.id,
|
||||
name: scenic.name,
|
||||
kind: .scenic,
|
||||
status: scenic.status,
|
||||
address: scenic.location?.address,
|
||||
latitude: scenic.location?.lat,
|
||||
longitude: scenic.location?.lng,
|
||||
coverURLString: scenic.coverImg
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
suixinkan_ios/App/State/ScenicSpotContext.swift
Normal file
60
suixinkan_ios/App/State/ScenicSpotContext.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// ScenicSpotContext.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 景点加载阶段实体,表示当前景区景点列表的加载状态。
|
||||
enum ScenicSpotLoadState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 景点上下文状态中心,保存当前景区下的景点或打卡点列表。
|
||||
final class ScenicSpotContext {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var scenicId: Int? { didSet { onChange?() } }
|
||||
private(set) var spots: [ScenicSpotItem] = [] { didSet { onChange?() } }
|
||||
private(set) var loadState: ScenicSpotLoadState = .idle { didSet { onChange?() } }
|
||||
|
||||
/// 按景区 ID 重新加载景点列表,失败只影响景点模块本身。
|
||||
func reload(scenicId: Int?, api: AccountContextServing) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if self.scenicId != scenicId {
|
||||
spots = []
|
||||
}
|
||||
self.scenicId = scenicId
|
||||
loadState = .loading
|
||||
|
||||
do {
|
||||
let response = try await api.scenicSpotListAll(scenicId: scenicId)
|
||||
guard !Task.isCancelled else { return }
|
||||
spots = response.list
|
||||
loadState = .loaded
|
||||
} catch is CancellationError {
|
||||
loadState = .idle
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
spots = []
|
||||
loadState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空景点上下文,通常在退出登录或没有当前景区时调用。
|
||||
func reset() {
|
||||
scenicId = nil
|
||||
spots = []
|
||||
loadState = .idle
|
||||
}
|
||||
}
|
||||
111
suixinkan_ios/App/State/SessionBootstrapper.swift
Normal file
111
suixinkan_ios/App/State/SessionBootstrapper.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// SessionBootstrapper.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 登录态启动恢复器,负责冷启动时从缓存恢复并校验 token。
|
||||
final class SessionBootstrapper {
|
||||
private let tokenStore: SessionTokenStore
|
||||
private let snapshotStore: AccountSnapshotStore
|
||||
private var didAttemptRestore = false
|
||||
|
||||
/// 初始化启动恢复器,并注入 token 与账号快照存储。
|
||||
init(
|
||||
tokenStore: SessionTokenStore,
|
||||
snapshotStore: AccountSnapshotStore
|
||||
) {
|
||||
self.tokenStore = tokenStore
|
||||
self.snapshotStore = snapshotStore
|
||||
}
|
||||
|
||||
/// 创建使用默认存储服务的启动恢复器。
|
||||
convenience init() {
|
||||
self.init(
|
||||
tokenStore: SessionTokenStore(),
|
||||
snapshotStore: AccountSnapshotStore()
|
||||
)
|
||||
}
|
||||
|
||||
/// 从本地缓存恢复登录态并请求服务端校验。
|
||||
func restore(
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
profileAPI: UserProfileServing,
|
||||
accountContextAPI: AccountContextServing,
|
||||
toastCenter: ToastCenter
|
||||
) async {
|
||||
guard !didAttemptRestore, !appSession.isLoggedIn else { return }
|
||||
didAttemptRestore = true
|
||||
|
||||
guard let token = tokenStore.load() else {
|
||||
appSession.logout()
|
||||
return
|
||||
}
|
||||
|
||||
appSession.beginRestoring(token: token)
|
||||
let cachedSnapshot = restoreCachedSnapshot(to: accountContext)
|
||||
|
||||
do {
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentRoleId: cachedSnapshot?.currentRoleId,
|
||||
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
|
||||
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
|
||||
)
|
||||
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
|
||||
appSession.markLoggedIn(token: token)
|
||||
} catch {
|
||||
if APIError.isAuthenticationExpired(error) {
|
||||
try? tokenStore.clear()
|
||||
snapshotStore.clear()
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
appSession.logout()
|
||||
toastCenter.show("登录状态已失效,请重新登录")
|
||||
} else {
|
||||
appSession.markLoggedIn(token: token)
|
||||
toastCenter.show("网络异常,已使用本地登录状态")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将缓存快照恢复到账号上下文。
|
||||
private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? {
|
||||
guard let snapshot = snapshotStore.load() else { return nil }
|
||||
accountContext.applyLogin(profile: snapshot.profile)
|
||||
accountContext.replaceScopes(
|
||||
scenic: snapshot.scenicScopes,
|
||||
stores: snapshot.storeScopes,
|
||||
currentScenicId: snapshot.currentScenicId,
|
||||
currentStoreId: snapshot.currentStoreId
|
||||
)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 保存服务端校验后的最新账号上下文。
|
||||
private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) {
|
||||
let existing = snapshotStore.load()
|
||||
snapshotStore.save(
|
||||
AccountSnapshot(
|
||||
profile: accountContext.profile,
|
||||
accountType: existing?.accountType,
|
||||
businessUserId: existing?.businessUserId,
|
||||
currentRoleId: permissionContext.currentRole?.id ?? existing?.currentRoleId,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
storeScopes: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
currentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
182
suixinkan_ios/App/State/ToastCenter.swift
Normal file
182
suixinkan_ios/App/State/ToastCenter.swift
Normal file
@ -0,0 +1,182 @@
|
||||
//
|
||||
// ToastCenter.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||||
final class ToastCenter {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var message: String? { didSet { onChange?() } }
|
||||
|
||||
private let autoDismissNanoseconds: UInt64
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
private var displayToken: UInt64 = 0
|
||||
private weak var overlayView: ToastOverlayView?
|
||||
|
||||
/// 创建 Toast 状态中心,默认 2.2 秒后自动隐藏。
|
||||
init(autoDismissNanoseconds: UInt64 = 2_200_000_000) {
|
||||
self.autoDismissNanoseconds = autoDismissNanoseconds
|
||||
}
|
||||
|
||||
/// 显示一条全局 Toast 文案。
|
||||
func show(_ message: String) {
|
||||
displayToken &+= 1
|
||||
self.message = message
|
||||
scheduleAutoDismiss(token: displayToken)
|
||||
}
|
||||
|
||||
/// 清除当前 Toast 文案。
|
||||
func dismiss() {
|
||||
displayToken &+= 1
|
||||
dismissTask?.cancel()
|
||||
dismissTask = nil
|
||||
message = nil
|
||||
}
|
||||
|
||||
/// 将全局 Toast 展示层挂载到指定窗口,通常只在应用根部调用一次。
|
||||
func attachToWindow(_ window: UIWindow) {
|
||||
if let overlayView, overlayView.superview === window {
|
||||
return
|
||||
}
|
||||
|
||||
let overlay = ToastOverlayView(center: self)
|
||||
overlay.frame = window.bounds
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
window.addSubview(overlay)
|
||||
overlayView = overlay
|
||||
}
|
||||
|
||||
/// 为当前 Toast 安排自动隐藏任务,并避免旧任务误清除新 Toast。
|
||||
private func scheduleAutoDismiss(token: UInt64) {
|
||||
dismissTask?.cancel()
|
||||
let delay = autoDismissNanoseconds
|
||||
dismissTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
guard let self, self.displayToken == token else { return }
|
||||
self.message = nil
|
||||
self.dismissTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 测试专用快照,业务代码不应读取 Toast 展示状态。
|
||||
var snapshotForTests: ToastSnapshot {
|
||||
ToastSnapshot(message: message)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 全局 Toast 测试快照,用于验证文案和自动隐藏行为。
|
||||
struct ToastSnapshot: Equatable {
|
||||
let message: String?
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 全局 Toast 展示层,负责把 Toast 展示到窗口顶部。
|
||||
final class ToastOverlayView: UIView {
|
||||
private weak var toastCenter: ToastCenter?
|
||||
private let bannerView = UIView()
|
||||
private let messageLabel = UILabel()
|
||||
|
||||
/// 使用 Toast 状态中心初始化展示层。
|
||||
init(center: ToastCenter) {
|
||||
self.toastCenter = center
|
||||
super.init(frame: .zero)
|
||||
isUserInteractionEnabled = false
|
||||
configureViews()
|
||||
center.onChange = { [weak self] in
|
||||
self?.refreshPresentation(animated: true)
|
||||
}
|
||||
refreshPresentation(animated: false)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
private func configureViews() {
|
||||
bannerView.backgroundColor = AppDesign.primary
|
||||
bannerView.isHidden = true
|
||||
bannerView.alpha = 0
|
||||
|
||||
messageLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
messageLabel.textColor = .white
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.numberOfLines = 2
|
||||
|
||||
bannerView.addSubview(messageLabel)
|
||||
addSubview(bannerView)
|
||||
|
||||
bannerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
messageLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
bannerView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
bannerView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
bannerView.topAnchor.constraint(equalTo: topAnchor),
|
||||
|
||||
messageLabel.leadingAnchor.constraint(equalTo: bannerView.leadingAnchor, constant: AppMetrics.Spacing.pageHorizontal),
|
||||
messageLabel.trailingAnchor.constraint(equalTo: bannerView.trailingAnchor, constant: -AppMetrics.Spacing.pageHorizontal),
|
||||
messageLabel.topAnchor.constraint(equalTo: bannerView.safeAreaLayoutGuide.topAnchor, constant: AppMetrics.Spacing.small),
|
||||
messageLabel.bottomAnchor.constraint(equalTo: bannerView.bottomAnchor, constant: -AppMetrics.Spacing.small)
|
||||
])
|
||||
}
|
||||
|
||||
private func refreshPresentation(animated: Bool) {
|
||||
guard let message = toastCenter?.message, !message.isEmpty else {
|
||||
hideBanner(animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
messageLabel.text = message
|
||||
showBanner(animated: animated)
|
||||
}
|
||||
|
||||
private func showBanner(animated: Bool) {
|
||||
guard bannerView.isHidden || bannerView.alpha < 1 else { return }
|
||||
|
||||
bannerView.isHidden = false
|
||||
guard animated else {
|
||||
bannerView.alpha = 1
|
||||
bannerView.transform = .identity
|
||||
return
|
||||
}
|
||||
|
||||
bannerView.alpha = 0
|
||||
bannerView.transform = CGAffineTransform(translationX: 0, y: -12)
|
||||
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut]) {
|
||||
self.bannerView.alpha = 1
|
||||
self.bannerView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
private func hideBanner(animated: Bool) {
|
||||
guard !bannerView.isHidden else { return }
|
||||
|
||||
guard animated else {
|
||||
bannerView.isHidden = true
|
||||
bannerView.alpha = 0
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut], animations: {
|
||||
self.bannerView.alpha = 0
|
||||
self.bannerView.transform = CGAffineTransform(translationX: 0, y: -12)
|
||||
}, completion: { _ in
|
||||
self.bannerView.isHidden = true
|
||||
self.bannerView.transform = .identity
|
||||
})
|
||||
}
|
||||
}
|
||||
138
suixinkan_ios/App/ViewControllers/RootViewController.swift
Normal file
138
suixinkan_ios/App/ViewControllers/RootViewController.swift
Normal file
@ -0,0 +1,138 @@
|
||||
//
|
||||
// RootViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 应用根 ViewController,根据登录阶段切换子页面并挂载全局 Overlay。
|
||||
final class RootViewController: UIViewController {
|
||||
|
||||
private let services: AppServices
|
||||
private var currentChild: UIViewController?
|
||||
private let restoringView = UIView()
|
||||
|
||||
init(services: AppServices = .shared) {
|
||||
self.services = services
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
restoringView.backgroundColor = .systemBackground
|
||||
|
||||
services.appSession.onChange = { [weak self] in
|
||||
self?.handleSessionPhaseChange()
|
||||
}
|
||||
|
||||
mountChild(for: services.appSession.phase)
|
||||
bootstrapSession()
|
||||
configurePushNotifications()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
attachGlobalOverlays()
|
||||
}
|
||||
|
||||
private func bootstrapSession() {
|
||||
Task {
|
||||
await services.globalLoading.withLoading {
|
||||
await services.sessionBootstrapper.restore(
|
||||
appSession: services.appSession,
|
||||
accountContext: services.accountContext,
|
||||
permissionContext: services.permissionContext,
|
||||
profileAPI: services.profileAPI,
|
||||
accountContextAPI: services.accountContextAPI,
|
||||
toastCenter: services.toastCenter
|
||||
)
|
||||
}
|
||||
|
||||
if services.appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
|
||||
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configurePushNotifications() {
|
||||
services.configurePushNotifications()
|
||||
}
|
||||
|
||||
private func attachGlobalOverlays() {
|
||||
guard let window = view.window else { return }
|
||||
services.toastCenter.attachToWindow(window)
|
||||
services.globalLoading.attachToWindow(window)
|
||||
}
|
||||
|
||||
private func handleSessionPhaseChange() {
|
||||
mountChild(for: services.appSession.phase)
|
||||
|
||||
switch services.appSession.phase {
|
||||
case .loggedIn:
|
||||
if !AppUITestLaunchState.isRunningUITests {
|
||||
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
||||
}
|
||||
Task { await reloadScenicSpotContextIfNeeded() }
|
||||
case .loggedOut:
|
||||
services.accountContext.reset()
|
||||
services.permissionContext.reset()
|
||||
services.scenicSpotContext.reset()
|
||||
services.appRouter.reset()
|
||||
services.toastCenter.dismiss()
|
||||
services.scenicQueueRuntime.stop()
|
||||
case .restoring:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadScenicSpotContextIfNeeded() async {
|
||||
guard services.appSession.isLoggedIn else {
|
||||
services.scenicSpotContext.reset()
|
||||
return
|
||||
}
|
||||
await services.scenicSpotContext.reload(
|
||||
scenicId: services.accountContext.currentScenic?.id,
|
||||
api: services.accountContextAPI
|
||||
)
|
||||
}
|
||||
|
||||
private func mountChild(for phase: AuthPhase) {
|
||||
let nextChild: UIViewController
|
||||
switch phase {
|
||||
case .loggedOut:
|
||||
nextChild = LoginViewController(services: services)
|
||||
case .restoring:
|
||||
nextChild = restoringViewController()
|
||||
case .loggedIn:
|
||||
nextChild = MainTabBarController(services: services)
|
||||
}
|
||||
|
||||
guard currentChild !== nextChild else { return }
|
||||
|
||||
currentChild?.willMove(toParent: nil)
|
||||
currentChild?.view.removeFromSuperview()
|
||||
currentChild?.removeFromParent()
|
||||
|
||||
addChild(nextChild)
|
||||
view.addSubview(nextChild.view)
|
||||
nextChild.view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
nextChild.didMove(toParent: self)
|
||||
currentChild = nextChild
|
||||
}
|
||||
|
||||
private func restoringViewController() -> UIViewController {
|
||||
let controller = UIViewController()
|
||||
controller.view.backgroundColor = .systemBackground
|
||||
return controller
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user