Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,36 @@
//
// AMapBootstrap.swift
// suixinkan
//
// SDK AMAP_ENABLED AMap
//
import Foundation
#if AMAP_ENABLED
import AMapFoundationKit
import AMapLocationKit
import AMapSearchKit
import MAMapKit
enum AMapBootstrap {
/// App `apiKey` Key
static func configure(apiKey: String) {
guard !apiKey.isEmpty else { return }
AMapServices.shared().apiKey = apiKey
AMapServices.shared().enableHTTPS = true
AMapSearchAPI.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapSearchAPI.updatePrivacyAgree(.didAgree)
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
MAMapView.updatePrivacyAgree(.didAgree)
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapLocationManager.updatePrivacyAgree(.didAgree)
}
}
#else
enum AMapBootstrap {
static func configure(apiKey: String) {}
}
#endif

79
suixinkan_ios/App/App.md Normal file
View File

@ -0,0 +1,79 @@
# App 模块业务逻辑
## 模块职责
App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast。
该模块不直接处理具体页面业务,主要承担 App 级基础设施编排:
- 根据登录状态展示登录页、恢复态或主 Tab。
- 创建并注入全局状态和共享服务。
- 管理每个 Tab 独立的 `NavigationStack` 路径。
- 协调登录成功、退出登录、冷启动恢复和本地缓存同步。
## 核心对象
- `suixinkanApp`SwiftUI 应用入口,挂载 `RootView`
- `RootView`:创建 `AppSession``AccountContext``PermissionContext``ScenicSpotContext``AppRouter``ToastCenter``APIClient` 和业务 API并注入 SwiftUI Environment。
- `AppSession`:保存认证阶段和正式 token只负责登录态不承载业务资料。
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。
- `ToastCenter`:管理当前全局 Toast 文案和自动隐藏任务。
- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。
- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。
- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。
## 启动流程
1. `suixinkanApp` 创建 `RootView`
2. `RootView` 初始化共享依赖,并把它们注入 Environment。
3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。
4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。
5. 无 token 时保持 `loggedOut`,展示 `LoginView`
6. 有 token 时进入 `restoring`,先恢复本地账号快照。
7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。
8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`
9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。
10. 明确 token 失效时清空 token 和账号快照,回到登录页。
11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。
## UI Test 启动约定
- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket避免系统弹窗干扰自动化。
- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。
- `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。
- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings``realNameAuth`)。
- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。
- 详细运行方式见 `suixinkanUITests/README.md`
## 登录和退出
登录完成由 `AuthSessionCoordinator.completeLogin` 统一处理:
- 正式 token 写入 `SessionTokenStore`
- 上次手机号和协议状态写入 `AppPreferencesStore`
- 账号资料、景区列表、门店列表写入 `AccountContext`
- 角色权限和当前角色写入 `PermissionContext`
- 非敏感账号快照写入 `AccountSnapshotStore`
- `AppSession` 切换为 `loggedIn`
退出登录由 `AuthSessionCoordinator.logout` 统一处理:
- 清空 Keychain token。
- 清空账号快照。
- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。
- `AppSession` 切换为 `loggedOut`
- 保留上次手机号、协议状态等非敏感偏好。
## Toast
全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
## 导航规则
主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。
Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,`MainTabsView` 会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar避免每个业务页面重复处理。
当前路由枚举 `AppRoute` 仍以占位详情页为主,后续新增真实页面时应优先扩展 `AppRoute`,再由对应 Tab 的 `NavigationStack` 处理跳转。

View File

@ -0,0 +1,123 @@
//
// AppServices.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
@MainActor
/// ViewModel ViewController API
final class AppServices {
static let shared = AppServices()
let appSession = AppSession()
let accountContext = AccountContext()
let permissionContext = PermissionContext()
let scenicSpotContext = ScenicSpotContext()
let appRouter = AppRouter()
let toastCenter = ToastCenter()
let globalLoading = GlobalLoadingCenter()
let scenicQueueRuntime = ScenicQueueRuntime()
let apiClient: APIClient
let authAPI: AuthAPI
let profileAPI: ProfileAPI
let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService
let accountContextAPI: AccountContextAPI
let ordersAPI: OrdersAPI
let statisticsAPI: StatisticsAPI
let paymentAPI: PaymentAPI
let walletAPI: WalletAPI
let pushAPI: PushAPI
let scenicPermissionAPI: ScenicPermissionAPI
let scenicSettlementAPI: ScenicSettlementAPI
let messageCenterAPI: MessageCenterAPI
let scenicQueueAPI: ScenicQueueAPI
let liveAPI: LiveAPI
let operatingAreaAPI: OperatingAreaAPI
let pilotCertificationAPI: PilotCertificationAPI
let taskAPI: TaskAPI
let projectAPI: ProjectAPI
let scheduleAPI: ScheduleAPI
let inviteAPI: InviteAPI
let assetsAPI: AssetsAPI
let punchPointAPI: PunchPointAPI
let locationReportAPI: LocationReportAPI
let authSessionCoordinator: AuthSessionCoordinator
let sessionBootstrapper: SessionBootstrapper
private let tokenStore: SessionTokenStore
private let snapshotStore: AccountSnapshotStore
/// ID
var currentScenicId: Int? {
accountContext.currentScenic?.id
}
/// staffId
var staffId: Int? {
Int(accountContext.profile?.userId ?? "")
}
/// ID
var userId: String? {
accountContext.profile?.userId
}
private init() {
let client = APIClient()
let tokenStore = SessionTokenStore()
let snapshotStore = AccountSnapshotStore()
self.tokenStore = tokenStore
self.snapshotStore = snapshotStore
apiClient = client
authAPI = AuthAPI(client: client)
profileAPI = ProfileAPI(client: client)
uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI)
accountContextAPI = AccountContextAPI(client: client)
ordersAPI = OrdersAPI(client: client)
statisticsAPI = StatisticsAPI(client: client)
paymentAPI = PaymentAPI(client: client)
walletAPI = WalletAPI(client: client)
pushAPI = PushAPI(client: client)
scenicPermissionAPI = ScenicPermissionAPI(client: client)
scenicSettlementAPI = ScenicSettlementAPI(client: client)
messageCenterAPI = MessageCenterAPI(client: client)
scenicQueueAPI = ScenicQueueAPI(client: client)
liveAPI = LiveAPI(client: client)
operatingAreaAPI = OperatingAreaAPI(client: client)
pilotCertificationAPI = PilotCertificationAPI(client: client)
taskAPI = TaskAPI(client: client)
projectAPI = ProjectAPI(client: client)
scheduleAPI = ScheduleAPI(client: client)
inviteAPI = InviteAPI(client: client)
assetsAPI = AssetsAPI(client: client)
punchPointAPI = PunchPointAPI(client: client)
locationReportAPI = LocationReportAPI(client: client)
authSessionCoordinator = AuthSessionCoordinator(
tokenStore: tokenStore,
snapshotStore: snapshotStore,
preferencesStore: AppPreferencesStore()
)
sessionBootstrapper = SessionBootstrapper(
tokenStore: tokenStore,
snapshotStore: snapshotStore
)
apiClient.bindAuthTokenProvider { [unowned self] in
appSession.token
}
}
/// UIKit
func configurePushNotifications() {
PushNotificationManager.shared.configure(
api: pushAPI,
session: appSession,
router: appRouter
)
}
}

View File

@ -0,0 +1,55 @@
//
// AppUITestLaunchState.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
/// UI
enum AppUITestLaunchState {
/// UI Test
static let uiTestsArgument = "-suixinkan-ui-tests"
///
static let resetArgument = "-suixinkan-ui-tests-reset-state"
///
static let openMenuArgument = "-suixinkan-ui-tests-open-menu"
///
static let openProfileArgument = "-suixinkan-ui-tests-open-profile"
/// XCUITest
static var isRunningUITests: Bool {
ProcessInfo.processInfo.arguments.contains(uiTestsArgument)
}
///
static var pendingMenuTitle: String? {
argumentValue(following: openMenuArgument)
}
///
static var pendingProfileRouteRawValue: String? {
argumentValue(following: openProfileArgument)
}
/// flag
private static func argumentValue(following flag: String) -> String? {
let arguments = ProcessInfo.processInfo.arguments
guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else {
return nil
}
let value = arguments[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
/// token
static func resetIfNeeded(arguments: [String] = ProcessInfo.processInfo.arguments) {
guard arguments.contains(resetArgument) else { return }
try? SessionTokenStore().clear()
AccountSnapshotStore().clear()
let preferences = AppPreferencesStore()
preferences.clear()
}
}

View File

@ -0,0 +1,95 @@
//
// AppUITestRouteDriver.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
#if DEBUG
import UIKit
/// UI Test TabBar XCUITest push
@MainActor
enum AppUITestRouteDriver {
private static var didApply = false
///
static func applyIfNeeded(services: AppServices = .shared) async {
guard AppUITestLaunchState.isRunningUITests, !didApply else { return }
guard AppUITestLaunchState.pendingMenuTitle != nil
|| AppUITestLaunchState.pendingProfileRouteRawValue != nil else { return }
didApply = true
try? await Task.sleep(nanoseconds: 1_200_000_000)
if let menuTitle = AppUITestLaunchState.pendingMenuTitle {
openHomeMenu(title: menuTitle, services: services)
return
}
if let rawValue = AppUITestLaunchState.pendingProfileRouteRawValue {
openProfileRoute(rawValue: rawValue, services: services)
}
}
///
private static func openHomeMenu(title: String, services: AppServices) {
let resolvedTitle = resolveMenuTitle(title)
guard let item = HomeMenuRouter.debugAllMenuItems().first(where: { $0.title == resolvedTitle }) else {
return
}
services.appRouter.reset()
let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
switch resolved {
case .tab(let tab):
services.appRouter.select(tab)
case .orders(let entry):
services.appRouter.selectOrders(entry: entry)
case .destination(let homeRoute):
services.appRouter.navigateHome(homeRoute, animated: false)
case .unsupported(let uri, let title, _):
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
case .placeholder(let uri, let title):
services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false)
}
}
///
private static func openProfileRoute(rawValue: String, services: AppServices) {
let route: ProfileRoute?
switch rawValue {
case "settings":
route = .settings
case "realNameAuth":
route = .realNameAuth
case "accountSwitch":
route = .accountSwitch
case "debugHomeMenus":
route = .debugHomeMenus
case "agreement.about":
route = .agreement(.about)
case "agreement.userAgreement":
route = .agreement(.userAgreement)
case "agreement.privacyPolicy":
route = .agreement(.privacyPolicy)
default:
route = nil
}
guard let route else { return }
services.appRouter.reset()
services.appRouter.navigateProfile(route, animated: false)
}
/// UI Test
private static func resolveMenuTitle(_ title: String) -> String {
switch title {
case "系统设置":
return "设置中心"
default:
return title
}
}
}
#endif

View File

@ -0,0 +1,181 @@
//
// AppRouteViewControllerFactory.swift
// suixinkan
//
import UIKit
@MainActor
/// ViewController `AppRoute` UIKit
enum AppRouteViewControllerFactory {
/// ViewController使
static func makeViewController(for route: AppRoute, services: AppServices) -> UIViewController {
switch route {
case .placeholder(let title):
return PlaceholderViewController(title: title)
case .home(let homeRoute):
return makeViewController(for: homeRoute, services: services)
case .profile(let profileRoute):
return makeViewController(for: profileRoute, services: services)
case .orders(let ordersRoute):
return makeViewController(for: ordersRoute, services: services)
}
}
/// ViewController
static func makeViewController(for route: HomeRoute, services: AppServices) -> UIViewController {
switch route {
case .profileSpace:
return ProfileViewController()
case .scenicSelection:
return ScenicSelectionViewController()
case .permissionApply:
return PermissionApplyViewController()
case .permissionApplyStatus:
return PermissionApplyStatusViewController()
case .scenicApplication:
return ScenicApplicationViewController()
case .moreFunctions:
return HomeMoreFunctionsViewController()
case .settings:
return SettingsViewController()
case .paymentCollection:
return PaymentCollectionViewController()
case .wallet:
return WalletViewController()
case .taskManagement:
return TaskManagementViewController()
case .taskCreate:
return TaskCreateViewController()
case .taskDetail(let id, let summary):
return TaskDetailViewController(taskId: id, summary: summary)
case .projectManagement:
return ProjectManagementViewController()
case .pmProjectManagement:
return StoreProjectManagementViewController()
case .projectDetail(let id, let storeMode):
return ProjectDetailViewController(projectId: id, storeMode: storeMode)
case .projectEditor(let id, let storeMode):
if storeMode {
return StoreProjectEditorViewController(projectId: id)
}
return ProjectEditorViewController(projectId: id)
case .scheduleManagement:
return ScheduleManagementViewController()
case .scheduleAdd:
return ScheduleAddViewController()
case .photographerInvite:
return PhotographerInviteViewController()
case .inviteRecord:
return InviteRecordViewController()
case .cloudStorage:
return CloudStorageViewController()
case .cloudStorageTransit:
return CloudStorageTransitViewController()
case .materialLibrary:
return MediaLibraryViewController(kind: .material)
case .materialUpload:
return MediaLibraryUploadViewController(kind: .material)
case .sampleLibrary:
return MediaLibraryViewController(kind: .sample)
case .sampleUpload:
return MediaLibraryUploadViewController(kind: .sample)
case .albumList:
return AlbumListViewController()
case .albumTrailer:
return AlbumTrailerViewController()
case .punchPointList:
return PunchPointListViewController()
case .punchPointDetail(let id, let summary):
return PunchPointDetailViewController(punchPointId: id, summary: summary)
case .punchPointEditor(let id):
return PunchPointEditorViewController(punchPointId: id)
case .punchPointQR(_, let title, let qrURL):
return PunchPointQRViewController(title: title, qrURL: qrURL)
case .locationReport:
return LocationReportViewController()
case .locationReportHistory:
return LocationReportHistoryViewController()
case .depositOrders:
return DepositOrderListViewController()
case .withdrawalAudit:
return WithdrawalAuditViewController()
case .scenicSettlement:
return ScenicSettlementViewController()
case .scenicSettlementReview:
return ScenicSettlementReviewViewController()
case .messageCenter:
return MessageCenterViewController()
case .queueManagement:
return QueueManagementViewController()
case .liveManagement:
return LiveManagementViewController()
case .liveAlbum:
return LiveAlbumViewController()
case .operatingArea:
return OperatingAreaViewController()
case .pilotCertification:
return PilotCertificationViewController()
case .modulePlaceholder(_, let title):
return FeaturePlaceholderViewController(title: title, uri: homeRouteURI(route))
}
}
/// ViewController
static func makeViewController(for route: ProfileRoute, services: AppServices) -> UIViewController {
switch route {
case .accountSwitch:
return AccountSwitchViewController()
case .realNameAuth:
return RealNameAuthViewController()
case .settings:
return SettingsViewController()
case .agreement(let page):
return AgreementViewController(page: page)
#if DEBUG
case .debugHomeMenus:
return HomeMoreFunctionsViewController()
#endif
}
}
/// ViewController
static func makeViewController(for route: OrdersRoute, services: AppServices) -> UIViewController {
switch route {
case .storeDetail(let item):
return StoreOrderDetailViewController(item: item)
case .writeOffDetail(let item):
return WriteOffOrderDetailViewController(item: item)
case .depositDetail(let orderNumber):
return DepositOrderDetailViewController(orderNumber: orderNumber)
case .depositShootingInfo(let orderNumber, let scenicSpotId, let photogUid):
return DepositOrderShootingInfoViewController(
orderNumber: orderNumber,
scenicSpotId: scenicSpotId,
photogUid: photogUid
)
case .historicalShooting(let orderNumber):
return HistoricalShootingInfoViewController(orderNumber: orderNumber)
case .multiTravelTaskUpload(let orderNumber):
return MultiTravelTaskUploadViewController(initialOrderNumber: orderNumber)
case .orderTrailer(let orderNumber, let title):
return FeaturePlaceholderViewController(title: title, uri: "order_trailer:\(orderNumber)")
}
}
private static func homeRouteURI(_ route: HomeRoute) -> String {
if case let .modulePlaceholder(uri, _) = route {
return uri
}
return String(describing: route)
}
}
@MainActor
/// ViewController `UIKitAppNavigation`
enum HomeRouteViewControllerFactory {
static func make(for route: HomeRoute) -> UIViewController {
AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
}
}

View File

@ -0,0 +1,58 @@
//
// AppTab.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import UIKit
/// Tab
enum AppTab: String, CaseIterable, Identifiable, Hashable {
case home
case orders
case statistics
case profile
var id: Self { self }
var title: String {
switch self {
case .home:
"首页"
case .orders:
"订单"
case .statistics:
"数据"
case .profile:
"我的"
}
}
var systemImage: String {
switch self {
case .home:
"house"
case .orders:
"doc.text"
case .statistics:
"chart.bar"
case .profile:
"person"
}
}
/// Tab ViewController
func makeRootViewController(services: AppServices) -> UIViewController {
switch self {
case .home:
return HomeViewController()
case .orders:
return OrdersViewController()
case .statistics:
return StatisticsViewController()
case .profile:
return ProfileViewController()
}
}
}

View File

@ -0,0 +1,109 @@
//
// NavigationRouter.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// Tab
enum AppRoute: Hashable {
case placeholder(title: String)
case home(HomeRoute)
case profile(ProfileRoute)
case orders(OrdersRoute)
/// push TabBar
var hidesTabBarWhenPushed: Bool {
true
}
}
@MainActor
/// Tab
final class RouterPath {
var onChange: (() -> Void)?
var path: [AppRoute] = [] { didSet { onChange?() } }
/// Tab
func navigate(to route: AppRoute) {
path.append(route)
}
/// Tab
func navigateBack() {
guard !path.isEmpty else { return }
path.removeLast()
}
/// Tab
func reset() {
path = []
}
}
@MainActor
/// Tab Tab
final class AppRouter {
var onChange: (() -> Void)?
var selectedTab: AppTab = .home { didSet { onChange?() } }
var selectedOrdersEntry: OrdersEntry = .storeOrders { didSet { onChange?() } }
private(set) var pendingOrderScanCode: String? { didSet { onChange?() } }
private let routers: [AppTab: RouterPath]
init() {
var builtRouters: [AppTab: RouterPath] = [:]
for tab in AppTab.allCases {
builtRouters[tab] = RouterPath()
}
routers = builtRouters
routers.values.forEach { router in
router.onChange = { [weak self] in
self?.onChange?()
}
}
}
/// Tab
func router(for tab: AppTab) -> RouterPath {
guard let router = routers[tab] else {
assertionFailure("Missing router path for tab: \(tab)")
return RouterPath()
}
return router
}
/// Tab
func select(_ tab: AppTab) {
selectedTab = tab
}
/// Tab
func selectOrders(entry: OrdersEntry) {
selectedOrdersEntry = entry
selectedTab = .orders
}
///
func routeToOrderVerification(scannedCode: String) {
pendingOrderScanCode = scannedCode
selectOrders(entry: .verificationOrders)
}
///
func consumePendingOrderScanCode() -> String? {
let code = pendingOrderScanCode
pendingOrderScanCode = nil
return code
}
/// Tab Tab
func reset() {
selectedTab = .home
selectedOrdersEntry = .storeOrders
pendingOrderScanCode = nil
routers.values.forEach { $0.reset() }
}
}

View File

@ -0,0 +1,111 @@
//
// TabNavigationController.swift
// suixinkan
//
import UIKit
@MainActor
/// Tab `AppRouter` push TabBar
final class TabNavigationController: UINavigationController, UINavigationControllerDelegate {
let appTab: AppTab
private let services: AppServices
private var isSyncingStack = false
weak var tabBarHost: MainTabBarController?
init(tab: AppTab, services: AppServices) {
self.appTab = tab
self.services = services
super.init(nibName: nil, bundle: nil)
delegate = self
navigationBar.prefersLargeTitles = false
setViewControllers([tab.makeRootViewController(services: services)], animated: false)
registerNavigationBridge()
services.appRouter.router(for: appTab).onChange = { [weak self] in
self?.syncFromRouterPath()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Push RouterPath
func pushRoute(_ route: AppRoute, animated: Bool = true) {
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
isSyncingStack = true
services.appRouter.router(for: appTab).navigate(to: route)
isSyncingStack = false
pushViewController(viewController, animated: animated)
}
func navigationController(
_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool
) {
let isRoot = viewController === viewControllers.first
tabBarHost?.setCustomTabBarHidden(!isRoot, animated: animated)
}
func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool
) {
syncRouterPathFromStack()
}
private func registerNavigationBridge() {
switch appTab {
case .home:
UIKitAppNavigation.homeNavigationController = self
case .profile:
UIKitAppNavigation.profileNavigationController = self
default:
break
}
}
private func syncFromRouterPath() {
guard !isSyncingStack else { return }
let routerPath = services.appRouter.router(for: appTab)
let targetDepth = routerPath.path.count
let currentDepth = max(viewControllers.count - 1, 0)
if targetDepth > currentDepth {
for index in currentDepth..<targetDepth {
let route = routerPath.path[index]
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services)
viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed
pushViewController(viewController, animated: true)
}
return
}
if targetDepth < currentDepth {
if targetDepth == 0 {
popToRootViewController(animated: true)
} else if targetDepth < viewControllers.count {
popToViewController(viewControllers[targetDepth], animated: true)
}
}
}
private func syncRouterPathFromStack() {
guard !isSyncingStack else { return }
let routerPath = services.appRouter.router(for: appTab)
let depth = max(viewControllers.count - 1, 0)
if depth < routerPath.path.count {
isSyncingStack = true
routerPath.path = Array(routerPath.path.prefix(depth))
isSyncingStack = false
}
}
}

View File

@ -0,0 +1,46 @@
//
// UIKitAppNavigation.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
@MainActor
/// UIKit AppRouter UI Test
enum UIKitAppNavigation {
weak static var homeNavigationController: UINavigationController?
weak static var profileNavigationController: UINavigationController?
/// Push
static func pushHomeRoute(_ route: HomeRoute, animated: Bool = true) {
let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
homeNavigationController?.pushViewController(viewController, animated: animated)
}
/// Push
static func pushProfileRoute(_ route: ProfileRoute, animated: Bool = true) {
let viewController = AppRouteViewControllerFactory.makeViewController(
for: .profile(route),
services: AppServices.shared
)
profileNavigationController?.pushViewController(viewController, animated: animated)
}
}
extension AppRouter {
/// UIKit push
func navigateHome(_ route: HomeRoute, animated: Bool = true) {
select(.home)
router(for: .home).navigate(to: .home(route))
UIKitAppNavigation.pushHomeRoute(route, animated: animated)
}
/// UIKit push
func navigateProfile(_ route: ProfileRoute, animated: Bool = true) {
select(.profile)
router(for: .profile).navigate(to: .profile(route))
UIKitAppNavigation.pushProfileRoute(route, animated: animated)
}
}

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

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

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

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

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

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

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

View File

@ -0,0 +1,138 @@
//
// RootViewController.swift
// suixinkan
//
import SnapKit
import UIKit
@MainActor
/// ViewController Overlay
final class RootViewController: UIViewController {
private let services: AppServices
private var currentChild: UIViewController?
private let restoringView = UIView()
init(services: AppServices = .shared) {
self.services = services
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
restoringView.backgroundColor = .systemBackground
services.appSession.onChange = { [weak self] in
self?.handleSessionPhaseChange()
}
mountChild(for: services.appSession.phase)
bootstrapSession()
configurePushNotifications()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
attachGlobalOverlays()
}
private func bootstrapSession() {
Task {
await services.globalLoading.withLoading {
await services.sessionBootstrapper.restore(
appSession: services.appSession,
accountContext: services.accountContext,
permissionContext: services.permissionContext,
profileAPI: services.profileAPI,
accountContextAPI: services.accountContextAPI,
toastCenter: services.toastCenter
)
}
if services.appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests {
PushNotificationManager.shared.requestAuthorizationAndRegister()
}
}
}
private func configurePushNotifications() {
services.configurePushNotifications()
}
private func attachGlobalOverlays() {
guard let window = view.window else { return }
services.toastCenter.attachToWindow(window)
services.globalLoading.attachToWindow(window)
}
private func handleSessionPhaseChange() {
mountChild(for: services.appSession.phase)
switch services.appSession.phase {
case .loggedIn:
if !AppUITestLaunchState.isRunningUITests {
PushNotificationManager.shared.requestAuthorizationAndRegister()
}
Task { await reloadScenicSpotContextIfNeeded() }
case .loggedOut:
services.accountContext.reset()
services.permissionContext.reset()
services.scenicSpotContext.reset()
services.appRouter.reset()
services.toastCenter.dismiss()
services.scenicQueueRuntime.stop()
case .restoring:
break
}
}
private func reloadScenicSpotContextIfNeeded() async {
guard services.appSession.isLoggedIn else {
services.scenicSpotContext.reset()
return
}
await services.scenicSpotContext.reload(
scenicId: services.accountContext.currentScenic?.id,
api: services.accountContextAPI
)
}
private func mountChild(for phase: AuthPhase) {
let nextChild: UIViewController
switch phase {
case .loggedOut:
nextChild = LoginViewController(services: services)
case .restoring:
nextChild = restoringViewController()
case .loggedIn:
nextChild = MainTabBarController(services: services)
}
guard currentChild !== nextChild else { return }
currentChild?.willMove(toParent: nil)
currentChild?.view.removeFromSuperview()
currentChild?.removeFromParent()
addChild(nextChild)
view.addSubview(nextChild.view)
nextChild.view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
nextChild.didMove(toParent: self)
currentChild = nextChild
}
private func restoringViewController() -> UIViewController {
let controller = UIViewController()
controller.view.backgroundColor = .systemBackground
return controller
}
}

View File

@ -0,0 +1,36 @@
//
// AppDelegate.swift
// suixinkan_ios
//
// Created by hanqiu on 2026/6/26.
//
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
// MARK: UISceneSession Lifecycle
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,93 @@
# Core 模块业务逻辑
## 模块职责
Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存储和通用设计常量。业务页面不应直接重复实现这些能力。
主要子模块:
- `Networking`:统一 API 请求、响应解析、错误处理和 token 注入。
- `Storage`:统一登录 token、账号快照和 App 偏好的本地存储。
- `Design`:统一颜色、字号、间距、控件尺寸和圆角。
- `Upload`:统一阿里云 OSS 上传、图片压缩、上传策略和 Kingfisher 网络图片展示。
## Networking
`APIClient` 是统一网络客户端,负责:
- 根据 `APIRequest` 生成 `URLRequest`
- 注入公共 Header`Content-Type``Accept`、App 版本和系统类型。
- 通过 token provider 或 `tokenOverride` 注入登录 token。
- 发送请求并处理 URLSession 错误。
- 校验 HTTP 状态码。
- 解码后端统一 `APIEnvelope`
- 将 HTTP 错误、业务错误、解析错误转成 `APIError`
业务模块只应该封装自己的 API 类,例如 `AuthAPI``ProfileAPI`,然后调用 `APIClient.send`。页面和 ViewModel 不应直接拼接 URL 或处理原始响应体。
### 响应约定
后端响应通过 `APIEnvelope<Response>` 解包:
- `code` 表示业务状态。
- `msg` 表示业务提示。
- `data` 是真正业务数据。
`APIEnvelope.isSuccess` 为 false 时,`APIClient` 抛出 `APIError.serverCode`
### token 失效判断
`APIError.isAuthenticationExpired` 用于判断是否需要清空登录态:
- HTTP 401 / 403 视为登录失效。
- 业务码 `200001` 视为登录失效。
- 错误文案包含 token、过期、登录失效、重新登录、unauthorized、验证失败等关键词时视为登录失效。
## Storage
本地缓存按安全级别拆分:
- `SessionTokenStore`:使用 Keychain 保存正式 token。
- `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。
- `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。
缓存边界:
- 正式 token 只放 Keychain。
- 临时 token 只放内存。
- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。
- 头像、证件照等图片缓存交给 KingfisherCore 不保存图片 Data。
`AccountSnapshot` 保存可重建的账号展示和业务上下文:
- `AccountProfile`
- 账号类型和业务账号 ID
- 当前角色 ID
- 景区作用域和门店作用域
- 当前景区 ID 和当前门店 ID
## Design
`AppDesign` 管理跨页面颜色。`AppMetrics` 管理常用字号、间距、控件尺寸、行距和圆角。
新增页面时优先使用 `AppMetrics``AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。
### 全局 Loading
`GlobalLoadingCenter` 是全局 Loading 的命令中心,只负责展示加载状态,不保存业务数据。业务 View 只能通过 Environment 获取它并调用 `show``hide``updateMessage``withLoading``withOptionalLoading`,不要在 `body` 中读取 `isVisible``message` 等展示状态。
Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay不会让当前页面、Tab 根视图或业务子视图形成观察依赖。
当前全局 Loading UI 不展示文案;`message` 字段保留为内部展示状态,便于后续需要时恢复文案展示。
全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。
### 全局 Toast
`ToastCenter` 只用于展示轻量提示文案,业务页面通过 `toastCenter.show(...)` 发出提示命令,不直接读取 Toast 展示状态。
Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部安全区和屏幕左右边;文案居中展示,无关闭按钮,默认 2.2 秒自动隐藏。新的 Toast 会覆盖旧 Toast 并重新计时。
## Upload
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
上传模块只保存内存状态:
- STS token 不写入 Keychain 或 UserDefaults。
- 用户选择的本地图片 Data 不落盘。
- 上传进度只用于当前页面展示。
网络图片统一使用 `RemoteImage` / `RemoteAvatarImage`,内部由 Kingfisher 负责下载和缓存。业务页面不要再直接使用 `AsyncImage` 加载网络图片。

View File

@ -0,0 +1,111 @@
//
// AppContentUnavailableView.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SnapKit
import UIKit
/// iOS 16
final class AppContentUnavailableView: UIView {
private let contentStack = UIStackView()
private let labelStack = UIStackView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let descriptionLabel = UILabel()
private let actionsContainer = UIStackView()
/// 使
init(
title: String,
systemImage: String,
description: String? = nil,
actions: [UIView] = []
) {
super.init(frame: .zero)
configureViews(title: title, systemImage: systemImage, description: description, actions: actions)
}
required init?(coder: NSCoder) {
nil
}
///
func update(title: String, systemImage: String, description: String? = nil) {
titleLabel.text = title
iconView.image = UIImage(systemName: systemImage)
descriptionLabel.text = description
descriptionLabel.isHidden = description?.isEmpty != false
}
///
func setActions(_ actions: [UIView]) {
actionsContainer.arrangedSubviews.forEach { view in
actionsContainer.removeArrangedSubview(view)
view.removeFromSuperview()
}
actions.forEach { actionsContainer.addArrangedSubview($0) }
actionsContainer.isHidden = actions.isEmpty
}
private func configureViews(
title: String,
systemImage: String,
description: String?,
actions: [UIView]
) {
contentStack.axis = .vertical
contentStack.alignment = .center
contentStack.spacing = AppMetrics.Spacing.small
labelStack.axis = .vertical
labelStack.alignment = .center
labelStack.spacing = AppMetrics.Spacing.xSmall
iconView.image = UIImage(systemName: systemImage)
iconView.tintColor = AppDesign.textSecondary
iconView.contentMode = .scaleAspectFit
iconView.setContentHuggingPriority(.required, for: .vertical)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
titleLabel.textColor = AppDesign.textPrimary
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 0
descriptionLabel.text = description
descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
descriptionLabel.textColor = AppDesign.textSecondary
descriptionLabel.textAlignment = .center
descriptionLabel.numberOfLines = 0
descriptionLabel.isHidden = description?.isEmpty != false
actionsContainer.axis = .vertical
actionsContainer.alignment = .center
actionsContainer.spacing = AppMetrics.Spacing.xSmall
actionsContainer.isHidden = actions.isEmpty
actions.forEach { actionsContainer.addArrangedSubview($0) }
labelStack.addArrangedSubview(iconView)
labelStack.addArrangedSubview(titleLabel)
if description?.isEmpty == false {
labelStack.addArrangedSubview(descriptionLabel)
}
contentStack.addArrangedSubview(labelStack)
if !actions.isEmpty {
contentStack.addArrangedSubview(actionsContainer)
}
addSubview(contentStack)
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppMetrics.Spacing.large)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(44)
}
}
}

View File

@ -0,0 +1,21 @@
//
// AppDesign.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import UIKit
///
enum AppDesign {
static let primary = UIColor(hex: 0x0073FF)
static let primarySoft = UIColor(hex: 0xEFF6FF)
static let textPrimary = UIColor(hex: 0x1F2937)
static let textSecondary = UIColor(hex: 0x6B7280)
static let placeholder = UIColor(hex: 0xA8B2C1)
static let success = UIColor(hex: 0x14964A)
static let warning = UIColor(hex: 0xFF7B00)
static let inputBackground = UIColor(hex: 0xF8FAFC)
static let inputBorder = UIColor(hex: 0xE2E8F0)
}

View File

@ -0,0 +1,68 @@
//
// AppMetrics.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import CoreGraphics
/// App
final class AppMetrics {
private init() {}
/// App
enum FontSize {
static let caption: CGFloat = 12
static let footnote: CGFloat = 13
static let subheadline: CGFloat = 14
static let body: CGFloat = 16
static let callout: CGFloat = 17
static let title3: CGFloat = 18
static let title2: CGFloat = 20
static let title: CGFloat = 24
static let largeTitle: CGFloat = 30
}
///
enum Spacing {
static let xxxSmall: CGFloat = 2
static let xxSmall: CGFloat = 4
static let xSmall: CGFloat = 8
static let small: CGFloat = 12
static let substantial: CGFloat = 14
static let medium: CGFloat = 16
static let mediumLarge: CGFloat = 18
static let large: CGFloat = 20
static let sheet: CGFloat = 22
static let xLarge: CGFloat = 24
static let xxLarge: CGFloat = 30
static let pageHorizontal: CGFloat = 16
static let pageVertical: CGFloat = 24
}
///
enum ControlSize {
static let smallIcon: CGFloat = 16
static let checkboxIcon: CGFloat = 20
static let passwordIcon: CGFloat = 22
static let progressWidth: CGFloat = 22
static let checkboxTapArea: CGFloat = 28
static let iconTapArea: CGFloat = 32
static let sheetButtonHeight: CGFloat = 48
static let primaryButtonHeight: CGFloat = 50
static let inputHeight: CGFloat = 52
}
///
enum LineSpacing {
static let title: CGFloat = 3
}
///
enum CornerRadius {
static let input: CGFloat = 12
static let button: CGFloat = 12
static let card: CGFloat = 16
}
}

View File

