Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user