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

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