@ -0,0 +1,266 @@
//
// GlobalLoadingCenter.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import Foundation
import UIKit
#if canImport(Lottie)
import Lottie
#endif
@MainActor
/// Loading
final class GlobalLoadingCenter {
fileprivate let state = GlobalLoadingState()
private weak var overlayView: GlobalLoadingOverlayView?
/// Loading
func show(message: String = "") {
if !message.isEmpty {
state.message = message
}
state.activeCount += 1
state.isVisible = true
}
/// Loading
func hide() {
state.activeCount = max(0, state.activeCount - 1)
guard state.activeCount == 0 else { return }
state.isVisible = false
state.message = ""
}
/// Loading Loading
func updateMessage(_ message: String) {
guard state.isVisible, !message.isEmpty else { return }
state.message = message
}
/// Loading
func withLoading<T>(
message: String = "",
operation: () async throws -> T
) async rethrows -> T {
show(message: message)
defer { hide() }
return try await operation()
}
/// Loading loading
func withOptionalLoading<T>(
_ enabled: Bool,
message: String = "",
operation: () async throws -> T
) async rethrows -> T {
if enabled {
return try await withLoading(message: message, operation: operation)
}
return try await operation()
}
/// Loading
func attachToWindow(_ window: UIWindow) {
if let overlayView, overlayView.superview === window {
return
}
let overlay = GlobalLoadingOverlayView(state: state)
overlay.frame = window.bounds
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(overlay)
overlayView = overlay
}
#if DEBUG
/// Loading
var snapshotForTests: GlobalLoadingSnapshot {
GlobalLoadingSnapshot(
isVisible: state.isVisible,
message: state.message,
activeCount: state.activeCount
)
}
#endif
}
@MainActor
/// Loading Overlay
final class GlobalLoadingState {
var onChange: (() -> Void)?
fileprivate var isVisible = false { didSet { onChange?() } }
fileprivate var message = "" { didSet { onChange?() } }
fileprivate var activeCount = 0 { didSet { onChange?() } }
}
/// Loading Loading
fileprivate final class GlobalLoadingOverlayView: UIView {
private let state: GlobalLoadingState
private let dimmingView = UIView()
private let cardView = UIView()
private let animationContainer = UIView()
private var animationView: UIView?
/// 使 Loading
init(state: GlobalLoadingState) {
self.state = state
super.init(frame: .zero)
configureViews()
state.onChange = { [weak self] in
self?.refreshPresentation(animated: true)
}
refreshPresentation(animated: false)
}
required init?(coder: NSCoder) {
nil
}
private func configureViews() {
isUserInteractionEnabled = true
accessibilityLabel = "加载中"
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.28)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 18
cardView.layer.cornerCurve = .continuous
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.12
cardView.layer.shadowRadius = 24
cardView.layer.shadowOffset = CGSize(width: 0, height: 10)
animationView = Self.makeAnimationView()
if let animationView {
animationContainer.addSubview(animationView)
animationView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: animationContainer.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: animationContainer.trailingAnchor),
animationView.topAnchor.constraint(equalTo: animationContainer.topAnchor),
animationView.bottomAnchor.constraint(equalTo: animationContainer.bottomAnchor)
])
}
addSubview(dimmingView)
addSubview(cardView)
cardView.addSubview(animationContainer)
dimmingView.translatesAutoresizingMaskIntoConstraints = false
cardView.translatesAutoresizingMaskIntoConstraints = false
animationContainer.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
dimmingView.leadingAnchor.constraint(equalTo: leadingAnchor),
dimmingView.trailingAnchor.constraint(equalTo: trailingAnchor),
dimmingView.topAnchor.constraint(equalTo: topAnchor),
dimmingView.bottomAnchor.constraint(equalTo: bottomAnchor),
cardView.centerXAnchor.constraint(equalTo: centerXAnchor),
cardView.centerYAnchor.constraint(equalTo: centerYAnchor),
animationContainer.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 10),
animationContainer.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -10),
animationContainer.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 10),
animationContainer.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10),
animationContainer.widthAnchor.constraint(equalToConstant: 132),
animationContainer.heightAnchor.constraint(equalToConstant: 132)
])
}
private func refreshPresentation(animated: Bool) {
let shouldShow = state.isVisible
let updates = {
self.isHidden = !shouldShow
self.alpha = shouldShow ? 1 : 0
}
guard animated else {
updates()
return
}
if shouldShow {
isHidden = false
alpha = 0
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut]) {
self.alpha = 1
}
} else {
UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: {
self.alpha = 0
}, completion: { _ in
self.isHidden = true
})
}
}
/// Loading
private static var hasAnimationResource: Bool {
loadAnimation() != nil
}
/// Lottie
private static func makeAnimationView() -> UIView? {
#if canImport(Lottie)
if hasAnimationResource {
let container = UIView()
container.backgroundColor = .clear
let animationView = LottieAnimationView()
animationView.translatesAutoresizingMaskIntoConstraints = false
animationView.contentMode = .scaleAspectFit
animationView.loopMode = .loop
animationView.backgroundBehavior = .pauseAndRestore
animationView.animation = loadAnimation()
animationView.play()
container.addSubview(animationView)
NSLayoutConstraint.activate([
animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
animationView.topAnchor.constraint(equalTo: container.topAnchor),
animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
return container
}
#endif
let indicator = UIActivityIndicatorView(style: .large)
indicator.color = AppDesign.primary
indicator.startAnimating()
return indicator
}
/// loading Resources
#if canImport(Lottie)
private static func loadAnimation() -> LottieAnimation? {
if let animation = LottieAnimation.named("loading") {
return animation
}
if let url = Bundle.main.url(forResource: "loading", withExtension: "json", subdirectory: "Resources") {
return LottieAnimation.filepath(url.path)
}
if let url = Bundle.main.url(forResource: "loading", withExtension: "json") {
return LottieAnimation.filepath(url.path)
}
return nil
}
#else
private static func loadAnimation() -> Any? {
nil
}
#endif
}
#if DEBUG
/// Loading
struct GlobalLoadingSnapshot: Equatable {
let isVisible: Bool
let message: String
let activeCount: Int
}
#endif

View File

@ -0,0 +1,105 @@
//
// ForegroundLocationProvider.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import CoreLocation
import Foundation
///
struct ForegroundLocationResult: Equatable {
let latitude: Double
let longitude: Double
let address: String
}
/// Provider
@MainActor
final class ForegroundLocationProvider: NSObject {
private let manager = CLLocationManager()
private let geocoder = CLGeocoder()
private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
}
///
func requestCurrentLocation() async throws -> ForegroundLocationResult {
if let continuation {
continuation.resume(throwing: LocationProviderError.duplicatedRequest)
self.continuation = nil
}
let status = manager.authorizationStatus
if status == .notDetermined {
manager.requestWhenInUseAuthorization()
}
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
manager.requestLocation()
}
}
/// CLLocation
private func makeResult(from location: CLLocation) async -> ForegroundLocationResult {
let address: String
if let placemark = try? await geocoder.reverseGeocodeLocation(location).first {
address = [
placemark.administrativeArea,
placemark.locality,
placemark.subLocality,
placemark.thoroughfare,
placemark.name
]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "")
} else {
address = "当前位置"
}
return ForegroundLocationResult(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude,
address: address.isEmpty ? "当前位置" : address
)
}
}
extension ForegroundLocationProvider: CLLocationManagerDelegate {
///
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
Task { @MainActor in
guard let continuation = self.continuation else { return }
self.continuation = nil
let result = await self.makeResult(from: location)
continuation.resume(returning: result)
}
}
///
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
Task { @MainActor in
guard let continuation = self.continuation else { return }
self.continuation = nil
continuation.resume(throwing: error)
}
}
}
/// Provider
enum LocationProviderError: LocalizedError {
case duplicatedRequest
var errorDescription: String? {
switch self {
case .duplicatedRequest:
"已有定位请求正在执行"
}
}
}

View File

@ -0,0 +1,166 @@
//
// SharedBusinessModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
struct AvailableOrderResponse: Decodable, Identifiable, Hashable {
var id: String { orderNumber }
let projectName: String
let orderNumber: String
let orderStatus: Int
let orderStatusLabel: String
let payTime: String
let userPhone: String
/// 线
enum CodingKeys: String, CodingKey {
case projectName = "project_name"
case orderNumber = "order_number"
case orderStatus = "order_status"
case orderStatusLabel = "order_status_label"
case payTime = "pay_time"
case userPhone = "user_phone"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
projectName = try container.decodeSharedLossyString(forKey: .projectName)
orderNumber = try container.decodeSharedLossyString(forKey: .orderNumber)
orderStatus = try container.decodeSharedLossyInt(forKey: .orderStatus) ?? 0
orderStatusLabel = try container.decodeSharedLossyString(forKey: .orderStatusLabel)
payTime = try container.decodeSharedLossyString(forKey: .payTime)
userPhone = try container.decodeSharedLossyString(forKey: .userPhone)
}
}
///
struct PhotographerProjectItem: Decodable, Identifiable, Hashable {
let id: Int
let type: Int
let typeName: String
let status: Int
let statusName: String
let name: String
let coverProject: String
let coverVideo: String
let price: String
let otPrice: String
let priceDeposit: String
let attrLabel: [String]
let label: String
///
init(
id: Int,
type: Int = 0,
typeName: String = "",
status: Int = 0,
statusName: String = "",
name: String,
coverProject: String = "",
coverVideo: String = "",
price: String = "",
otPrice: String = "",
priceDeposit: String = "",
attrLabel: [String] = [],
label: String = ""
) {
self.id = id
self.type = type
self.typeName = typeName
self.status = status
self.statusName = statusName
self.name = name
self.coverProject = coverProject
self.coverVideo = coverVideo
self.price = price
self.otPrice = otPrice
self.priceDeposit = priceDeposit
self.attrLabel = attrLabel
self.label = label
}
/// 线
enum CodingKeys: String, CodingKey {
case id
case type
case typeName = "type_name"
case status
case statusName = "status_name"
case name
case coverProject = "cover_project"
case coverVideo = "cover_video"
case price
case otPrice = "ot_price"
case priceDeposit = "price_deposit"
case attrLabel = "attr_label"
case label
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeSharedLossyInt(forKey: .id) ?? 0
type = try container.decodeSharedLossyInt(forKey: .type) ?? 0
typeName = try container.decodeSharedLossyString(forKey: .typeName)
status = try container.decodeSharedLossyInt(forKey: .status) ?? 0
statusName = try container.decodeSharedLossyString(forKey: .statusName)
name = try container.decodeSharedLossyString(forKey: .name)
coverProject = try container.decodeSharedLossyString(forKey: .coverProject)
coverVideo = try container.decodeSharedLossyString(forKey: .coverVideo)
price = try container.decodeSharedLossyString(forKey: .price)
otPrice = try container.decodeSharedLossyString(forKey: .otPrice)
priceDeposit = try container.decodeSharedLossyString(forKey: .priceDeposit)
label = try container.decodeSharedLossyString(forKey: .label)
if let labels = try? container.decodeIfPresent([String].self, forKey: .attrLabel) {
attrLabel = labels
} else if let text = try? container.decodeIfPresent(String.self, forKey: .attrLabel) {
attrLabel = text
.components(separatedBy: CharacterSet(charactersIn: ","))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
} else {
attrLabel = []
}
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeSharedLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
/// StringDouble Int Int
func decodeSharedLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Int(text) ?? Double(text).map(Int.init)
}
return nil
}
}

View File

@ -0,0 +1,255 @@
//
// APIClient.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// URLSession
protocol URLSessionProtocol {
/// URLRequest
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol {}
@MainActor
/// token Envelope
final class APIClient {
private let session: URLSessionProtocol
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private var authTokenProvider: (() -> String?)?
private let environment: APIEnvironment
private let appVersion: String
private let osType: String
///
init(
environment: APIEnvironment = .current,
session: URLSessionProtocol = APIClient.defaultSession,
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder(),
appVersion: String = AppClientInfo.appVersion(),
osType: String = AppClientInfo.osType
) {
self.environment = environment
self.session = session
self.encoder = encoder
self.decoder = decoder
self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0"
self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType
}
nonisolated private static let defaultSession: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 12
configuration.timeoutIntervalForResource = 20
configuration.waitsForConnectivity = false
return URLSession(configuration: configuration)
}()
/// token API
func bindAuthTokenProvider(_ provider: @escaping () -> String?) {
authTokenProvider = provider
}
/// APIRequest
func send<Response: Decodable>(
_ apiRequest: APIRequest<Response>,
tokenOverride: String? = nil
) async throws -> Response {
let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride)
logRequest(request)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
} catch is CancellationError {
logCancelled(for: request, reason: "CancellationError")
throw CancellationError()
} catch let error as URLError {
if error.code == .cancelled {
logCancelled(for: request, reason: "URLError.cancelled")
throw CancellationError()
}
throw APIError.networkFailed(networkErrorMessage(for: error))
} catch {
throw APIError.networkFailed(error.localizedDescription)
}
logResponse(for: request, response: response, data: data)
try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data)
}
/// URLRequest Headertoken
private func makeURLRequest<Response: Decodable>(
_ apiRequest: APIRequest<Response>,
tokenOverride: String?
) throws -> URLRequest {
let path = apiRequest.path.hasPrefix("/") ? apiRequest.path : "/" + apiRequest.path
guard var components = URLComponents(
string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path
) else {
throw APIError.invalidURL
}
if !apiRequest.queryItems.isEmpty {
components.queryItems = apiRequest.queryItems
}
guard let url = components.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = apiRequest.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION")
request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE")
apiRequest.headers.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
let token = tokenOverride ?? authTokenProvider?()
if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue(token, forHTTPHeaderField: "token")
}
if let body = apiRequest.body {
request.httpBody = try encoder.encode(body)
}
return request
}
/// HTTP 2xx
private func validateHTTPResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard 200 ..< 300 ~= httpResponse.statusCode else {
throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data))
}
}
/// Envelope data
private func decodeEnvelope<Response: Decodable>(_ responseType: Response.Type, from data: Data) throws -> Response {
let envelope: APIEnvelope<Response>
do {
envelope = try decoder.decode(APIEnvelope<Response>.self, from: data)
} catch {
throw APIError.decodeFailed(error.localizedDescription)
}
guard envelope.isSuccess else {
throw APIError.serverCode(envelope.code, envelope.msg ?? "业务请求失败")
}
if responseType == EmptyPayload.self {
return EmptyPayload() as! Response
}
guard let payload = envelope.data else {
throw APIError.emptyData
}
return payload
}
/// HTTP
private func parseHTTPErrorMessage(data: Data) -> String {
if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data) {
if let msg = envelope.msg?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty {
return msg
}
if let message = envelope.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
return message
}
if let error = envelope.error?.trimmingCharacters(in: .whitespacesAndNewlines), !error.isEmpty {
return error
}
}
if let plainText = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!plainText.isEmpty {
return plainText.count > 120 ? String(plainText.prefix(120)) + "..." : plainText
}
return "服务端返回错误"
}
/// URLError
private func networkErrorMessage(for error: URLError) -> String {
switch error.code {
case .timedOut:
"请求超时,请稍后重试"
case .notConnectedToInternet:
"网络不可用,请检查网络连接"
case .networkConnectionLost:
"网络连接中断,请重试"
case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed:
"无法连接服务器,请稍后重试"
default:
error.localizedDescription
}
}
/// Debug
private func logRequest(_ request: URLRequest) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
print("[API][Request] \(method) \(url)")
#endif
}
/// Debug
private func logResponse(for request: URLRequest, response: URLResponse, data: Data) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown"
let body = Self.debugResponseBody(from: data)
print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)")
#endif
}
/// Debug
private func logCancelled(for request: URLRequest, reason: String) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
print("[API][Cancelled] \(method) \(url) reason=\(reason)")
#endif
}
#if DEBUG
/// 便
private static func debugResponseBody(from data: Data) -> String {
guard !data.isEmpty else { return "<empty response>" }
if
let object = try? JSONSerialization.jsonObject(with: data),
let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]),
let prettyJSON = String(data: prettyData, encoding: .utf8) {
return prettyJSON
}
return String(data: data, encoding: .utf8) ?? "<non-utf8 response: \(data.count) bytes>"
}
#endif
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,62 @@
//
// APIEnvelope.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// codemsg data
struct APIEnvelope<T: Decodable>: Decodable {
let data: T?
let code: Int
let msg: String?
/// code
var isSuccess: Bool {
code == 100000
}
/// Envelope JSON
enum CodingKeys: String, CodingKey {
case data
case code
case msg
}
/// data EmptyPayload
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0
msg = try container.decodeIfPresent(String.self, forKey: .msg)
guard code == 100000 else {
data = nil
return
}
guard container.contains(.data), try !container.decodeNil(forKey: .data) else {
data = nil
return
}
if T.self == EmptyPayload.self {
data = EmptyPayload() as? T
return
}
data = try container.decode(T.self, forKey: .data)
}
}
/// data
struct EmptyPayload: Codable {}
/// HTTP
struct ErrorEnvelope: Decodable {
let code: Int?
let msg: String?
let message: String?
let error: String?
}

View File

@ -0,0 +1,62 @@
//
// APIEnvironment.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// HTTP WebSocket
struct APIEnvironment: Equatable {
let baseURL: URL
let webSocketURL: URL
nonisolated static let production = APIEnvironment(
baseURL: URL(string: "https://api.zhifly.cn")!,
webSocketURL: URL(string: "wss://api.zhifly.cn/wss")!
)
nonisolated static let testing = APIEnvironment(
baseURL: URL(string: "https://api-test.zhifly.cn")!,
webSocketURL: URL(string: "wss://api-test.zhifly.cn/wss")!
)
nonisolated static var current: APIEnvironment {
#if DEBUG
.testing
#else
.production
#endif
}
}
/// App
enum AppClientInfo {
nonisolated static let osType = "iOS"
/// App build
nonisolated static func appVersion(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String {
let version = (infoDictionary?["CFBundleShortVersionString"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty ?? "1.0.0"
let build = (infoDictionary?["CFBundleVersion"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
let versionParts = version.split(separator: ".", omittingEmptySubsequences: false)
if versionParts.count >= 3 {
return version
}
if versionParts.count == 2, let build {
return "\(version).\(build)"
}
return version
}
}
private extension String {
nonisolated var nonEmpty: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,61 @@
//
// APIError.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// URLHTTP
enum APIError: Error, LocalizedError {
case invalidURL
case invalidResponse
case httpStatus(Int, String)
case serverCode(Int, String)
case emptyData
case decodeFailed(String)
case networkFailed(String)
var errorDescription: String? {
switch self {
case .invalidURL:
"请求地址无效"
case .invalidResponse:
"服务响应异常"
case let .httpStatus(statusCode, message):
"请求失败HTTP \(statusCode)\(message)"
case .serverCode(_, let message):
message
case .emptyData:
"接口返回数据为空"
case .decodeFailed(let message):
"数据解析失败:\(message)"
case .networkFailed(let message):
"网络请求失败:\(message)"
}
}
}
extension APIError {
///
static func isAuthenticationExpired(_ error: Error) -> Bool {
guard let apiError = error as? APIError else { return false }
switch apiError {
case let .httpStatus(statusCode, _):
return statusCode == 401 || statusCode == 403
case let .serverCode(code, message):
let text = message.lowercased()
return code == 200001
|| text.contains("token")
|| text.contains("过期")
|| text.contains("登录失效")
|| text.contains("重新登录")
|| text.contains("unauthorized")
|| text.contains("验证失败")
|| text.contains("驗證失敗")
default:
return false
}
}
}

View File

@ -0,0 +1,55 @@
//
// APIRequest.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
/// HTTP
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
/// API Header
struct APIRequest<Response: Decodable> {
var method: HTTPMethod
var path: String
var queryItems: [URLQueryItem]
var headers: [String: String]
var body: AnyEncodable?
/// API AnyEncodable
init<Body: Encodable>(
method: HTTPMethod,
path: String,
queryItems: [URLQueryItem] = [],
headers: [String: String] = [:],
body: Body? = Optional<EmptyPayload>.none
) {
self.method = method
self.path = path
self.queryItems = queryItems
self.headers = headers
self.body = body.map(AnyEncodable.init)
}
}
/// Encodable APIRequest
struct AnyEncodable: Encodable {
private let encodeValue: (Encoder) throws -> Void
/// Encodable
nonisolated init<Value: Encodable>(_ value: Value) {
encodeValue = value.encode(to:)
}
/// Encoder
nonisolated func encode(to encoder: Encoder) throws {
try encodeValue(encoder)
}
}

View File

@ -0,0 +1,83 @@
//
// ListPayload.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// total + list
struct ListPayload<T: Decodable>: Decodable {
let total: Int
let list: [T]
/// JSON
enum CodingKeys: String, CodingKey {
case total
case list
}
///
init(total: Int, list: [T]) {
self.total = total
self.list = list
}
/// total
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([T].self, forKey: .list) ?? []
}
}
/// total + data list
struct DataListPayload<T: Decodable>: Decodable {
let total: Int
let data: [T]
/// JSON
enum CodingKeys: String, CodingKey {
case total
case data
case list
}
///
init(total: Int, data: [T]) {
self.total = total
self.data = data
}
/// data/list total
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
data = try container.decodeIfPresent([T].self, forKey: .data)
?? container.decodeIfPresent([T].self, forKey: .list)
?? []
}
}
private extension KeyedDecodingContainer {
/// IntDouble
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
}

View File

@ -0,0 +1,305 @@
# Networking 模块业务逻辑
## 模块职责
Networking 模块是 App 的统一网络请求入口。业务模块不直接使用 `URLSession`,而是通过自己的 API 类创建 `APIRequest`,再交给 `APIClient.send` 发送。
这个模块负责:
- 维护不同环境的服务器地址。
- 描述一次接口请求的方法、路径、query、header、body 和响应类型。
- 把业务请求转换成 `URLRequest`
- 自动注入公共 Header 和登录 token。
- 发送请求并处理网络错误。
- 校验 HTTP 状态码。
- 解包后端统一 Envelope。
- 把错误转换成统一的 `APIError`
## 文件职责
- `APIEnvironment.swift`定义生产环境、测试环境、HTTP baseURL 和 WebSocket 地址。
- `APIRequest.swift`:定义强类型请求模型,业务 API 通过它声明接口。
- `APIClient.swift`:统一网络客户端,负责真正构造、发送、解析请求。
- `APIEnvelope.swift`:定义后端统一响应结构。
- `APIError.swift`:定义网络层错误和登录失效判断。
## 请求调用链路
一次业务请求的大致流程是:
1. 业务 API 创建 `APIRequest<Response>`
2. `APIClient.send` 接收这个请求。
3. `APIClient.makeURLRequest``APIRequest` 转成 `URLRequest`
4. `APIClient` 注入公共 Header、业务 Header、token 和请求体。
5. `URLSessionProtocol.data(for:)` 发送请求。
6. `APIClient.validateHTTPResponse` 校验 HTTP 状态码。
7. `APIClient.decodeEnvelope` 解包后端 Envelope。
8. 成功时返回 `Response` 类型的业务数据。
9. 失败时抛出 `APIError`
## APIRequest 的作用
`APIRequest<Response>` 是业务接口和网络底层之间的桥梁。
它包含:
- `method`HTTP 方法,目前支持 GET、POST、PUT、DELETE。
- `path`:接口路径,例如 `/api/app/v9/login`
- `queryItems`URL query 参数。
- `headers`:接口额外 Header。
- `body`:请求体,内部通过 `AnyEncodable` 做类型擦除。
- `Response`:接口成功后期望返回的数据类型。
示例:
```swift
try await client.send(
APIRequest(
method: .post,
path: "/api/app/v9/login",
body: LoginRequest(username: username, password: password)
)
)
```
业务 API 类应该负责创建 `APIRequest`View 和 ViewModel 不应该直接拼 URL。
## URLRequest 构造规则
`APIClient.makeURLRequest` 会做这些事情:
1. 如果 `path` 没有 `/` 前缀,会自动补上。
2. 使用 `APIEnvironment.baseURL + path` 生成完整 URL。
3. 如果 `queryItems` 非空,则写入 URL query。
4. 设置 HTTP method。
5. 设置公共 Header
- `Content-Type: application/json`
- `Accept: application/json`
- `X-APP-VERSION`
- `X-OS-TYPE`
6. 合并业务 API 传入的额外 Header。
7. 选择并注入 token。
8. 如果有 body则使用 `JSONEncoder` 编码为 JSON。
## token 注入规则
token 有两个来源:
1. `tokenOverride`
2. `authTokenProvider`
优先级是:
```text
tokenOverride > authTokenProvider()
```
正常登录后的接口走 `authTokenProvider``RootView` 启动时会绑定:
```swift
apiClient.bindAuthTokenProvider { appSession.token }
```
登录流程里的 `set-user` 比较特殊,它需要使用登录接口返回的临时 token所以会传入 `tokenOverride`
token 最终会写入请求 Header
```text
token: <token>
```
空 token 不会写入 Header。
## 环境选择
`APIEnvironment.current` 根据编译环境选择接口地址:
- Debug`https://api-test.zhifly.cn`
- Release`https://api.zhifly.cn`
WebSocket 地址也在 `APIEnvironment` 中定义,当前网络客户端主要使用 HTTP baseURL。
## App 版本 Header
`AppClientInfo.appVersion` 会从 `Info.plist` 读取:
- `CFBundleShortVersionString`
- `CFBundleVersion`
如果版本号已经有三段,则直接使用版本号。
如果版本号只有两段,并且有 build 号,则拼成 `版本号.build`
如果读取失败,则兜底为 `1.0.0`
这个值会作为 `X-APP-VERSION` Header 发送给后端。
系统类型固定为:
```text
X-OS-TYPE: iOS
```
## 后端 Envelope 约定
后端统一响应结构由 `APIEnvelope<T>` 表示:
```swift
struct APIEnvelope<T: Decodable>: Decodable {
let data: T?
let code: Int
let msg: String?
}
```
当前成功业务码是:
```text
100000
```
只有 `code == 100000` 时才会继续解析 `data`
如果接口成功但没有业务数据,使用 `EmptyPayload`
```swift
let _: EmptyPayload = try await client.send(...)
```
## 错误处理规则
网络错误分为几层:
### 1. URL 构造错误
URL 拼接失败时抛出:
```swift
APIError.invalidURL
```
### 2. URLSession 错误
`URLError` 会被转换成中文提示:
- 超时:请求超时,请稍后重试
- 无网络:网络不可用,请检查网络连接
- 连接中断:网络连接中断,请重试
- 无法连接服务器:无法连接服务器,请稍后重试
取消请求会继续抛出 `CancellationError`,不会包装成业务错误。
### 3. HTTP 状态码错误
非 2xx 状态码会抛出:
```swift
APIError.httpStatus(statusCode, message)
```
错误文案优先从响应体里解析:
1. `msg`
2. `message`
3. `error`
4. plain text 响应体
5. 兜底文案“服务端返回错误”
### 4. Envelope 解码错误
响应体无法按 `APIEnvelope<Response>` 解码时抛出:
```swift
APIError.decodeFailed(message)
```
### 5. 后端业务码错误
HTTP 成功但 `code != 100000` 时抛出:
```swift
APIError.serverCode(code, msg)
```
### 6. 空数据错误
接口声明需要返回 `Response`,但 Envelope 里没有 `data` 时抛出:
```swift
APIError.emptyData
```
## 登录失效判断
`APIError.isAuthenticationExpired` 用于判断是否需要清空登录态。
会被视为登录失效的情况:
- HTTP 401
- HTTP 403
- 业务码 `200001`
- 错误文案包含:
- `token`
- `过期`
- `登录失效`
- `重新登录`
- `unauthorized`
- `验证失败`
- `驗證失敗`
`SessionBootstrapper` 会用这个方法判断冷启动校验失败时是否要清空 token 和账号快照。
账号上下文相关接口集中在 `AccountContextAPI`
- `rolePermissions()` 读取角色权限。
- `scenicListAll()` 读取景区列表。
- `storeAll()` 读取门店列表。
- `scenicSpotListAll(scenicId:)` 按景区读取景点/打卡点列表。
这些接口仍然只通过 `APIClient.send` 发起请求token 由 `APIClient` 的 token provider 注入。
## Debug 日志
Debug 环境下,`APIClient` 会打印:
- 请求方法和 URL
- 响应状态码
- 格式化后的响应体
- 被取消的请求信息
Release 环境不会打印这些日志。
## 新增接口的推荐写法
新增业务接口时,优先按这个结构写:
```swift
@MainActor
@Observable
final class SomeFeatureAPI {
@ObservationIgnored private let client: APIClient
init(client: APIClient) {
self.client = client
}
func loadData() async throws -> SomeResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/example/path"
)
)
}
}
```
规则:
- API 类只负责接口封装,不处理 UI 状态。
- ViewModel 调用 API 类,不直接调用 `APIClient`
- 请求体单独定义 `Encodable` Model。
- 响应体单独定义 `Decodable` Model。
- 接口成功无 data 时使用 `EmptyPayload`
- 需要临时 token 的接口使用 `tokenOverride`
## 当前注意点
- `APIClient``@MainActor @Observable`,当前用于方便通过 Environment 注入和共享。网络发送本身是 async不会阻塞主线程等待网络返回。
- `URLSessionProtocol` 用于后续单元测试注入假 session。
- `APIEnvelope.isSuccess` 当前只认 `100000`,如果后端未来新增成功码,需要集中改这里。
- `APIEnvironment.current` 在 Debug 下默认测试环境,真机调试时需要注意接口环境。

View File

@ -0,0 +1,30 @@
//
// PushAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
@MainActor
/// API iOS APNs token
final class PushAPI {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// APNs device token沿 Android/JPush
func registerJPushId(_ registrationId: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/user/register-jpush-id",
queryItems: [URLQueryItem(name: "jpush_reg_id", value: registrationId)]
)
)
}
}

View File

@ -0,0 +1,151 @@
//
// PushNotificationManager.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
import UserNotifications
@MainActor
/// APNs token
final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate {
static let shared = PushNotificationManager()
private var api: PushAPI?
private weak var session: AppSession?
private weak var router: AppRouter?
private var latestDeviceToken: String?
private var latestAuthorizationStatus: UNAuthorizationStatus = .notDetermined
private let tokenDefaultsKey = "apns_device_token"
private let uploadedTokenDefaultsKey = "apns_uploaded_token"
override init() {
super.init()
}
///
func configure(api: PushAPI, session: AppSession, router: AppRouter) {
self.api = api
self.session = session
self.router = router
UNUserNotificationCenter.current().delegate = self
latestDeviceToken = UserDefaults.standard.string(forKey: tokenDefaultsKey)
}
/// APNs token
func requestAuthorizationAndRegister() {
Task { @MainActor in
let settings = await UNUserNotificationCenter.current().notificationSettings()
latestAuthorizationStatus = settings.authorizationStatus
switch settings.authorizationStatus {
case .notDetermined:
let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false
guard granted else { return }
UIApplication.shared.registerForRemoteNotifications()
case .authorized, .provisional, .ephemeral:
UIApplication.shared.registerForRemoteNotifications()
uploadPendingTokenIfPossible()
case .denied:
break
@unknown default:
break
}
}
}
/// APNs token
func handleDeviceToken(_ deviceToken: Data) {
let token = APNsDeviceToken.hexString(from: deviceToken)
latestDeviceToken = token
UserDefaults.standard.set(token, forKey: tokenDefaultsKey)
uploadPendingTokenIfPossible()
}
/// APNs
func handleRegistrationError(_ error: Error) {
#if DEBUG
print("APNs registration failed: \(error.localizedDescription)")
#endif
}
/// token
func uploadPendingTokenIfPossible() {
guard let api, session?.isLoggedIn == true else { return }
guard let token = latestDeviceToken, !token.isEmpty else { return }
let uploaded = UserDefaults.standard.string(forKey: uploadedTokenDefaultsKey)
guard uploaded != token else { return }
Task {
do {
try await api.registerJPushId(token)
UserDefaults.standard.set(token, forKey: uploadedTokenDefaultsKey)
} catch {
#if DEBUG
print("Push token upload failed: \(error.localizedDescription)")
#endif
}
}
}
/// userInfo
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
route(from: PushPayload(userInfo: userInfo))
}
/// payload
func handleRemoteNotification(_ payload: PushPayload) {
route(from: payload)
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let payload = PushPayload(userInfo: notification.request.content.userInfo)
Task { @MainActor in
handleRemoteNotification(payload)
}
completionHandler([.banner, .list, .sound, .badge])
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let payload = PushPayload(userInfo: response.notification.request.content.userInfo)
Task { @MainActor in
handleRemoteNotification(payload)
completionHandler()
}
}
private func route(from payload: PushPayload) {
guard let router else { return }
switch payload.route {
case .payment:
navigateHomeRoute(.paymentCollection)
case .order:
router.selectOrders(entry: .storeOrders)
case .verificationOrder:
router.selectOrders(entry: .verificationOrders)
case .task:
navigateHomeRoute(.taskManagement)
case .queue:
navigateHomeRoute(.queueManagement)
case .messageCenter:
navigateHomeRoute(.messageCenter)
}
}
private func navigateHomeRoute(_ route: HomeRoute) {
guard let router else { return }
router.select(.home)
router.router(for: .home).navigate(to: .home(route))
UIKitAppNavigation.pushHomeRoute(route, animated: true)
}
}

View File

@ -0,0 +1,99 @@
//
// PushPayload.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
/// APNs token Data
enum APNsDeviceToken {
static func hexString(from data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
}
/// payload extras/data
struct PushPayload: Sendable {
enum Route: Sendable, Equatable {
case payment
case order
case verificationOrder
case task
case queue
case messageCenter
}
private let values: [String: String]
nonisolated init(userInfo: [AnyHashable: Any]) {
var result: [String: String] = [:]
for (key, value) in userInfo {
guard let key = key as? String else { continue }
result[key] = Self.stringValue(value)
if key == "extras" || key == "extra" || key == "data" || key == "JMessageExtra" {
Self.mergeJSON(value, into: &result)
}
}
values = result
}
nonisolated var route: Route {
let typeText = values["type"] ?? ""
let routeText = values["route"] ?? ""
let uriText = values["uri"] ?? ""
let actionText = values["action"] ?? ""
let merged = [typeText, routeText, uriText, actionText].joined(separator: " ").lowercased()
if typeText == "1" || merged.contains("payment") || merged.contains("pay") || merged.contains("收款") {
return .payment
}
if merged.contains("queue") || merged.contains("排队") || merged.contains("叫号") || uriText == "/scenic-queue" {
return .queue
}
if merged.contains("verification") || merged.contains("writeoff") || merged.contains("核销") {
return .verificationOrder
}
if merged.contains("order") || merged.contains("订单") {
return .order
}
if merged.contains("task") || merged.contains("任务") {
return .task
}
return .messageCenter
}
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
if let dict = value as? [String: Any] {
for (key, value) in dict {
result[key] = stringValue(value)
mergeJSON(value, into: &result)
}
return
}
guard let text = value as? String,
let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return }
for (key, value) in object {
result[key] = stringValue(value)
mergeJSON(value, into: &result)
}
}
nonisolated private static func stringValue(_ value: Any?) -> String {
switch value {
case let string as String:
return string
case let number as NSNumber:
return number.stringValue
case .some(let value):
return "\(value)"
case nil:
return ""
}
}
}

View File

@ -0,0 +1,73 @@
//
// ScenicQueueAnnouncementState.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
struct ScenicQueueAnnouncement: Equatable {
enum Kind: Equatable {
case newTickets(count: Int)
case calledTicket(id: Int64)
}
let kind: Kind
let text: String
}
///
struct ScenicQueueAnnouncementState {
private var knownTicketIds = Set<Int64>()
private var calledTicketIds = Set<Int64>()
///
mutating func nextAnnouncement(
stats: ScenicQueueStatsData,
tickets: [ScenicQueueTicket],
customCallText: String?
) -> ScenicQueueAnnouncement? {
let currentIds = Set(tickets.map(\.id))
let newTickets = tickets.filter { !knownTicketIds.contains($0.id) }
knownTicketIds = currentIds
if let ticket = tickets.first(where: { ($0.isCalled == 1 || $0.statusText.contains("已叫号")) && !calledTicketIds.contains($0.id) }) {
calledTicketIds.insert(ticket.id)
return ScenicQueueAnnouncement(kind: .calledTicket(id: ticket.id), text: Self.callText(for: ticket, customText: customCallText))
}
guard !newTickets.isEmpty else { return nil }
let text = "排队提醒,当前新增\(newTickets.count)位游客,在排人数\(stats.queueCount)人。"
return ScenicQueueAnnouncement(kind: .newTickets(count: newTickets.count), text: text)
}
///
mutating func reset() {
knownTicketIds.removeAll()
calledTicketIds.removeAll()
}
///
static func callText(for ticket: ScenicQueueTicket, customText: String?) -> String {
callText(forQueueCode: ticket.queueCode, customText: customText)
}
///
static func callText(forQueueCode queueCode: String, customText: String?) -> String {
let custom = customText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !custom.isEmpty, custom != "A0001" {
return custom.replacingOccurrences(of: "{number}", with: queueCode)
}
let code = queueCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "当前游客" : ttsLabel(for: queueCode)
return "\(code)到拍摄点拍摄。"
}
///
static func ttsLabel(for queueCode: String) -> String {
let trimmed = queueCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "当前游客" }
return trimmed.map(String.init).joined(separator: " ")
}
}

View File

