初始提交
This commit is contained in:
129
suixinkan/App/State/AccountContext.swift
Normal file
129
suixinkan/App/State/AccountContext.swift
Normal file
@ -0,0 +1,129 @@
|
||||
//
|
||||
// AccountContext.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Observation
|
||||
|
||||
/// 当前账号的基础资料实体,用于跨页面展示昵称、手机号和头像。
|
||||
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?
|
||||
|
||||
/// 创建业务作用域,门店可通过 parentScenicId 标记所属景区。
|
||||
init(id: Int, name: String, kind: Kind, parentScenicId: Int? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.parentScenicId = parentScenicId
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 账号上下文状态中心,保存当前账号资料和景区/门店作用域。
|
||||
final class AccountContext {
|
||||
private(set) var profile: AccountProfile?
|
||||
private(set) var scenicScopes: [BusinessScope] = []
|
||||
private(set) var storeScopes: [BusinessScope] = []
|
||||
var currentScenic: BusinessScope?
|
||||
var currentStore: BusinessScope?
|
||||
|
||||
/// 应用登录成功后写入账号资料。
|
||||
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/App/State/AccountContextLoader.swift
Normal file
115
suixinkan/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/App/State/AppSession.swift
Normal file
46
suixinkan/App/State/AppSession.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// AppSession.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 登录阶段实体,表示根视图当前应该展示的认证状态。
|
||||
enum AuthPhase: Equatable {
|
||||
case loggedOut
|
||||
case restoring
|
||||
case loggedIn
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录会话状态中心,保存 token 和当前认证阶段。
|
||||
final class AppSession {
|
||||
private(set) var phase: AuthPhase = .loggedOut
|
||||
private(set) var token: String?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
211
suixinkan/App/State/AuthSessionCoordinator.swift
Normal file
211
suixinkan/App/State/AuthSessionCoordinator.swift
Normal file
@ -0,0 +1,211 @@
|
||||
//
|
||||
// AuthSessionCoordinator.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录会话协调器,统一处理登录完成、退出登录和缓存同步。
|
||||
final class AuthSessionCoordinator {
|
||||
@ObservationIgnored private let tokenStore: SessionTokenStore
|
||||
@ObservationIgnored private let snapshotStore: AccountSnapshotStore
|
||||
@ObservationIgnored 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
|
||||
}
|
||||
}
|
||||
83
suixinkan/App/State/PermissionContext.swift
Normal file
83
suixinkan/App/State/PermissionContext.swift
Normal file
@ -0,0 +1,83 @@
|
||||
//
|
||||
// PermissionContext.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 权限上下文状态中心,保存角色权限、当前角色和扁平化权限 URI。
|
||||
final class PermissionContext {
|
||||
private(set) var rolePermissions: [RolePermissionResponse] = []
|
||||
private(set) var permissionURIs: Set<String> = []
|
||||
var currentRole: RoleInfo?
|
||||
|
||||
/// 替换角色权限列表,并尽量按缓存角色 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
suixinkan/App/State/ScenicSpotContext.swift
Normal file
60
suixinkan/App/State/ScenicSpotContext.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// ScenicSpotContext.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 景点加载阶段实体,表示当前景区景点列表的加载状态。
|
||||
enum ScenicSpotLoadState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景点上下文状态中心,保存当前景区下的景点或打卡点列表。
|
||||
final class ScenicSpotContext {
|
||||
private(set) var scenicId: Int?
|
||||
private(set) var spots: [ScenicSpotItem] = []
|
||||
private(set) var loadState: ScenicSpotLoadState = .idle
|
||||
|
||||
/// 按景区 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
|
||||
}
|
||||
}
|
||||
113
suixinkan/App/State/SessionBootstrapper.swift
Normal file
113
suixinkan/App/State/SessionBootstrapper.swift
Normal file
@ -0,0 +1,113 @@
|
||||
//
|
||||
// SessionBootstrapper.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录态启动恢复器,负责冷启动时从缓存恢复并校验 token。
|
||||
final class SessionBootstrapper {
|
||||
@ObservationIgnored private let tokenStore: SessionTokenStore
|
||||
@ObservationIgnored 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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
69
suixinkan/App/State/ToastCenter.swift
Normal file
69
suixinkan/App/State/ToastCenter.swift
Normal file
@ -0,0 +1,69 @@
|
||||
//
|
||||
// ToastCenter.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||||
final class ToastCenter {
|
||||
fileprivate var message: String?
|
||||
|
||||
/// 显示一条全局 Toast 文案。
|
||||
func show(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
/// 清除当前 Toast 文案。
|
||||
func dismiss() {
|
||||
message = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局 Toast 叠加层修饰器,负责把 Toast 展示到页面顶部。
|
||||
private struct GlobalToastOverlay: ViewModifier {
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(alignment: .top) {
|
||||
if let message = toastCenter.message {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button("关闭") {
|
||||
toastCenter.dismiss()
|
||||
}
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: toastCenter.message)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// 给任意根视图追加全局 Toast 展示能力。
|
||||
func globalToastOverlay() -> some View {
|
||||
modifier(GlobalToastOverlay())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user