Initial commit

This commit is contained in:
2026-06-22 11:28:01 +08:00
commit ace9c94359
84 changed files with 8899 additions and 0 deletions

64
suixinkan/App/App.md Normal file
View File

@ -0,0 +1,64 @@
# 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. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
## 登录和退出
登录完成由 `AuthSessionCoordinator.completeLogin` 统一处理:
- 正式 token 写入 `SessionTokenStore`
- 上次手机号和协议状态写入 `AppPreferencesStore`
- 账号资料、景区列表、门店列表写入 `AccountContext`
- 角色权限和当前角色写入 `PermissionContext`
- 非敏感账号快照写入 `AccountSnapshotStore`
- `AppSession` 切换为 `loggedIn`
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
- 清空 Keychain token。
- 清空账号快照。
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
- `AppSession` 切换为 `loggedOut`
- 保留上次手机号、协议状态等非敏感偏好。
## 导航规则
主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。
Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,`MainTabsView` 会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar避免每个业务页面重复处理。
当前路由枚举 `AppRoute` 仍以占位详情页为主,后续新增真实页面时应优先扩展 `AppRoute`,再由对应 Tab 的 `NavigationStack` 处理跳转。

View File

@ -0,0 +1,63 @@
//
// AppTab.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import SwiftUI
/// 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"
}
}
@ViewBuilder
var rootView: some View {
switch self {
case .home:
HomeRootView()
case .orders:
OrdersRootView()
case .statistics:
StatisticsRootView()
case .profile:
ProfileRootView()
}
}
@ViewBuilder
var label: some View {
Label(title, systemImage: systemImage)
}
}

View File

@ -0,0 +1,88 @@
//
// NavigationRouter.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Observation
import SwiftUI
/// Tab
enum AppRoute: Hashable {
case placeholder(title: String)
case home(HomeRoute)
/// NavigationStack push TabBar
var hidesTabBarWhenPushed: Bool {
true
}
///
@ViewBuilder
var destinationView: some View {
switch self {
case .placeholder(let title):
PlaceholderDetailView(title: title)
case .home(let route):
route.destinationView
}
}
}
@MainActor
@Observable
/// NavigationStack Tab
final class RouterPath {
var path: [AppRoute] = []
/// Tab
func navigate(to route: AppRoute) {
path.append(route)
}
/// Tab
func reset() {
path = []
}
}
@MainActor
@Observable
/// Tab Tab NavigationStack
final class AppRouter {
var selectedTab: AppTab = .home
private var routers: [AppTab: RouterPath] = [:]
/// Tab
func router(for tab: AppTab) -> RouterPath {
if let router = routers[tab] {
return router
}
let router = RouterPath()
routers[tab] = router
return router
}
/// SwiftUI NavigationStack 使
func binding(for tab: AppTab) -> Binding<[AppRoute]> {
let router = router(for: tab)
return Binding(
get: { router.path },
set: { router.path = $0 }
)
}
/// Tab
func select(_ tab: AppTab) {
selectedTab = tab
}
/// Tab Tab
func reset() {
selectedTab = .home
routers.values.forEach { $0.reset() }
}
}

View File

@ -0,0 +1,125 @@
//
// RootView.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import SwiftUI
/// App
struct RootView: View {
@State private var appSession = AppSession()
@State private var accountContext = AccountContext()
@State private var permissionContext = PermissionContext()
@State private var scenicSpotContext = ScenicSpotContext()
@State private var appRouter = AppRouter()
@State private var toastCenter = ToastCenter()
@State private var apiClient: APIClient
@State private var authAPI: AuthAPI
@State private var profileAPI: ProfileAPI
@State private var accountContextAPI: AccountContextAPI
@State private var authSessionCoordinator: AuthSessionCoordinator
@State private var sessionBootstrapper: SessionBootstrapper
/// API token provider
init() {
let apiClient = APIClient()
let tokenStore = SessionTokenStore()
let snapshotStore = AccountSnapshotStore()
_apiClient = State(initialValue: apiClient)
_authAPI = State(initialValue: AuthAPI(client: apiClient))
_profileAPI = State(initialValue: ProfileAPI(client: apiClient))
_accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient))
_authSessionCoordinator = State(
initialValue: AuthSessionCoordinator(
tokenStore: tokenStore,
snapshotStore: snapshotStore,
preferencesStore: AppPreferencesStore()
)
)
_sessionBootstrapper = State(
initialValue: SessionBootstrapper(
tokenStore: tokenStore,
snapshotStore: snapshotStore
)
)
}
var body: some View {
rootContent
.globalToastOverlay()
.environment(appSession)
.environment(accountContext)
.environment(permissionContext)
.environment(scenicSpotContext)
.environment(appRouter)
.environment(toastCenter)
.environment(apiClient)
.environment(authAPI)
.environment(profileAPI)
.environment(accountContextAPI)
.environment(authSessionCoordinator)
.task {
apiClient.bindAuthTokenProvider { appSession.token }
await sessionBootstrapper.restore(
appSession: appSession,
accountContext: accountContext,
permissionContext: permissionContext,
profileAPI: profileAPI,
accountContextAPI: accountContextAPI,
toastCenter: toastCenter
)
}
.task(id: scenicSpotTaskID) {
guard appSession.isLoggedIn else {
scenicSpotContext.reset()
return
}
await scenicSpotContext.reload(
scenicId: accountContext.currentScenic?.id,
api: accountContextAPI
)
}
.onChange(of: appSession.phase) { _, phase in
guard phase == .loggedOut else { return }
accountContext.reset()
permissionContext.reset()
scenicSpotContext.reset()
appRouter.reset()
toastCenter.dismiss()
}
}
@ViewBuilder
private var rootContent: some View {
switch appSession.phase {
case .loggedOut:
LoginView()
case .restoring:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))
case .loggedIn:
MainTabsView()
}
}
///
private var scenicSpotTaskID: String {
let phaseText: String
switch appSession.phase {
case .loggedOut:
phaseText = "loggedOut"
case .restoring:
phaseText = "restoring"
case .loggedIn:
phaseText = "loggedIn"
}
return "\(phaseText)-\(accountContext.currentScenic?.id ?? 0)"
}
}
#Preview {
RootView()
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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)
}
}
}

View 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
}
}

View 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
)
)
}
}

View 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())
}
}

View File

@ -0,0 +1,18 @@
//
// suixinkanApp.swift
// suixinkan
//
// Created by hanqiu on 2026/6/18.
//
import SwiftUI
/// SwiftUI
@main
struct suixinkanApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}