@ -0,0 +1,295 @@
//
// ScenicQueueRuntime.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import AVFoundation
import UIKit
///
enum AppScenePhase {
case active
case inactive
case background
}
@MainActor
///
final class ScenicQueueRuntime {
var onChange: (() -> Void)?
private(set) var isMonitoring = false { didSet { onChange?() } }
private(set) var lastPollText = "--" { didSet { onChange?() } }
private(set) var lastSpokenText = "" { didSet { onChange?() } }
private(set) var lastQueueCount = 0 { didSet { onChange?() } }
private(set) var lastError: String? { didSet { onChange?() } }
private let speaker = ScenicQueueSpeechService()
private weak var api: (any ScenicQueueServing)?
private var userId: String?
private var scenicId: Int?
private var scenePhase: AppScenePhase = .active
private var pollTask: Task<Void, Never>?
private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
private var announcementState = ScenicQueueAnnouncementState()
private var lastScenicId: Int?
private var lastSpotId: Int?
private var suspendedByQueueScreen = false
///
var voiceEnabled: Bool {
get {
if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.ttsEnabledKey) == nil { return true }
return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.ttsEnabledKey)
}
set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.ttsEnabledKey) }
}
///
var backgroundPollingEnabled: Bool {
get {
if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) == nil { return false }
return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey)
}
set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) }
}
///
func update(
api: any ScenicQueueServing,
userId: String?,
scenicId: Int?,
scenePhase: AppScenePhase
) {
self.api = api
self.userId = userId
self.scenicId = scenicId
self.scenePhase = scenePhase
guard !suspendedByQueueScreen else {
stop()
return
}
guard let scenicId else {
stop()
resetSnapshot()
return
}
let spotId = selectedSpotId()
guard spotId > 0 else {
stop()
lastError = "排队监听未启动:请先在排队设置里选择打卡点"
return
}
if lastScenicId != scenicId || lastSpotId != spotId {
resetSnapshot()
lastScenicId = scenicId
lastSpotId = spotId
}
if scenePhase == .background, !backgroundPollingEnabled {
stop()
return
}
startIfNeeded()
}
///
func setSuspendedByQueueScreen(_ suspended: Bool) {
guard suspendedByQueueScreen != suspended else { return }
suspendedByQueueScreen = suspended
if suspended {
stop()
} else if let api {
update(api: api, userId: userId, scenicId: scenicId, scenePhase: scenePhase)
}
}
///
func stop() {
pollTask?.cancel()
pollTask = nil
isMonitoring = false
speaker.stop()
endBackgroundTask()
}
///
func pollNow() {
guard pollTask != nil else { return }
Task { await pollOnce() }
}
private func startIfNeeded() {
guard pollTask == nil else { return }
isMonitoring = true
if scenePhase == .background {
beginBackgroundTask()
}
pollTask = Task { [weak self] in
await self?.runLoop()
}
}
private func runLoop() async {
while !Task.isCancelled {
await pollOnce()
let seconds = pollIntervalSeconds()
try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
}
}
private func pollOnce() async {
guard let api, let scenicId else { return }
let spotId = selectedSpotId()
guard spotId > 0 else { return }
if scenePhase == .background {
beginBackgroundTask()
}
do {
if lastScenicId != scenicId || lastSpotId != spotId {
resetSnapshot()
lastScenicId = scenicId
lastSpotId = spotId
}
async let statsData = api.scenicQueueStats(scenicId: scenicId, scenicSpotId: spotId)
async let homeData = api.scenicQueueHome(
scenicId: scenicId,
scenicSpotId: spotId,
type: QueueListType.queueing.rawValue,
page: 1,
pageSize: 20
)
let (stats, home) = try await (statsData, homeData)
handle(stats: stats, tickets: home.list?.list ?? [])
lastError = nil
} catch {
lastError = error.localizedDescription
}
}
private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
lastPollText = formatter.string(from: Date())
lastQueueCount = stats.queueCount
guard voiceEnabled else { return }
let customText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: lastSpotId)
if let announcement = announcementState.nextAnnouncement(stats: stats, tickets: tickets, customCallText: customText) {
lastSpokenText = announcement.text
speaker.speak(announcement.text)
}
}
private func pollIntervalSeconds() -> UInt64 {
switch scenePhase {
case .active:
return 12
case .inactive:
return 20
case .background:
return 30
@unknown default:
return 20
}
}
private func selectedSpotId() -> Int {
ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
}
private func resetSnapshot() {
announcementState.reset()
lastQueueCount = 0
lastPollText = "--"
lastSpokenText = ""
}
private func beginBackgroundTask() {
guard backgroundTaskId == .invalid else { return }
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in
Task { @MainActor in
self?.endBackgroundTask()
self?.stop()
}
}
}
private func endBackgroundTask() {
guard backgroundTaskId != .invalid else { return }
UIApplication.shared.endBackgroundTask(backgroundTaskId)
backgroundTaskId = .invalid
}
}
@MainActor
///
final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
private let synthesizer = AVSpeechSynthesizer()
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
override init() {
super.init()
synthesizer.delegate = self
}
///
func speak(_ text: String) {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return }
enqueue(normalized, continuation: nil, replacePending: true)
}
/// 使
func speakAndWait(_ text: String) async {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return }
await withCheckedContinuation { continuation in
enqueue(normalized, continuation: continuation, replacePending: false)
}
}
///
func stop() {
synthesizer.stopSpeaking(at: .immediate)
resumePending()
}
private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) {
if replacePending {
synthesizer.stopSpeaking(at: .immediate)
resumePending()
}
if let continuation {
pendingContinuations.append(continuation)
}
let utterance = AVSpeechUtterance(string: normalized)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
utterance.rate = 0.48
synthesizer.speak(utterance)
}
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
Task { @MainActor in self.resumePending() }
}
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
Task { @MainActor in self.resumePending() }
}
private func resumePending() {
let continuations = pendingContinuations
pendingContinuations.removeAll()
continuations.forEach { $0.resume() }
}
}

View File

@ -0,0 +1,166 @@
//
// ScenicQueueSocketClient.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
/// WebSocket
struct ScenicQueueSocketMessage: Equatable {
static let queueUpdatedAction = "scenic_spot_queue_updated"
static let ticketCalledAction = "scenic_queue_ticket_called"
let code: Int
let data: ScenicQueueSocketData?
var isScenicQueueEvent: Bool {
let action = data?.action ?? ""
return action == Self.queueUpdatedAction || action == Self.ticketCalledAction
}
}
/// WebSocket data
struct ScenicQueueSocketData: Equatable {
let action: String
let params: ScenicQueueSocketParams?
}
/// WebSocket
struct ScenicQueueSocketParams: Equatable {
let scenicSpotId: Int64?
let recordId: Int64?
let operatorUid: Int64?
let eventId: String?
}
@MainActor
/// WebSocket
final class ScenicQueueSocketClient {
private var webSocketTask: URLSessionWebSocketTask?
private var receiveTask: Task<Void, Never>?
/// WebSocket
func connect(
socketToken: String,
scenicSpotId: Int,
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
) {
let token = socketToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { return }
disconnect(reason: "Reconnect before scenic queue socket connect")
let task = URLSession.shared.webSocketTask(with: APIEnvironment.current.webSocketURL)
webSocketTask = task
task.resume()
receiveTask = Task { [weak self] in
await self?.receiveLoop(task: task, onMessage: onMessage)
}
Task {
for payload in Self.subscriptionPayloads(scenicSpotId: scenicSpotId) {
try? await task.send(.string(payload))
}
}
}
/// WebSocket
func disconnect(reason: String = "Scenic queue page stopped") {
receiveTask?.cancel()
receiveTask = nil
webSocketTask?.cancel(with: .normalClosure, reason: reason.data(using: .utf8))
webSocketTask = nil
}
private func receiveLoop(
task: URLSessionWebSocketTask,
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
) async {
while !Task.isCancelled {
do {
let message = try await task.receive()
let text: String?
switch message {
case .string(let value):
text = value
case .data(let data):
text = String(data: data, encoding: .utf8)
@unknown default:
text = nil
}
guard let text, let parsed = Self.parseMessage(text), parsed.isScenicQueueEvent else { continue }
onMessage(parsed)
} catch {
return
}
}
}
/// payloadtype Android
static func subscriptionPayloads(scenicSpotId: Int) -> [String] {
[
#"{"type":306,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#,
#"{"type":307,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#
]
}
/// WebSocket
static func parseMessage(_ raw: String) -> ScenicQueueSocketMessage? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8) else { return nil }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let code = intValue(root["code"]) ?? 0
var socketData: ScenicQueueSocketData?
if let dataObject = root["data"] as? [String: Any] {
let action = stringValue(dataObject["action"])
var params: ScenicQueueSocketParams?
if let paramsObject = dataObject["params"] as? [String: Any] {
params = ScenicQueueSocketParams(
scenicSpotId: int64Value(paramsObject["scenic_spot_id"]),
recordId: int64Value(paramsObject["record_id"]),
operatorUid: int64Value(paramsObject["operator_uid"]),
eventId: stringValue(paramsObject["event_id"]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
)
}
socketData = ScenicQueueSocketData(action: action, params: params)
}
return ScenicQueueSocketMessage(code: code, data: socketData)
}
private static func stringValue(_ value: Any?) -> String {
switch value {
case let value as String:
return value
case let value as NSNumber:
return value.stringValue
case let value?:
return "\(value)"
case nil:
return ""
}
}
private static func intValue(_ value: Any?) -> Int? {
if let value = value as? Int { return value }
if let value = value as? NSNumber { return value.intValue }
if let value = value as? String { return Int(value) }
return nil
}
private static func int64Value(_ value: Any?) -> Int64? {
if let value = value as? Int64 { return value }
if let value = value as? Int { return Int64(value) }
if let value = value as? NSNumber { return value.int64Value }
if let value = value as? String { return Int64(value) }
return nil
}
}
private extension String {
var nilIfEmpty: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,105 @@
//
// AccountSnapshotStore.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
///
struct AccountSnapshot: Codable, Equatable {
var profile: AccountProfile?
var accountType: String?
var businessUserId: Int?
var currentRoleId: Int?
var scenicScopes: [BusinessScope]
var storeScopes: [BusinessScope]
var currentScenicId: Int?
var currentStoreId: Int?
///
init(
profile: AccountProfile? = nil,
accountType: String? = nil,
businessUserId: Int? = nil,
currentRoleId: Int? = nil,
scenicScopes: [BusinessScope] = [],
storeScopes: [BusinessScope] = [],
currentScenicId: Int? = nil,
currentStoreId: Int? = nil
) {
self.profile = profile
self.accountType = accountType
self.businessUserId = businessUserId
self.currentRoleId = currentRoleId
self.scenicScopes = scenicScopes
self.storeScopes = storeScopes
self.currentScenicId = currentScenicId
self.currentStoreId = currentStoreId
}
}
/// 使 UserDefaults
final class AccountSnapshotStore {
private let defaults: UserDefaults
private let key: String
private let encoder: JSONEncoder
private let decoder: JSONDecoder
/// UserDefaults
init(
defaults: UserDefaults = .standard,
key: String = "suixinkan.account.snapshot.v1",
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder()
) {
self.defaults = defaults
self.key = key
self.encoder = encoder
self.decoder = decoder
}
///
func save(_ snapshot: AccountSnapshot) {
guard let data = try? encoder.encode(snapshot) else { return }
defaults.set(data, forKey: key)
}
///
func load() -> AccountSnapshot? {
guard let data = defaults.data(forKey: key) else { return nil }
do {
return try decoder.decode(AccountSnapshot.self, from: data)
} catch {
clear()
return nil
}
}
///
func clear() {
defaults.removeObject(forKey: key)
}
///
@MainActor
func saveCurrentSelection(
accountContext: AccountContext,
currentRoleId: Int?
) {
let existing = load()
save(
AccountSnapshot(
profile: accountContext.profile ?? existing?.profile,
accountType: existing?.accountType,
businessUserId: existing?.businessUserId,
currentRoleId: currentRoleId ?? existing?.currentRoleId,
scenicScopes: accountContext.scenicScopes,
storeScopes: accountContext.storeScopes,
currentScenicId: accountContext.currentScenic?.id,
currentStoreId: accountContext.currentStore?.id
)
)
}
}

View File

@ -0,0 +1,50 @@
//
// AppPreferencesStore.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
/// App
final class AppPreferencesStore {
private let defaults: UserDefaults
private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
/// UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
///
func saveLastLoginUsername(_ username: String) {
let value = username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
defaults.set(value, forKey: lastLoginUsernameKey)
}
///
func loadLastLoginUsername() -> String? {
let value = defaults.string(forKey: lastLoginUsernameKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? nil : value
}
///
func savePrivacyAgreementAccepted(_ accepted: Bool) {
defaults.set(accepted, forKey: privacyAgreementAcceptedKey)
}
///
func loadPrivacyAgreementAccepted() -> Bool {
defaults.bool(forKey: privacyAgreementAcceptedKey)
}
///
func clear() {
defaults.removeObject(forKey: lastLoginUsernameKey)
defaults.removeObject(forKey: privacyAgreementAcceptedKey)
}
}

View File

@ -0,0 +1,100 @@
//
// SessionTokenStore.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
import Security
/// token Keychain
enum SessionTokenStoreError: LocalizedError {
case unexpectedStatus(OSStatus)
var errorDescription: String? {
switch self {
case let .unexpectedStatus(status):
"登录凭证存储失败(\(status)"
}
}
}
/// token Keychain API
final class SessionTokenStore {
private let service: String
private let account: String
/// token App Bundle Keychain
init(
service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan",
account: String = "session.token"
) {
self.service = service
self.account = account
}
/// token token
func save(_ token: String) throws {
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedToken.isEmpty else {
try clear()
return
}
let data = Data(trimmedToken.utf8)
let query = baseQuery()
let updateAttributes: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary)
if updateStatus == errSecSuccess {
return
}
guard updateStatus == errSecItemNotFound else {
throw SessionTokenStoreError.unexpectedStatus(updateStatus)
}
var addQuery = query
updateAttributes.forEach { addQuery[$0.key] = $0.value }
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw SessionTokenStoreError.unexpectedStatus(addStatus)
}
}
/// token nil
func load() -> String? {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty else {
return nil
}
return token
}
/// token
func clear() throws {
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw SessionTokenStoreError.unexpectedStatus(status)
}
}
/// App 使 Keychain
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
}
}

View File

@ -0,0 +1,62 @@
//
// FeaturePlaceholderViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// UIKit
final class FeaturePlaceholderViewController: UIViewController {
private let pageTitle: String
private let uri: String
init(title: String, uri: String) {
self.pageTitle = title
self.uri = uri
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA)
title = pageTitle
let icon = UIImageView(image: UIImage(systemName: "square.grid.2x2"))
icon.tintColor = AppDesignUIKit.primary
icon.contentMode = .scaleAspectFit
let titleLabel = UILabel()
titleLabel.text = pageTitle
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .semibold)
titleLabel.textColor = AppDesignUIKit.textPrimary
titleLabel.textAlignment = .center
let uriLabel = UILabel()
uriLabel.text = uri
uriLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
uriLabel.textColor = AppDesignUIKit.textSecondary
uriLabel.textAlignment = .center
uriLabel.numberOfLines = 0
let stack = UIStackView(arrangedSubviews: [icon, titleLabel, uriLabel])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.medium
stack.alignment = .center
view.addSubview(stack)
icon.snp.makeConstraints { make in
make.width.height.equalTo(44)
}
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
}
}
}

View File

@ -0,0 +1,107 @@
//
// RemoteImage.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Kingfisher
import UIKit
extension UIImageView {
/// 使 Kingfisher 退
func loadRemoteImage(
urlString: String?,
contentMode: UIView.ContentMode = .scaleAspectFill,
placeholder: UIImage? = nil,
failureImage: UIImage? = nil
) {
let normalized = normalizedURLString(from: urlString)
self.contentMode = contentMode
guard let normalized, let url = URL(string: normalized) else {
kf.cancelDownloadTask()
image = failureImage ?? placeholder
return
}
kf.setImage(
with: url,
placeholder: placeholder,
options: [
.cacheOriginalImage,
.transition(.fade(0.2))
]
) { [weak self] result in
guard let self else { return }
if case .failure = result {
self.image = failureImage ?? placeholder
}
}
}
///
func loadRemoteAvatar(
urlString: String,
systemImageName: String = "person.fill",
iconSize: CGFloat = 44
) {
clipsToBounds = true
layer.cornerRadius = min(bounds.width, bounds.height) / 2
let placeholder = Self.avatarPlaceholder(
systemImageName: systemImageName,
iconSize: iconSize,
size: bounds.size == .zero ? CGSize(width: iconSize, height: iconSize) : bounds.size
)
loadRemoteImage(
urlString: urlString,
contentMode: .scaleAspectFill,
placeholder: placeholder,
failureImage: placeholder
)
}
///
func cancelRemoteImageLoad() {
kf.cancelDownloadTask()
}
private func normalizedURLString(from urlString: String?) -> String? {
let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text
}
private static func avatarPlaceholder(
systemImageName: String,
iconSize: CGFloat,
size: CGSize
) -> UIImage? {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { context in
AppDesign.primarySoft.setFill()
context.fill(CGRect(origin: .zero, size: size))
let configuration = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .semibold)
guard let symbol = UIImage(systemName: systemImageName, withConfiguration: configuration)?
.withTintColor(AppDesign.primary, renderingMode: .alwaysOriginal) else {
return
}
let origin = CGPoint(
x: (size.width - symbol.size.width) / 2,
y: (size.height - symbol.size.height) / 2
)
symbol.draw(at: origin)
}
}
}
extension UIImageView {
///
func refreshRemoteAvatarCornerRadius() {
guard bounds.width > 0, bounds.height > 0 else { return }
layer.cornerRadius = min(bounds.width, bounds.height) / 2
}
}

View File

@ -0,0 +1,31 @@
//
// UIColor+AppDesign.swift
// suixinkan
//
import UIKit
extension UIColor {
/// 0xRRGGBB UIColor
convenience init(hex: UInt, alpha: CGFloat = 1.0) {
self.init(
red: CGFloat((hex >> 16) & 0xff) / 255.0,
green: CGFloat((hex >> 8) & 0xff) / 255.0,
blue: CGFloat(hex & 0xff) / 255.0,
alpha: alpha
)
}
}
/// UIKit SwiftUI `AppDesign`
enum AppDesignUIKit {
static let primary = UIColor(hex: 0x0073FF)
static let primarySoft = UIColor(hex: 0xEFF6FF)
static let textPrimary = UIColor(hex: 0x1F2937)
static let textSecondary = UIColor(hex: 0x6B7280)
static let placeholder = UIColor(hex: 0xA8B2C1)
static let success = UIColor(hex: 0x14964A)
static let warning = UIColor(hex: 0xFF7B00)
static let pageBackground = UIColor(hex: 0xF5F5F5)
static let cardBackground = UIColor.white
}

View File

@ -0,0 +1,111 @@
//
// ViewControllerHelpers.swift
// suixinkan
//
import SnapKit
import UIKit
/// ViewController ToastLoading ViewModel
@MainActor
enum ViewControllerHelpers {
static var services: AppServices { AppServices.shared }
/// ViewModel onChange deinit
static func bind(onChange: (() -> Void)?, owner: AnyObject, handler: @escaping () -> Void) {
onChange?()
_ = ViewModelBindingToken(owner: owner, handler: handler)
}
}
/// ViewModel onChange
private final class ViewModelBindingToken {
private weak var owner: AnyObject?
private let handler: () -> Void
init(owner: AnyObject, handler: @escaping () -> Void) {
self.owner = owner
self.handler = handler
}
deinit {
handler()
}
}
extension UIViewController {
var appServices: AppServices { AppServices.shared }
func showToast(_ message: String) {
appServices.toastCenter.show(message)
}
func showGlobalLoading(_ message: String = "") {
appServices.globalLoading.show(message: message)
}
func hideGlobalLoading() {
appServices.globalLoading.hide()
}
///
func makeCardView(cornerRadius: CGFloat = 8) -> UIView {
let view = UIView()
view.backgroundColor = AppDesignUIKit.cardBackground
view.layer.cornerRadius = cornerRadius
view.clipsToBounds = true
return view
}
///
func makeEmptyStateView(title: String, message: String, systemImage: String) -> UIView {
let container = UIView()
let stack = UIStackView()
stack.axis = .vertical
stack.alignment = .center
stack.spacing = AppMetrics.Spacing.small
let imageView = UIImageView(image: UIImage(systemName: systemImage))
imageView.tintColor = AppDesignUIKit.textSecondary
imageView.contentMode = .scaleAspectFit
imageView.snp.makeConstraints { make in
make.width.height.equalTo(48)
}
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
titleLabel.textColor = AppDesignUIKit.textPrimary
titleLabel.textAlignment = .center
let messageLabel = UILabel()
messageLabel.text = message
messageLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
messageLabel.textColor = AppDesignUIKit.textSecondary
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
stack.addArrangedSubview(imageView)
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(messageLabel)
container.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
}
return container
}
///
func makePrimaryButton(title: String) -> UIButton {
var config = UIButton.Configuration.filled()
config.title = title
config.baseBackgroundColor = AppDesignUIKit.primary
config.baseForegroundColor = .white
config.cornerStyle = .medium
config.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)
let button = UIButton(configuration: config)
return button
}
}

View File

@ -0,0 +1,156 @@
//
// ModuleViewControllerSupport.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SnapKit
import UIKit
/// Cell
final class TitleSubtitleTableViewCell: UITableViewCell {
static let reuseIdentifier = "TitleSubtitleTableViewCell"
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
selectionStyle = .default
textLabel?.font = .systemFont(ofSize: 16, weight: .medium)
textLabel?.textColor = AppDesign.textPrimary
textLabel?.numberOfLines = 2
detailTextLabel?.font = .systemFont(ofSize: 13)
detailTextLabel?.textColor = AppDesign.textSecondary
detailTextLabel?.numberOfLines = 3
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(title: String, subtitle: String? = nil, detail: String? = nil) {
textLabel?.text = title
if let subtitle, !subtitle.isEmpty {
detailTextLabel?.text = subtitle
} else {
detailTextLabel?.text = detail
}
}
}
/// UITableView ViewModel onChange
@MainActor
class ModuleTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
let services = AppServices.shared
let tableView = UITableView(frame: .zero, style: .insetGrouped)
private let refreshControl = UIRefreshControl()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private var viewModelReloadHandler: (() -> Void)?
var isLoading = false {
didSet {
if isLoading, tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) == 0 {
activityIndicator.startAnimating()
} else {
activityIndicator.stopAnimating()
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0xF5F7FA)
navigationItem.largeTitleDisplayMode = .never
tableView.dataSource = self
tableView.delegate = self
tableView.backgroundColor = .clear
tableView.register(
TitleSubtitleTableViewCell.self,
forCellReuseIdentifier: TitleSubtitleTableViewCell.reuseIdentifier
)
tableView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
activityIndicator.hidesWhenStopped = true
view.addSubview(tableView)
view.addSubview(activityIndicator)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
activityIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
Task { await reloadContent() }
}
func bindViewModel(onChange: (() -> Void)?) {
viewModelReloadHandler = onChange
}
func reloadTable() {
tableView.reloadData()
}
func tableRowCount() -> Int { 0 }
func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {}
func didSelectTableRow(at indexPath: IndexPath) {}
func reloadContent() async {}
@objc private func handleRefresh() {
Task {
await reloadContent()
refreshControl.endRefreshing()
}
}
func numberOfSections(in tableView: UITableView) -> Int { 1 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
tableRowCount()
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
for: indexPath
) as? TitleSubtitleTableViewCell else {
return UITableViewCell()
}
configureCell(cell, at: indexPath)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
didSelectTableRow(at: indexPath)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
willDisplayTableRow(at: indexPath)
}
func willDisplayTableRow(at indexPath: IndexPath) {}
}
extension ModuleTableViewController {
func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) {
if let bindable = viewModel as? ViewModelBindable {
bindable.onChange = { [weak self] in
reload()
self?.reloadTable()
}
}
reload()
}
}
/// ViewModel
@MainActor
protocol ViewModelBindable: AnyObject {
var onChange: (() -> Void)? { get set }
}

View File

@ -0,0 +1,283 @@
//
// OSSUploadService.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import UniformTypeIdentifiers
#if canImport(AlibabaCloudOSS)
import AlibabaCloudOSS
#endif
/// OSS
@MainActor
protocol OSSUploadServing {
/// 访 OSS URL
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// 访 OSS URL
func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
}
@MainActor
/// OSS STS SDK URL
final class OSSUploadService {
private let configService: any OSSConfigServing
/// OSS STS
init(configService: any OSSConfigServing) {
self.configService = configService
}
/// 访 OSS URL
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "user_avatar", onProgress: onProgress)
}
/// 访 OSS URL
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name", onProgress: onProgress)
}
/// 访 OSS URL
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "cloud_driver", onProgress: onProgress)
}
/// 访 OSS URL
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "album_upload", onProgress: onProgress)
}
/// 访 OSS URL
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "alive_album", onProgress: onProgress)
}
/// 访 OSS URL
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "task_upload", onProgress: onProgress)
}
/// 访 OSS URL
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "project", onProgress: onProgress)
}
/// 访 OSS URL
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "punch_point", onProgress: onProgress)
}
/// 访 OSS URL
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", onProgress: onProgress)
}
/// 访 OSS URL
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
}
/// 访 OSS URL
func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "pilot_cert", onProgress: onProgress)
}
/// OSS
private func uploadFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
moduleType: String,
onProgress: @escaping (Int) -> Void
) async throws -> String {
onProgress(1)
try OSSUploadPolicy.validate(dataSize: data.count, fileName: fileName)
let config = try await configService.aliyunOSSBucket(bucket: "vipsky")
let objectKey = OSSUploadPolicy.objectKey(fileName: fileName, scenicId: scenicId, moduleType: moduleType)
onProgress(12)
#if canImport(AlibabaCloudOSS)
let credentialsProvider = StaticCredentialsProvider(
accessKeyId: config.credentials.accessKeyId,
accessKeySecret: config.credentials.accessKeySecret,
securityToken: config.credentials.securityToken
)
let clientConfig = Configuration.default()
.withRegion(config.region)
.withCredentialsProvider(credentialsProvider)
if !config.endpoint.isEmpty {
clientConfig.withEndpoint(config.endpoint)
}
onProgress(25)
let client = Client(clientConfig)
_ = try await client.putObject(
PutObjectRequest(
bucket: config.bucket,
key: objectKey,
contentType: OSSUploadPolicy.contentType(for: fileName, fileType: fileType),
body: .data(data),
progress: ProgressClosure { _, transferred, expected in
guard expected > 0 else { return }
let uploadProgress = Double(transferred) / Double(expected)
let progress = max(26, min(99, 25 + Int(uploadProgress * 74)))
onProgress(progress)
}
)
)
onProgress(100)
return OSSUploadPolicy.joinURL(baseURL: config.baseUrl, objectKey: objectKey)
#else
throw OSSUploadError.sdkUnavailable
#endif
}
}
extension OSSUploadService: OSSUploadServing {}
/// OSS MIME URL
enum OSSUploadPolicy {
static let maxFileSize = 2_048 * 1_024 * 1_024
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
///
static func validate(dataSize: Int, fileName: String) throws {
guard dataSize > 0 else {
throw OSSUploadError.emptyFile
}
guard dataSize <= maxFileSize else {
throw OSSUploadError.fileTooLarge
}
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
guard allowedExtensions.contains(ext) else {
throw OSSUploadError.unsupportedFileType
}
}
/// OSS objectKey
static func objectKey(
fileName: String,
scenicId: Int,
moduleType: String,
date: Date = Date(),
uuid: UUID = UUID(),
timeZone: TimeZone = .current
) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = timeZone
formatter.dateFormat = "yyyyMMdd"
let currentDate = formatter.string(from: date)
let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "")
let safeName = sanitizedFileName(fileName)
switch moduleType {
case "task_upload":
return "task_upload/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "cloud_driver":
return "cloud_driver/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "album_upload":
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "alive_album":
return "live_albums/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "project":
return "project/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "punch_point":
return "punch_point/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "scenic_apply":
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "user_avatar":
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "real_name":
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "bank_card":
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "pilot_cert":
return "pilot_cert/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
default:
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
}
}
/// MIME
static func contentType(for fileName: String, fileType: Int) -> String {
let ext = URL(fileURLWithPath: fileName).pathExtension
if let type = UTType(filenameExtension: ext)?.preferredMIMEType {
return type
}
return fileType == 1 ? "video/mp4" : "image/jpeg"
}
/// OSS 访 objectKey
static func joinURL(baseURL: String, objectKey: String) -> String {
if baseURL.hasSuffix("/") {
return baseURL + objectKey
}
return baseURL + "/" + objectKey
}
/// objectKey
private static func sanitizedFileName(_ fileName: String) -> String {
let trimmedName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
var unsafeCharacters = CharacterSet.controlCharacters
unsafeCharacters.formUnion(CharacterSet(charactersIn: "/\\"))
let safeName = trimmedName.unicodeScalars.reduce(into: "") { result, scalar in
result += unsafeCharacters.contains(scalar) ? "_" : String(scalar)
}
return safeName.isEmpty ? "upload" : safeName
}
}
/// OSS SDK
enum OSSUploadError: LocalizedError, Equatable {
case sdkUnavailable
case emptyFile
case fileTooLarge
case unsupportedFileType
var errorDescription: String? {
switch self {
case .sdkUnavailable:
"阿里云 OSS Swift SDK 尚未链接到当前 iOS target"
case .emptyFile:
"文件内容不能为空"
case .fileTooLarge:
"文件大小不能超过2048MB"
case .unsupportedFileType:
"仅支持.mp4,.mov,.m4v,.avi,.png,.jpg,.jpeg,.heic,.heif格式"
}
}
}

View File

@ -0,0 +1,52 @@
# Upload 模块业务逻辑
## 模块职责
Upload 模块负责 App 内通用文件上传能力,当前主要服务个人头像和实名认证证件图片,后续云盘、相册、任务、打卡点等模块迁移时复用同一套 OSS 上传入口。
该模块不负责业务表单提交,只负责:
- 获取阿里云 OSS STS 临时配置。
- 校验待上传文件大小和扩展名。
- 生成按业务模块隔离的 OSS objectKey。
- 调用阿里云 OSS Swift SDK 上传文件。
- 返回最终可访问的文件 URL。
## 核心对象
- `UploadAPI`:封装 `/api/app/config/get-sts-token`,只负责获取 STS 临时上传配置。
- `OSSUploadService`:统一上传服务,封装 SDK 调用和进度回调。
- `OSSUploadPolicy`上传策略管理大小限制、扩展名白名单、路径规则、MIME 类型和 URL 拼接。
- `AvatarImageProcessor`:头像图片处理器,上传前把图片压缩为 JPEG。
- `RealNameImageProcessor`:实名认证证件图片处理器,上传前把证件图压缩为 JPEG。
- `RemoteImage`Kingfisher 网络图片组件,统一远程图片加载、缓存和失败占位。
## 上传流程
1. 页面或 ViewModel 将用户选择的本地图片处理成上传数据。
2. ViewModel 调用 `OSSUploadService` 的业务上传方法。
3. `OSSUploadService` 调用 `UploadAPI.aliyunOSSBucket(bucket:)` 获取 STS 配置。
4. `OSSUploadPolicy` 校验文件并生成 objectKey。
5. `OSSUploadService` 使用 `AlibabaCloudOSS` SDK 上传数据。
6. 上传成功后返回 `base_url + objectKey`
7. 业务 ViewModel 再把 URL 提交给对应业务接口。
## 路径规则
当前模块路径:
- `user_avatar``avatar/yyyyMMdd/scenicId/uuid_fileName`
- `real_name``real_name/yyyyMMdd/scenicId/uuid_fileName`
- `task_upload``task_upload/yyyyMMdd/scenicId/uuid_fileName`
- `cloud_driver``cloud_driver/yyyyMMdd/scenicId/uuid_fileName`
- `album_upload``album/yyyyMMdd/scenicId/uuid_fileName`
- `alive_album``live_albums/yyyyMMdd/scenicId/uuid_fileName`
- `punch_point``punch_point/yyyyMMdd/scenicId/uuid_fileName`
- `scenic_apply``scenic_apply/yyyyMMdd/scenicId/uuid_fileName`
文件名会清理控制字符、`/``\`,避免生成非法 objectKey。
## 缓存边界
- OSS STS token 不落盘,只在一次上传流程中临时使用。
- 原始图片 Data、压缩后图片 Data、上传进度不落盘。
- 图片展示缓存交给 Kingfisher业务代码不自行保存网络图片文件。
- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。

View File

@ -0,0 +1,39 @@
//
// UploadAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// OSS STS token 便
@MainActor
protocol OSSConfigServing {
/// bucket OSS
func aliyunOSSBucket(bucket: String) async throws -> AliyunOSSResponse
}
@MainActor
/// API
final class UploadAPI {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// bucket OSS
func aliyunOSSBucket(bucket: String = "vipsky") async throws -> AliyunOSSResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/config/get-sts-token",
queryItems: [URLQueryItem(name: "bucket", value: bucket)]
)
)
}
}
extension UploadAPI: OSSConfigServing {}

View File

@ -0,0 +1,121 @@
//
// UploadImageProcessors.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import UIKit
import UniformTypeIdentifiers
///
enum AvatarImageProcessor {
static let maxPixelLength: CGFloat = 1024
static let jpegCompressionQuality: CGFloat = 0.82
///
struct ProcessedImage: Equatable {
let data: Data
let fileName: String
let contentType: UTType
}
/// JPEG
static func process(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> ProcessedImage {
try UploadImageRenderer.process(
data: data,
fileNamePrefix: "avatar",
maxPixelLength: maxPixelLength,
jpegCompressionQuality: jpegCompressionQuality,
timestamp: timestamp
)
}
}
///
enum RealNameImageProcessor {
static let maxPixelLength: CGFloat = 1800
static let jpegCompressionQuality: CGFloat = 0.88
/// JPEG
static func process(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> AvatarImageProcessor.ProcessedImage {
try UploadImageRenderer.process(
data: data,
fileNamePrefix: "real_name_\(side.rawValue)",
maxPixelLength: maxPixelLength,
jpegCompressionQuality: jpegCompressionQuality,
timestamp: timestamp
)
}
}
///
enum RealNameImageSide: String {
case front
case back
}
///
enum UploadImageProcessingError: LocalizedError, Equatable {
case invalidImage
case encodingFailed
var errorDescription: String? {
switch self {
case .invalidImage:
"请选择有效的图片"
case .encodingFailed:
"图片处理失败,请重新选择"
}
}
}
/// JPEG
private enum UploadImageRenderer {
/// JPEG
static func process(
data: Data,
fileNamePrefix: String,
maxPixelLength: CGFloat,
jpegCompressionQuality: CGFloat,
timestamp: TimeInterval
) throws -> AvatarImageProcessor.ProcessedImage {
guard let image = UIImage(data: data), image.size.width > 0, image.size.height > 0 else {
throw UploadImageProcessingError.invalidImage
}
let normalized = image.normalizedForUpload(maxPixelLength: maxPixelLength)
guard let jpegData = normalized.jpegData(compressionQuality: jpegCompressionQuality), !jpegData.isEmpty else {
throw UploadImageProcessingError.encodingFailed
}
return AvatarImageProcessor.ProcessedImage(
data: jpegData,
fileName: "\(fileNamePrefix)_\(Int(timestamp)).jpg",
contentType: .jpeg
)
}
}
private extension UIImage {
///
func normalizedForUpload(maxPixelLength: CGFloat) -> UIImage {
let longestSide = max(size.width, size.height)
let scale = min(1, maxPixelLength / longestSide)
let targetSize = CGSize(
width: max(1, (size.width * scale).rounded()),
height: max(1, (size.height * scale).rounded())
)
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
format.opaque = true
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
return renderer.image { context in
UIColor.white.setFill()
context.fill(CGRect(origin: .zero, size: targetSize))
draw(in: CGRect(origin: .zero, size: targetSize))
}
}
}

View File

@ -0,0 +1,100 @@
//
// UploadModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// OSS STS
struct AliyunOSSResponse: Decodable, Equatable {
let baseUrl: String
let endpoint: String
let region: String
let bucket: String
let expireSeconds: Int
let credentials: AliyunOSSCredentials
/// OSS STS JSON
enum CodingKeys: String, CodingKey {
case baseUrl = "base_url"
case endpoint
case region
case bucket
case expireSeconds = "expire_seconds"
case credentials
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
baseUrl = try container.decodeLossyString(forKey: .baseUrl)
endpoint = try container.decodeLossyString(forKey: .endpoint)
region = try container.decodeLossyString(forKey: .region)
bucket = try container.decodeLossyString(forKey: .bucket)
expireSeconds = try container.decodeLossyInt(forKey: .expireSeconds) ?? 0
credentials = try container.decode(AliyunOSSCredentials.self, forKey: .credentials)
}
}
/// OSS SDK 访 token
struct AliyunOSSCredentials: Decodable, Equatable {
let accessKeyId: String
let accessKeySecret: String
let securityToken: String
/// OSS JSON
enum CodingKeys: String, CodingKey {
case accessKeyId = "access_key_id"
case accessKeySecret = "access_key_secret"
case securityToken = "security_token"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
accessKeyId = try container.decodeLossyString(forKey: .accessKeyId)
accessKeySecret = try container.decodeLossyString(forKey: .accessKeySecret)
securityToken = try container.decodeLossyString(forKey: .securityToken)
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "true" : "false"
}
return ""
}
/// IntDouble
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
}

View File

@ -0,0 +1,74 @@
//
// AppFormValidator.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
enum AppFormValidator {
///
static func normalizedPhoneNumber(_ value: String) -> String {
value.filter { !$0.isWhitespace }
}
/// 11
static func isValidMainlandPhoneNumber(_ value: String) -> Bool {
let phone = normalizedPhoneNumber(value)
guard phone.count == 11, phone.first == "1" else { return false }
return phone.allSatisfy(\.isNumber)
}
///
static func rangedInteger(_ text: String, name: String, range: ClosedRange<Int>) throws -> Int {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let value = Int(trimmed) else {
throw APIError.networkFailed("\(name)格式不正确")
}
guard range.contains(value) else {
throw APIError.networkFailed("\(name)须在 \(range.lowerBound)\(range.upperBound)")
}
return value
}
///
static func validateQueueNoticeThresholds(first: Int, second: Int) throws {
guard second <= first else {
throw APIError.networkFailed("第二次通知阈值不能大于第一次通知阈值")
}
}
///
static func sanitizedMoneyInput(_ input: String, maxDecimalPlaces: Int = 2) -> String {
var result = ""
var hasDot = false
var decimalCount = 0
for char in input {
if char.isNumber {
if hasDot {
guard decimalCount < maxDecimalPlaces else { continue }
decimalCount += 1
}
result.append(char)
} else if char == ".", !hasDot {
hasDot = true
result.append(char)
}
}
if result.hasPrefix(".") {
result = "0" + result
}
return result
}
///
static func normalizedMoneyForSubmit(_ input: String) -> String {
let sanitized = sanitizedMoneyInput(input, maxDecimalPlaces: 2)
guard !sanitized.isEmpty, sanitized != "." else { return "" }
return sanitized
}
}

View File

@ -0,0 +1,76 @@
//
// AccountContextAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
@MainActor
/// 便
protocol AccountContextServing {
///
func rolePermissions() async throws -> [RolePermissionResponse]
/// 访
func scenicListAll() async throws -> ScenicListAllResponse
/// 访
func storeAll() async throws -> ListPayload<StoreItem>
///
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem>
}
@MainActor
/// API
final class AccountContextAPI: AccountContextServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func rolePermissions() async throws -> [RolePermissionResponse] {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/role-permission"
)
)
}
/// 访
func scenicListAll() async throws -> ScenicListAllResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic/list-all"
)
)
}
/// 访
func storeAll() async throws -> ListPayload<StoreItem> {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/store/all"
)
)
}
///
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")]
)
)
}
}

View File

@ -0,0 +1,36 @@
# Account 模块业务逻辑
## 模块职责
Account 模块负责同步账号进入业务页前必须具备的上下文数据,包括角色权限、景区、门店和当前景区下的景点/打卡点。
该模块只做读取和上下文装配,不负责登录 token 存储,也不负责页面导航。
## 数据边界
- 角色权限:由 `/api/yf-handset-app/role-permission` 返回,写入 `PermissionContext`
- 景区列表:由 `/api/yf-handset-app/photog/scenic/list-all` 返回,用于账号业务作用域。
- 门店列表:由 `/api/app/store/all` 返回,并通过 `scenic_id` 关联景区。
- 景点/打卡点:由 `/api/yf-handset-app/photog/scenic-spot/list-all` 按当前景区懒加载。
## 加载流程
登录成功或冷启动恢复时,`AccountContextLoader` 会并行请求用户资料和角色权限。权限成功后继续读取景区和门店:
1. 用户资料刷新 `AccountContext.profile`
2. 角色权限刷新 `PermissionContext`,并计算当前角色的权限 URI 集合。
3. 景区列表失败时,从角色权限里的景区数据去重兜底。
4. 门店列表失败时不阻断登录,只写入空门店列表。
5. 当前景区确定后,`ScenicSpotContext` 再按景区 ID 拉取景点/打卡点。
## 切换规则
- 切换角色时,当前景区优先保留在新角色可访问景区中,否则选择新角色第一个景区。
- 切换景区时,当前门店优先保留同景区门店,否则选择该景区第一个门店。
- 切换景区后,景点/打卡点列表会重新加载。
## 缓存规则
`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前角色 ID、当前景区 ID、当前门店 ID 和基础账号展示信息。
景点/打卡点列表只保存在内存中,不长期落盘。后续业务需要离线缓存时,需要按账号类型、业务账号 ID 和景区 ID 做隔离。

View File

@ -0,0 +1,355 @@
//
// AccountContextModels.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// 访
struct RolePermissionResponse: Decodable, Equatable {
let role: RoleInfo
let scenic: [ScenicInfo]
}
///
struct RoleInfo: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let notes: String?
let permission: [PermissionItem]
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
case notes
case permission
}
///
init(id: Int, name: String, notes: String? = nil, permission: [PermissionItem] = []) {
self.id = id
self.name = name
self.notes = notes
self.permission = permission
}
/// ID
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
notes = try container.decodeIfPresent(String.self, forKey: .notes)
permission = try container.decodeIfPresent([PermissionItem].self, forKey: .permission) ?? []
}
}
///
struct PermissionItem: Decodable, Equatable, Identifiable {
let id: Int
let pid: Int
let name: String
let uri: String
let iconSrc: String?
let children: [PermissionItem]
/// JSON
enum CodingKeys: String, CodingKey {
case id
case pid
case name
case uri
case iconSrc = "icon_src"
case children
}
///
init(id: Int, pid: Int = 0, name: String, uri: String = "", iconSrc: String? = nil, children: [PermissionItem] = []) {
self.id = id
self.pid = pid
self.name = name
self.uri = uri
self.iconSrc = iconSrc
self.children = children
}
/// ID children
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
pid = try container.decodeLossyInt(forKey: .pid) ?? 0
name = try container.decodeLossyString(forKey: .name)
uri = try container.decodeLossyString(forKey: .uri)
iconSrc = try container.decodeIfPresent(String.self, forKey: .iconSrc)
children = try container.decodeIfPresent([PermissionItem].self, forKey: .children) ?? []
}
}
/// 访
struct ScenicInfo: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let status: Int?
let location: ScenicLocationInfo?
let coverImg: String?
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
case status
case location
case coverImg = "cover_img"
}
///
init(id: Int, name: String, status: Int? = nil, location: ScenicLocationInfo? = nil, coverImg: String? = nil) {
self.id = id
self.name = name
self.status = status
self.location = location
self.coverImg = coverImg
}
/// location JSON
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
status = try container.decodeLossyInt(forKey: .status)
coverImg = try container.decodeIfPresent(String.self, forKey: .coverImg)
if let value = try? container.decodeIfPresent(ScenicLocationInfo.self, forKey: .location) {
location = value
} else if let rawValue = try? container.decodeIfPresent(String.self, forKey: .location),
let data = rawValue.data(using: .utf8) {
location = try? JSONDecoder().decode(ScenicLocationInfo.self, from: data)
} else {
location = nil
}
}
}
///
struct ScenicLocationInfo: Decodable, Equatable {
let lng: Double
let lat: Double
let address: String
/// JSON
enum CodingKeys: String, CodingKey {
case lng
case lat
case address
}
///
init(lng: Double, lat: Double, address: String) {
self.lng = lng
self.lat = lat
self.address = address
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
lng = try container.decodeLossyDouble(forKey: .lng) ?? 0
lat = try container.decodeLossyDouble(forKey: .lat) ?? 0
address = try container.decodeLossyString(forKey: .address)
}
}
///
struct ScenicListAllResponse: Decodable, Equatable {
let total: Int
let list: [ScenicListItem]
/// JSON
enum CodingKeys: String, CodingKey {
case total
case list
}
///
init(total: Int, list: [ScenicListItem]) {
self.total = total
self.list = list
}
/// total
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
list = try container.decodeIfPresent([ScenicListItem].self, forKey: .list) ?? []
}
}
///
struct ScenicListItem: Decodable, Equatable, Identifiable {
let id: Int
let name: String
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
}
///
init(id: Int, name: String) {
self.id = id
self.name = name
}
/// ID
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
}
}
///
struct StoreItem: Decodable, Equatable, Identifiable {
let id: Int
let scenicId: Int
let name: String
let address: String
let status: Int
let statusText: String
let tel: String?
let logo: String?
/// JSON
enum CodingKeys: String, CodingKey {
case id
case scenicId = "scenic_id"
case name
case address
case status
case statusText = "status_text"
case tel
case logo
}
///
init(
id: Int,
scenicId: Int,
name: String,
address: String = "",
status: Int = 0,
statusText: String = "",
tel: String? = nil,
logo: String? = nil
) {
self.id = id
self.scenicId = scenicId
self.name = name
self.address = address
self.status = status
self.statusText = statusText
self.tel = tel
self.logo = logo
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
name = try container.decodeLossyString(forKey: .name)
address = try container.decodeLossyString(forKey: .address)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusText = try container.decodeLossyString(forKey: .statusText)
tel = try container.decodeIfPresent(String.self, forKey: .tel)
logo = try container.decodeIfPresent(String.self, forKey: .logo)
}
}
///
struct ScenicSpotItem: Decodable, Equatable, Identifiable {
let id: Int
let name: String
let status: Int
let statusLabel: String
/// JSON
enum CodingKeys: String, CodingKey {
case id
case name
case status
case statusLabel = "status_label"
}
///
init(id: Int, name: String, status: Int = 0, statusLabel: String = "") {
self.id = id
self.name = name
self.status = status
self.statusLabel = statusLabel
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "true" : "false"
}
return ""
}
/// IntDouble
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
/// DoubleInt
func decodeLossyDouble(forKey key: Key) throws -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return Double(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
return Double(value.trimmingCharacters(in: .whitespacesAndNewlines))
}
return nil
}
}

View File

@ -0,0 +1,373 @@
//
// AssetsAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import Foundation
/// 便 ViewModel
@MainActor
protocol AssetsServing {
///
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload<CloudDriveFile>
///
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws
/// OSS
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws
///
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws
///
func cloudFileMove(_ request: CloudFileMoveRequest) async throws
///
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws
///
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws
///
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse
///
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse
///
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail
///
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws
///
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws
///
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws
///
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws
///
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws
///
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem>
///
func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload<AlbumFolderItem>
///
func albumFolderInfo(id: Int) async throws -> AlbumFolderItem
///
func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload<AlbumFileItem>
///
func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws
///
func editAlbumFolder(folderId: Int, coverFileId: Int?, name: String?, remark: String?) async throws
///
func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws
/// OSS
func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws
}
/// API
@MainActor
final class AssetsAPI: AssetsServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func cloudFileList(
parentFolderId: Int,
name: String = "",
type: Int = 0,
orderBy: Int = 2,
page: Int = 1,
pageSize: Int = 20
) async throws -> ListPayload<CloudDriveFile> {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/cloud-driver/list",
queryItems: [
URLQueryItem(name: "parent_folder_id", value: "\(parentFolderId)"),
URLQueryItem(name: "name", value: name),
URLQueryItem(name: "type", value: "\(type)"),
URLQueryItem(name: "order_by", value: "\(orderBy)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
)
)
}
///
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-create", body: request)
) as EmptyPayload
}
/// OSS
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-upload", body: request)
) as EmptyPayload
}
///
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/delete", body: request)
) as EmptyPayload
}
///
func cloudFileMove(_ request: CloudFileMoveRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/move", body: request)
) as EmptyPayload
}
///
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-edit", body: request)
) as EmptyPayload
}
///
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-edit", body: request)
) as EmptyPayload
}
///
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/cloud-driver/check-upload-permission")
)
}
///
func mediaAlbumList(
kind: MediaLibraryKind = .material,
keyword: String = "",
status: Int? = nil,
page: Int = 1,
pageSize: Int = 10
) async throws -> MediaLibraryListResponse {
var query = [
URLQueryItem(name: "type", value: "\(kind.rawValue)"),
URLQueryItem(name: "keyword", value: keyword),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
if let status, status >= 0 {
query.append(URLQueryItem(name: "status", value: "\(status)"))
}
return try await client.send(
APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: query)
)
}
///
func mediaAlbumDetail(id: Int, kind: MediaLibraryKind = .material) async throws -> MediaLibraryDetail {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/media-album/detail",
queryItems: [
URLQueryItem(name: "id", value: "\(id)"),
URLQueryItem(name: "type", value: "\(kind.rawValue)")
]
)
)
}
///
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/operation", body: request)
) as EmptyPayload
}
///
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/delete", body: request)
) as EmptyPayload
}
///
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/add-tag", body: request)
) as EmptyPayload
}
///
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
) as EmptyPayload
}
///
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws {
_ = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/edit", body: request)
) as EmptyPayload
}
///
func projectList(
scenicId: Int,
name: String? = nil,
page: Int = 1,
pageSize: Int = 50
) async throws -> ListPayload<PhotographerProjectItem> {
var query = [
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
if let name, !name.isEmpty {
query.append(URLQueryItem(name: "name", value: name))
}
return try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: query)
)
}
///
func albumFolderList(
scenicId: Int,
page: Int = 1,
pageSize: Int = 10,
name: String = "",
startTime: String? = nil,
endTime: String? = nil
) async throws -> ListPayload<AlbumFolderItem> {
var query = [
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"),
URLQueryItem(name: "cloud_folder_type", value: "1")
]
if !name.isEmpty {
query.append(URLQueryItem(name: "name", value: name))
}
if let startTime, !startTime.isEmpty {
query.append(URLQueryItem(name: "start_time", value: startTime))
}
if let endTime, !endTime.isEmpty {
query.append(URLQueryItem(name: "end_time", value: endTime))
}
return try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/album/folder-list", queryItems: query)
)
}
///
func albumFolderInfo(id: Int) async throws -> AlbumFolderItem {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/album/folder-info",
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
)
)
}
///
func albumFileList(
scenicId: Int,
folderId: Int,
fileType: Int,
page: Int = 1,
pageSize: Int = 20
) async throws -> ListPayload<AlbumFileItem> {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/album/file-list",
queryItems: [
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
URLQueryItem(name: "folder_id", value: "\(folderId)"),
URLQueryItem(name: "file_type", value: "\(fileType)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
)
)
}
///
func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws {
_ = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/album/folder-add",
body: AlbumFolderAddRequest(
scenicId: "\(scenicId)",
name: name,
remark: remark,
cloudFolderType: 1
)
)
) as EmptyPayload
}
///
func editAlbumFolder(folderId: Int, coverFileId: Int? = nil, name: String? = nil, remark: String? = nil) async throws {
_ = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/album/folder-edit",
body: AlbumFolderEditRequest(folderId: folderId, coverFileId: coverFileId, name: name, remark: remark)
)
) as EmptyPayload
}
///
func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws {
_ = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/album/file-delete",
body: AlbumFileDeleteRequest(idList: idList, folderId: folderId)
)
) as EmptyPayload
}
/// OSS
func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws {
_ = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/album/file-upload-url",
queryItems: [
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
URLQueryItem(name: "file_url", value: fileURL),
URLQueryItem(name: "folder_id", value: "\(folderId)")
]
)
) as EmptyPayload
}
}

View File

@ -0,0 +1,90 @@
# Assets 模块业务逻辑
## 模块职责
Assets 模块负责首页中的相册、相册云盘、素材管理和样片管理入口。
- `album_list` 进入 `AlbumListView`
- `album_trailer` 进入 `AlbumTrailerEntryView`
- `cloud_management` 进入 `CloudStorageView`
- `cloud_storage_transit` 进入 `CloudStorageTransitView`
- `asset_management` 进入 `MediaLibraryView(kind: .material)`
- `material_upload` 进入 `MediaLibraryUploadView`
- `sample_management` 进入 `MediaLibraryView(kind: .sample)`
- `sample_upload` 进入 `MediaLibraryUploadView(kind: .sample)`
样片复用媒体库列表、详情、上下架、删除和 OSS 上传链路;上传样片额外要求选择关联项目。
## 相册管理逻辑
`AlbumListViewModel` 只保存相册列表、搜索条件、日期筛选、分页和创建状态。缺少当前景区时清空列表并停止请求接口。
相册列表流程:
1. 页面从 `AccountContext.currentScenic` 读取当前景区 ID。
2. 调用 `albumFolderList` 获取 `cloud_folder_type = 1` 的相册文件夹。
3. 支持按相册名称、开始时间、结束时间筛选。
4. 新建相册调用 `addAlbumFolder`,成功后刷新第一页。
`AlbumDetailViewModel` 管理单个相册的信息和文件列表。图片和视频通过 `AlbumFileTab` 区分,图片使用 `file_type = 2`,视频使用 `file_type = 1`。详情页支持编辑相册名称、编辑备注、设置封面和删除相册文件,操作成功后刷新详情。
相册文件预览使用 `RemoteImage` 展示图片,视频使用系统 `VideoPlayer` 播放。
## 相册预览上传逻辑
`AlbumTrailerViewModel` 管理相册选择、本地图片/视频选择、上传进度和提交状态。
上传流程:
1. 页面加载当前景区下的相册列表。
2. 用户通过 `PhotosPicker` 选择图片或视频。
3. 提交时先调用 `OSSUploadService.uploadAlbumFile` 上传到 OSS。
4. 上传成功后调用 `albumFileUploadURL` 把文件 URL 写入相册。
5. 任一文件上传失败时停止后续入库,并保留错误提示。
本地文件数据、OSS STS 和上传进度只保存在当前上传流程内,不落盘。
## 云盘逻辑
`CloudStorageViewModel` 只保存云盘模块内状态,包括目录路径、文件列表、筛选、排序、分页和当前操作状态。
云盘上传流程:
1. 页面通过 `PhotosPicker` 选择图片或视频。
2. `CloudStorageViewModel` 调用 `checkCloudUploadPermission`
3. 权限通过后调用 `OSSUploadService.uploadCloudFile` 上传到 OSS。
4. 上传成功后调用 `cloudFileUpload` 把文件 URL 写入云盘。
5. 刷新当前目录列表。
下载文件只保存到 App 沙盒 `Documents/CloudDownloads`,不自动写入系统相册。上传/下载记录由 `CloudTransferStore` 保存,生命周期仅限本次 App 会话,不落盘。
## 素材/样片管理逻辑
`MediaLibraryViewModel``MediaLibraryKind` 管理素材或样片列表、关键词、审核状态、分页、详情、删除和上下架。素材接口使用 `type = 1`,样片接口使用 `type = 2`
媒体库上下架规则:
- 审核通过的素材或样片可以上下架。
- 审核未通过或待审核的素材或样片禁止上下架。
- 操作成功后刷新当前媒体库列表。
`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验名称、当前景区、打卡点、封面、媒体文件和标签长度。样片上传还会加载当前景区下的项目列表,并强制选择关联项目。
素材/样片上传流程:
1. 页面通过 `PhotosPicker` 选择封面和媒体文件。
2. 打卡点来源于 `ScenicSpotContext.spots`
3. 样片上传从 `projectList` 获取可关联项目,提交时携带 `project_id`
4. 提交时先把封面和媒体文件上传 OSS。
5. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID、标签和项目 ID 提交给媒体库接口。
6. 上传失败时不提交媒体库接口。
## 缓存边界
相册列表、相册文件列表、云盘文件列表、素材列表、样片列表、项目选择、上传进度、下载记录、OSS STS、本地文件数据和媒体库表单都不进入 `AppSession``AccountContext` 或 TabBar 状态。
图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data`
## 测试要求
新增相册、云盘、素材或样片逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,231 @@
//
// AssetsViewControllers.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
///
final class CloudStorageViewController: ModuleTableViewController {
private let viewModel = CloudStorageViewModel()
override func viewDidLoad() {
title = "云盘"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "传输",
style: .plain,
target: self,
action: #selector(openTransit)
)
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func tableRowCount() -> Int { viewModel.files.count }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let file = viewModel.files[indexPath.row]
cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)")
}
override func reloadContent() async {
await viewModel.reload(api: services.assetsAPI)
}
override func willDisplayTableRow(at indexPath: IndexPath) {
guard indexPath.row >= viewModel.files.count - 2 else { return }
Task { await viewModel.loadMore(api: services.assetsAPI) }
}
@objc private func openTransit() {
navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true)
}
}
extension CloudStorageViewModel: ViewModelBindable {}
///
final class CloudStorageTransitViewController: ModuleTableViewController {
private let store = CloudTransferStore.shared
override func viewDidLoad() {
title = "传输记录"
super.viewDidLoad()
store.onChange = { [weak self] in self?.reloadTable() }
}
override func tableRowCount() -> Int { store.records.count }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let record = store.records[indexPath.row]
cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%")
}
override func reloadContent() async {}
}
enum MediaLibraryKindRoute {
case material
case sample
}
/// /
final class MediaLibraryViewController: ModuleTableViewController {
private let kind: MediaLibraryKindRoute
private let viewModel: MediaLibraryViewModel
init(kind: MediaLibraryKindRoute = .material) {
self.kind = kind
switch kind {
case .material:
viewModel = MediaLibraryViewModel(kind: .material)
case .sample:
viewModel = MediaLibraryViewModel(kind: .sample)
}
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
title = kind == .sample ? "样片库" : "素材库"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "上传",
style: .plain,
target: self,
action: #selector(openUpload)
)
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func tableRowCount() -> Int { viewModel.items.count }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt)
}
override func reloadContent() async {
await viewModel.reload(api: services.assetsAPI)
}
@objc private func openUpload() {
navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true)
}
}
extension MediaLibraryViewModel: ViewModelBindable {}
/// /
final class MediaLibraryUploadViewController: ModuleTableViewController {
private let kind: MediaLibraryKindRoute
private let viewModel = MediaLibraryEditorViewModel()
private let nameField = UITextField()
init(kind: MediaLibraryKindRoute) {
self.kind = kind
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
title = kind == .sample ? "上传样片" : "上传素材"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "提交",
style: .done,
target: self,
action: #selector(submit)
)
super.viewDidLoad()
nameField.placeholder = "素材名称"
nameField.borderStyle = .roundedRect
nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
tableView.tableHeaderView = nameField
wireViewModel(viewModel) { }
}
override func tableRowCount() -> Int { viewModel.projects.count }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let project = viewModel.projects[indexPath.row]
cell.configure(title: project.name, subtitle: project.statusName)
}
override func reloadContent() async {
await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI)
}
@objc private func submit() {
viewModel.name = nameField.text ?? ""
Task {
let mediaKind: MediaLibraryKind = kind == .sample ? .sample : .material
let success = await viewModel.submit(
kind: mediaKind,
scenicId: services.currentScenicId,
api: services.assetsAPI,
uploadService: services.ossUploadService
)
if success { navigationController?.popViewController(animated: true) }
}
}
}
extension MediaLibraryEditorViewModel: ViewModelBindable {}
///
final class AlbumListViewController: ModuleTableViewController {
private let viewModel = AlbumListViewModel()
override func viewDidLoad() {
title = "相册"
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func tableRowCount() -> Int { viewModel.folders.count }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let folder = viewModel.folders[indexPath.row]
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount)")
}
override func reloadContent() async {
await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId)
}
}
extension AlbumListViewModel: ViewModelBindable {}
///
final class AlbumTrailerViewController: ModuleTableViewController {
private let viewModel = AlbumTrailerViewModel()
override func viewDidLoad() {
title = "相册预告"
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func tableRowCount() -> Int { viewModel.folders.count }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let folder = viewModel.folders[indexPath.row]
cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount)")
}
override func reloadContent() async {
await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId)
}
}
extension AlbumTrailerViewModel: ViewModelBindable {}

View File

@ -0,0 +1,974 @@
//
// AssetsViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import Foundation
/// App
@MainActor
final class CloudTransferStore {
var onChange: (() -> Void)?
static let shared = CloudTransferStore()
private(set) var records: [CloudTransferItem] = [] { didSet { onChange?() } }
/// ID
@discardableResult
func start(fileName: String, direction: CloudTransferDirection) -> UUID {
let id = UUID()
records.insert(CloudTransferItem(id: id, fileName: fileName, direction: direction), at: 0)
return id
}
///
func update(id: UUID, progress: Int, message: String = "") {
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
records[index].progress = max(0, min(100, progress))
if !message.isEmpty {
records[index].message = message
}
}
///
func succeed(id: UUID, message: String = "已完成") {
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
records[index].progress = 100
records[index].status = .success
records[index].message = message
}
///
func fail(id: UUID, message: String) {
guard let index = records.firstIndex(where: { $0.id == id }) else { return }
records[index].status = .failed
records[index].message = message
}
///
func clear() {
records = []
}
}
/// ViewModel
@MainActor
final class CloudStorageViewModel {
var onChange: (() -> Void)?
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] { didSet { onChange?() } }
var files: [CloudDriveFile] = [] { didSet { onChange?() } }
var total = 0 { didSet { onChange?() } }
var page = 1 { didSet { onChange?() } }
var searchText = "" { didSet { onChange?() } }
var selectedFilter: CloudDriveFilter = .all { didSet { onChange?() } }
var selectedSort: CloudDriveSort = .updatedDesc { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var isLoadingMore = false { didSet { onChange?() } }
var isMutating = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private let pageSize = 20
/// ID
var currentFolderId: Int {
path.last?.id ?? 0
}
///
var hasMore: Bool {
files.count < total
}
///
func reload(api: any AssetsServing) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let payload = try await requestFiles(api: api, page: 1)
page = 1
files = payload.list
total = payload.total
} catch {
files = []
total = 0
page = 1
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: any AssetsServing) async {
guard hasMore, !isLoadingMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let nextPage = page + 1
let payload = try await requestFiles(api: api, page: nextPage)
page = nextPage
total = payload.total
files.append(contentsOf: payload.list)
} catch {
errorMessage = error.localizedDescription
}
}
///
func enterFolder(_ folder: CloudDriveFile, api: any AssetsServing) async {
guard folder.isFolder else { return }
path.append(folder)
await reload(api: api)
}
///
func popToFolder(at index: Int, api: any AssetsServing) async {
guard path.indices.contains(index) else { return }
path = Array(path.prefix(index + 1))
await reload(api: api)
}
///
func createFolder(name: String, api: any AssetsServing) async -> Bool {
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !folderName.isEmpty else {
errorMessage = "请输入文件夹名称"
return false
}
return await mutate(api: api) {
try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: currentFolderId, name: folderName))
}
}
///
func rename(_ item: CloudDriveFile, newName: String, api: any AssetsServing) async -> Bool {
let fileName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !fileName.isEmpty else {
errorMessage = "请输入名称"
return false
}
return await mutate(api: api) {
if item.isFolder {
try await api.cloudFolderEdit(CloudFolderModifyRequest(id: item.id, name: fileName))
} else {
try await api.cloudFileEdit(CloudFileModifyRequest(id: item.id, fileName: fileName))
}
}
}
///
func delete(_ item: CloudDriveFile, api: any AssetsServing) async -> Bool {
await mutate(api: api) {
let action = CloudFileActionItem(id: item.id, type: item.type)
try await api.cloudFileDelete(CloudFileDeleteRequest(list: [action]))
}
}
///
func move(_ item: CloudDriveFile, targetFolderId: Int, api: any AssetsServing) async -> Bool {
await mutate(api: api) {
let action = CloudFileActionItem(id: item.id, type: item.type)
try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: targetFolderId, list: [action]))
}
}
/// OSS
func upload(
localFiles: [CloudLocalUploadFile],
scenicId: Int?,
api: any AssetsServing,
uploadService: any OSSUploadServing,
transferStore: CloudTransferStore
) async -> Bool {
guard let scenicId else {
errorMessage = "缺少当前景区,无法上传"
return false
}
guard !localFiles.isEmpty else { return true }
isMutating = true
defer { isMutating = false }
do {
let permission = try await api.checkCloudUploadPermission()
guard permission.canUpload else {
let reason = permission.reason.trimmingCharacters(in: .whitespacesAndNewlines)
errorMessage = reason.isEmpty ? "当前账号暂无上传权限" : reason
return false
}
for file in localFiles {
let transferId = transferStore.start(fileName: file.fileName, direction: .upload)
do {
let url = try await uploadService.uploadCloudFile(
data: file.data,
fileName: file.fileName,
fileType: file.fileType,
scenicId: scenicId
) { progress in
Task { @MainActor in
transferStore.update(id: transferId, progress: progress)
}
}
try await api.cloudFileUpload(
CloudFileUploadRequest(parentFolderId: currentFolderId, fileUrl: url, fileName: file.fileName)
)
transferStore.succeed(id: transferId)
} catch {
transferStore.fail(id: transferId, message: error.localizedDescription)
throw error
}
}
await reload(api: api)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
private func mutate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
isMutating = true
defer { isMutating = false }
do {
try await operation()
await reload(api: api)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
private func requestFiles(api: any AssetsServing, page: Int) async throws -> ListPayload<CloudDriveFile> {
try await api.cloudFileList(
parentFolderId: currentFolderId,
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
type: selectedFilter.rawValue,
orderBy: selectedSort.rawValue,
page: page,
pageSize: pageSize
)
}
}
/// ViewModel
@MainActor
final class MediaLibraryViewModel {
var onChange: (() -> Void)?
let kind: MediaLibraryKind
var items: [MediaLibraryItem] = [] { didSet { onChange?() } }
var selectedDetail: MediaLibraryDetail? { didSet { onChange?() } }
var orderInfo: MediaLibraryOrderInfo? { didSet { onChange?() } }
var total = 0 { didSet { onChange?() } }
var page = 1 { didSet { onChange?() } }
var keyword = "" { didSet { onChange?() } }
var selectedAuditFilter: MediaLibraryAuditFilter = .all { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var isLoadingMore = false { didSet { onChange?() } }
var isLoadingDetail = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private let pageSize = 10
/// ViewModel
init(kind: MediaLibraryKind = .material) {
self.kind = kind
}
///
var hasMore: Bool {
items.count < total
}
///
func reload(api: any AssetsServing) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await requestList(api: api, page: 1)
page = 1
items = response.list
total = response.total
orderInfo = response.order
} catch {
items = []
total = 0
orderInfo = nil
page = 1
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: any AssetsServing) async {
guard hasMore, !isLoadingMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let nextPage = page + 1
let response = try await requestList(api: api, page: nextPage)
page = nextPage
total = response.total
orderInfo = response.order ?? orderInfo
items.append(contentsOf: response.list)
} catch {
errorMessage = error.localizedDescription
}
}
///
func loadDetail(id: Int, api: any AssetsServing) async {
isLoadingDetail = true
errorMessage = nil
defer { isLoadingDetail = false }
do {
selectedDetail = try await api.mediaAlbumDetail(id: id, kind: kind)
} catch {
errorMessage = error.localizedDescription
}
}
///
func toggleListing(_ item: MediaLibraryItem, api: any AssetsServing) async -> Bool {
guard item.isApproved else {
errorMessage = "审核通过后才能上下架"
return false
}
return await operate(api: api) {
let target = item.listing.toggled
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: item.id, type: kind.rawValue, listingStatus: target.rawValue))
}
}
///
func toggleListing(detail: MediaLibraryDetail, api: any AssetsServing) async -> Bool {
guard detail.isApproved else {
errorMessage = "审核通过后才能上下架"
return false
}
return await operate(api: api) {
let target = detail.listing.toggled
try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: detail.id, type: kind.rawValue, listingStatus: target.rawValue))
}
}
///
func delete(id: Int, api: any AssetsServing) async -> Bool {
await operate(api: api) {
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id, type: kind.rawValue))
}
}
///
private func operate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool {
do {
try await operation()
await reload(api: api)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
private func requestList(api: any AssetsServing, page: Int) async throws -> MediaLibraryListResponse {
try await api.mediaAlbumList(
kind: kind,
keyword: keyword.trimmingCharacters(in: .whitespacesAndNewlines),
status: selectedAuditFilter.rawValue >= 0 ? selectedAuditFilter.rawValue : nil,
page: page,
pageSize: pageSize
)
}
}
/// ViewModelOSS
@MainActor
final class MediaLibraryEditorViewModel {
var onChange: (() -> Void)?
var name = "" { didSet { onChange?() } }
var description = "" { didSet { onChange?() } }
var selectedSpotId: Int? { didSet { onChange?() } }
var selectedProjectId: Int? { didSet { onChange?() } }
var projects: [PhotographerProjectItem] = [] { didSet { onChange?() } }
var tagsText = "" { didSet { onChange?() } }
var coverFile: MediaLocalUploadFile? { didSet { onChange?() } }
var mediaFiles: [MediaLocalUploadFile] = [] { didSet { onChange?() } }
var isSubmitting = false { didSet { onChange?() } }
var isLoadingProjects = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
var didSubmitSuccessfully = false { didSet { onChange?() } }
private let editingId: Int?
///
init(detail: MediaLibraryDetail? = nil) {
editingId = detail?.id
name = detail?.name ?? ""
description = detail?.description ?? ""
}
///
var isEditing: Bool {
editingId != nil
}
///
func loadProjects(scenicId: Int?, api: any AssetsServing) async {
guard let scenicId else {
projects = []
errorMessage = "缺少当前景区,无法加载项目"
return
}
isLoadingProjects = true
errorMessage = nil
defer { isLoadingProjects = false }
do {
let payload = try await api.projectList(scenicId: scenicId, name: nil, page: 1, pageSize: 50)
projects = payload.list
if let selectedProjectId, !projects.contains(where: { $0.id == selectedProjectId }) {
self.selectedProjectId = nil
}
} catch {
projects = []
errorMessage = error.localizedDescription
}
}
///
func setCover(_ file: MediaLocalUploadFile) {
coverFile = file
}
///
func addMediaFiles(_ files: [MediaLocalUploadFile]) {
mediaFiles.append(contentsOf: files)
}
///
func removeMediaFile(id: UUID) {
mediaFiles.removeAll { $0.id == id }
}
///
func submit(
kind: MediaLibraryKind = .material,
scenicId: Int?,
api: any AssetsServing,
uploadService: any OSSUploadServing
) async -> Bool {
guard validate(kind: kind, scenicId: scenicId) else { return false }
guard !isSubmitting else { return false }
isSubmitting = true
defer { isSubmitting = false }
do {
let actualScenicId = scenicId ?? 0
let coverUpload = try await uploadCover(scenicId: actualScenicId, uploadService: uploadService)
let uploads = try await uploadMediaFiles(scenicId: actualScenicId, uploadService: uploadService)
if let editingId {
let request = MediaAlbumEditRequest(
id: editingId,
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
type: kind.rawValue,
mediaType: resolvedMediaType(),
mediaList: uploads,
coverUrl: coverUpload.url,
coverSize: coverUpload.size,
scenicSpotId: selectedSpotId ?? 0,
description: description,
materialTag: normalizedTags()
)
try await api.mediaAlbumEdit(request)
} else {
let request = MediaAlbumUploadRequest(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
type: kind.rawValue,
mediaType: resolvedMediaType(),
mediaList: uploads,
coverUrl: coverUpload.url,
coverSize: coverUpload.size,
scenicSpotId: selectedSpotId ?? 0,
description: description,
materialTag: normalizedTags(),
projectId: kind == .sample ? (selectedProjectId ?? 0) : 0
)
try await api.mediaAlbumUpload(request)
}
didSubmitSuccessfully = true
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
private func validate(kind: MediaLibraryKind, scenicId: Int?) -> Bool {
let title = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard scenicId != nil else {
errorMessage = "缺少当前景区,无法提交\(kind == .sample ? "样片" : "素材")"
return false
}
guard !title.isEmpty else {
errorMessage = "请输入\(kind == .sample ? "样片" : "素材")名称"
return false
}
guard selectedSpotId != nil else {
errorMessage = "请选择打卡点"
return false
}
guard kind != .sample || selectedProjectId != nil else {
errorMessage = "请选择关联项目"
return false
}
guard coverFile != nil else {
errorMessage = "请选择封面"
return false
}
guard !mediaFiles.isEmpty else {
errorMessage = "请选择\(kind == .sample ? "样片" : "素材")文件"
return false
}
guard normalizedTags().count <= 120 else {
errorMessage = "标签内容过长"
return false
}
return true
}
/// URL
private func uploadCover(
scenicId: Int,
uploadService: any OSSUploadServing
) async throws -> (url: String, size: MediaAlbumFileSize) {
guard let coverFile else {
throw APIError.emptyData
}
let url = try await uploadService.uploadCloudFile(
data: coverFile.data,
fileName: coverFile.fileName,
fileType: coverFile.fileType,
scenicId: scenicId,
onProgress: { _ in }
)
return (url, MediaAlbumFileSize(width: coverFile.width, height: coverFile.height))
}
///
private func uploadMediaFiles(
scenicId: Int,
uploadService: any OSSUploadServing
) async throws -> [MediaAlbumUploadItem] {
var result: [MediaAlbumUploadItem] = []
for file in mediaFiles {
let url = try await uploadService.uploadCloudFile(
data: file.data,
fileName: file.fileName,
fileType: file.fileType,
scenicId: scenicId,
onProgress: { _ in }
)
result.append(
MediaAlbumUploadItem(
originalName: file.fileName,
ossUrl: url,
size: Int64(file.data.count),
fileWidthSize: MediaAlbumFileSize(width: file.width, height: file.height)
)
)
}
return result
}
///
private func resolvedMediaType() -> Int {
mediaFiles.contains { $0.fileType == 1 } ? 1 : 2
}
///
private func normalizedTags() -> String {
tagsText
.split { $0 == "," || $0 == "" || $0 == " " || $0 == "\n" }
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ",")
}
}
/// ViewModel
@MainActor
final class AlbumListViewModel {
var onChange: (() -> Void)?
var folders: [AlbumFolderItem] = [] { didSet { onChange?() } }
var total = 0 { didSet { onChange?() } }
var page = 1 { didSet { onChange?() } }
var searchText = "" { didSet { onChange?() } }
var startTime = "" { didSet { onChange?() } }
var endTime = "" { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var isLoadingMore = false { didSet { onChange?() } }
var isMutating = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private let pageSize = 10
///
var hasMore: Bool {
folders.count < total
}
///
func reload(api: any AssetsServing, scenicId: Int?) async {
guard let scenicId else {
folders = []
total = 0
page = 1
errorMessage = "缺少当前景区,无法加载相册"
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let payload = try await requestFolders(api: api, scenicId: scenicId, page: 1)
self.page = 1
folders = payload.list
total = payload.total
} catch {
folders = []
total = 0
page = 1
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: any AssetsServing, scenicId: Int?) async {
guard let scenicId, hasMore, !isLoadingMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let nextPage = page + 1
let payload = try await requestFolders(api: api, scenicId: scenicId, page: nextPage)
page = nextPage
total = payload.total
folders.append(contentsOf: payload.list)
} catch {
errorMessage = error.localizedDescription
}
}
///
func createFolder(name: String, remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool {
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard let scenicId else {
errorMessage = "缺少当前景区,无法创建相册"
return false
}
guard !folderName.isEmpty else {
errorMessage = "请输入相册名称"
return false
}
guard !isMutating else { return false }
isMutating = true
defer { isMutating = false }
do {
try await api.addAlbumFolder(scenicId: scenicId, name: folderName, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines))
await reload(api: api, scenicId: scenicId)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
private func requestFolders(api: any AssetsServing, scenicId: Int, page: Int) async throws -> ListPayload<AlbumFolderItem> {
try await api.albumFolderList(
scenicId: scenicId,
page: page,
pageSize: pageSize,
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
startTime: startTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty,
endTime: endTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty
)
}
}
/// ViewModel/
@MainActor
final class AlbumDetailViewModel {
var onChange: (() -> Void)?
let folderId: Int
var folder: AlbumFolderItem? { didSet { onChange?() } }
var files: [AlbumFileItem] = [] { didSet { onChange?() } }
var selectedTab: AlbumFileTab = .image { didSet { onChange?() } }
var total = 0 { didSet { onChange?() } }
var page = 1 { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var isLoadingMore = false { didSet { onChange?() } }
var isMutating = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private let pageSize = 20
/// ViewModel
init(folderId: Int, summary: AlbumFolderItem? = nil) {
self.folderId = folderId
folder = summary
}
///
var hasMore: Bool {
files.count < total
}
/// Tab
func reload(api: any AssetsServing, scenicId: Int?) async {
guard let scenicId else {
files = []
total = 0
page = 1
errorMessage = "缺少当前景区,无法加载相册内容"
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
folder = try await api.albumFolderInfo(id: folderId)
let listPayload = try await api.albumFileList(
scenicId: scenicId,
folderId: folderId,
fileType: selectedTab.rawValue,
page: 1,
pageSize: pageSize
)
files = listPayload.list
total = listPayload.total
page = 1
} catch {
page = 1
errorMessage = error.localizedDescription
}
}
///
func selectTab(_ tab: AlbumFileTab, api: any AssetsServing, scenicId: Int?) async {
guard selectedTab != tab else { return }
selectedTab = tab
files = []
total = 0
page = 1
await reload(api: api, scenicId: scenicId)
}
/// Tab
func loadMore(api: any AssetsServing, scenicId: Int?) async {
guard let scenicId, hasMore, !isLoadingMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let nextPage = page + 1
let payload = try await api.albumFileList(
scenicId: scenicId,
folderId: folderId,
fileType: selectedTab.rawValue,
page: nextPage,
pageSize: pageSize
)
page = nextPage
total = payload.total
files.append(contentsOf: payload.list)
} catch {
errorMessage = error.localizedDescription
}
}
///
func delete(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool {
await mutate(api: api, scenicId: scenicId) {
try await api.deleteAlbumFiles(folderId: folderId, idList: [file.id])
}
}
///
func setCover(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool {
await mutate(api: api, scenicId: scenicId) {
try await api.editAlbumFolder(folderId: folderId, coverFileId: file.id, name: nil, remark: nil)
}
}
///
func rename(name: String, scenicId: Int?, api: any AssetsServing) async -> Bool {
let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !folderName.isEmpty else {
errorMessage = "请输入相册名称"
return false
}
return await mutate(api: api, scenicId: scenicId) {
try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: folderName, remark: nil)
}
}
///
func updateRemark(_ remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool {
await mutate(api: api, scenicId: scenicId) {
try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: nil, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
///
private func mutate(api: any AssetsServing, scenicId: Int?, operation: () async throws -> Void) async -> Bool {
guard !isMutating else { return false }
guard scenicId != nil else {
errorMessage = "缺少当前景区,无法操作相册"
return false
}
isMutating = true
defer { isMutating = false }
do {
try await operation()
await reload(api: api, scenicId: scenicId)
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
}
/// ViewModel
@MainActor
final class AlbumTrailerViewModel {
var onChange: (() -> Void)?
var folders: [AlbumFolderItem] = [] { didSet { onChange?() } }
var selectedFolderId: Int? { didSet { onChange?() } }
var localFiles: [AlbumLocalUploadFile] = [] { didSet { onChange?() } }
var uploadProgress = 0 { didSet { onChange?() } }
var isLoadingFolders = false { didSet { onChange?() } }
var isSubmitting = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
var didUploadSuccessfully = false { didSet { onChange?() } }
///
func loadFolders(api: any AssetsServing, scenicId: Int?) async {
guard let scenicId else {
folders = []
selectedFolderId = nil
errorMessage = "缺少当前景区,无法加载相册"
return
}
isLoadingFolders = true
errorMessage = nil
defer { isLoadingFolders = false }
do {
let payload = try await api.albumFolderList(
scenicId: scenicId,
page: 1,
pageSize: 100,
name: "",
startTime: nil,
endTime: nil
)
folders = payload.list
if selectedFolderId == nil {
selectedFolderId = folders.first?.id
}
} catch {
folders = []
selectedFolderId = nil
errorMessage = error.localizedDescription
}
}
///
func addLocalFiles(_ files: [AlbumLocalUploadFile]) {
localFiles.append(contentsOf: files)
}
///
func removeLocalFile(id: UUID) {
localFiles.removeAll { $0.id == id }
}
/// OSS
func submit(scenicId: Int?, api: any AssetsServing, uploadService: any OSSUploadServing) async -> Bool {
guard let scenicId else {
errorMessage = "缺少当前景区,无法上传"
return false
}
guard let folderId = selectedFolderId else {
errorMessage = "请选择相册"
return false
}
guard !localFiles.isEmpty else {
errorMessage = "请选择要上传的图片或视频"
return false
}
guard !isSubmitting else { return false }
isSubmitting = true
didUploadSuccessfully = false
uploadProgress = 0
defer { isSubmitting = false }
do {
let fileCount = max(localFiles.count, 1)
for (index, file) in localFiles.enumerated() {
let url = try await uploadService.uploadAlbumFile(
data: file.data,
fileName: file.fileName,
fileType: file.fileType,
scenicId: scenicId
) { progress in
Task { @MainActor in
let base = Double(index) / Double(fileCount)
let step = Double(progress) / Double(fileCount)
self.uploadProgress = min(99, Int((base + step / 100) * 100))
}
}
try await api.albumFileUploadURL(scenicId: scenicId, fileURL: url, folderId: folderId)
}
uploadProgress = 100
didUploadSuccessfully = true
localFiles = []
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
}
private extension String {
/// nil便
var assetsNilIfEmpty: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,42 @@
//
// AuthAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
@MainActor
/// API v9
final class AuthAPI {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// 使 v9 token
func login(username: String, password: String) async throws -> V9AuthResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/app/v9/login",
body: LoginRequest(username: username, password: password)
)
)
}
/// token
func setUser(_ request: SetUserRequest, tokenOverride: String? = nil) async throws -> V9AuthResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/app/v9/set-user",
body: request
),
tokenOverride: tokenOverride
)
}
}

View File

@ -0,0 +1,63 @@
# Auth 模块业务逻辑
## 模块职责
Auth 模块负责登录页、手机号密码登录、多账号选择和账号选择后的正式登录确认。
该模块只处理登录流程本身:
- 表单输入、校验和协议确认。
- 调用 v9 登录接口获取临时 token 和账号列表。
- 单账号时自动调用 set-user。
- 多账号时展示账号选择页。
- 选择账号后调用 set-user 换取正式 token。
正式 token 存储、账号快照和全局登录态写入由 App 模块的 `AuthSessionCoordinator` 统一处理。
## 核心对象
- `LoginView`:登录页 UI展示手机号、密码、协议确认和登录按钮。
- `AccountSelectionView`:多账号选择弹窗,展示景区账号和门店账号。
- `LoginViewModel`:维护表单状态、校验状态、登录请求状态和多账号选择状态。
- `AuthAPI`:封装 `/api/app/v9/login``/api/app/v9/set-user`
- `LoginRequest`:登录请求体。
- `SetUserRequest`:账号选择请求体。
- `V9AuthResponse`v9 登录和 set-user 响应。
- `V9ScenicUser` / `V9StoreUser`:后端返回的景区账号和门店账号。
- `AccountSwitchAccount`:统一后的可选择账号模型。
## 登录流程
1. 用户输入手机号和密码。
2. `LoginViewModel.validateForLogin` 校验手机号、密码和协议勾选状态。
3. 未勾选协议时,`LoginView` 弹出协议确认 Sheet。
4. 校验通过后,`LoginViewModel.login` 调用 `AuthAPI.login`
5. 登录接口返回临时 token 和可用账号列表。
6. `LoginViewModel` 过滤掉业务账号 ID 无效的账号。
7. 没有可用账号时抛出 `LoginFlowError.noAvailableAccount`
8. 只有一个账号时,自动调用 `AuthAPI.setUser`,并返回 `.completed`
9. 多个账号时,保存 `AccountSelectionPayload`,并返回 `.needsAccountSelection`
10. `LoginView` 展示 `AccountSelectionView`
11. 用户确认账号后,`LoginViewModel.selectAccount` 使用临时 token 调用 set-user。
12. set-user 返回正式 token 后,`LoginView` 调用 `AuthSessionCoordinator.completeLogin` 写入 token并同步用户资料、角色权限、景区和门店。
## 账号模型转换
后端景区账号和门店账号字段不完全一致,统一转换为 `AccountSwitchAccount`
- 景区账号通过 `ssUserId``scenicUserId``userId``id` 兜底生成业务账号 ID。
- 门店账号通过 `storeUserId``userId``id` 兜底生成业务账号 ID。
- `AccountSwitchAccount.toSetUserRequest` 根据账号类型生成 `store_user_id``ss_user_id`
`V9AuthResponse` 还会派生:
- `accounts`:合并后的账号选择列表。
- `primaryProfile`:登录后用于全局展示的账号资料。
- `scenicScopes`:当前账号可用景区作用域。
- `storeScopes`:当前账号可用门店作用域。
## 缓存规则
Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责:
- 正式 token 写 Keychain。
- 上次手机号和协议状态写 UserDefaults。
- 账号资料、当前角色和业务作用域写入账号快照。
临时 token 只存在于 `AccountSelectionPayload`,不落盘。

View File

@ -0,0 +1,409 @@
//
// AuthModels.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
///
struct LoginRequest: Encodable {
let username: String
let type: Int
let password: String
let mobile: String
let code: String
///
init(username: String, password: String) {
self.username = username
self.type = 1
self.password = password
self.mobile = ""
self.code = ""
}
}
/// set-user ID
struct SetUserRequest: Encodable, Equatable {
let storeUserId: Int?
let ssUserId: Int?
/// set-user JSON
enum CodingKeys: String, CodingKey {
case storeUserId = "store_user_id"
case ssUserId = "ss_user_id"
}
/// storeUserId ssUserId
init(storeUserId: Int? = nil, ssUserId: Int? = nil) {
self.storeUserId = storeUserId
self.ssUserId = ssUserId
}
}
///
struct AccountSwitchAccount: Identifiable, Hashable {
let accountType: String
let businessUserId: Int
let title: String
let subtitle: String
let phone: String
let avatar: String
let scenicName: String
let storeId: Int?
let storeName: String
let scenicId: Int?
let isCurrent: Bool
var id: String {
"\(accountType)_\(businessUserId)"
}
var isStoreUser: Bool {
accountType == V9StoreUser.accountTypeValue
}
var accountTypeLabel: String {
isStoreUser ? "门店账号" : "景区账号"
}
/// set-user
func toSetUserRequest() -> SetUserRequest {
if isStoreUser {
return SetUserRequest(storeUserId: businessUserId)
}
return SetUserRequest(ssUserId: businessUserId)
}
}
/// token
struct AccountSelectionPayload: Equatable, Identifiable {
let id = UUID()
let tempToken: String
let accounts: [AccountSwitchAccount]
var hasTempToken: Bool {
!tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
/// v9 token
struct V9AuthResponse: Decodable, Equatable {
let token: String
let scenicUsers: [V9ScenicUser]
let storeUsers: [V9StoreUser]
///
var accounts: [AccountSwitchAccount] {
scenicUsers.map { $0.toAccountSwitchAccount() } + storeUsers.map { $0.toAccountSwitchAccount() }
}
///
var primaryProfile: AccountProfile? {
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first {
return AccountProfile(
userId: String(storeUser.userId),
displayName: storeUser.displayName,
phone: storeUser.phone.nonEmpty,
avatarURL: storeUser.avatar.nonEmpty
)
}
if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first {
return AccountProfile(
userId: String(scenicUser.userId),
displayName: scenicUser.displayName,
phone: scenicUser.phone.nonEmpty,
avatarURL: nil
)
}
return nil
}
///
var scenicScopes: [BusinessScope] {
var seen = Set<Int>()
let scenicFromScenicUsers = scenicUsers
.sorted { $0.isCurrent && !$1.isCurrent }
.compactMap { user -> BusinessScope? in
guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil }
return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic)
}
let scenicFromStoreUsers = storeUsers
.sorted { $0.isCurrent && !$1.isCurrent }
.compactMap { user -> BusinessScope? in
guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil }
return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic)
}
return scenicFromScenicUsers + scenicFromStoreUsers
}
///
var storeScopes: [BusinessScope] {
var seen = Set<Int>()
return storeUsers
.sorted { $0.isCurrent && !$1.isCurrent }
.compactMap { user -> BusinessScope? in
guard user.storeId > 0, seen.insert(user.storeId).inserted else { return nil }
return BusinessScope(
id: user.storeId,
name: user.storeName,
kind: .store,
parentScenicId: user.scenicId > 0 ? user.scenicId : nil
)
}
}
/// v9 JSON
enum CodingKeys: String, CodingKey {
case token
case scenicUsers = "scenic_users"
case storeUsers = "store_users"
}
///
init(token: String = "", scenicUsers: [V9ScenicUser] = [], storeUsers: [V9StoreUser] = []) {
self.token = token
self.scenicUsers = scenicUsers
self.storeUsers = storeUsers
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
token = try container.decodeLossyString(forKey: .token)
scenicUsers = (try? container.decodeIfPresent([V9ScenicUser].self, forKey: .scenicUsers)) ?? []
storeUsers = (try? container.decodeIfPresent([V9StoreUser].self, forKey: .storeUsers)) ?? []
}
}
/// v9
struct V9ScenicUser: Decodable, Equatable {
static let accountTypeValue = "scenic_user"
let accountType: String
let id: Int
let userId: Int
let scenicUserId: Int
let ssUserId: Int
let username: String
let realName: String
let nickname: String
let phone: String
let scenicId: Int
let scenicName: String
let isCurrent: Bool
/// set-user 使 ID
var businessUserId: Int {
[ssUserId, scenicUserId, userId, id].first { $0 > 0 } ?? 0
}
///
var displayName: String {
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username
}
/// 使
func toAccountSwitchAccount() -> AccountSwitchAccount {
AccountSwitchAccount(
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId,
title: scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? "景区账号",
subtitle: [realName.nonEmpty, nickname.nonEmpty]
.compactMap(\.self)
.joined(separator: " · "),
phone: phone,
avatar: "",
scenicName: scenicName,
storeId: nil,
storeName: "",
scenicId: scenicId > 0 ? scenicId : nil,
isCurrent: isCurrent
)
}
/// JSON
enum CodingKeys: String, CodingKey {
case accountType = "account_type"
case id
case userId = "user_id"
case scenicUserId = "scenic_user_id"
case ssUserId = "ss_user_id"
case username
case realName = "real_name"
case nickname
case phone
case scenicId = "scenic_id"
case scenicName = "scenic_name"
case isCurrent = "is_current"
}
/// ID
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue
id = try container.decodeLossyInt(forKey: .id) ?? 0
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
username = try container.decodeLossyString(forKey: .username)
realName = try container.decodeLossyString(forKey: .realName)
nickname = try container.decodeLossyString(forKey: .nickname)
phone = try container.decodeLossyString(forKey: .phone)
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
scenicName = try container.decodeLossyString(forKey: .scenicName)
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
}
}
/// v9
struct V9StoreUser: Decodable, Equatable {
static let accountTypeValue = "store_user"
let accountType: String
let id: Int
let userId: Int
let storeUserId: Int
let username: String
let userName: String
let realName: String
let phone: String
let avatar: String
let scenicId: Int
let scenicName: String
let storeId: Int
let storeName: String
let isCurrent: Bool
/// set-user 使 ID
var businessUserId: Int {
[storeUserId, userId, id].first { $0 > 0 } ?? 0
}
///
var displayName: String {
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username
}
/// 使
func toAccountSwitchAccount() -> AccountSwitchAccount {
AccountSwitchAccount(
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId,
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号",
subtitle: [scenicName.nonEmpty, realName.nonEmpty ?? userName.nonEmpty]
.compactMap(\.self)
.joined(separator: " · "),
phone: phone,
avatar: avatar,
scenicName: scenicName,
storeId: storeId > 0 ? storeId : nil,
storeName: storeName,
scenicId: scenicId > 0 ? scenicId : nil,
isCurrent: isCurrent
)
}
/// JSON
enum CodingKeys: String, CodingKey {
case accountType = "account_type"
case id
case userId = "user_id"
case storeUserId = "store_user_id"
case username
case userName = "user_name"
case realName = "real_name"
case phone
case avatar
case scenicId = "scenic_id"
case scenicName = "scenic_name"
case storeId = "store_id"
case storeName = "store_name"
case isCurrent = "is_current"
}
/// ID
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue
id = try container.decodeLossyInt(forKey: .id) ?? 0
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
username = try container.decodeLossyString(forKey: .username)
userName = try container.decodeLossyString(forKey: .userName)
realName = try container.decodeLossyString(forKey: .realName)
phone = try container.decodeLossyString(forKey: .phone)
avatar = try container.decodeLossyString(forKey: .avatar)
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
scenicName = try container.decodeLossyString(forKey: .scenicName)
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
storeName = try container.decodeLossyString(forKey: .storeName)
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "true" : "false"
}
return ""
}
/// IntDouble
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
/// Bool
func decodeLossyBool(forKey key: Key) throws -> Bool? {
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value != 0
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if ["1", "true", "yes"].contains(normalized) { return true }
if ["0", "false", "no"].contains(normalized) { return false }
}
return nil
}
}
private extension String {
var nonEmpty: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

View File

@ -0,0 +1,291 @@
//
// AccountSelectionViewController.swift
// suixinkan
//
import SnapKit
import UIKit
@MainActor
/// /
final class AccountSelectionViewController: UIViewController {
private let payload: AccountSelectionPayload
private var isLoading: Bool
private let onCancel: () -> Void
private let onConfirm: (AccountSwitchAccount) -> Void
private var selectedAccountId: String?
private let tableView = UITableView(frame: .zero, style: .plain)
private let confirmButton = UIButton(type: .system)
private let bottomBar = UIView()
init(
payload: AccountSelectionPayload,
isLoading: Bool,
onCancel: @escaping () -> Void,
onConfirm: @escaping (AccountSwitchAccount) -> Void
) {
self.payload = payload
self.isLoading = isLoading
self.onCancel = onCancel
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = "选择账号"
view.backgroundColor = UIColor(hex: 0xF5F7FB)
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "取消", style: .plain, target: self, action: #selector(cancelTapped))
isModalInPresentation = isLoading
selectedAccountId = payload.accounts.first?.id
configureTableView()
configureBottomBar()
}
func updateLoading(_ loading: Bool) {
isLoading = loading
isModalInPresentation = loading
confirmButton.isEnabled = canConfirm
confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6)
}
private var selectedAccount: AccountSwitchAccount? {
payload.accounts.first { $0.id == selectedAccountId }
}
private var canConfirm: Bool {
selectedAccount != nil && !isLoading
}
private func configureTableView() {
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.dataSource = self
tableView.delegate = self
tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier)
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(90)
}
}
private func configureBottomBar() {
bottomBar.backgroundColor = .white
let divider = UIView()
divider.backgroundColor = UIColor.separator
confirmButton.setTitle("进入系统", for: .normal)
confirmButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
confirmButton.setTitleColor(.white, for: .normal)
confirmButton.layer.cornerRadius = AppMetrics.CornerRadius.button
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
updateLoading(isLoading)
view.addSubview(bottomBar)
bottomBar.addSubview(divider)
bottomBar.addSubview(confirmButton)
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
divider.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(0.5)
}
confirmButton.snp.makeConstraints { make in
make.top.equalTo(divider.snp.bottom).offset(AppMetrics.Spacing.medium)
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.medium)
make.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight)
}
}
@objc private func cancelTapped() {
onCancel()
dismiss(animated: true)
}
@objc private func confirmTapped() {
guard let selectedAccount else { return }
onConfirm(selectedAccount)
}
}
extension AccountSelectionViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
payload.accounts.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: AccountSelectionCell.reuseIdentifier,
for: indexPath
) as? AccountSelectionCell else {
return UITableViewCell()
}
let account = payload.accounts[indexPath.row]
cell.configure(account: account, selected: account.id == selectedAccountId)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
selectedAccountId = payload.accounts[indexPath.row].id
tableView.reloadData()
confirmButton.isEnabled = canConfirm
confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6)
}
}
/// Cell
private final class AccountSelectionCell: UITableViewCell {
static let reuseIdentifier = "AccountSelectionCell"
private let avatarView = UIView()
private let avatarLabel = UILabel()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let phoneLabel = UILabel()
private let typeTag = UILabel()
private let currentTag = UILabel()
private let checkmark = UIImageView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .white
contentView.layer.cornerRadius = AppMetrics.CornerRadius.button
contentView.layer.masksToBounds = true
avatarView.layer.cornerRadius = AppMetrics.ControlSize.primaryButtonHeight / 2
avatarLabel.font = .systemFont(ofSize: AppMetrics.FontSize.callout, weight: .semibold)
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold)
titleLabel.textColor = AppDesign.textPrimary
subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.footnote)
subtitleLabel.textColor = AppDesign.textSecondary
phoneLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
phoneLabel.textColor = UIColor(hex: 0x9AA1AA)
typeTag.font = .systemFont(ofSize: 11, weight: .semibold)
typeTag.textAlignment = .center
typeTag.layer.cornerRadius = AppMetrics.Spacing.xxSmall
typeTag.clipsToBounds = true
currentTag.font = .systemFont(ofSize: 11, weight: .semibold)
currentTag.textAlignment = .center
currentTag.text = "当前"
currentTag.textColor = AppDesign.primary
currentTag.backgroundColor = AppDesign.primarySoft
currentTag.layer.cornerRadius = AppMetrics.Spacing.xxSmall
currentTag.clipsToBounds = true
currentTag.isHidden = true
checkmark.contentMode = .scaleAspectFit
contentView.addSubview(avatarView)
avatarView.addSubview(avatarLabel)
contentView.addSubview(titleLabel)
contentView.addSubview(subtitleLabel)
contentView.addSubview(phoneLabel)
contentView.addSubview(typeTag)
contentView.addSubview(currentTag)
contentView.addSubview(checkmark)
avatarView.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(AppMetrics.FontSize.subheadline)
make.centerY.equalToSuperview()
make.width.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight)
}
avatarLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().inset(AppMetrics.FontSize.subheadline)
make.leading.equalTo(avatarView.snp.trailing).offset(AppMetrics.FontSize.footnote)
make.trailing.lessThanOrEqualTo(checkmark.snp.leading).offset(-8)
}
currentTag.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(AppMetrics.Spacing.xSmall)
make.centerY.equalTo(titleLabel)
make.height.equalTo(AppMetrics.Spacing.sheet)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.xxSmall)
make.leading.equalTo(titleLabel)
make.trailing.equalTo(checkmark.snp.leading).offset(-8)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(AppMetrics.Spacing.xxSmall)
make.leading.equalTo(titleLabel)
make.bottom.equalToSuperview().inset(AppMetrics.FontSize.subheadline)
}
typeTag.snp.makeConstraints { make in
make.trailing.equalTo(checkmark.snp.leading).offset(-AppMetrics.Spacing.xSmall)
make.top.equalTo(titleLabel)
make.height.equalTo(AppMetrics.Spacing.sheet)
}
checkmark.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppMetrics.FontSize.subheadline)
make.centerY.equalToSuperview()
make.width.height.equalTo(AppMetrics.ControlSize.passwordIcon)
}
}
required init?(coder: NSCoder) {
nil
}
func configure(account: AccountSwitchAccount, selected: Bool) {
titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title
subtitleLabel.text = account.subtitle
subtitleLabel.isHidden = account.subtitle.isEmpty
phoneLabel.text = account.phone
phoneLabel.isHidden = account.phone.isEmpty
currentTag.isHidden = !account.isCurrent
if account.isStoreUser {
avatarView.backgroundColor = UIColor(hex: 0xE8F8F1)
avatarLabel.text = ""
avatarLabel.textColor = UIColor(hex: 0x0F9F6E)
typeTag.text = " 门店 "
typeTag.textColor = UIColor(hex: 0x0F9F6E)
typeTag.backgroundColor = UIColor(hex: 0xE8F8F1)
} else {
avatarView.backgroundColor = UIColor(hex: 0xF3ECFF)
avatarLabel.text = ""
avatarLabel.textColor = UIColor(hex: 0x7C3AED)
typeTag.text = " 景区 "
typeTag.textColor = UIColor(hex: 0x7C3AED)
typeTag.backgroundColor = UIColor(hex: 0xF3ECFF)
}
let symbolName = selected ? "checkmark.circle.fill" : "circle"
checkmark.image = UIImage(systemName: symbolName)
checkmark.tintColor = selected ? AppDesign.primary : UIColor(hex: 0xB6BECA)
contentView.layer.borderWidth = selected ? 1.4 : 1
contentView.layer.borderColor = (selected ? AppDesign.primary : UIColor(hex: 0xE6ECF4)).cgColor
}
}

View File

@ -0,0 +1,532 @@
//
// LoginViewController.swift
// suixinkan
//
import SnapKit
import UIKit
@MainActor
/// /
final class LoginViewController: UIViewController {
private let services: AppServices
private let viewModel = LoginViewModel()
private let scrollView = UIScrollView()
private let contentView = UIView()
private let backgroundImageView = UIImageView(image: UIImage(named: "LoginBackground"))
private let titleLabel = UILabel()
private let cardView = UIView()
private let usernameField = LoginInputField(iconName: "person", placeholder: "请输入手机号", isSecure: false)
private let passwordField = LoginInputField(iconName: "lock", placeholder: "请输入密码", isSecure: true)
private let privacyButton = UIButton(type: .custom)
private let privacyLabel = UILabel()
private let userAgreementButton = UIButton(type: .system)
private let privacyPolicyButton = UIButton(type: .system)
private let loginButton = UIButton(type: .system)
init(services: AppServices) {
self.services = services
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(hex: 0x0B1220)
configureViews()
bindViewModel()
viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences())
}
private func configureViews() {
backgroundImageView.contentMode = .scaleAspectFill
backgroundImageView.clipsToBounds = true
titleLabel.text = "欢迎使用\n随心瞰商家版"
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.largeTitle, weight: .semibold)
titleLabel.textColor = .white
titleLabel.numberOfLines = 0
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppMetrics.CornerRadius.card
cardView.layer.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.12
cardView.layer.shadowRadius = 18
cardView.layer.shadowOffset = CGSize(width: 0, height: 8)
usernameField.textField.keyboardType = .phonePad
usernameField.textField.textContentType = .telephoneNumber
usernameField.textField.autocorrectionType = .no
usernameField.textField.autocapitalizationType = .none
usernameField.textField.returnKeyType = .next
usernameField.textField.accessibilityIdentifier = "login.username"
usernameField.textField.delegate = self
usernameField.textField.addTarget(self, action: #selector(usernameChanged), for: .editingChanged)
passwordField.textField.autocorrectionType = .no
passwordField.textField.autocapitalizationType = .none
passwordField.textField.returnKeyType = .go
passwordField.textField.accessibilityIdentifier = "login.password"
passwordField.textField.delegate = self
passwordField.textField.addTarget(self, action: #selector(passwordChanged), for: .editingChanged)
passwordField.onToggleVisibility = { [weak self] in
self?.viewModel.showsPassword.toggle()
}
privacyButton.accessibilityIdentifier = "login.privacy"
privacyButton.addTarget(self, action: #selector(togglePrivacy), for: .touchUpInside)
configureAgreementText()
loginButton.setTitle("登录", for: .normal)
loginButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
loginButton.layer.cornerRadius = AppMetrics.CornerRadius.button
loginButton.accessibilityIdentifier = "login.submit"
loginButton.addTarget(self, action: #selector(loginTapped), for: .touchUpInside)
view.addSubview(backgroundImageView)
view.addSubview(scrollView)
scrollView.addSubview(contentView)
contentView.addSubview(titleLabel)
contentView.addSubview(cardView)
cardView.addSubview(usernameField)
cardView.addSubview(passwordField)
cardView.addSubview(privacyButton)
cardView.addSubview(privacyLabel)
cardView.addSubview(loginButton)
backgroundImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
contentView.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide)
make.width.equalTo(scrollView.frameLayoutGuide)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(contentView.safeAreaLayoutGuide).offset(72)
make.leading.trailing.equalToSuperview().inset(24)
}
titleLabel.accessibilityIdentifier = "login.title"
cardView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.xxLarge)
make.leading.trailing.equalToSuperview().inset(20)
make.bottom.equalToSuperview().inset(AppMetrics.Spacing.xxLarge)
}
usernameField.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.height.equalTo(AppMetrics.ControlSize.inputHeight)
}
passwordField.snp.makeConstraints { make in
make.top.equalTo(usernameField.snp.bottom).offset(AppMetrics.Spacing.xSmall)
make.leading.trailing.equalTo(usernameField)
make.height.equalTo(AppMetrics.ControlSize.inputHeight)
}
privacyButton.snp.makeConstraints { make in
make.top.equalTo(passwordField.snp.bottom).offset(AppMetrics.ControlSize.checkboxTapArea)
make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.width.height.equalTo(AppMetrics.ControlSize.checkboxTapArea)
}
privacyLabel.snp.makeConstraints { make in
make.leading.equalTo(privacyButton.snp.trailing)
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.top.equalTo(privacyButton).offset(AppMetrics.Spacing.xxSmall)
}
loginButton.snp.makeConstraints { make in
make.top.equalTo(privacyLabel.snp.bottom).offset(AppMetrics.Spacing.mediumLarge)
make.leading.trailing.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight)
}
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
tap.cancelsTouchesInView = false
view.addGestureRecognizer(tap)
}
private func configureAgreementText() {
privacyLabel.numberOfLines = 0
privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
userAgreementButton.setTitle("《用户协议》", for: .normal)
userAgreementButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
userAgreementButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal)
userAgreementButton.accessibilityIdentifier = "login.userAgreement"
userAgreementButton.addTarget(self, action: #selector(openUserAgreement), for: .touchUpInside)
privacyPolicyButton.setTitle("《隐私政策》", for: .normal)
privacyPolicyButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
privacyPolicyButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal)
privacyPolicyButton.accessibilityIdentifier = "login.privacyPolicy"
privacyPolicyButton.addTarget(self, action: #selector(openPrivacyPolicy), for: .touchUpInside)
let prefix = UILabel()
prefix.text = "已阅读并同意"
prefix.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
prefix.textColor = UIColor(hex: 0x666666)
let separator = UILabel()
separator.text = ""
separator.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
separator.textColor = UIColor(hex: 0x666666)
let row = UIStackView(arrangedSubviews: [prefix, userAgreementButton, separator, privacyPolicyButton])
row.axis = .horizontal
row.spacing = 0
row.alignment = .center
privacyLabel.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
private func bindViewModel() {
viewModel.onChange = { [weak self] in
self?.renderViewModel()
}
renderViewModel()
}
private func renderViewModel() {
if usernameField.textField.text != viewModel.username {
usernameField.textField.text = viewModel.username
}
if passwordField.textField.text != viewModel.password {
passwordField.textField.text = viewModel.password
}
passwordField.setSecureEntry(!viewModel.showsPassword)
let checkboxImage = viewModel.privacyChecked ? "LoginCheckboxChecked" : "LoginCheckboxUnchecked"
privacyButton.setImage(UIImage(named: checkboxImage), for: .normal)
loginButton.isEnabled = viewModel.canSubmit && !viewModel.isLoading
loginButton.backgroundColor = viewModel.canSubmit ? AppDesign.primary : .gray
loginButton.setTitleColor(.white, for: .normal)
loginButton.alpha = viewModel.isLoading ? 0.7 : 1
if viewModel.showsAgreementSheet {
viewModel.showsAgreementSheet = false
presentAgreementSheet()
}
if let payload = viewModel.pendingAccountSelection {
if let controller = accountSelectionController {
controller.updateLoading(viewModel.isSelectingAccount)
} else if presentedViewController == nil {
presentAccountSelection(payload)
}
} else if accountSelectionController != nil {
dismiss(animated: true)
accountSelectionController = nil
}
}
private weak var accountSelectionController: AccountSelectionViewController?
@objc private func usernameChanged() {
viewModel.username = usernameField.textField.text ?? ""
viewModel.normalizeUsernameCountryCodeIfNeeded()
services.toastCenter.dismiss()
}
@objc private func passwordChanged() {
viewModel.password = passwordField.textField.text ?? ""
services.toastCenter.dismiss()
}
@objc private func togglePrivacy() {
viewModel.privacyChecked.toggle()
}
@objc private func openUserAgreement() {
showToast("用户协议页面待接入")
}
@objc private func openPrivacyPolicy() {
showToast("隐私政策页面待接入")
}
@objc private func loginTapped() {
view.endEditing(true)
performLogin()
}
@objc private func dismissKeyboard() {
view.endEditing(true)
}
private func performLogin() {
if let validationError = viewModel.validateForLogin() {
if validationError == .privacyUnchecked {
presentAgreementSheet()
} else {
showToast(validationError.message)
if validationError.focusField == .username {
usernameField.textField.becomeFirstResponder()
} else if validationError.focusField == .password {
passwordField.textField.becomeFirstResponder()
}
}
return
}
Task {
do {
try await services.globalLoading.withLoading(message: "登录中...") {
let resolution = try await viewModel.login(authAPI: services.authAPI)
switch resolution {
case let .completed(response):
await completeLogin(with: response)
case .needsAccountSelection:
break
}
}
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func presentAgreementSheet() {
let controller = LoginAgreementConsentViewController(
onOpenAgreement: { [weak self] title in
self?.showToast("\(title)页面待接入")
},
onAgreeAndContinue: { [weak self] in
self?.viewModel.acceptAgreement()
self?.performLogin()
}
)
if let sheet = controller.sheetPresentationController {
sheet.detents = [.custom(resolver: { _ in 240 })]
sheet.prefersGrabberVisible = true
}
present(controller, animated: true)
}
private func presentAccountSelection(_ payload: AccountSelectionPayload) {
let controller = AccountSelectionViewController(
payload: payload,
isLoading: viewModel.isSelectingAccount,
onCancel: { [weak self] in
self?.viewModel.clearPendingAccountSelection()
self?.accountSelectionController = nil
},
onConfirm: { [weak self] account in
self?.selectAccount(account)
}
)
accountSelectionController = controller
let navigation = UINavigationController(rootViewController: controller)
navigation.modalPresentationStyle = .formSheet
present(navigation, animated: true)
}
private func selectAccount(_ account: AccountSwitchAccount) {
Task {
do {
try await services.globalLoading.withLoading(message: "账号切换中...") {
let response = try await viewModel.selectAccount(account, authAPI: services.authAPI)
await completeLogin(with: response)
accountSelectionController = nil
dismiss(animated: true)
}
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func completeLogin(with response: V9AuthResponse) async {
do {
try await services.authSessionCoordinator.completeLogin(
with: response,
username: viewModel.normalizedUsername,
privacyAgreementAccepted: viewModel.privacyChecked,
appSession: services.appSession,
accountContext: services.accountContext,
permissionContext: services.permissionContext,
profileAPI: services.profileAPI,
accountContextAPI: services.accountContextAPI
)
} catch {
showToast(error.localizedDescription)
}
}
}
extension LoginViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if textField === usernameField.textField {
passwordField.textField.becomeFirstResponder()
} else if viewModel.canSubmit, !viewModel.isLoading {
performLogin()
}
return true
}
}
///
private final class LoginInputField: UIView {
let textField = UITextField()
var onToggleVisibility: (() -> Void)?
private let toggleButton = UIButton(type: .custom)
private var isSecure = false
init(iconName: String, placeholder: String, isSecure: Bool) {
self.isSecure = isSecure
super.init(frame: .zero)
backgroundColor = UIColor(hex: 0xF1F5F9)
layer.cornerRadius = AppMetrics.CornerRadius.input
layer.borderWidth = 1
layer.borderColor = UIColor(hex: 0xDDE3EA).cgColor
let icon = UIImageView(image: UIImage(systemName: iconName))
icon.tintColor = AppDesign.textSecondary
icon.contentMode = .scaleAspectFit
textField.placeholder = placeholder
textField.font = .systemFont(ofSize: AppMetrics.FontSize.body)
textField.textColor = AppDesign.textPrimary
textField.tintColor = AppDesign.primary
textField.borderStyle = .none
textField.isSecureTextEntry = isSecure
addSubview(icon)
addSubview(textField)
icon.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.centerY.equalToSuperview()
make.width.height.equalTo(AppMetrics.ControlSize.smallIcon)
}
if isSecure {
toggleButton.addTarget(self, action: #selector(toggleVisibility), for: .touchUpInside)
addSubview(toggleButton)
toggleButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.small)
make.centerY.equalToSuperview()
make.width.height.equalTo(AppMetrics.ControlSize.iconTapArea)
}
textField.snp.makeConstraints { make in
make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall)
make.trailing.equalTo(toggleButton.snp.leading)
make.centerY.equalToSuperview()
}
} else {
textField.snp.makeConstraints { make in
make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall)
make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.centerY.equalToSuperview()
}
}
}
required init?(coder: NSCoder) {
nil
}
func setSecureEntry(_ secure: Bool) {
textField.isSecureTextEntry = secure
let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible"
toggleButton.setImage(UIImage(named: imageName), for: .normal)
}
@objc private func toggleVisibility() {
onToggleVisibility?()
}
}
///
private final class LoginAgreementConsentViewController: UIViewController {
private let onOpenAgreement: (String) -> Void
private let onAgreeAndContinue: () -> Void
init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) {
self.onOpenAgreement = onOpenAgreement
self.onAgreeAndContinue = onAgreeAndContinue
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
nil
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let titleLabel = UILabel()
titleLabel.text = "请先阅读并同意相关协议"
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
titleLabel.textColor = AppDesign.textPrimary
titleLabel.textAlignment = .center
let descriptionLabel = UILabel()
descriptionLabel.text = "为了保障你的账号安全和服务体验,请阅读并同意用户协议和隐私政策。"
descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
descriptionLabel.textColor = AppDesign.textSecondary
descriptionLabel.numberOfLines = 0
descriptionLabel.textAlignment = .center
let continueButton = UIButton(type: .system)
continueButton.setTitle("同意并继续", for: .normal)
continueButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
continueButton.backgroundColor = AppDesign.primary
continueButton.setTitleColor(.white, for: .normal)
continueButton.layer.cornerRadius = AppMetrics.CornerRadius.button
continueButton.accessibilityIdentifier = "login.agreement.continue"
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
view.addSubview(titleLabel)
view.addSubview(descriptionLabel)
view.addSubview(continueButton)
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.sheet)
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large)
}
descriptionLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.small)
make.leading.trailing.equalTo(titleLabel)
}
continueButton.snp.makeConstraints { make in
make.leading.trailing.equalTo(titleLabel)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.mediumLarge)
make.height.equalTo(AppMetrics.ControlSize.sheetButtonHeight)
}
}
@objc private func continueTapped() {
dismiss(animated: true) { [onAgreeAndContinue] in
onAgreeAndContinue()
}
}
}

View File

@ -0,0 +1,212 @@
//
// LoginViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/18.
//
import Foundation
///
enum LoginField: Hashable {
case username
case password
}
///
enum LoginValidationError: Equatable {
case invalidPhone
case emptyPassword
case privacyUnchecked
var message: String {
switch self {
case .invalidPhone:
"请输入有效的手机号码"
case .emptyPassword:
"请输入密码"
case .privacyUnchecked:
"请先阅读并同意相关协议"
}
}
var focusField: LoginField? {
switch self {
case .invalidPhone:
.username
case .emptyPassword:
.password
case .privacyUnchecked:
nil
}
}
}
///
enum LoginResolution {
case completed(V9AuthResponse)
case needsAccountSelection(AccountSelectionPayload)
}
/// token ID
enum LoginFlowError: LocalizedError {
case missingToken
case noAvailableAccount
case invalidAccount
var errorDescription: String? {
switch self {
case .missingToken:
"登录响应缺少 token"
case .noAvailableAccount:
"当前账号没有可用的景区账号或门店账号,请联系管理员"
case .invalidAccount:
"账号信息异常,请重新选择账号"
}
}
}
@MainActor
/// ViewModel
final class LoginViewModel {
var onChange: (() -> Void)?
var username = "" { didSet { onChange?() } }
var password = "" { didSet { onChange?() } }
var privacyChecked = false { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var isSelectingAccount = false { didSet { onChange?() } }
var showsPassword = false { didSet { onChange?() } }
var showsAgreementSheet = false { didSet { onChange?() } }
var pendingAccountSelection: AccountSelectionPayload? { didSet { onChange?() } }
var canSubmit: Bool {
isValidPhone && !trimmedPassword.isEmpty
}
var trimmedPassword: String {
password.trimmingCharacters(in: .whitespacesAndNewlines)
}
var normalizedUsername: String {
normalizedPhoneNumber(username)
}
var isValidPhone: Bool {
normalizedUsername.count == 11 && normalizedUsername.first == "1"
}
///
func applyPreferences(_ preferences: LoginPreferences) {
if username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let lastUsername = preferences.lastUsername {
username = lastUsername
}
privacyChecked = preferences.privacyAgreementAccepted
}
///
func validateForLogin() -> LoginValidationError? {
guard isValidPhone else {
return .invalidPhone
}
guard !trimmedPassword.isEmpty else {
return .emptyPassword
}
guard privacyChecked else {
return .privacyUnchecked
}
return nil
}
///
func acceptAgreement() {
privacyChecked = true
showsAgreementSheet = false
}
/// +86 11
func normalizeUsernameCountryCodeIfNeeded() {
let digits = username.filter(\.isNumber)
let normalizedPhone = normalizedPhoneNumber(username)
guard normalizedPhone != digits, normalizedPhone.count == 11 else { return }
username = normalizedPhone
}
///
func login(authAPI: AuthAPI) async throws -> LoginResolution {
guard !isLoading else {
throw CancellationError()
}
isLoading = true
defer { isLoading = false }
let response = try await authAPI.login(
username: normalizedUsername,
password: trimmedPassword
)
return try await resolveLoginResponse(response, authAPI: authAPI)
}
/// set-user token
func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse {
guard let payload = pendingAccountSelection, payload.hasTempToken else {
throw LoginFlowError.missingToken
}
guard account.businessUserId > 0 else {
throw LoginFlowError.invalidAccount
}
guard !isSelectingAccount else {
throw CancellationError()
}
isSelectingAccount = true
defer { isSelectingAccount = false }
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken)
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
pendingAccountSelection = nil
return response
}
///
func clearPendingAccountSelection() {
pendingAccountSelection = nil
}
/// set-user
private func resolveLoginResponse(_ response: V9AuthResponse, authAPI: AuthAPI) async throws -> LoginResolution {
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else {
throw LoginFlowError.missingToken
}
let accounts = response.accounts.filter { $0.businessUserId > 0 }
guard !accounts.isEmpty else {
throw LoginFlowError.noAvailableAccount
}
if accounts.count == 1, let account = accounts.first {
let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token)
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
return .completed(finalResponse)
}
let payload = AccountSelectionPayload(tempToken: token, accounts: accounts)
pendingAccountSelection = payload
return .needsAccountSelection(payload)
}
/// 86
private func normalizedPhoneNumber(_ value: String) -> String {
var digits = value.filter(\.isNumber)
if digits.hasPrefix("86"), digits.count == 13 {
digits.removeFirst(2)
}
return digits
}
}

View File

@ -0,0 +1,38 @@
# Home 模块业务逻辑
## 模块职责
Home 模块负责登录后的首页工作台,包括当前景区展示、工作状态、位置上报入口、快捷操作、常用应用和全部功能入口。
首页菜单来自 `PermissionContext` 中的角色权限。当前模块只同步首页壳和入口路由,入口背后的业务子模块后续逐个迁移。
## 菜单生成
`HomeViewModel` 从当前角色的权限树递归提取菜单:
- 当前角色 ID 存在但找不到时清空菜单。
- 当前角色 ID 为空时使用第一个角色。
- URI 按旧工程顺序排序。
- 同义 URI 按 `HomeMenuRouter.menuAliasKey` 去重。
## 常用应用
`HomeCommonMenuStore` 使用 UserDefaults 保存常用应用 URI
- 首次无配置时写入默认常用应用。
- 读取时按当前角色权限过滤不可用 URI。
- 添加和移除时按同义 URI 去重。
## 路由规则
`HomeMenuRouter` 将权限 URI 分为四类:
- `tab`:切换到订单或数据 Tab。
- `destination`:进入已接入的本地页面,如个人信息、景区选择、权限申请、全部功能。
- `unsupported`:已知 iOS 不支持的入口,进入占位说明。
- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。
`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转。
## 后续迁移
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload``album_list``album_trailer``sample_management``sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report``location_report_history` 已由 `Features/LocationReport` 接管,`pm``project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation``photographer_invite``invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail``deposit_order``deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement``scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue``queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management``live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。

View File

@ -0,0 +1,85 @@
//
// HomeIconCatalog.swift
// suixinkan
//
import Foundation
/// URI
enum HomeIconCatalog {
/// URI SF Symbols
static func iconName(for uri: String) -> String {
switch uri {
case "space_settings":
"person.crop.square.fill"
case "album_list":
"photo.stack"
case "album_trailer":
"square.stack.3d.up.fill"
case "wallet":
"creditcard.fill"
case "cloud_management":
"icloud.fill"
case "cloud_storage_transit":
"arrow.left.arrow.right.circle.fill"
case "asset_management", "/scenic-order-manage":
"photo.on.rectangle.angled"
case "material_upload":
"square.and.arrow.up"
case "task_management", "task_management_editor":
"checklist"
case "schedule_management":
"calendar"
case "system_settings":
"gearshape.fill"
case "message_center":
"bell.fill"
case "checkin_points":
"mappin.and.ellipse"
case "sample_management":
"point.3.connected.trianglepath.dotted"
case "sample_upload":
"square.and.arrow.up.on.square"
case "live_stream_management":
"dot.radiowaves.left.and.right"
case "verification_order":
"checkmark.seal.fill"
case "live_album":
"play.rectangle.on.rectangle.fill"
case "pm", "pm_manager", "project_edit":
"circle.grid.2x2.fill"
case "location_report", "location_report_history":
"mappin.circle.fill"
case "registration_invitation", "photographer_invite":
"envelope.open.fill"
case "store":
"storefront.fill"
case "fly", "pilot_cert", "pilot_controller":
"paperplane.fill"
case "/scenic-queue", "queue_management":
"person.3.fill"
case "operating-area":
"map.fill"
case "scenicselection":
"location.magnifyingglass"
case "scenicapplication":
"doc.badge.plus"
case "permission_apply", "permission_apply_status":
"person.badge.key"
case "scenic_settlement", "scenic_settlement_review":
"checklist"
case "payment_collection", "payment_qr", "payment_code":
"qrcode"
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
"doc.text.magnifyingglass"
case "withdrawal_audit":
"banknote.fill"
case "invite_record":
"list.bullet.rectangle.fill"
case "more_functions":
"ellipsis"
default:
"square.grid.2x2.fill"
}
}
}

View File

@ -0,0 +1,104 @@
//
// HomeMenuItem.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// URI
struct HomeMenuItem: Equatable, Identifiable {
let title: String
let uri: String
let iconSrc: String?
var id: String {
uri
}
}
///
enum HomeRoute: Hashable {
case profileSpace
case scenicSelection
case permissionApply
case permissionApplyStatus
case scenicApplication
case moreFunctions
case settings
case paymentCollection
case wallet
case taskManagement
case taskCreate
case taskDetail(id: Int, summary: PhotographerTaskItem?)
case projectManagement
case pmProjectManagement
case projectDetail(id: Int, storeMode: Bool)
case projectEditor(id: Int?, storeMode: Bool)
case scheduleManagement
case scheduleAdd
case photographerInvite
case inviteRecord
case cloudStorage
case cloudStorageTransit
case materialLibrary
case materialUpload
case sampleLibrary
case sampleUpload
case albumList
case albumTrailer
case punchPointList
case punchPointDetail(id: Int, summary: PunchPointItem?)
case punchPointEditor(id: Int?)
case punchPointQR(id: Int, title: String, qrURL: String)
case locationReport
case locationReportHistory
case depositOrders
case withdrawalAudit
case scenicSettlement
case scenicSettlementReview
case messageCenter
case queueManagement
case liveManagement
case liveAlbum
case operatingArea
case pilotCertification
case modulePlaceholder(uri: String, title: String)
}
/// URI Tab
enum HomeMenuResolvedRoute: Equatable {
case tab(AppTab)
case orders(OrdersEntry)
case destination(HomeRoute)
case unsupported(uri: String, title: String, reason: String)
case placeholder(uri: String, title: String)
}
/// iOS URI
struct UnknownHomeRouteRecord: Codable, Equatable {
let uri: String
let title: String
let firstSeenAt: Date
let lastSeenAt: Date
let count: Int
}
///
struct HomePermissionRouteAuditEntry: Equatable {
let uri: String
let title: String
let route: HomeMenuResolvedRoute
}
///
struct HomePermissionRouteAudit: Equatable {
let routable: [HomePermissionRouteAuditEntry]
let unsupported: [HomePermissionRouteAuditEntry]
let unknown: [HomePermissionRouteAuditEntry]
var hasUnknownRoutes: Bool {
!unknown.isEmpty
}
}

View File

@ -0,0 +1,503 @@
//
// HomeMenuRouter.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import os
/// URI iOS
enum HomeMenuRouter {
private static let titleMap: [String: String] = [
"basic_info": "基本信息",
"space_settings": "空间设置",
"album_list": "相册管理",
"album_trailer": "相册预览上传",
"photographer_stats": "数据统计",
"photographer_orders": "订单管理",
"/scenic-order-manage": "景区订单",
"verification_order": "核销订单",
"wallet": "我的钱包",
"cloud_management": "相册云盘",
"cloud_storage_transit": "传输管理",
"asset_management": "素材管理",
"material_upload": "上传素材",
"task_management": "任务管理",
"task_management_editor": "任务管理",
"task_create": "发布任务",
"schedule_management": "日程管理",
"system_settings": "设置中心",
"message_center": "消息中心",
"checkin_points": "打卡点管理",
"sample_management": "样片管理",
"sample_upload": "上传样片",
"live_stream_management": "直播管理",
"live_album": "直播相册",
"scenicselection": "景区选择",
"scenicapplication": "景区申请",
"permission_apply": "权限申请",
"permission_apply_status": "权限申请状态",
"scenic_settlement": "景区结算",
"scenic_settlement_review": "结算审核",
"pm": "项目管理",
"pm_manager": "店铺项目管理",
"project_edit": "项目管理",
"location_report": "位置上报",
"registration_invitation": "注册邀请",
"store": "店铺管理",
"fly": "飞行管理",
"pilot_cert": "飞手认证",
"pilot_controller": "飞控",
"/scenic-queue": "排队管理",
"queue_management": "排队管理",
"operating-area": "运营区域",
"payment_collection": "立即收款",
"payment_qr": "收款码",
"payment_code": "收款码",
"deposit_order_detail": "押金订单详情",
"deposit_order": "押金订单详情",
"deposit_order_shooting_info": "押金拍摄信息",
"withdrawal_audit": "提现审核",
"location_report_history": "定位上报历史",
"photographer_invite": "邀请摄影师",
"invite_record": "邀请记录",
"more_functions": "更多功能"
]
/// URI
static func resolve(uri: String, title: String) -> HomeMenuResolvedRoute {
switch uri {
case "photographer_orders", "/scenic-order-manage":
return .orders(.storeOrders)
case "verification_order":
return .orders(.verificationOrders)
case "photographer_stats":
return .tab(.statistics)
case "space_settings", "basic_info":
return .destination(.profileSpace)
case "system_settings":
return .destination(.settings)
case "scenicselection":
return .destination(.scenicSelection)
case "permission_apply":
return .destination(.permissionApply)
case "permission_apply_status":
return .destination(.permissionApplyStatus)
case "scenicapplication":
return .destination(.scenicApplication)
case "wallet":
return .destination(.wallet)
case "payment_collection", "payment_qr", "payment_code":
return .destination(.paymentCollection)
case "task_management", "task_management_editor":
return .destination(.taskManagement)
case "task_create":
return .destination(.taskCreate)
case "schedule_management":
return .destination(.scheduleManagement)
case "pm", "project_edit":
return .destination(.projectManagement)
case "pm_manager":
return .destination(.pmProjectManagement)
case "registration_invitation", "photographer_invite":
return .destination(.photographerInvite)
case "invite_record":
return .destination(.inviteRecord)
case "cloud_management":
return .destination(.cloudStorage)
case "cloud_storage_transit":
return .destination(.cloudStorageTransit)
case "asset_management":
return .destination(.materialLibrary)
case "material_upload":
return .destination(.materialUpload)
case "sample_management":
return .destination(.sampleLibrary)
case "sample_upload":
return .destination(.sampleUpload)
case "album_list":
return .destination(.albumList)
case "album_trailer":
return .destination(.albumTrailer)
case "checkin_points":
return .destination(.punchPointList)
case "location_report":
return .destination(.locationReport)
case "location_report_history":
return .destination(.locationReportHistory)
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
return .destination(.depositOrders)
case "withdrawal_audit":
return .destination(.withdrawalAudit)
case "scenic_settlement":
return .destination(.scenicSettlement)
case "scenic_settlement_review":
return .destination(.scenicSettlementReview)
case "message_center":
return .destination(.messageCenter)
case "/scenic-queue", "queue_management":
return .destination(.queueManagement)
case "live_stream_management":
return .destination(.liveManagement)
case "live_album":
return .destination(.liveAlbum)
case "operating-area":
return .destination(.operatingArea)
case "pilot_cert":
return .destination(.pilotCertification)
case "store", "more_functions":
return .destination(.moreFunctions)
case "fly", "pilot_controller":
return .unsupported(
uri: uri,
title: title.isEmpty ? self.title(for: uri) : title,
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
)
default:
return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title)
}
}
/// URI
static func title(for uri: String) -> String {
titleMap[uri] ?? uri
}
#if DEBUG
/// 使便
static func debugAllMenuItems() -> [HomeMenuItem] {
let preferredOrder = [
"space_settings",
"basic_info",
"album_list",
"album_trailer",
"wallet",
"payment_collection",
"payment_qr",
"payment_code",
"cloud_management",
"cloud_storage_transit",
"asset_management",
"material_upload",
"task_management",
"task_management_editor",
"task_create",
"schedule_management",
"system_settings",
"message_center",
"checkin_points",
"sample_management",
"sample_upload",
"live_stream_management",
"live_album",
"scenicselection",
"scenicapplication",
"permission_apply",
"permission_apply_status",
"scenic_settlement",
"scenic_settlement_review",
"verification_order",
"photographer_orders",
"/scenic-order-manage",
"photographer_stats",
"pm",
"pm_manager",
"project_edit",
"location_report",
"registration_invitation",
"photographer_invite",
"invite_record",
"store",
"fly",
"pilot_cert",
"pilot_controller",
"/scenic-queue",
"queue_management",
"operating-area",
"deposit_order_detail",
"deposit_order",
"deposit_order_shooting_info",
"withdrawal_audit",
"location_report_history",
"more_functions"
]
let orderedURIs = preferredOrder + titleMap.keys.sorted().filter { !preferredOrder.contains($0) }
return orderedURIs.map { uri in
HomeMenuItem(title: title(for: uri), uri: uri, iconSrc: nil)
}
}
#endif
/// URI
static func canonicalURI(for uri: String, availableURIs: Set<String>) -> String {
if availableURIs.contains(uri) {
return uri
}
switch uri {
case "task_management":
return availableURIs.contains("task_management_editor") ? "task_management_editor" : uri
case "task_management_editor":
return availableURIs.contains("task_management") ? "task_management" : uri
case "registration_invitation":
return availableURIs.contains("photographer_invite") ? "photographer_invite" : uri
case "photographer_invite":
return availableURIs.contains("registration_invitation") ? "registration_invitation" : uri
case "pm":
return availableURIs.contains("project_edit") ? "project_edit" : uri
case "project_edit":
return availableURIs.contains("pm") ? "pm" : uri
case "payment_code":
return availableURIs.contains("payment_qr") ? "payment_qr" : uri
default:
return uri
}
}
/// URI
static func menuAliasKey(for uri: String) -> String {
switch uri {
case "task_management", "task_management_editor":
return "task_management"
case "registration_invitation", "photographer_invite":
return "registration_invitation"
case "pm", "project_edit":
return "pm"
case "payment_collection", "payment_qr", "payment_code":
return "payment_collection"
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
return "deposit_order"
case "photographer_orders", "/scenic-order-manage":
return "photographer_orders"
case "/scenic-queue", "queue_management":
return "queue_management"
default:
return uri
}
}
///
static func displayTitle(for uri: String, fallback: String) -> String {
switch uri {
case "registration_invitation", "photographer_invite":
return "注册邀请"
case "location_report":
return "位置上报"
case "pm", "project_edit":
return "项目管理"
case "pm_manager":
return "店铺项目"
case "space_settings":
return "空间设置"
case "task_management", "task_management_editor":
return "任务管理"
default:
return fallback
}
}
}
/// URI
enum HomePermissionRouteAuditor {
/// URI
static func audit(permissions: [RolePermissionResponse], currentRoleId: Int?) -> HomePermissionRouteAudit {
let source: RolePermissionResponse?
if let currentRoleId {
source = permissions.first { $0.role.id == currentRoleId }
} else {
source = permissions.first
}
let entries = uniquePermissionItems(from: source?.role.permission ?? []).map { item in
let title = item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name
return HomePermissionRouteAuditEntry(
uri: item.uri,
title: title,
route: HomeMenuRouter.resolve(uri: item.uri, title: title)
)
}
return HomePermissionRouteAudit(
routable: entries.filter { entry in
switch entry.route {
case .tab, .orders, .destination:
return true
case .unsupported, .placeholder:
return false
}
},
unsupported: entries.filter { entry in
if case .unsupported = entry.route { return true }
return false
},
unknown: entries.filter { entry in
if case .placeholder = entry.route { return true }
return false
}
)
}
/// Markdown
static func markdownReport(audit: HomePermissionRouteAudit, generatedAt: Date = Date()) -> String {
var lines = [
"# Home Permission Route Audit",
"",
"Generated: \(reportDateFormatter.string(from: generatedAt))",
"",
"Routable: \(audit.routable.count)",
"Unsupported: \(audit.unsupported.count)",
"Unknown: \(audit.unknown.count)",
""
]
if audit.unknown.isEmpty {
lines.append("No unknown permission routes.")
} else {
lines.append("## Unknown Routes")
lines.append("")
lines.append("| URI | Title |")
lines.append("| --- | --- |")
lines.append(contentsOf: audit.unknown.map { "| \(escape($0.uri)) | \(escape($0.title)) |" })
lines.append("")
lines.append("Action: map these URIs in `HomeMenuRouter`, confirm unsupported scope, or remove them before release.")
}
if !audit.unsupported.isEmpty {
lines.append("")
lines.append("## Known Unsupported Routes")
lines.append("")
lines.append("| URI | Title | Reason |")
lines.append("| --- | --- | --- |")
lines.append(contentsOf: audit.unsupported.map { entry in
let reason: String
if case let .unsupported(_, _, value) = entry.route {
reason = value
} else {
reason = ""
}
return "| \(escape(entry.uri)) | \(escape(entry.title)) | \(escape(reason)) |"
})
}
return lines.joined(separator: "\n")
}
private static var reportDateFormatter: ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}
/// URI URI
private static func uniquePermissionItems(from items: [PermissionItem]) -> [PermissionItem] {
var seen = Set<String>()
return flatten(items).filter { item in
guard !item.uri.isEmpty else { return false }
return seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted
}
}
///
private static func flatten(_ items: [PermissionItem]) -> [PermissionItem] {
items.flatMap { item in
[item] + flatten(item.children)
}
}
/// Markdown
private static func escape(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "|", with: "\\|")
.replacingOccurrences(of: "\n", with: " ")
}
}
/// URI
enum HomeRouteDiagnostics {
private static let logger = Logger(subsystem: "com.yuanzhixiang.suixinkan", category: "HomeRouting")
private static let defaultsKey = "home.routing.unknown.records"
private static let dateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
/// URI
static func recordUnknown(uri: String, title: String) {
logger.warning("Unknown home menu uri: \(uri, privacy: .public), title: \(title, privacy: .public)")
var records = unknownRoutes()
let now = Date()
if let index = records.firstIndex(where: { $0.uri == uri }) {
let current = records[index]
records[index] = UnknownHomeRouteRecord(
uri: current.uri,
title: title.isEmpty ? current.title : title,
firstSeenAt: current.firstSeenAt,
lastSeenAt: now,
count: current.count + 1
)
} else {
records.append(UnknownHomeRouteRecord(uri: uri, title: title, firstSeenAt: now, lastSeenAt: now, count: 1))
}
save(records)
}
///
static func unknownRoutes() -> [UnknownHomeRouteRecord] {
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
let records = try? JSONDecoder().decode([UnknownHomeRouteRecord].self, from: data)
else { return [] }
return records
}
/// Markdown
static func unknownRouteReport(generatedAt: Date = Date()) -> String {
let records = unknownRoutes().sorted { lhs, rhs in
if lhs.count != rhs.count {
return lhs.count > rhs.count
}
return lhs.lastSeenAt > rhs.lastSeenAt
}
var lines = [
"# Home Route Diagnostics",
"",
"Generated: \(dateFormatter.string(from: generatedAt))",
""
]
guard !records.isEmpty else {
lines.append("No unknown home routes recorded.")
return lines.joined(separator: "\n")
}
lines.append("| URI | Title | Count | First Seen | Last Seen |")
lines.append("| --- | --- | ---: | --- | --- |")
lines.append(contentsOf: records.map { record in
"| \(escape(record.uri)) | \(escape(record.title)) | \(record.count) | \(dateFormatter.string(from: record.firstSeenAt)) | \(dateFormatter.string(from: record.lastSeenAt)) |"
})
lines.append("")
lines.append("Action: each URI above must be mapped in `HomeMenuRouter`, confirmed as unsupported scope, or explicitly accepted before release.")
return lines.joined(separator: "\n")
}
///
static func resetUnknownRoutes() {
UserDefaults.standard.removeObject(forKey: defaultsKey)
}
///
private static func save(_ records: [UnknownHomeRouteRecord]) {
guard let data = try? JSONEncoder().encode(records) else { return }
UserDefaults.standard.set(data, forKey: defaultsKey)
}
/// Markdown
private static func escape(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "|", with: "\\|")
.replacingOccurrences(of: "\n", with: " ")
}
}

View File

@ -0,0 +1,91 @@
//
// HomeCommonMenuStore.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
/// URI
struct HomeCommonMenuStore {
static let defaultStorageKey = "home.common.menu.uris"
static let defaultBaselineKey = "home.common.menu.android.baseline.v2"
private let defaults: UserDefaults
private let storageKey: String
private let baselineKey: String
/// UserDefaults
init(
defaults: UserDefaults = .standard,
storageKey: String = Self.defaultStorageKey,
baselineKey: String = Self.defaultBaselineKey
) {
self.defaults = defaults
self.storageKey = storageKey
self.baselineKey = baselineKey
}
/// URI使
func load(menuItems: [HomeMenuItem]) -> [String] {
let availableURIs = Set(menuItems.map(\.uri))
if !defaults.bool(forKey: baselineKey) {
let defaults = defaultCommonURIs(availableURIs: availableURIs)
save(defaults)
self.defaults.set(true, forKey: baselineKey)
return defaults
}
let saved = defaults.stringArray(forKey: storageKey) ?? []
let filtered = unique(saved.compactMap { uri -> String? in
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
return availableURIs.contains(resolved) ? resolved : nil
})
save(filtered)
return filtered
}
/// URI
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem]) -> [String] {
let availableURIs = Set(menuItems.map(\.uri))
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard availableURIs.contains(resolved) else { return current }
guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else {
return current
}
let next = current + [resolved]
save(next)
return next
}
/// URI URI
func remove(_ uri: String, current: [String]) -> [String] {
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
save(next)
return next
}
/// URI
func save(_ uris: [String]) {
defaults.set(unique(uris), forKey: storageKey)
}
/// URI
private func defaultCommonURIs(availableURIs: Set<String>) -> [String] {
let preferred = ["registration_invitation", "location_report", "pm_manager", "pm"]
return unique(preferred.compactMap { uri in
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
return availableURIs.contains(resolved) ? resolved : nil
})
}
/// URI URI
private func unique(_ uris: [String]) -> [String] {
var seen = Set<String>()
return uris.filter { uri in
seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted
}
}
}

View File

@ -0,0 +1,59 @@
//
// HomeMenuRouting.swift
// suixinkan
//
import UIKit
@MainActor
/// UIKit URI Tab Navigation push
enum HomeMenuRouting {
///
static func openMenu(_ item: HomeMenuItem, from viewController: UIViewController) {
openRoute(HomeMenuRouter.resolve(uri: item.uri, title: item.title), from: viewController)
}
/// URI
static func openRoute(_ route: HomeMenuResolvedRoute, from viewController: UIViewController) {
let services = AppServices.shared
switch route {
case .tab(let tab):
services.appRouter.select(tab)
case .orders(let entry):
services.appRouter.selectOrders(entry: entry)
case .destination(let homeRoute):
push(homeRoute, from: viewController)
case .unsupported(let uri, let title, _):
pushPlaceholder(title: title, uri: uri, from: viewController)
case .placeholder(let uri, let title):
HomeRouteDiagnostics.recordUnknown(uri: uri, title: title)
pushPlaceholder(title: title, uri: uri, from: viewController)
}
}
/// Push
static func push(_ route: HomeRoute, from viewController: UIViewController) {
let target = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared)
viewController.navigationController?.pushViewController(target, animated: true)
}
/// Push
static func pushOrders(_ route: OrdersRoute, from viewController: UIViewController) {
let target = AppRouteViewControllerFactory.makeViewController(for: .orders(route), services: AppServices.shared)
viewController.navigationController?.pushViewController(target, animated: true)
}
/// Push
static func pushProfile(_ route: ProfileRoute, from viewController: UIViewController) {
let target = AppRouteViewControllerFactory.makeViewController(for: .profile(route), services: AppServices.shared)
viewController.navigationController?.pushViewController(target, animated: true)
}
private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) {
viewController.navigationController?.pushViewController(
FeaturePlaceholderViewController(title: title, uri: uri),
animated: true
)
}
}

View File

@ -0,0 +1,260 @@
//
// HomeMoreFunctionsViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class HomeMoreFunctionsViewController: UIViewController {
private let viewModel = HomeViewModel()
private let commonMenuStore = HomeCommonMenuStore()
private var commonURIs: [String] = []
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .grouped)
table.backgroundColor = AppDesignUIKit.pageBackground
table.separatorStyle = .none
table.dataSource = self
table.delegate = self
table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID)
return table
}()
override func viewDidLoad() {
super.viewDidLoad()
title = "全部功能"
view.backgroundColor = AppDesignUIKit.pageBackground
navigationItem.largeTitleDisplayMode = .never
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
viewModel.onChange = { [weak self] in
self?.tableView.reloadData()
}
rebuildMenus()
appServices.permissionContext.onChange = { [weak self] in
self?.rebuildMenus()
}
}
private func rebuildMenus() {
let services = appServices
viewModel.buildMenus(
from: services.permissionContext.rolePermissions,
currentRoleId: services.permissionContext.currentRole?.id
)
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
tableView.reloadData()
}
private var commonItems: [HomeMenuItem] {
commonURIs.compactMap { menuItem(for: $0) }
}
private var moreItems: [HomeMenuItem] {
viewModel.menuItems.filter { !isCommonURI($0.uri) }
}
private func menuItem(for uri: String) -> HomeMenuItem? {
let availableURIs = Set(viewModel.menuItems.map(\.uri))
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { return nil }
return HomeMenuItem(
title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title),
uri: resolvedUri,
iconSrc: existing.iconSrc
)
}
private func isCommonURI(_ uri: String) -> Bool {
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
return commonURIs.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey }
}
private func toggleCommon(_ item: HomeMenuItem, isCommon: Bool) {
if isCommon {
commonURIs = commonMenuStore.remove(item.uri, current: commonURIs)
} else {
commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems)
}
tableView.reloadData()
}
private func openMenu(_ item: HomeMenuItem) {
let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title)
if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return }
HomeMenuRouting.openRoute(route, from: self)
}
}
extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int { 2 }
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 }
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 0 ? "常用应用" : "更多功能"
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMoreMenuGridCell.reuseID, for: indexPath) as! HomeMoreMenuGridCell
let isCommon = indexPath.section == 0
let items = isCommon ? commonItems : moreItems
cell.configure(items: items, isCommon: isCommon) { [weak self] item in
self?.openMenu(item)
} onToggle: { [weak self] item in
self?.toggleCommon(item, isCommon: isCommon)
}
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let count = indexPath.section == 0 ? commonItems.count : moreItems.count
let rows = max(1, Int(ceil(Double(count) / 3.0)))
return CGFloat(rows) * 124 + 8
}
}
private final class HomeMoreMenuGridCell: UITableViewCell {
static let reuseID = "HomeMoreMenuGridCell"
private var items: [HomeMenuItem] = []
private var isCommon = false
private var onSelect: ((HomeMenuItem) -> Void)?
private var onToggle: ((HomeMenuItem) -> Void)?
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 14
layout.minimumLineSpacing = AppMetrics.Spacing.mediumLarge
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .clear
view.isScrollEnabled = false
view.dataSource = self
view.delegate = self
view.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID)
return view
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppMetrics.Spacing.mediumLarge)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(
items: [HomeMenuItem],
isCommon: Bool,
onSelect: @escaping (HomeMenuItem) -> Void,
onToggle: @escaping (HomeMenuItem) -> Void
) {
self.items = items
self.isCommon = isCommon
self.onSelect = onSelect
self.onToggle = onToggle
collectionView.reloadData()
}
}
extension HomeMoreMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
items.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMoreMenuItemCell.reuseID, for: indexPath) as! HomeMoreMenuItemCell
let item = items[indexPath.item]
cell.configure(item: item, isCommon: isCommon) { [weak self] in
self?.onToggle?(item)
}
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
onSelect?(items[indexPath.item])
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = (collectionView.bounds.width - 28) / 3
return CGSize(width: width, height: 112)
}
}
private final class HomeMoreMenuItemCell: UICollectionViewCell {
static let reuseID = "HomeMoreMenuItemCell"
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let toggleButton = UIButton(type: .system)
private var onToggle: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 8
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.callout)
titleLabel.textColor = UIColor(hex: 0x252525)
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 1
titleLabel.adjustsFontSizeToFitWidth = true
toggleButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside)
contentView.addSubview(toggleButton)
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.mediumLarge + 1
stack.alignment = .center
contentView.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(4)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(34)
}
toggleButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(2)
make.width.height.equalTo(28)
}
iconView.contentMode = .scaleAspectFit
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) {
self.onToggle = onToggle
titleLabel.text = item.title
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
iconView.loadRemoteImage(urlString: item.iconSrc, contentMode: .scaleAspectFit, placeholder: symbol)
iconView.tintColor = AppDesign.primary
let iconName = isCommon ? "minus.circle.fill" : "plus.circle.fill"
toggleButton.setImage(UIImage(systemName: iconName), for: .normal)
toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary
}
@objc private func toggleTapped() {
onToggle?()
}
}

View File

@ -0,0 +1,63 @@
//
// HomePlaceholderViewController.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SnapKit
import UIKit
/// UIKit
final class HomePlaceholderViewController: UIViewController {
private let pageTitle: String
private let uri: String?
init(title: String, uri: String? = nil) {
pageTitle = title
self.uri = uri
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = pageTitle
view.backgroundColor = UIColor(hex: 0xF5F7FA)
let iconView = UIImageView(image: UIImage(systemName: "square.grid.2x2"))
iconView.tintColor = AppDesign.primary
iconView.contentMode = .scaleAspectFit
let titleLabel = UILabel()
titleLabel.text = pageTitle
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textColor = AppDesign.textPrimary
titleLabel.textAlignment = .center
let uriLabel = UILabel()
uriLabel.text = uri
uriLabel.font = .systemFont(ofSize: 14)
uriLabel.textColor = AppDesign.textSecondary
uriLabel.textAlignment = .center
uriLabel.numberOfLines = 2
uriLabel.isHidden = uri?.isEmpty != false
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel, uriLabel])
stack.axis = .vertical
stack.spacing = 12
stack.alignment = .center
view.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(24)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(44)
}
}
}

View File

@ -0,0 +1,583 @@
//
// HomeViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class HomeViewController: UIViewController {
private let viewModel = HomeViewModel()
private let commonMenuStore = HomeCommonMenuStore()
private var commonURIs: [String] = []
private var isOnline = false
private var reminderMinutes = 0
private var secondsUntilReport = 0
private var countdownTimer: Timer?
private let minimalTopRoleIds: Set<Int> = [46, 47, 52, 53, 54]
private lazy var scenicButton: UIButton = {
var config = UIButton.Configuration.plain()
config.baseForegroundColor = UIColor(hex: 0x333333)
config.image = UIImage(systemName: "chevron.down")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: AppMetrics.FontSize.subheadline, weight: .bold)
)
config.imagePlacement = .trailing
config.imagePadding = AppMetrics.Spacing.xSmall
config.contentInsets = .zero
let button = UIButton(configuration: config)
button.contentHorizontalAlignment = .leading
button.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside)
return button
}()
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .grouped)
table.backgroundColor = AppDesignUIKit.pageBackground
table.separatorStyle = .none
table.showsVerticalScrollIndicator = false
table.dataSource = self
table.delegate = self
table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID)
table.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
return table
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = AppDesignUIKit.pageBackground
setupTopBar()
setupTableView()
bindViewModel()
rebuildMenus()
observeContextChanges()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
startCountdownTimerIfNeeded()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
countdownTimer?.invalidate()
countdownTimer = nil
}
private func setupTopBar() {
let topBar = UIView()
topBar.backgroundColor = .white
view.addSubview(topBar)
topBar.addSubview(scenicButton)
topBar.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.height.equalTo(78)
}
scenicButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
make.centerY.equalToSuperview()
}
updateScenicTitle()
}
private func setupTableView() {
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(78)
make.leading.trailing.bottom.equalToSuperview()
}
}
private func bindViewModel() {
viewModel.onChange = { [weak self] in
self?.tableView.reloadData()
}
}
private func observeContextChanges() {
let services = appServices
services.permissionContext.onChange = { [weak self] in
self?.rebuildMenus()
}
services.accountContext.onChange = { [weak self] in
self?.updateScenicTitle()
self?.tableView.reloadData()
}
}
private func rebuildMenus() {
let services = appServices
viewModel.buildMenus(
from: services.permissionContext.rolePermissions,
currentRoleId: services.permissionContext.currentRole?.id
)
commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems)
tableView.reloadData()
}
private func updateScenicTitle() {
let name = appServices.accountContext.currentScenic?.name ?? "请选择景区"
scenicButton.configuration?.attributedTitle = AttributedString(
name,
attributes: AttributeContainer([
.font: UIFont.systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold)
])
)
}
private var currentRoleId: Int? {
appServices.permissionContext.currentRole?.id
}
private var shouldShowWorkStatus: Bool {
guard let currentRoleId else { return true }
return !minimalTopRoleIds.contains(currentRoleId)
}
private var isStoreManager: Bool {
currentRoleId == 46
}
private var displayMenuItems: [HomeMenuItem] {
let selected = commonURIs.compactMap { menuItem(for: $0) }
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
}
private func menuItem(for uri: String) -> HomeMenuItem? {
let availableURIs = Set(viewModel.menuItems.map(\.uri))
let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { return nil }
return HomeMenuItem(
title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title),
uri: resolvedUri,
iconSrc: existing.iconSrc
)
}
private var countdownDisplay: String {
let hours = secondsUntilReport / 3_600
let minutes = (secondsUntilReport % 3_600) / 60
let seconds = secondsUntilReport % 60
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
}
private var reminderText: String {
reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
}
private func startCountdownTimerIfNeeded() {
countdownTimer?.invalidate()
guard isOnline else { return }
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self, self.isOnline, self.secondsUntilReport > 0 else { return }
self.secondsUntilReport -= 1
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
}
}
@objc private func scenicTapped() {
HomeMenuRouting.push(.scenicSelection, from: self)
}
@objc private func onlineTapped() {
let message = isOnline
? "是否确认切换为离线状态?离线后将暂停位置上报。"
: "是否确认切换为在线状态?在线后将开始位置上报和计时。"
let alert = UIAlertController(title: "切换在线状态", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
guard let self else { return }
self.isOnline.toggle()
if self.isOnline {
self.secondsUntilReport = 7_200
self.startCountdownTimerIfNeeded()
} else {
self.countdownTimer?.invalidate()
}
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
})
present(alert, animated: true)
}
@objc private func reminderTapped() {
let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet)
for minute in [0, 5, 10, 15, 30] {
let title = minute == 0 ? "不提醒" : "\(minute)分钟"
sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
self?.reminderMinutes = minute
self?.tableView.reloadSections(IndexSet(integer: 0), with: .none)
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func locationReportTapped() {
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self)
}
@objc private func paymentTapped() {
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self)
}
@objc private func taskCreateTapped() {
HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self)
}
}
// MARK: - UITableView
extension HomeViewController: UITableViewDataSource, UITableViewDelegate {
func numberOfSections(in tableView: UITableView) -> Int {
var count = 1
if shouldShowWorkStatus { count += 2 }
if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 }
return count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
1
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
if section == tableView.numberOfSections - 1 {
return "常用应用"
}
return nil
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if indexPath.section == tableView.numberOfSections - 1 {
let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell
cell.configure(items: displayMenuItems) { [weak self] item in
self.flatMap { HomeMenuRouting.openMenu(item, from: $0) }
}
return cell
}
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.selectionStyle = .none
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
if shouldShowWorkStatus {
if indexPath.section == 0 {
cell.contentView.addSubview(makeStatusCard())
} else if indexPath.section == 1 {
cell.contentView.addSubview(makeLocationCard())
} else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore {
cell.contentView.addSubview(makeStoreCard(store))
} else {
cell.contentView.addSubview(makeQuickActionsRow())
}
} else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 {
cell.contentView.addSubview(makeStoreCard(store))
}
cell.contentView.subviews.first?.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(
top: AppMetrics.Spacing.xxSmall,
left: AppMetrics.Spacing.pageHorizontal,
bottom: AppMetrics.Spacing.xxSmall,
right: AppMetrics.Spacing.pageHorizontal
))
}
cell.backgroundColor = .clear
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if indexPath.section == tableView.numberOfSections - 1 { return 240 }
if shouldShowWorkStatus {
switch indexPath.section {
case 0: return 100
case 1: return 148
case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88
default: return 118
}
}
if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 }
return UITableView.automaticDimension
}
private func makeStatusCard() -> UIView {
let card = makeCardView()
let onlineButton = UIButton(type: .system)
onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal)
onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium)
onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal)
onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4)
onlineButton.layer.cornerRadius = 4
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
let clockLabel = UILabel()
clockLabel.text = " \(countdownDisplay)"
clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
clockLabel.textColor = AppDesignUIKit.primary
let reminderButton = UIButton(type: .system)
reminderButton.setTitle(" \(reminderText)", for: .normal)
reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium)
reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal)
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton])
stack.axis = .horizontal
stack.distribution = .equalSpacing
stack.alignment = .center
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15)
}
return card
}
private func makeLocationCard() -> UIView {
let card = makeCardView()
let titleLabel = UILabel()
titleLabel.text = "立即上报"
titleLabel.font = .systemFont(ofSize: 22, weight: .bold)
titleLabel.textColor = UIColor(hex: 0x333333)
let subtitleLabel = UILabel()
subtitleLabel.text = "您已进入打卡范围"
subtitleLabel.font = .systemFont(ofSize: 15)
subtitleLabel.textColor = UIColor(hex: 0x999999)
let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
textStack.axis = .vertical
textStack.spacing = AppMetrics.Spacing.xxSmall
let actionButton = UIButton(type: .system)
actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal)
actionButton.tintColor = .white
actionButton.backgroundColor = AppDesignUIKit.primary
actionButton.layer.cornerRadius = 46
actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside)
card.addSubview(textStack)
card.addSubview(actionButton)
textStack.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(15)
make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12)
}
actionButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(15)
make.centerY.equalToSuperview()
make.width.height.equalTo(92)
}
return card
}
private func makeQuickActionsRow() -> UIView {
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = AppMetrics.Spacing.small
stack.distribution = .fillEqually
stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped)))
stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped)))
stack.addArrangedSubview(quickActionButton(
icon: isOnline ? "wifi" : "wifi.slash",
title: isOnline ? "在线" : "离线",
action: #selector(onlineTapped),
active: isOnline
))
return stack
}
private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView {
let card = makeCardView()
card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white
let iconView = UIImageView(image: UIImage(systemName: icon))
iconView.tintColor = AppDesignUIKit.primary
iconView.contentMode = .scaleAspectFit
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: AppMetrics.FontSize.body)
label.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [iconView, label])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.xSmall
stack.alignment = .center
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.height.equalTo(34)
}
let button = UIButton(type: .custom)
button.addTarget(self, action: action, for: .touchUpInside)
card.addSubview(button)
button.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
card.snp.makeConstraints { make in
make.height.equalTo(102)
}
return card
}
private func makeStoreCard(_ store: BusinessScope) -> UIView {
let card = makeCardView()
let nameLabel = UILabel()
nameLabel.text = store.name
nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold)
let scenicLabel = UILabel()
scenicLabel.text = appServices.accountContext.currentScenic?.name
scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline)
scenicLabel.textColor = UIColor(hex: 0x7B8EAA)
scenicLabel.numberOfLines = 2
let statusLabel = UILabel()
statusLabel.text = "营业中"
statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption)
statusLabel.textColor = UIColor(hex: 0x22C55E)
statusLabel.backgroundColor = UIColor(hex: 0xF0FDF4)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
let textStack = UIStackView(arrangedSubviews: [nameLabel, scenicLabel])
textStack.axis = .vertical
textStack.spacing = AppMetrics.Spacing.xSmall
card.addSubview(textStack)
card.addSubview(statusLabel)
textStack.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8)
}
statusLabel.snp.makeConstraints { make in
make.trailing.top.equalToSuperview().inset(AppMetrics.Spacing.medium)
make.width.greaterThanOrEqualTo(52)
make.height.equalTo(22)
}
return card
}
}
// MARK: - Menu Grid Cell
private final class HomeMenuGridCell: UITableViewCell {
static let reuseID = "HomeMenuGridCell"
private var onSelect: ((HomeMenuItem) -> Void)?
private var items: [HomeMenuItem] = []
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.minimumInteritemSpacing = 15
layout.minimumLineSpacing = 15
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.backgroundColor = .clear
view.isScrollEnabled = false
view.dataSource = self
view.delegate = self
view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID)
return view
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(collectionView)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal)
make.height.equalTo(220)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) {
self.items = items
self.onSelect = onSelect
collectionView.reloadData()
}
}
extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
items.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell
cell.configure(item: items[indexPath.item])
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
onSelect?(items[indexPath.item])
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = (collectionView.bounds.width - 30) / 3
return CGSize(width: width, height: 102)
}
}
private final class HomeMenuItemCell: UICollectionViewCell {
static let reuseID = "HomeMenuItemCell"
private let iconView = UIImageView()
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .white
contentView.layer.cornerRadius = 8
titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body)
titleLabel.textColor = UIColor(hex: 0x4B5563)
titleLabel.textAlignment = .center
titleLabel.numberOfLines = 1
titleLabel.adjustsFontSizeToFitWidth = true
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.spacing = AppMetrics.Spacing.small
stack.alignment = .center
contentView.addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(4)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(30)
}
iconView.contentMode = .scaleAspectFit
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(item: HomeMenuItem) {
titleLabel.text = item.title
let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri))
iconView.loadRemoteImage(urlString: item.iconSrc, contentMode: .scaleAspectFit, placeholder: symbol)
iconView.tintColor = AppDesign.primary
}
}

View File

@ -0,0 +1,115 @@
//
// HomeViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
@MainActor
/// ViewModel
final class HomeViewModel {
var onChange: (() -> Void)?
private(set) var menuItems: [HomeMenuItem] = [] { didSet { onChange?() } }
/// Android
private let preferredOrder: [String] = [
"space_settings",
"album_list",
"album_trailer",
"wallet",
"cloud_management",
"cloud_storage_transit",
"asset_management",
"material_upload",
"task_management",
"schedule_management",
"system_settings",
"message_center",
"checkin_points",
"sample_management",
"sample_upload",
"live_stream_management",
"scenicselection",
"scenicapplication",
"permission_apply",
"permission_apply_status",
"scenic_settlement",
"scenic_settlement_review",
"verification_order",
"live_album",
"pm",
"location_report",
"registration_invitation",
"store",
"fly",
"pilot_cert",
"task_management_editor",
"deposit_order_detail",
"deposit_order",
"pm_manager",
"pilot_controller",
"/scenic-queue",
"queue_management",
"operating-area",
"/scenic-order-manage",
"photographer_orders"
]
/// URI
func buildMenus(from permissions: [RolePermissionResponse], currentRoleId: Int?) {
let source: RolePermissionResponse?
if let currentRoleId {
source = permissions.first { $0.role.id == currentRoleId }
} else {
source = permissions.first
}
guard let source else {
menuItems = []
return
}
let permissionItems = flatten(source.role.permission)
var seen = Set<String>()
var deduplicated: [PermissionItem] = []
for item in permissionItems where !item.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted {
deduplicated.append(item)
}
}
let preferredIndexMap = Dictionary(uniqueKeysWithValues: preferredOrder.enumerated().map { ($1, $0) })
let sortedItems = deduplicated.sorted { lhs, rhs in
let lhsRank = preferredIndexMap[lhs.uri] ?? Int.max
let rhsRank = preferredIndexMap[rhs.uri] ?? Int.max
if lhsRank != rhsRank {
return lhsRank < rhsRank
}
let lhsIndex = deduplicated.firstIndex(where: { $0.id == lhs.id }) ?? Int.max
let rhsIndex = deduplicated.firstIndex(where: { $0.id == rhs.id }) ?? Int.max
return lhsIndex < rhsIndex
}
menuItems = sortedItems.map { item in
HomeMenuItem(
title: item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name,
uri: item.uri,
iconSrc: item.iconSrc
)
}
}
/// URI
func title(for uri: String) -> String {
HomeMenuRouter.title(for: uri)
}
///
private func flatten(_ items: [PermissionItem]) -> [PermissionItem] {
items.flatMap { item in
[item] + flatten(item.children)
}
}
}

View File

@ -0,0 +1,48 @@
//
// InviteAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
@MainActor
protocol InviteServing {
///
func inviteInfo() async throws -> InviteInfoResponse
///
func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem]
}
/// API
@MainActor
final class InviteAPI: InviteServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func inviteInfo() async throws -> InviteInfoResponse {
try await client.send(APIRequest(method: .get, path: "/api/yf-handset-app/photog/invite-info"))
}
///
func inviteUserList(page: Int = 1, pageSize: Int = 20) async throws -> [InviteUserItem] {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/invite/user-list",
queryItems: [
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
)
)
}
}

View File

@ -0,0 +1,30 @@
# Invite 模块业务逻辑
## 模块职责
Invite 模块负责首页 `registration_invitation``photographer_invite``invite_record` 入口。
邀请页展示邀请码、邀请链接、二维码和规则;邀请记录页展示邀请用户和奖励记录。模块不保存业务状态到全局对象。
## 邀请页
`PhotographerInviteViewModel` 通过 `InviteAPI.inviteInfo()` 获取邀请信息,并使用 CoreImage 在本地生成二维码图片。二维码图片只用于当前页面展示,不落盘。
复制邀请链接时只由 View 层写入剪贴板ViewModel 不关心系统剪贴板状态。
## 邀请记录
`InviteRecordViewModel` 提供“邀请记录 / 奖励记录”分段:
- 邀请记录来自 `InviteAPI.inviteUserList`
- 奖励记录复用 `WalletAPI.walletEarningDetail`
- 顶部奖励汇总复用 `WalletAPI.walletSummary(type: 2)`
分页状态、筛选分段和展示行只存在 ViewModel 内存中。
## 接口边界
`InviteAPI` 只封装邀请信息和邀请用户列表。钱包收益和提现入口继续由 Wallet 模块负责Invite 模块只调用其公开服务协议,不持有钱包业务状态。
## 缓存边界
邀请码、邀请记录、奖励记录和二维码都不落盘。后续如果需要分享图片,应在分享流程中临时生成。

View File

@ -0,0 +1,156 @@
//
// InviteModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
struct InviteInfoResponse: Decodable, Equatable {
let enableInvite: Bool
let inviteCode: String
let inviteUrl: String
let description: [String]
/// 线
enum CodingKeys: String, CodingKey {
case enableInvite = "enable_invite"
case inviteCode = "invite_code"
case inviteUrl = "invite_url"
case description
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
enableInvite = try container.decodeInviteLossyBool(forKey: .enableInvite) ?? true
inviteCode = try container.decodeInviteLossyString(forKey: .inviteCode)
inviteUrl = try container.decodeInviteLossyString(forKey: .inviteUrl)
description = (try? container.decodeIfPresent([String].self, forKey: .description)) ?? []
}
}
///
struct InviteUserItem: Decodable, Identifiable, Hashable {
let id: Int
let realName: String
let phone: String
let avatar: String
let createdAt: String
let inviteLevel: Int
/// 线
enum CodingKeys: String, CodingKey {
case id
case realName = "real_name"
case phone
case avatar
case createdAt = "created_at"
case inviteLevel = "invite_level"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeInviteLossyInt(forKey: .id) ?? 0
realName = try container.decodeInviteLossyString(forKey: .realName)
phone = try container.decodeInviteLossyString(forKey: .phone)
avatar = try container.decodeInviteLossyString(forKey: .avatar)
createdAt = try container.decodeInviteLossyString(forKey: .createdAt)
inviteLevel = try container.decodeInviteLossyInt(forKey: .inviteLevel) ?? 0
}
}
///
struct InviteDisplayRow: Identifiable, Equatable {
let id: String
let title: String
let subtitle: String
let amount: String
let time: String
let phone: String
let avatar: String
let inviteLevel: Int
///
var levelText: String {
inviteLevel == 1 ? "一级" : "二级"
}
///
var avatarPlaceholder: String {
if let first = title.first {
return String(first)
}
if let first = phone.first {
return String(first)
}
return "?"
}
}
///
enum InviteRecordTab: CaseIterable, Equatable {
case invite
case reward
///
var title: String {
switch self {
case .invite: return "邀请记录"
case .reward: return "奖励记录"
}
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeInviteLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
/// StringDouble Int Int
func decodeInviteLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Int(text) ?? Double(text).map(Int.init)
}
return nil
}
/// String Bool Bool
func decodeInviteLossyBool(forKey key: Key) throws -> Bool? {
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value != 0
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if ["1", "true", "yes"].contains(text) { return true }
if ["0", "false", "no"].contains(text) { return false }
}
return nil
}
}

View File

@ -0,0 +1,148 @@
//
// InviteViewControllers.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SnapKit
import UIKit
///
final class PhotographerInviteViewController: UIViewController {
private let services = AppServices.shared
private let viewModel = PhotographerInviteViewModel()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let codeLabel = UILabel()
private let urlLabel = UILabel()
private let qrImageView = UIImageView()
private let rulesLabel = UILabel()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
override func viewDidLoad() {
super.viewDidLoad()
title = "邀请摄影师"
view.backgroundColor = UIColor(hex: 0xF5F7FA)
setupUI()
viewModel.onChange = { [weak self] in self?.render() }
Task { await viewModel.reload(api: services.inviteAPI) }
}
private func setupUI() {
navigationItem.rightBarButtonItems = [
UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)),
UIBarButtonItem(title: "复制链接", style: .plain, target: self, action: #selector(copyURL))
]
contentStack.axis = .vertical
contentStack.spacing = 12
scrollView.addSubview(contentStack)
view.addSubview(scrollView)
view.addSubview(activityIndicator)
scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView.snp.width).offset(-32)
}
activityIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
codeLabel.font = .systemFont(ofSize: 22, weight: .bold)
urlLabel.font = .systemFont(ofSize: 14)
urlLabel.numberOfLines = 0
urlLabel.textColor = AppDesign.textSecondary
qrImageView.contentMode = .scaleAspectFit
qrImageView.snp.makeConstraints { $0.height.equalTo(220) }
rulesLabel.numberOfLines = 0
rulesLabel.font = .systemFont(ofSize: 14)
rulesLabel.textColor = AppDesign.textSecondary
contentStack.addArrangedSubview(codeLabel)
contentStack.addArrangedSubview(urlLabel)
contentStack.addArrangedSubview(qrImageView)
contentStack.addArrangedSubview(rulesLabel)
}
private func render() {
activityIndicator.isHidden = !viewModel.loading
if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() }
codeLabel.text = viewModel.inviteCode.isEmpty ? "邀请码加载中..." : "邀请码:\(viewModel.inviteCode)"
urlLabel.text = viewModel.inviteUrl
qrImageView.image = viewModel.qrImage
rulesLabel.text = viewModel.rules.joined(separator: "\n")
if let error = viewModel.errorMessage {
rulesLabel.text = (rulesLabel.text ?? "") + "\n\n" + error
}
}
@objc private func copyCode() { viewModel.copyInviteCode() }
@objc private func copyURL() { viewModel.copyInviteUrl() }
}
extension PhotographerInviteViewModel: ViewModelBindable {}
///
final class InviteRecordViewController: ModuleTableViewController {
private let viewModel = InviteRecordViewModel()
private let summaryLabel = UILabel()
override func viewDidLoad() {
title = "邀请记录"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "奖励明细",
style: .plain,
target: self,
action: #selector(toggleTab)
)
super.viewDidLoad()
setupHeader()
viewModel.onChange = { [weak self] in
self?.updateSummary()
self?.reloadTable()
}
}
private func setupHeader() {
summaryLabel.font = .systemFont(ofSize: 14)
summaryLabel.textColor = AppDesign.textSecondary
summaryLabel.numberOfLines = 0
summaryLabel.textAlignment = .center
summaryLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 56)
tableView.tableHeaderView = summaryLabel
}
override func tableRowCount() -> Int {
viewModel.displayRows.count
}
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let row = viewModel.displayRows[indexPath.row]
cell.configure(
title: row.title,
subtitle: row.subtitle,
detail: row.amount.isEmpty ? row.time : "\(row.amount) · \(row.time)"
)
}
override func reloadContent() async {
await viewModel.reload(
inviteAPI: services.inviteAPI,
walletAPI: services.walletAPI,
refresh: true
)
updateSummary()
}
private func updateSummary() {
summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)"
navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户"
}
@objc private func toggleTab() {
Task {
let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite
await viewModel.selectTab(next, inviteAPI: services.inviteAPI, walletAPI: services.walletAPI)
}
}
}

View File

@ -0,0 +1,203 @@
//
// InviteViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import CoreImage.CIFilterBuiltins
import Foundation
import UIKit
/// ViewModel
@MainActor
final class PhotographerInviteViewModel {
var onChange: (() -> Void)?
private(set) var loading = false { didSet { onChange?() } }
private(set) var inviteCode = "" { didSet { onChange?() } }
private(set) var inviteUrl = "" { didSet { onChange?() } }
private(set) var rules: [String] = [] { didSet { onChange?() } }
private(set) var qrImage: UIImage? { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private let qrContext = CIContext()
private let qrFilter = CIFilter.qrCodeGenerator()
///
func reload(api: any InviteServing) async {
loading = true
errorMessage = nil
defer { loading = false }
do {
let response = try await api.inviteInfo()
inviteCode = response.inviteCode
inviteUrl = response.inviteUrl
rules = response.description
qrImage = makeQrImage(from: response.inviteUrl)
} catch {
clear()
errorMessage = error.localizedDescription
}
}
///
func copyInviteCode() {
UIPasteboard.general.string = inviteCode
}
///
func copyInviteUrl() {
UIPasteboard.general.string = inviteUrl
}
///
func makeQrImage(from text: String) -> UIImage? {
guard !text.isEmpty else { return nil }
qrFilter.setValue(Data(text.utf8), forKey: "inputMessage")
qrFilter.setValue("M", forKey: "inputCorrectionLevel")
guard let output = qrFilter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 12, y: 12))
guard let cgImage = qrContext.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
private func clear() {
inviteCode = ""
inviteUrl = ""
rules = []
qrImage = nil
}
}
/// ViewModel
@MainActor
final class InviteRecordViewModel {
var onChange: (() -> Void)?
var tab: InviteRecordTab = .invite { didSet { onChange?() } }
private(set) var totalRewardText = "¥ 0.00" { didSet { onChange?() } }
private(set) var withdrawableText = "¥ 0.00" { didSet { onChange?() } }
private(set) var displayRows: [InviteDisplayRow] = [] { didSet { onChange?() } }
private(set) var loading = false { didSet { onChange?() } }
private(set) var loadingMore = false { didSet { onChange?() } }
private(set) var hasMore = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private var inviteRows: [InviteDisplayRow] = []
private var rewardRows: [InviteDisplayRow] = []
private var invitePage = 1
private var rewardPage = 1
private let invitePageSize = 20
private let rewardPageSize = 10
///
func reload(inviteAPI: any InviteServing, walletAPI: any WalletServing, refresh: Bool) async {
if refresh {
loading = true
} else {
guard hasMore else { return }
loadingMore = true
}
errorMessage = nil
defer {
loading = false
loadingMore = false
}
do {
async let summary = walletAPI.walletSummary(type: 2)
if tab == .invite {
try await loadInviteRows(api: inviteAPI, refresh: refresh)
} else {
try await loadRewardRows(api: walletAPI, refresh: refresh)
}
let summaryValue = try await summary
totalRewardText = "¥ \(summaryValue.amountTotal)"
withdrawableText = "¥ \(summaryValue.amountWithdrawable)"
displayRows = tab == .invite ? inviteRows : rewardRows
} catch {
if refresh {
clear()
}
errorMessage = error.localizedDescription
}
}
///
func selectTab(_ newTab: InviteRecordTab, inviteAPI: any InviteServing, walletAPI: any WalletServing) async {
tab = newTab
await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true)
}
private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws {
let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize)
let mapped = users.map {
InviteDisplayRow(
id: "invite_\($0.id)",
title: $0.realName.isEmpty ? "**\($0.phone.suffix(4))" : $0.realName,
subtitle: "",
amount: "",
time: $0.createdAt,
phone: $0.phone,
avatar: $0.avatar,
inviteLevel: $0.inviteLevel
)
}
if refresh {
inviteRows = mapped
invitePage = 2
} else {
inviteRows.append(contentsOf: mapped)
invitePage += 1
}
hasMore = users.count >= invitePageSize
}
private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws {
let response = try await api.walletEarningDetail(
startDate: "2025-01-01",
endDate: Self.dayFormatter.string(from: Date()),
page: refresh ? 1 : rewardPage,
pageSize: rewardPageSize
)
let mapped = response.list.flatMap { group in
group.items.map {
InviteDisplayRow(
id: "reward_\($0.id)",
title: $0.typeLabel.isEmpty ? "奖励记录" : $0.typeLabel,
subtitle: $0.withdrawLabel ?? "订单尾号:\($0.orderNumberSuffix.isEmpty ? "--" : $0.orderNumberSuffix)",
amount: $0.amount.isEmpty ? "--" : $0.amount,
time: $0.createdAt.isEmpty ? group.date : $0.createdAt,
phone: "",
avatar: "",
inviteLevel: 0
)
}
}
if refresh {
rewardRows = mapped
rewardPage = 2
} else {
rewardRows.append(contentsOf: mapped)
rewardPage += 1
}
hasMore = rewardRows.count < response.total && !mapped.isEmpty
}
private func clear() {
totalRewardText = "¥ 0.00"
withdrawableText = "¥ 0.00"
displayRows = []
inviteRows = []
rewardRows = []
invitePage = 1
rewardPage = 1
hasMore = false
}
private static let dayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
}

View File

@ -0,0 +1,159 @@
//
// LiveAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
@MainActor
protocol LiveServing {
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse
func liveDetail(liveId: Int) async throws -> LiveEntity
func liveCreate(_ request: LiveCreateRequest) async throws
func liveStart(liveId: Int) async throws
func liveStop(liveId: Int) async throws
func liveFinish(liveId: Int) async throws
func liveSetPushMode(liveId: Int, mode: Int) async throws
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws
func liveAlbumDeleteFolder(folderId: Int) async throws
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws
}
@MainActor
/// API
final class LiveAPI {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func liveList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> LiveListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/manual-live/list",
queryItems: [
URLQueryItem(name: "scenic_id", value: String(scenicId)),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
]
)
)
}
///
func liveDetail(liveId: Int) async throws -> LiveEntity {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/manual-live/detail",
queryItems: [URLQueryItem(name: "live_id", value: String(liveId))]
)
)
}
///
func liveCreate(_ request: LiveCreateRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/manual-live/create", body: request)
)
}
///
func liveStart(liveId: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/manual-live/start", body: LiveControlRequest(liveId: liveId))
)
}
///
func liveStop(liveId: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/manual-live/stop", body: LiveControlRequest(liveId: liveId))
)
}
///
func liveFinish(liveId: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/manual-live/finish", body: LiveControlRequest(liveId: liveId))
)
}
///
func liveSetPushMode(liveId: Int, mode: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/manual-live/set-push-mode", body: LivePushModeRequest(liveId: liveId, manualPushMode: mode))
)
}
///
func liveAlbumList(
scenicId: Int,
startTime: String? = nil,
endTime: String? = nil,
page: Int = 1,
pageSize: Int = 10
) async throws -> LiveAlbumFolderListResponse {
var queryItems = [
URLQueryItem(name: "scenic_id", value: String(scenicId)),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
]
if let startTime = startTime?.liveTrimmed, !startTime.isEmpty {
queryItems.append(URLQueryItem(name: "start_time", value: startTime))
}
if let endTime = endTime?.liveTrimmed, !endTime.isEmpty {
queryItems.append(URLQueryItem(name: "end_time", value: endTime))
}
return try await client.send(
APIRequest(method: .get, path: "/api/app/view-album/folders", queryItems: queryItems)
)
}
///
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/view-album/create-folder", body: request)
)
}
///
func liveAlbumDeleteFolder(folderId: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/view-album/delete-folder", body: LiveAlbumDeleteFolderRequest(folderId: folderId))
)
}
///
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/view-album/folder-detail",
queryItems: [URLQueryItem(name: "folder_id", value: String(folderId))]
)
)
}
///
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/view-album/delete-files",
body: LiveAlbumDeleteFilesRequest(folderId: folderId, fileIds: fileIds)
)
)
}
}
extension LiveAPI: LiveServing {}

View File

@ -0,0 +1,27 @@
# 直播模块
## 模块职责
`Features/Live` 承接首页 `live_stream_management``live_album` 权限入口,负责手动直播管理和直播相册素材管理。旧 iOS 工程未接入真推流 SDK本模块按旧 iOS 对齐,不新增腾讯 TRTC、RTMP 或其他推流 SDK 依赖。
## 代码结构
- `LiveAPI`:封装 `/api/app/manual-live/...``/api/app/view-album/...` 接口。
- `LiveManagementViewModel`:管理直播列表分页、创建、开始/暂停、结束和详情失败清理。
- `LivePlaybackViewModel`:管理直播详情和直播相册视频的系统播放器 URL、播放、暂停和释放状态。
- `LivePushReadinessViewModel`:诊断直播推流地址、相机/麦克风权限、网络状态和当前推流 SDK 接入状态,不触发本机采集推流。
- `LiveAlbumViewModel`:管理直播相册日期筛选、分页和删除相册。
- `LiveAlbumCreateViewModel`:管理本地图片/视频上传到 OSS 后创建直播相册。
- `LiveAlbumPreviewViewModel`:进入相册预览时加载相册详情,并支持删除单个素材。
## 业务边界
直播详情会从 `play_url``pull_url``hls_url``live_url``flv_url` 中筛选系统播放器可处理的 http/https 播放地址;`push_url` 是给 OBS 或第三方工具使用的外部推流地址,不作为播放地址使用。只有 RTMP 推流地址时,页面展示封面、提示和复制入口。
直播相册视频预览使用系统 `VideoPlayer` 播放可支持的视频地址;图片仍使用 `RemoteImage` 展示。视频地址不可播放时,页面保留打开和复制原始 URL 的操作。
推流专项按旧 iOS 对齐,只保留诊断层和适配协议,默认 `UnsupportedLivePushAdapter` 明确提示未接入真推流 SDK不引入腾讯/TRTC、Agora、HaishinKit、Zego 或 Android `youfun_control` 的飞控/抓娃娃直播接口。直播详情的“开始/暂停/结束”和“推流模式”只调用 `manual-live` 业务接口,不启动本机摄像头、麦克风或后台推流。
后续若要接入真推流 SDK可参考 Android `youfun_control` 中腾讯 TRTC 的实现路线但需要单独确认后端房间参数、SDK 版本、真机权限、推流源和人工联调范围。
直播封面创建沿用旧 iOS 的 URL 输入。直播相册素材通过 `PhotosPicker` 选择后,使用 `OSSUploadService.uploadAliveAlbumFile` 上传到 `live_albums/yyyyMMdd/scenicId/...`,再把最终 URL 提交给创建相册接口。

View File

@ -0,0 +1,415 @@
//
// LiveModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
struct LiveListResponse: Decodable {
let items: [LiveEntity]
let total: Int
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case items
case total
case page
case pageSize = "page_size"
}
init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) {
self.items = items
self.total = total
self.page = page
self.pageSize = pageSize
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? []
total = try container.liveDecodeLossyInt(forKey: .total) ?? 0
page = try container.liveDecodeLossyInt(forKey: .page) ?? 1
pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10
}
}
///
struct LiveEntity: Decodable, Identifiable, Equatable {
let id: Int
let title: String
let coverImg: String
let pushUrl: String
let playUrl: String
let pullUrl: String
let hlsUrl: String
let flvUrl: String
let liveUrl: String
let startTime: Int64
let endTime: Int64
let duration: Int64
let status: Int
let statusLabel: String
let manualPushMode: Int
let manualPushState: Int
let viewsCount: Int
enum CodingKeys: String, CodingKey {
case id
case title
case coverImg = "cover_img"
case pushUrl = "push_url"
case playUrl = "play_url"
case pullUrl = "pull_url"
case hlsUrl = "hls_url"
case flvUrl = "flv_url"
case liveUrl = "live_url"
case startTime = "start_time"
case endTime = "end_time"
case duration
case status
case statusLabel = "status_label"
case manualPushMode = "manaul_push_mode"
case manualPushState = "manaul_push_state"
case viewsCount = "views_count"
}
init(
id: Int = 0,
title: String = "",
coverImg: String = "",
pushUrl: String = "",
playUrl: String = "",
pullUrl: String = "",
hlsUrl: String = "",
flvUrl: String = "",
liveUrl: String = "",
startTime: Int64 = 0,
endTime: Int64 = 0,
duration: Int64 = 0,
status: Int = 0,
statusLabel: String = "",
manualPushMode: Int = 1,
manualPushState: Int = 0,
viewsCount: Int = 0
) {
self.id = id
self.title = title
self.coverImg = coverImg
self.pushUrl = pushUrl
self.playUrl = playUrl
self.pullUrl = pullUrl
self.hlsUrl = hlsUrl
self.flvUrl = flvUrl
self.liveUrl = liveUrl
self.startTime = startTime
self.endTime = endTime
self.duration = duration
self.status = status
self.statusLabel = statusLabel
self.manualPushMode = manualPushMode
self.manualPushState = manualPushState
self.viewsCount = viewsCount
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
title = try container.liveDecodeLossyString(forKey: .title)
coverImg = try container.liveDecodeLossyString(forKey: .coverImg)
pushUrl = try container.liveDecodeLossyString(forKey: .pushUrl)
playUrl = try container.liveDecodeLossyString(forKey: .playUrl)
pullUrl = try container.liveDecodeLossyString(forKey: .pullUrl)
hlsUrl = try container.liveDecodeLossyString(forKey: .hlsUrl)
flvUrl = try container.liveDecodeLossyString(forKey: .flvUrl)
liveUrl = try container.liveDecodeLossyString(forKey: .liveUrl)
startTime = Int64(try container.liveDecodeLossyInt(forKey: .startTime) ?? 0)
endTime = Int64(try container.liveDecodeLossyInt(forKey: .endTime) ?? 0)
duration = Int64(try container.liveDecodeLossyInt(forKey: .duration) ?? 0)
status = try container.liveDecodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.liveDecodeLossyString(forKey: .statusLabel)
manualPushMode = try container.liveDecodeLossyInt(forKey: .manualPushMode) ?? 1
manualPushState = try container.liveDecodeLossyInt(forKey: .manualPushState) ?? 0
viewsCount = try container.liveDecodeLossyInt(forKey: .viewsCount) ?? 0
}
var displayStatus: String {
statusLabel.liveNonEmpty ?? "状态\(status)"
}
var playbackURLCandidates: [String] {
[playUrl, pullUrl, hlsUrl, liveUrl, flvUrl]
}
}
///
struct LiveCreateRequest: Encodable, Equatable {
let scenicId: String
let title: String
let coverImg: String
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case title
case coverImg = "cover_img"
}
}
///
struct LiveControlRequest: Encodable, Equatable {
let liveId: Int
enum CodingKeys: String, CodingKey {
case liveId = "live_id"
}
}
///
struct LivePushModeRequest: Encodable, Equatable {
let liveId: Int
let manualPushMode: Int
enum CodingKeys: String, CodingKey {
case liveId = "live_id"
case manualPushMode = "manual_push_mode"
}
}
///
struct LiveAlbumFolderListResponse: Decodable {
let items: [LiveAlbumFolderItem]
let page: Int
let pageSize: Int
let total: Int
let totalPages: Int
enum CodingKeys: String, CodingKey {
case items
case page
case pageSize = "page_size"
case total
case totalPages = "total_pages"
}
init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
self.items = items
self.page = page
self.pageSize = pageSize
self.total = total
self.totalPages = totalPages
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? []
page = try container.liveDecodeLossyInt(forKey: .page) ?? 1
pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10
total = try container.liveDecodeLossyInt(forKey: .total) ?? 0
totalPages = try container.liveDecodeLossyInt(forKey: .totalPages) ?? 0
}
}
///
struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
let id: Int
let albumId: Int
let name: String
let creator: String
let items: [LiveAlbumFileItem]
enum CodingKeys: String, CodingKey {
case id
case albumId = "album_id"
case name
case creator
case items
}
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) {
self.id = id
self.albumId = albumId
self.name = name
self.creator = creator
self.items = items
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
albumId = try container.liveDecodeLossyInt(forKey: .albumId) ?? 0
name = try container.liveDecodeLossyString(forKey: .name)
creator = try container.liveDecodeLossyString(forKey: .creator)
items = (try? container.decode([LiveAlbumFileItem].self, forKey: .items)) ?? []
}
}
///
struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
let id: Int
let url: String
let type: Int
let size: Int64
let coverImg: String?
enum CodingKeys: String, CodingKey {
case id
case url
case type
case size
case coverImg = "cover_img"
}
init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) {
self.id = id
self.url = url
self.type = type
self.size = size
self.coverImg = coverImg
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
url = try container.liveDecodeLossyString(forKey: .url)
type = try container.liveDecodeLossyInt(forKey: .type) ?? 1
size = Int64(try container.liveDecodeLossyInt(forKey: .size) ?? 0)
coverImg = try? container.decodeIfPresent(String.self, forKey: .coverImg)
}
var isVideo: Bool { type == 2 }
var previewURL: String {
if let cover = coverImg?.liveTrimmed, !cover.isEmpty {
return cover
}
return url
}
}
///
struct LiveAlbumCreateFolderRequest: Encodable, Equatable {
let scenicId: String
let name: String
let items: [LiveAlbumCreateFileItem]
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case name
case items
}
}
///
struct LiveAlbumCreateFileItem: Encodable, Equatable {
let url: String
let type: Int
let size: Int64
let coverImg: String?
enum CodingKeys: String, CodingKey {
case url
case type
case size
case coverImg = "cover_img"
}
}
///
struct LiveAlbumDeleteFolderRequest: Encodable, Equatable {
let folderId: Int
enum CodingKeys: String, CodingKey {
case folderId = "folder_id"
}
}
///
struct LiveAlbumDeleteFilesRequest: Encodable, Equatable {
let folderId: Int
let fileIds: [Int]
enum CodingKeys: String, CodingKey {
case folderId = "folder_id"
case fileIds = "file_ids"
}
}
///
struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
let id: UUID
let data: Data
let fileName: String
let fileType: Int
let size: Int64
var uploadedURL: String?
init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) {
self.id = id
self.data = data
self.fileName = fileName
self.fileType = fileType
self.size = size ?? Int64(data.count)
self.uploadedURL = uploadedURL
}
var isVideo: Bool { fileType == 2 }
}
private extension KeyedDecodingContainer {
func liveDecodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
func liveDecodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(trimmed) {
return intValue
}
if let doubleValue = Double(trimmed) {
return Int(doubleValue)
}
}
return nil
}
}
extension String {
/// 使
var liveTrimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
/// 使
var liveNonEmpty: String? {
let value = liveTrimmed
return value.isEmpty ? nil : value
}
}

View File

@ -0,0 +1,65 @@
//
// LiveViewControllers.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
///
final class LiveManagementViewController: ModuleTableViewController {
private let viewModel = LiveManagementViewModel()
override func viewDidLoad() {
title = "直播管理"
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func tableRowCount() -> Int { viewModel.items.count }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
cell.configure(
title: item.title,
subtitle: item.statusLabel,
detail: String(item.startTime)
)
}
override func reloadContent() async {
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.row >= viewModel.items.count - 2 else { return }
Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) }
}
}
extension LiveManagementViewModel: ViewModelBindable {}
///
final class LiveAlbumViewController: ModuleTableViewController {
private let viewModel = LiveAlbumViewModel()
override func viewDidLoad() {
title = "直播相册"
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func tableRowCount() -> Int { viewModel.folders.count }
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let folder = viewModel.folders[indexPath.row]
cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count)")
}
override func reloadContent() async {
await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId)
}
}
extension LiveAlbumViewModel: ViewModelBindable {}

View File

@ -0,0 +1,131 @@
//
// LivePlaybackViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import AVFoundation
import Foundation
/// RTMP
enum LivePlaybackURLResolver {
static func playableURL(from live: LiveEntity) -> URL? {
playableURL(from: live.playbackURLCandidates)
}
static func playableURL(from candidates: [String]) -> URL? {
for candidate in candidates {
if let url = playableURL(from: candidate) {
return url
}
}
return nil
}
static func playableURL(from rawValue: String) -> URL? {
let value = rawValue.liveTrimmed
guard !value.isEmpty, let url = URL(string: value) else { return nil }
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil }
if url.pathExtension.lowercased() == "flv" {
return nil
}
return url
}
}
///
enum LivePlaybackState: Equatable {
case empty
case ready(URL)
case playing(URL)
case failed(String)
}
@MainActor
/// ViewModel URL
final class LivePlaybackViewModel {
var onChange: (() -> Void)?
var state: LivePlaybackState = .empty { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private(set) var player: AVPlayer? { didSet { onChange?() } }
var playableURL: URL? {
switch state {
case .ready(let url), .playing(let url):
url
case .empty, .failed:
nil
}
}
var isPlaying: Bool {
if case .playing = state { return true }
return false
}
init(live: LiveEntity? = nil, urlString: String? = nil) {
if let live {
load(live: live)
} else if let urlString {
load(urlString: urlString)
}
}
func load(live: LiveEntity) {
load(url: LivePlaybackURLResolver.playableURL(from: live))
}
func load(urlString: String) {
load(url: LivePlaybackURLResolver.playableURL(from: urlString))
}
func play() {
guard let url = playableURL else { return }
if player == nil {
player = AVPlayer(url: url)
}
player?.play()
state = .playing(url)
}
func pause() {
guard let url = playableURL else { return }
player?.pause()
state = .ready(url)
}
func reload() {
guard let url = playableURL else { return }
releasePlayer()
player = AVPlayer(url: url)
state = .ready(url)
}
func release() {
releasePlayer()
if let url = playableURL {
state = .ready(url)
} else {
state = .empty
}
}
private func load(url: URL?) {
releasePlayer()
guard let url else {
errorMessage = "暂无可播放地址"
state = .empty
return
}
errorMessage = nil
player = AVPlayer(url: url)
state = .ready(url)
}
private func releasePlayer() {
player?.pause()
player = nil
}
}

View File

@ -0,0 +1,291 @@
//
// LivePushReadinessViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import AVFoundation
import Foundation
import Network
///
enum LivePushPermissionState: Equatable {
case unknown
case granted
case denied
var displayText: String {
switch self {
case .unknown: "未检查"
case .granted: "已授权"
case .denied: "未授权"
}
}
}
///
enum LivePushNetworkState: Equatable {
case unknown
case unavailable
case wifi
case cellular
case other
var displayText: String {
switch self {
case .unknown: "检测中"
case .unavailable: "网络不可用"
case .wifi: "Wi-Fi"
case .cellular: "蜂窝网络"
case .other: "其他网络"
}
}
}
/// SDK RTMP/RTC SDK
protocol LivePushAdapter {
var name: String { get }
var isAvailable: Bool { get }
func prepare(pushURL: URL) async throws
func start() async throws
func stop() async throws
func dispose() async
}
/// AVFoundation 便
protocol LivePermissionProviding {
func cameraPermission() async -> LivePushPermissionState
func microphonePermission() async -> LivePushPermissionState
}
/// NWPathMonitor 便
protocol LiveNetworkMonitoring: AnyObject {
var currentState: LivePushNetworkState { get }
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void)
func stop()
}
enum LivePushReadinessError: LocalizedError, Equatable {
case missingPushURL
case invalidPushURL
case permissionDenied
case networkUnavailable
case sdkUnavailable
var errorDescription: String? {
switch self {
case .missingPushURL:
"暂无推流地址"
case .invalidPushURL:
"推流地址格式无效"
case .permissionDenied:
"请先开启相机和麦克风权限"
case .networkUnavailable:
"当前网络不可用"
case .sdkUnavailable:
"当前版本未接入真推流 SDK"
}
}
}
struct UnsupportedLivePushAdapter: LivePushAdapter {
let name = "未接入推流 SDK"
let isAvailable = false
func prepare(pushURL: URL) async throws {
throw LivePushReadinessError.sdkUnavailable
}
func start() async throws {
throw LivePushReadinessError.sdkUnavailable
}
func stop() async throws {}
func dispose() async {}
}
struct SystemLivePermissionProvider: LivePermissionProviding {
func cameraPermission() async -> LivePushPermissionState {
await permission(for: .video)
}
func microphonePermission() async -> LivePushPermissionState {
await permission(for: .audio)
}
private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState {
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
return .granted
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: mediaType)
return granted ? .granted : .denied
case .denied, .restricted:
return .denied
@unknown default:
return .denied
}
}
}
final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
var onChange: (() -> Void)?
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.suixinkan.live.network")
private(set) var currentState: LivePushNetworkState = .unknown
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
monitor.pathUpdateHandler = { [weak self] path in
let state = Self.state(from: path)
self?.currentState = state
onChange(state)
}
monitor.start(queue: queue)
}
func stop() {
monitor.cancel()
}
private static func state(from path: NWPath) -> LivePushNetworkState {
guard path.status == .satisfied else { return .unavailable }
if path.usesInterfaceType(.wifi) { return .wifi }
if path.usesInterfaceType(.cellular) { return .cellular }
return .other
}
}
@MainActor
/// ViewModel SDK
final class LivePushReadinessViewModel {
var onChange: (() -> Void)?
var cameraPermission: LivePushPermissionState = .unknown { didSet { onChange?() } }
var microphonePermission: LivePushPermissionState = .unknown { didSet { onChange?() } }
var networkState: LivePushNetworkState = .unknown { didSet { onChange?() } }
var prepared = false { didSet { onChange?() } }
var running = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
let adapterName: String
let sdkStatusText: String
private let permissionProvider: any LivePermissionProviding
private let networkMonitor: any LiveNetworkMonitoring
private let adapter: any LivePushAdapter
private var pushURL: URL?
init(
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(),
adapter: any LivePushAdapter = UnsupportedLivePushAdapter()
) {
self.permissionProvider = permissionProvider
self.networkMonitor = networkMonitor
self.adapter = adapter
self.adapterName = adapter.name
self.sdkStatusText = adapter.isAvailable ? "已接入" : "未接入真推流 SDK"
self.networkState = networkMonitor.currentState
}
func configure(pushURL rawValue: String) {
let value = rawValue.liveTrimmed
guard !value.isEmpty else {
pushURL = nil
errorMessage = LivePushReadinessError.missingPushURL.localizedDescription
return
}
guard let url = URL(string: value), url.scheme?.isEmpty == false else {
pushURL = nil
errorMessage = LivePushReadinessError.invalidPushURL.localizedDescription
return
}
pushURL = url
errorMessage = nil
}
func startMonitoring() {
networkState = networkMonitor.currentState
networkMonitor.start { [weak self] state in
Task { @MainActor in
self?.networkState = state
}
}
}
func stopMonitoring() {
networkMonitor.stop()
}
func refreshPermissions() async {
async let camera = permissionProvider.cameraPermission()
async let microphone = permissionProvider.microphonePermission()
cameraPermission = await camera
microphonePermission = await microphone
}
func runDiagnostics() throws {
do {
try validateReadiness()
} catch {
errorMessage = error.localizedDescription
throw error
}
guard adapter.isAvailable else {
errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription
throw LivePushReadinessError.sdkUnavailable
}
errorMessage = nil
}
func prepare() async throws {
try validateReadiness()
guard let pushURL else { throw LivePushReadinessError.missingPushURL }
do {
try await adapter.prepare(pushURL: pushURL)
prepared = true
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
throw error
}
}
func startPush() async throws {
try validateReadiness()
guard adapter.isAvailable else {
errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription
throw LivePushReadinessError.sdkUnavailable
}
try await adapter.start()
running = true
prepared = true
errorMessage = nil
}
func stopPush() async {
try? await adapter.stop()
running = false
}
func dispose() async {
stopMonitoring()
await adapter.dispose()
running = false
prepared = false
}
private func validateReadiness() throws {
guard pushURL != nil else {
throw LivePushReadinessError.missingPushURL
}
guard cameraPermission != .denied, microphonePermission != .denied else {
throw LivePushReadinessError.permissionDenied
}
guard networkState != .unavailable else {
throw LivePushReadinessError.networkUnavailable
}
}
}

View File

@ -0,0 +1,466 @@
//
// LiveViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
@MainActor
/// ViewModel
final class LiveManagementViewModel {
var onChange: (() -> Void)?
var items: [LiveEntity] = [] { didSet { onChange?() } }
var detail: LiveEntity? { didSet { onChange?() } }
var loading = false { didSet { onChange?() } }
var loadingMore = false { didSet { onChange?() } }
var hasMore = false { didSet { onChange?() } }
var page = 1 { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private let pageSize = 10
private var total = 0
///
var liveRunningCount: Int {
items.filter { $0.status == 2 }.count
}
///
var liveFinishedCount: Int {
items.filter { $0.status == 3 }.count
}
///
func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async {
guard let scenicId else {
reset()
return
}
if showLoading { loading = true }
errorMessage = nil
defer { loading = false }
do {
try await loadPage(api: api, scenicId: scenicId, page: 1)
} catch {
clearListAndDetail()
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: any LiveServing, scenicId: Int?) async {
guard let scenicId, hasMore, !loadingMore else { return }
loadingMore = true
defer { loadingMore = false }
do {
try await loadPage(api: api, scenicId: scenicId, page: page + 1)
} catch {
errorMessage = error.localizedDescription
}
}
///
func create(api: any LiveServing, scenicId: Int?, title: String, coverURL: String) async throws {
guard let scenicId else {
throw LiveValidationError.missingScenic
}
let normalizedTitle = title.liveTrimmed
let normalizedCover = coverURL.liveTrimmed
guard !normalizedTitle.isEmpty else {
throw LiveValidationError.emptyTitle
}
guard normalizedCover.liveIsHTTPURL else {
throw LiveValidationError.invalidCoverURL
}
try await api.liveCreate(LiveCreateRequest(scenicId: String(scenicId), title: normalizedTitle, coverImg: normalizedCover))
await reload(api: api, scenicId: scenicId, showLoading: false)
}
///
func loadDetail(api: any LiveServing, liveId: Int) async {
do {
detail = try await api.liveDetail(liveId: liveId)
} catch {
detail = nil
errorMessage = error.localizedDescription
}
}
///
func control(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws {
if item.status == 2 {
try await api.liveStop(liveId: item.id)
} else if item.status != 3 {
try await api.liveStart(liveId: item.id)
}
await reload(api: api, scenicId: scenicId, showLoading: false)
}
///
func finish(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws {
try await api.liveFinish(liveId: item.id)
await reload(api: api, scenicId: scenicId, showLoading: false)
}
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize)
if page == 1 {
items = response.items
} else {
let incomingById = Dictionary(uniqueKeysWithValues: response.items.map { ($0.id, $0) })
let kept = items.filter { incomingById[$0.id] == nil }
items = kept + response.items
}
self.page = page
total = response.total
hasMore = items.count < total
}
private func reset() {
clearListAndDetail()
loading = false
loadingMore = false
errorMessage = nil
}
private func clearListAndDetail() {
items = []
detail = nil
page = 1
total = 0
hasMore = false
}
}
@MainActor
/// ViewModel
final class LiveDetailViewModel {
var onChange: (() -> Void)?
var detail: LiveEntity { didSet { onChange?() } }
var loading = false { didSet { onChange?() } }
var actionInFlight = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
init(detail: LiveEntity) {
self.detail = detail
}
///
func refresh(api: any LiveServing, showLoading: Bool = true) async {
if showLoading { loading = true }
defer { loading = false }
do {
detail = try await api.liveDetail(liveId: detail.id)
} catch {
errorMessage = error.localizedDescription
}
}
///
func control(api: any LiveServing) async throws {
guard detail.status != 3 else { return }
actionInFlight = true
defer { actionInFlight = false }
if detail.status == 2 {
try await api.liveStop(liveId: detail.id)
} else {
try await api.liveStart(liveId: detail.id)
}
await refresh(api: api, showLoading: false)
}
///
func finish(api: any LiveServing) async throws {
guard detail.status != 3 else { return }
actionInFlight = true
defer { actionInFlight = false }
try await api.liveFinish(liveId: detail.id)
await refresh(api: api, showLoading: false)
}
///
func setPushMode(api: any LiveServing, mode: Int) async throws {
guard detail.manualPushMode != mode else { return }
actionInFlight = true
defer { actionInFlight = false }
try await api.liveSetPushMode(liveId: detail.id, mode: mode)
await refresh(api: api, showLoading: false)
}
}
@MainActor
/// ViewModel
final class LiveAlbumViewModel {
var onChange: (() -> Void)?
var folders: [LiveAlbumFolderItem] = [] { didSet { onChange?() } }
var startDate: Date? { didSet { onChange?() } }
var endDate: Date? { didSet { onChange?() } }
var loading = false { didSet { onChange?() } }
var loadingMore = false { didSet { onChange?() } }
var hasMore = false { didSet { onChange?() } }
var page = 1 { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private let pageSize = 10
private var total = 0
///
func setStartDate(_ date: Date) throws {
if let endDate, Calendar.current.startOfDay(for: date) > Calendar.current.startOfDay(for: endDate) {
throw LiveValidationError.invalidDateRange
}
startDate = date
}
///
func setEndDate(_ date: Date) throws {
if let startDate, Calendar.current.startOfDay(for: date) < Calendar.current.startOfDay(for: startDate) {
throw LiveValidationError.invalidDateRange
}
endDate = date
}
///
func clearDateFilters() {
startDate = nil
endDate = nil
}
///
func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async {
guard let scenicId else {
reset()
return
}
if showLoading { loading = true }
errorMessage = nil
defer { loading = false }
do {
try await loadPage(api: api, scenicId: scenicId, page: 1)
} catch {
clearFolders()
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: any LiveServing, scenicId: Int?) async {
guard let scenicId, hasMore, !loadingMore else { return }
loadingMore = true
let nextPage = page + 1
defer { loadingMore = false }
do {
try await loadPage(api: api, scenicId: scenicId, page: nextPage)
} catch {
page = max(1, nextPage - 1)
errorMessage = error.localizedDescription
}
}
///
func deleteFolder(api: any LiveServing, folderId: Int, scenicId: Int?) async throws {
try await api.liveAlbumDeleteFolder(folderId: folderId)
await reload(api: api, scenicId: scenicId, showLoading: false)
}
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
let response = try await api.liveAlbumList(
scenicId: scenicId,
startTime: startDate?.liveDayText,
endTime: endDate?.liveDayText,
page: page,
pageSize: pageSize
)
if page == 1 {
folders = response.items
} else {
let incomingById = Set(response.items.map(\.id))
folders = folders.filter { !incomingById.contains($0.id) } + response.items
}
self.page = page
total = response.total
hasMore = folders.count < total
}
private func reset() {
clearFolders()
loading = false
loadingMore = false
errorMessage = nil
}
private func clearFolders() {
folders = []
page = 1
total = 0
hasMore = false
}
}
@MainActor
/// ViewModel
final class LiveAlbumCreateViewModel {
var onChange: (() -> Void)?
var name = "" { didSet { onChange?() } }
var localFiles: [LiveAlbumLocalUploadFile] = [] { didSet { onChange?() } }
var submitting = false { didSet { onChange?() } }
var uploadProgress = 0 { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
///
func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) {
localFiles.append(contentsOf: files)
}
///
func removeLocalFile(id: UUID) {
localFiles.removeAll { $0.id == id }
}
///
func submit(scenicId: Int?, api: any LiveServing, uploader: any OSSUploadServing) async throws {
guard let scenicId else { throw LiveValidationError.missingScenic }
let normalizedName = name.liveTrimmed
guard !normalizedName.isEmpty else { throw LiveValidationError.emptyAlbumName }
guard !localFiles.isEmpty else { throw LiveValidationError.emptyAlbumFiles }
guard !submitting else { return }
submitting = true
uploadProgress = 0
defer { submitting = false }
var uploadedItems: [LiveAlbumCreateFileItem] = []
let count = max(localFiles.count, 1)
do {
for index in localFiles.indices {
let file = localFiles[index]
let url = try await uploader.uploadAliveAlbumFile(
data: file.data,
fileName: file.fileName,
fileType: file.fileType,
scenicId: scenicId
) { progress in
Task { @MainActor in
let base = Double(index) / Double(count)
let step = Double(progress) / Double(count)
self.uploadProgress = min(99, Int((base + step / 100) * 100))
}
}
localFiles[index].uploadedURL = url
uploadedItems.append(
LiveAlbumCreateFileItem(url: url, type: file.fileType, size: file.size, coverImg: nil)
)
}
try await api.liveAlbumCreateFolder(
LiveAlbumCreateFolderRequest(scenicId: String(scenicId), name: normalizedName, items: uploadedItems)
)
name = ""
localFiles = []
uploadProgress = 100
} catch {
errorMessage = error.localizedDescription
throw error
}
}
}
@MainActor
/// ViewModel
final class LiveAlbumPreviewViewModel {
var onChange: (() -> Void)?
let folderId: Int
var folder: LiveAlbumFolderItem? { didSet { onChange?() } }
var files: [LiveAlbumFileItem] = [] { didSet { onChange?() } }
var currentIndex: Int { didSet { onChange?() } }
var loading = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
self.folderId = folderId
currentIndex = max(startIndex, 0)
folder = summary
files = summary?.items ?? []
}
///
func load(api: any LiveServing) async {
loading = true
defer { loading = false }
do {
let detail = try await api.liveAlbumFolderDetail(folderId: folderId)
folder = detail
files = detail.items
if currentIndex >= files.count {
currentIndex = max(files.count - 1, 0)
}
} catch {
files = []
errorMessage = error.localizedDescription
}
}
///
func deleteCurrentFile(api: any LiveServing) async throws {
guard files.indices.contains(currentIndex) else { return }
let file = files[currentIndex]
try await api.liveAlbumDeleteFiles(folderId: folderId, fileIds: [file.id])
await load(api: api)
}
}
///
enum LiveValidationError: LocalizedError, Equatable {
case missingScenic
case emptyTitle
case invalidCoverURL
case invalidDateRange
case emptyAlbumName
case emptyAlbumFiles
var errorDescription: String? {
switch self {
case .missingScenic:
"当前账号缺少景区信息"
case .emptyTitle:
"请输入直播标题"
case .invalidCoverURL:
"封面图地址需以 http:// 或 https:// 开头"
case .invalidDateRange:
"开始时间不能大于结束时间"
case .emptyAlbumName:
"请输入相册名称"
case .emptyAlbumFiles:
"请上传素材"
}
}
}
extension Date {
/// 使
var liveDayText: String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: self)
}
}
private extension String {
var liveIsHTTPURL: Bool {
let lower = liveTrimmed.lowercased()
return lower.hasPrefix("http://") || lower.hasPrefix("https://")
}
}
extension Int64 {
///
var liveDurationText: String {
let totalSeconds = Swift.max(Int(self), 0)
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
}
}

View File

@ -0,0 +1,80 @@
//
// LocationReportAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
/// 便 ViewModel
@MainActor
protocol LocationReportServing {
///
func reportLocation(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int) async throws -> LocationReportSubmitResponse
///
func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload<LocationReportHistoryItem>
}
/// API
@MainActor
final class LocationReportAPI: LocationReportServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func reportLocation(
staffId: Int,
latitude: Double,
longitude: Double,
address: String,
type: LocationReportType,
scenicId: Int
) async throws -> LocationReportSubmitResponse {
try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/loacation/report",
queryItems: [
URLQueryItem(name: "staff_id", value: "\(staffId)"),
URLQueryItem(name: "latitude", value: "\(latitude)"),
URLQueryItem(name: "longitude", value: "\(longitude)"),
URLQueryItem(name: "address", value: address),
URLQueryItem(name: "type", value: "\(type.rawValue)"),
URLQueryItem(name: "scenic_id", value: "\(scenicId)")
]
)
)
}
///
func locationReportList(
staffId: Int,
page: Int = 1,
pageSize: Int = 20,
type: LocationReportType = .all,
startDate: String? = nil,
endDate: String? = nil
) async throws -> ListPayload<LocationReportHistoryItem> {
var query = [
URLQueryItem(name: "staff_id", value: "\(staffId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"),
URLQueryItem(name: "type", value: "\(max(type.rawValue, 0))")
]
if let startDate, !startDate.isEmpty {
query.append(URLQueryItem(name: "start_date", value: startDate))
}
if let endDate, !endDate.isEmpty {
query.append(URLQueryItem(name: "end_date", value: endDate))
}
return try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/loacation/list", queryItems: query)
)
}
}

View File

@ -0,0 +1,39 @@
# LocationReport 模块业务逻辑
## 模块职责
LocationReport 模块负责首页 `location_report``location_report_history` 入口。
本模块包含当前位置获取、标记点、在线状态、立即上报、提醒设置、历史记录、类型筛选、日期筛选和分页。上报状态只保存在页面 ViewModel 内,不进入全局 App 状态。
## 数据来源
- 当前景区 ID 从 `AccountContext.currentScenic` 读取。
- 上报人员 ID 从 `AccountSnapshotStore.load()?.businessUserId` 读取,避免把业务账号 ID 复制进页面全局状态。
- 提交接口使用旧工程拼写 `/api/yf-handset-app/photog/loacation/report`
- 历史接口使用 `/api/yf-handset-app/photog/loacation/list`
## 上报流程
`LocationReportViewModel` 管理当前位置、标记点、在线状态、提醒分钟数和页面内倒计时。
1. 页面进入时请求一次前台定位。
2. 用户可以立即上报当前位置,也可以把当前位置设为标记点后上报。
3. 切换到在线状态时提交一次在线状态上报。
4. 上报成功后按服务端 `expired` 重置倒计时;服务端未返回时兜底为 2 小时。
## 历史流程
`LocationReportHistoryViewModel` 管理类型筛选、日期筛选、分页和错误状态。
历史记录支持 `全部``立即上报``标记点``在线状态` 四类筛选。分页失败只保留错误提示,不影响登录态和其他模块。
## 定位边界
当前只做前台手动定位和页面内倒计时。本轮不做后台持续定位、后台定时上报、离线持久化队列或推送提醒。
定位结果、在线状态、提醒倒计时和提交表单均不落盘。
## 测试要求
新增位置上报逻辑时,需要同步补充 API、ViewModel 和路由测试。测试失败时先修复问题,再继续迁移后续功能。

View File

@ -0,0 +1,127 @@
//
// LocationReportModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
/// 线
enum LocationReportType: Int, CaseIterable, Identifiable {
case all = 0
case immediate = 1
case marked = 2
case onlineStatus = 3
var id: Int { rawValue }
///
var title: String {
switch self {
case .all: "全部"
case .immediate: "立即上报"
case .marked: "标记点"
case .onlineStatus: "在线状态"
}
}
}
///
struct LocationCoordinate: Equatable {
var latitude: Double
var longitude: Double
}
///
struct LocationReportSubmitResponse: Decodable, Equatable {
let staffId: String
let expired: Int
let status: Int
enum CodingKeys: String, CodingKey {
case staffId = "staff_id"
case expired
case status
}
///
init(staffId: String, expired: Int, status: Int) {
self.staffId = staffId
self.expired = expired
self.status = status
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
staffId = try container.decodeLossyString(forKey: .staffId)
expired = try container.decodeLossyInt(forKey: .expired) ?? 0
status = try container.decodeLossyInt(forKey: .status) ?? 0
}
}
///
struct LocationReportHistoryItem: Decodable, Equatable, Identifiable {
let id: Int
let staffId: Int
let type: Int
let latitude: String
let longitude: String
let address: String
let ip: String
let remark: String
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case staffId = "staff_id"
case type
case latitude
case longitude
case address
case ip
case remark
case createdAt = "created_at"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
staffId = try container.decodeLossyInt(forKey: .staffId) ?? 0
type = try container.decodeLossyInt(forKey: .type) ?? 0
latitude = try container.decodeLossyString(forKey: .latitude)
longitude = try container.decodeLossyString(forKey: .longitude)
address = try container.decodeLossyString(forKey: .address)
ip = try container.decodeLossyString(forKey: .ip)
remark = try container.decodeLossyString(forKey: .remark)
createdAt = try container.decodeLossyString(forKey: .createdAt)
}
///
var typeTitle: String {
LocationReportType(rawValue: type)?.title ?? "未知"
}
}
private extension KeyedDecodingContainer {
/// String Bool
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) { return value }
if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) }
if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) }
if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "1" : "0" }
return ""
}
/// StringDouble Int Int
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) { return value }
if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) }
if let value = try? decodeIfPresent(String.self, forKey: key) {
return Int(value.trimmingCharacters(in: .whitespacesAndNewlines))
}
return nil
}
}

View File

@ -0,0 +1,119 @@
//
// LocationReportViewControllers.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
///
final class LocationReportViewController: ModuleTableViewController {
private let viewModel = LocationReportViewModel()
override func viewDidLoad() {
title = "位置上报"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "上报",
style: .done,
target: self,
action: #selector(submitReport)
)
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 3 : 1
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 0 ? "状态" : "操作"
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
for: indexPath
) as! TitleSubtitleTableViewCell
if indexPath.section == 0 {
switch indexPath.row {
case 0:
cell.configure(title: "在线状态", subtitle: viewModel.isOnline ? "在线" : "离线")
case 1:
cell.configure(title: "当前地址", subtitle: viewModel.currentAddress)
case 2:
cell.configure(title: "倒计时", subtitle: viewModel.countdownText)
default:
break
}
} else {
cell.configure(title: "切换在线状态", subtitle: viewModel.isOnline ? "点击离线" : "点击上线")
}
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 1 {
Task {
_ = await viewModel.setOnline(
!viewModel.isOnline,
staffId: services.staffId,
scenicId: services.currentScenicId,
api: services.locationReportAPI
)
}
}
}
override func reloadContent() async {
viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入")
}
@objc private func submitReport() {
Task {
_ = await viewModel.submit(
type: .immediate,
staffId: services.staffId,
scenicId: services.currentScenicId,
api: services.locationReportAPI
)
}
}
}
extension LocationReportViewModel: ViewModelBindable {}
///
final class LocationReportHistoryViewController: ModuleTableViewController {
private let viewModel = LocationReportHistoryViewModel()
override func viewDidLoad() {
title = "上报历史"
super.viewDidLoad()
wireViewModel(viewModel) { }
}
override func tableRowCount() -> Int {
viewModel.items.count
}
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let record = viewModel.items[indexPath.row]
cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt)
}
override func reloadContent() async {
await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI)
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard indexPath.row >= viewModel.items.count - 2 else { return }
Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) }
}
}
extension LocationReportHistoryViewModel: ViewModelBindable {}

View File

@ -0,0 +1,232 @@
//
// LocationReportViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
/// ViewModel线
@MainActor
final class LocationReportViewModel {
var onChange: (() -> Void)?
var isOnline = false { didSet { onChange?() } }
var reminderMinutes = 30 { didSet { onChange?() } }
var secondsUntilReport = 0 { didSet { onChange?() } }
var currentCoordinate: LocationCoordinate? { didSet { onChange?() } }
var markedCoordinate: LocationCoordinate? { didSet { onChange?() } }
var currentAddress = "" { didSet { onChange?() } }
var markedAddress = "" { didSet { onChange?() } }
var lastReportText = "" { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
var isSubmitting = false { didSet { onChange?() } }
///
var countdownText: String {
guard secondsUntilReport > 0 else { return "可立即上报" }
let hours = secondsUntilReport / 3600
let minutes = (secondsUntilReport % 3600) / 60
return "\(hours)小时\(minutes)分钟后可再次提醒"
}
///
func applyCurrentLocation(latitude: Double, longitude: Double, address: String) {
currentCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude)
currentAddress = address
}
///
func applyMarkedLocation(latitude: Double, longitude: Double, address: String) {
markedCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude)
markedAddress = address
}
/// 线线
func setOnline(
_ value: Bool,
staffId: Int?,
scenicId: Int?,
api: any LocationReportServing
) async -> Bool {
isOnline = value
guard value else { return true }
return await submit(type: .onlineStatus, staffId: staffId, scenicId: scenicId, api: api)
}
///
func submit(
type: LocationReportType,
staffId: Int?,
scenicId: Int?,
api: any LocationReportServing
) async -> Bool {
guard !isSubmitting else { return false }
guard let staffId, staffId > 0 else {
errorMessage = "缺少上报人员"
return false
}
guard let scenicId, scenicId > 0 else {
errorMessage = "缺少当前景区"
return false
}
let source = reportSource(for: type)
guard let coordinate = source.coordinate else {
errorMessage = "请先获取或选择位置"
return false
}
let address = source.address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !address.isEmpty else {
errorMessage = "请填写位置地址"
return false
}
isSubmitting = true
errorMessage = nil
defer { isSubmitting = false }
do {
let response = try await api.reportLocation(
staffId: staffId,
latitude: coordinate.latitude,
longitude: coordinate.longitude,
address: address,
type: type,
scenicId: scenicId
)
secondsUntilReport = response.expired > 0 ? response.expired : 7200
lastReportText = LocationReportViewModel.displayFormatter.string(from: Date())
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
///
func tick() {
guard secondsUntilReport > 0 else { return }
secondsUntilReport -= 1
}
///
private func reportSource(for type: LocationReportType) -> (coordinate: LocationCoordinate?, address: String) {
switch type {
case .marked:
(markedCoordinate ?? currentCoordinate, markedAddress.isEmpty ? currentAddress : markedAddress)
case .all, .immediate, .onlineStatus:
(currentCoordinate, currentAddress)
}
}
private static let displayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
}
/// ViewModel
@MainActor
final class LocationReportHistoryViewModel {
var onChange: (() -> Void)?
var selectedType: LocationReportType = .all { didSet { onChange?() } }
var startDate: Date? { didSet { onChange?() } }
var endDate: Date? { didSet { onChange?() } }
var items: [LocationReportHistoryItem] = [] { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var isLoadingMore = false { didSet { onChange?() } }
var total = 0 { didSet { onChange?() } }
private var page = 1
private let pageSize = 20
///
var hasMore: Bool {
items.count < total
}
///
func reload(staffId: Int?, api: any LocationReportServing) async {
guard let staffId, staffId > 0 else {
reset()
errorMessage = "缺少上报人员"
return
}
page = 1
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let payload = try await api.locationReportList(
staffId: staffId,
page: page,
pageSize: pageSize,
type: selectedType,
startDate: dateString(startDate),
endDate: dateString(endDate)
)
items = payload.list
total = payload.total
} catch {
items = []
total = 0
errorMessage = error.localizedDescription
}
}
///
func loadMore(staffId: Int?, api: any LocationReportServing) async {
guard let staffId, staffId > 0, hasMore, !isLoading, !isLoadingMore else { return }
isLoadingMore = true
let nextPage = page + 1
defer { isLoadingMore = false }
do {
let payload = try await api.locationReportList(
staffId: staffId,
page: nextPage,
pageSize: pageSize,
type: selectedType,
startDate: dateString(startDate),
endDate: dateString(endDate)
)
page = nextPage
items.append(contentsOf: payload.list)
total = payload.total
} catch {
errorMessage = error.localizedDescription
}
}
///
func selectType(_ type: LocationReportType, staffId: Int?, api: any LocationReportServing) async {
guard selectedType != type else { return }
selectedType = type
await reload(staffId: staffId, api: api)
}
///
func reset() {
items = []
total = 0
page = 1
isLoading = false
isLoadingMore = false
}
/// yyyy-MM-dd
private func dateString(_ date: Date?) -> String? {
guard let date else { return nil }
return Self.dateFormatter.string(from: date)
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
}

View File

@ -0,0 +1,59 @@
# Main 模块业务逻辑
## 模块职责
Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。
主界面由 `MainTabsView` 承载:
- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`
- 默认使用自定义 TabBar如需切回系统实现将配置改为 `.system`
- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。
- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。
- 每个 Tab 的导航路径由 `AppRouter` 单独保存。
## Tab 结构
当前 Tab 来自 `AppTab`
- `home`:首页
- `orders`:订单
- `statistics`:数据
- `profile`:我的
`HomeRootView` 已接入真实的 `HomeView``OrdersRootView` 已接入真实的 `OrdersView``StatisticsRootView` 已接入真实的 `StatisticsView``ProfileRootView` 已接入真实的 `ProfileView`
## 导航流程
1. `MainTabsView` 从 Environment 读取 `AppRouter`
2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView``SystemMainTabsView`
3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态。
4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`
5. 路径绑定来自 `appRouter.binding(for:)`
6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`
7. `navigationDestination` 根据 `AppRoute` 展示详情页。
8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。
## 自定义 TabBar
自定义 TabBar 由 `CustomMainTabsView``CustomMainTabBar` 组成:
- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。
- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。
- `CustomMainTabBar` 只负责展示 UI不直接读取账号、订单 API 或业务上下文。
- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。
- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`
- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。
- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar也不会保留底部占位高度。
- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。
系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。
## 后续迁移规则
迁移新页面时:
- 优先替换对应 Tab 的 RootView。
- 保持每个 Tab 自己的 `NavigationStack`
- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。
- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)``AppRouter.routeToOrderVerification(scannedCode:)`
- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。
- 普通业务子页面默认隐藏 TabBar只有确有产品需求时才在 `AppRoute` 策略中单独放开。
- 不要把业务状态塞进自定义 TabBar 组件TabBar 只接收绑定、文案和动作回调。
- 真实业务页面接入后,应同步补充该模块文档和单元测试。

Some files were not shown because too many files have changed in this diff Show More