Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
36
suixinkan_ios/App/AMapBootstrap.swift
Normal file
36
suixinkan_ios/App/AMapBootstrap.swift
Normal 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
79
suixinkan_ios/App/App.md
Normal 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` 处理跳转。
|
||||
123
suixinkan_ios/App/AppServices.swift
Normal file
123
suixinkan_ios/App/AppServices.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
55
suixinkan_ios/App/AppUITestLaunchState.swift
Normal file
55
suixinkan_ios/App/AppUITestLaunchState.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
95
suixinkan_ios/App/AppUITestRouteDriver.swift
Normal file
95
suixinkan_ios/App/AppUITestRouteDriver.swift
Normal 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
|
||||
181
suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift
Normal file
181
suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
58
suixinkan_ios/App/Navigation/AppTab.swift
Normal file
58
suixinkan_ios/App/Navigation/AppTab.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
109
suixinkan_ios/App/Navigation/NavigationRouter.swift
Normal file
109
suixinkan_ios/App/Navigation/NavigationRouter.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
111
suixinkan_ios/App/Navigation/TabNavigationController.swift
Normal file
111
suixinkan_ios/App/Navigation/TabNavigationController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
46
suixinkan_ios/App/Navigation/UIKitAppNavigation.swift
Normal file
46
suixinkan_ios/App/Navigation/UIKitAppNavigation.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
150
suixinkan_ios/App/State/AccountContext.swift
Normal file
150
suixinkan_ios/App/State/AccountContext.swift
Normal file
@ -0,0 +1,150 @@
|
||||
//
|
||||
// AccountContext.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 当前账号的基础资料实体,用于跨页面展示昵称、手机号和头像。
|
||||
struct AccountProfile: Codable, Equatable {
|
||||
var userId: String
|
||||
var displayName: String
|
||||
var phone: String?
|
||||
var avatarURL: String?
|
||||
}
|
||||
|
||||
/// 当前业务作用域实体,表示景区或门店维度的上下文。
|
||||
struct BusinessScope: Codable, Equatable, Identifiable {
|
||||
/// 业务作用域类型,用于区分景区和门店。
|
||||
enum Kind: Codable, Equatable {
|
||||
case scenic
|
||||
case store
|
||||
}
|
||||
|
||||
var id: Int
|
||||
var name: String
|
||||
var kind: Kind
|
||||
var parentScenicId: Int?
|
||||
var status: Int?
|
||||
var address: String?
|
||||
var latitude: Double?
|
||||
var longitude: Double?
|
||||
var coverURLString: String?
|
||||
|
||||
/// 创建业务作用域,门店可通过 parentScenicId 标记所属景区。
|
||||
init(
|
||||
id: Int,
|
||||
name: String,
|
||||
kind: Kind,
|
||||
parentScenicId: Int? = nil,
|
||||
status: Int? = nil,
|
||||
address: String? = nil,
|
||||
latitude: Double? = nil,
|
||||
longitude: Double? = nil,
|
||||
coverURLString: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.kind = kind
|
||||
self.parentScenicId = parentScenicId
|
||||
self.status = status
|
||||
self.address = address
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.coverURLString = coverURLString
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文状态中心,保存当前账号资料和景区/门店作用域。
|
||||
final class AccountContext {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var profile: AccountProfile? { didSet { onChange?() } }
|
||||
private(set) var scenicScopes: [BusinessScope] = [] { didSet { onChange?() } }
|
||||
private(set) var storeScopes: [BusinessScope] = [] { didSet { onChange?() } }
|
||||
var currentScenic: BusinessScope? { didSet { onChange?() } }
|
||||
var currentStore: BusinessScope? { didSet { onChange?() } }
|
||||
|
||||
/// 应用登录成功后写入账号资料。
|
||||
func applyLogin(profile: AccountProfile? = nil) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
/// 替换当前账号资料,通常用于用户信息刷新后同步全局展示。
|
||||
func replaceProfile(_ profile: AccountProfile?) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
/// 替换景区和门店作用域,并尽量保持当前选择不变。
|
||||
func replaceScopes(
|
||||
scenic: [BusinessScope],
|
||||
stores: [BusinessScope],
|
||||
currentScenicId: Int? = nil,
|
||||
currentStoreId: Int? = nil
|
||||
) {
|
||||
scenicScopes = scenic
|
||||
storeScopes = stores
|
||||
currentScenic = currentScenicId.flatMap { id in
|
||||
scenic.first { $0.id == id }
|
||||
} ?? currentScenic.flatMap { current in
|
||||
scenic.first { $0.id == current.id }
|
||||
} ?? scenic.first
|
||||
currentStore = resolvedStore(
|
||||
in: stores,
|
||||
currentScenic: currentScenic,
|
||||
preferredStoreId: currentStoreId ?? currentStore?.id
|
||||
)
|
||||
}
|
||||
|
||||
/// 切换当前景区,并按景区重新校准当前门店。
|
||||
func selectScenic(id scenicId: Int) {
|
||||
guard let scenic = scenicScopes.first(where: { $0.id == scenicId }) else { return }
|
||||
currentScenic = scenic
|
||||
currentStore = resolvedStore(
|
||||
in: storeScopes,
|
||||
currentScenic: scenic,
|
||||
preferredStoreId: currentStore?.id
|
||||
)
|
||||
}
|
||||
|
||||
/// 切换当前门店,并在门店有关联景区时同步当前景区。
|
||||
func selectStore(id storeId: Int) {
|
||||
guard let store = storeScopes.first(where: { $0.id == storeId }) else { return }
|
||||
currentStore = store
|
||||
if let scenicId = store.parentScenicId,
|
||||
currentScenic?.id != scenicId,
|
||||
let scenic = scenicScopes.first(where: { $0.id == scenicId }) {
|
||||
currentScenic = scenic
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空账号上下文,通常在退出登录时调用。
|
||||
func reset() {
|
||||
profile = nil
|
||||
scenicScopes = []
|
||||
storeScopes = []
|
||||
currentScenic = nil
|
||||
currentStore = nil
|
||||
}
|
||||
|
||||
/// 按当前景区和期望门店 ID 选择有效门店。
|
||||
private func resolvedStore(
|
||||
in stores: [BusinessScope],
|
||||
currentScenic: BusinessScope?,
|
||||
preferredStoreId: Int?
|
||||
) -> BusinessScope? {
|
||||
let scenicId = currentScenic?.id
|
||||
let storesForScenic = stores.filter { store in
|
||||
guard let scenicId else { return true }
|
||||
return store.parentScenicId == nil || store.parentScenicId == scenicId
|
||||
}
|
||||
if let preferredStoreId,
|
||||
let store = storesForScenic.first(where: { $0.id == preferredStoreId }) {
|
||||
return store
|
||||
}
|
||||
return storesForScenic.first
|
||||
}
|
||||
}
|
||||
115
suixinkan_ios/App/State/AccountContextLoader.swift
Normal file
115
suixinkan_ios/App/State/AccountContextLoader.swift
Normal file
@ -0,0 +1,115 @@
|
||||
//
|
||||
// AccountContextLoader.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 用户资料服务协议,抽象用户信息读取能力以便测试替换。
|
||||
protocol UserProfileServing {
|
||||
/// 获取当前登录账号的用户资料。
|
||||
func userInfo() async throws -> UserInfoResponse
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文加载器,统一装配用户资料、角色权限、景区和门店上下文。
|
||||
struct AccountContextLoader {
|
||||
/// 刷新账号上下文,权限失败会向外抛错,景区和门店按旧工程规则兜底。
|
||||
func refresh(
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
profileAPI: UserProfileServing,
|
||||
accountContextAPI: AccountContextServing,
|
||||
cachedCurrentRoleId: Int? = nil,
|
||||
cachedCurrentScenicId: Int? = nil,
|
||||
cachedCurrentStoreId: Int? = nil
|
||||
) async throws {
|
||||
async let userData = profileAPI.userInfo()
|
||||
async let roleData = accountContextAPI.rolePermissions()
|
||||
let (userInfo, rolePermissions) = try await (userData, roleData)
|
||||
|
||||
permissionContext.replaceRolePermissions(rolePermissions, currentRoleId: cachedCurrentRoleId)
|
||||
|
||||
let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions)
|
||||
let storesResponse = await loadStores(accountContextAPI: accountContextAPI)
|
||||
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
|
||||
let scenicScopes = scopedScenics(
|
||||
permissionContext: permissionContext,
|
||||
scenicResponse: scenicResponse
|
||||
)
|
||||
let storeScopes = storesResponse?.list.map { store in
|
||||
BusinessScope(id: store.id, name: store.name, kind: .store, parentScenicId: store.scenicId)
|
||||
} ?? []
|
||||
|
||||
accountContext.replaceProfile(profile)
|
||||
accountContext.replaceScopes(
|
||||
scenic: scenicScopes,
|
||||
stores: storeScopes,
|
||||
currentScenicId: cachedCurrentScenicId,
|
||||
currentStoreId: cachedCurrentStoreId
|
||||
)
|
||||
}
|
||||
|
||||
/// 景区列表接口失败时,从角色权限中的景区数据去重构造兜底列表。
|
||||
private func loadScenicList(
|
||||
accountContextAPI: AccountContextServing,
|
||||
rolePermissions: [RolePermissionResponse]
|
||||
) async -> ScenicListAllResponse {
|
||||
do {
|
||||
return try await accountContextAPI.scenicListAll()
|
||||
} catch {
|
||||
let fallback = uniqueScenics(from: rolePermissions)
|
||||
return ScenicListAllResponse(total: fallback.count, list: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
/// 门店列表接口失败时返回 nil,不阻断登录和恢复流程。
|
||||
private func loadStores(accountContextAPI: AccountContextServing) async -> ListPayload<StoreItem>? {
|
||||
try? await accountContextAPI.storeAll()
|
||||
}
|
||||
|
||||
/// 根据当前角色优先返回角色可访问景区,角色无景区时使用景区列表接口结果。
|
||||
private func scopedScenics(
|
||||
permissionContext: PermissionContext,
|
||||
scenicResponse: ScenicListAllResponse
|
||||
) -> [BusinessScope] {
|
||||
let roleScenics = permissionContext.currentRoleScenicScopes()
|
||||
if !roleScenics.isEmpty {
|
||||
return roleScenics
|
||||
}
|
||||
return scenicResponse.list.map {
|
||||
BusinessScope(id: $0.id, name: $0.name, kind: .scenic)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从角色权限中去重生成景区列表。
|
||||
private func uniqueScenics(from rolePermissions: [RolePermissionResponse]) -> [ScenicListItem] {
|
||||
var seen = Set<Int>()
|
||||
var result: [ScenicListItem] = []
|
||||
for permission in rolePermissions {
|
||||
for scenic in permission.scenic where seen.insert(scenic.id).inserted {
|
||||
result.append(ScenicListItem(id: scenic.id, name: scenic.name))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// 将用户资料接口响应转换为全局账号资料。
|
||||
private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile {
|
||||
AccountProfile(
|
||||
userId: fallback?.userId ?? "",
|
||||
displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称",
|
||||
phone: nonEmpty(userInfo.phone) ?? fallback?.phone,
|
||||
avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL
|
||||
)
|
||||
}
|
||||
|
||||
/// 去除空白字符后返回非空字符串。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
46
suixinkan_ios/App/State/AppSession.swift
Normal file
46
suixinkan_ios/App/State/AppSession.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// AppSession.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 登录阶段实体,表示根视图当前应该展示的认证状态。
|
||||
enum AuthPhase: Equatable {
|
||||
case loggedOut
|
||||
case restoring
|
||||
case loggedIn
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 登录会话状态中心,保存 token 和当前认证阶段。
|
||||
final class AppSession {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var phase: AuthPhase = .loggedOut { didSet { onChange?() } }
|
||||
private(set) var token: String? { didSet { onChange?() } }
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
phase == .loggedIn
|
||||
}
|
||||
|
||||
/// 将会话切换到恢复中状态,用于冷启动恢复登录态。
|
||||
func beginRestoring(token: String? = nil) {
|
||||
self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
phase = .restoring
|
||||
}
|
||||
|
||||
/// 标记登录成功并保存正式 token。
|
||||
func markLoggedIn(token: String? = nil) {
|
||||
self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
phase = .loggedIn
|
||||
}
|
||||
|
||||
/// 清空 token 并回到未登录状态。
|
||||
func logout() {
|
||||
token = nil
|
||||
phase = .loggedOut
|
||||
}
|
||||
}
|
||||
209
suixinkan_ios/App/State/AuthSessionCoordinator.swift
Normal file
209
suixinkan_ios/App/State/AuthSessionCoordinator.swift
Normal file
@ -0,0 +1,209 @@
|
||||
//
|
||||
// AuthSessionCoordinator.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 登录会话协调器,统一处理登录完成、退出登录和缓存同步。
|
||||
final class AuthSessionCoordinator {
|
||||
private let tokenStore: SessionTokenStore
|
||||
private let snapshotStore: AccountSnapshotStore
|
||||
private let preferencesStore: AppPreferencesStore
|
||||
|
||||
/// 初始化登录会话协调器,并注入 token、账号快照和偏好存储。
|
||||
init(
|
||||
tokenStore: SessionTokenStore,
|
||||
snapshotStore: AccountSnapshotStore,
|
||||
preferencesStore: AppPreferencesStore
|
||||
) {
|
||||
self.tokenStore = tokenStore
|
||||
self.snapshotStore = snapshotStore
|
||||
self.preferencesStore = preferencesStore
|
||||
}
|
||||
|
||||
/// 创建使用默认存储服务的登录会话协调器。
|
||||
convenience init() {
|
||||
self.init(
|
||||
tokenStore: SessionTokenStore(),
|
||||
snapshotStore: AccountSnapshotStore(),
|
||||
preferencesStore: AppPreferencesStore()
|
||||
)
|
||||
}
|
||||
|
||||
/// 读取登录页需要的本地偏好。
|
||||
func loginPreferences() -> LoginPreferences {
|
||||
LoginPreferences(
|
||||
lastUsername: preferencesStore.loadLastLoginUsername(),
|
||||
privacyAgreementAccepted: preferencesStore.loadPrivacyAgreementAccepted()
|
||||
)
|
||||
}
|
||||
|
||||
/// 完成正式登录,写入内存状态、Keychain token 和账号快照。
|
||||
func completeLogin(
|
||||
with response: V9AuthResponse,
|
||||
username: String,
|
||||
privacyAgreementAccepted: Bool,
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
profileAPI: UserProfileServing,
|
||||
accountContextAPI: AccountContextServing
|
||||
) async throws {
|
||||
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try tokenStore.save(token)
|
||||
preferencesStore.saveLastLoginUsername(username)
|
||||
preferencesStore.savePrivacyAgreementAccepted(privacyAgreementAccepted)
|
||||
|
||||
let profile = response.primaryProfile
|
||||
let scenicScopes = response.scenicScopes
|
||||
let storeScopes = response.storeScopes
|
||||
let identity = response.currentAccountIdentity
|
||||
accountContext.applyLogin(profile: profile)
|
||||
accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes)
|
||||
appSession.beginRestoring(token: token)
|
||||
|
||||
do {
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentScenicId: accountContext.currentScenic?.id,
|
||||
cachedCurrentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
} catch {
|
||||
if APIError.isAuthenticationExpired(error) {
|
||||
try? tokenStore.clear()
|
||||
snapshotStore.clear()
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
appSession.logout()
|
||||
throw error
|
||||
}
|
||||
appSession.markLoggedIn(token: token)
|
||||
saveSnapshot(
|
||||
profile: accountContext.profile ?? profile,
|
||||
accountType: identity?.accountType,
|
||||
businessUserId: identity?.businessUserId,
|
||||
permissionContext: permissionContext,
|
||||
accountContext: accountContext
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
saveSnapshot(
|
||||
profile: accountContext.profile ?? profile,
|
||||
accountType: identity?.accountType,
|
||||
businessUserId: identity?.businessUserId,
|
||||
permissionContext: permissionContext,
|
||||
accountContext: accountContext
|
||||
)
|
||||
appSession.markLoggedIn(token: token)
|
||||
}
|
||||
|
||||
/// 使用最新用户资料刷新账号快照中的展示信息。
|
||||
func refreshCachedProfile(from userInfo: UserInfoResponse, accountContext: AccountContext) {
|
||||
let profile = makeProfile(from: userInfo, fallback: accountContext.profile)
|
||||
accountContext.replaceProfile(profile)
|
||||
saveSnapshot(
|
||||
profile: profile,
|
||||
accountType: snapshotStore.load()?.accountType,
|
||||
businessUserId: snapshotStore.load()?.businessUserId,
|
||||
currentRoleId: snapshotStore.load()?.currentRoleId,
|
||||
accountContext: accountContext
|
||||
)
|
||||
}
|
||||
|
||||
/// 退出登录并清空敏感缓存,保留手机号和协议状态等偏好。
|
||||
func logout(
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
scenicSpotContext: ScenicSpotContext,
|
||||
appRouter: AppRouter,
|
||||
toastCenter: ToastCenter
|
||||
) {
|
||||
try? tokenStore.clear()
|
||||
snapshotStore.clear()
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
scenicSpotContext.reset()
|
||||
appRouter.reset()
|
||||
toastCenter.dismiss()
|
||||
appSession.logout()
|
||||
}
|
||||
|
||||
/// 保存当前账号上下文的快照。
|
||||
private func saveSnapshot(
|
||||
profile: AccountProfile?,
|
||||
accountType: String?,
|
||||
businessUserId: Int?,
|
||||
permissionContext: PermissionContext? = nil,
|
||||
currentRoleId: Int? = nil,
|
||||
accountContext: AccountContext
|
||||
) {
|
||||
snapshotStore.save(
|
||||
AccountSnapshot(
|
||||
profile: profile,
|
||||
accountType: accountType,
|
||||
businessUserId: businessUserId,
|
||||
currentRoleId: permissionContext?.currentRole?.id ?? currentRoleId,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
storeScopes: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
currentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 将用户资料接口响应转换为全局账号资料。
|
||||
private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile {
|
||||
AccountProfile(
|
||||
userId: fallback?.userId ?? "",
|
||||
displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称",
|
||||
phone: nonEmpty(userInfo.phone) ?? fallback?.phone,
|
||||
avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL
|
||||
)
|
||||
}
|
||||
|
||||
/// 去除空白字符后返回非空字符串。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录页偏好实体,表示可安全恢复到表单的非敏感信息。
|
||||
struct LoginPreferences: Equatable {
|
||||
let lastUsername: String?
|
||||
let privacyAgreementAccepted: Bool
|
||||
}
|
||||
|
||||
private extension V9AuthResponse {
|
||||
/// 当前账号身份实体,表示登录后实际选中的业务账号。
|
||||
struct CurrentAccountIdentity {
|
||||
let accountType: String
|
||||
let businessUserId: Int
|
||||
}
|
||||
|
||||
/// 提取当前账号身份,优先使用后端标记的 isCurrent 账号。
|
||||
var currentAccountIdentity: CurrentAccountIdentity? {
|
||||
if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first {
|
||||
return CurrentAccountIdentity(
|
||||
accountType: storeUser.accountType,
|
||||
businessUserId: storeUser.businessUserId
|
||||
)
|
||||
}
|
||||
if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first {
|
||||
return CurrentAccountIdentity(
|
||||
accountType: scenicUser.accountType,
|
||||
businessUserId: scenicUser.businessUserId
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
92
suixinkan_ios/App/State/PermissionContext.swift
Normal file
92
suixinkan_ios/App/State/PermissionContext.swift
Normal file
@ -0,0 +1,92 @@
|
||||
//
|
||||
// PermissionContext.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 权限上下文状态中心,保存角色权限、当前角色和扁平化权限 URI。
|
||||
final class PermissionContext {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var rolePermissions: [RolePermissionResponse] = [] { didSet { onChange?() } }
|
||||
private(set) var permissionURIs: Set<String> = [] { didSet { onChange?() } }
|
||||
var currentRole: RoleInfo? { didSet { onChange?() } }
|
||||
|
||||
/// 替换角色权限列表,并尽量按缓存角色 ID 保持当前角色。
|
||||
func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) {
|
||||
self.rolePermissions = rolePermissions
|
||||
currentRole = currentRoleId.flatMap { id in
|
||||
rolePermissions.first { $0.role.id == id }?.role
|
||||
} ?? currentRole.flatMap { role in
|
||||
rolePermissions.first { $0.role.id == role.id }?.role
|
||||
} ?? rolePermissions.first?.role
|
||||
permissionURIs = Set(Self.flattenPermissions(currentRole?.permission ?? []))
|
||||
}
|
||||
|
||||
/// 切换当前角色,并同步账号上下文中的景区和门店选择。
|
||||
func selectRole(id roleId: Int, accountContext: AccountContext) {
|
||||
guard let rolePermission = rolePermissions.first(where: { $0.role.id == roleId }) else { return }
|
||||
currentRole = rolePermission.role
|
||||
permissionURIs = Set(Self.flattenPermissions(rolePermission.role.permission))
|
||||
|
||||
let scenicScopes = Self.uniqueScenicScopes(from: rolePermission.scenic)
|
||||
accountContext.replaceScopes(
|
||||
scenic: scenicScopes,
|
||||
stores: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
currentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
}
|
||||
|
||||
/// 判断当前角色是否拥有指定 URI 权限。
|
||||
func canAccess(_ uri: String) -> Bool {
|
||||
permissionURIs.contains(uri.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
/// 返回当前角色关联的景区作用域。
|
||||
func currentRoleScenicScopes() -> [BusinessScope] {
|
||||
guard let currentRole else { return [] }
|
||||
guard let rolePermission = rolePermissions.first(where: { $0.role.id == currentRole.id }) else {
|
||||
return []
|
||||
}
|
||||
return Self.uniqueScenicScopes(from: rolePermission.scenic)
|
||||
}
|
||||
|
||||
/// 清空权限上下文,通常在退出登录时调用。
|
||||
func reset() {
|
||||
rolePermissions = []
|
||||
permissionURIs = []
|
||||
currentRole = nil
|
||||
}
|
||||
|
||||
/// 递归提取权限树里的非空 URI。
|
||||
private static func flattenPermissions(_ permissions: [PermissionItem]) -> [String] {
|
||||
permissions.flatMap { item -> [String] in
|
||||
let current = item.uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let children = flattenPermissions(item.children)
|
||||
return current.isEmpty ? children : [current] + children
|
||||
}
|
||||
}
|
||||
|
||||
/// 将角色关联景区去重并转换为业务作用域。
|
||||
private static func uniqueScenicScopes(from scenics: [ScenicInfo]) -> [BusinessScope] {
|
||||
var seen = Set<Int>()
|
||||
return scenics.compactMap { scenic in
|
||||
guard seen.insert(scenic.id).inserted else { return nil }
|
||||
return BusinessScope(
|
||||
id: scenic.id,
|
||||
name: scenic.name,
|
||||
kind: .scenic,
|
||||
status: scenic.status,
|
||||
address: scenic.location?.address,
|
||||
latitude: scenic.location?.lat,
|
||||
longitude: scenic.location?.lng,
|
||||
coverURLString: scenic.coverImg
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
suixinkan_ios/App/State/ScenicSpotContext.swift
Normal file
60
suixinkan_ios/App/State/ScenicSpotContext.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// ScenicSpotContext.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 景点加载阶段实体,表示当前景区景点列表的加载状态。
|
||||
enum ScenicSpotLoadState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 景点上下文状态中心,保存当前景区下的景点或打卡点列表。
|
||||
final class ScenicSpotContext {
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var scenicId: Int? { didSet { onChange?() } }
|
||||
private(set) var spots: [ScenicSpotItem] = [] { didSet { onChange?() } }
|
||||
private(set) var loadState: ScenicSpotLoadState = .idle { didSet { onChange?() } }
|
||||
|
||||
/// 按景区 ID 重新加载景点列表,失败只影响景点模块本身。
|
||||
func reload(scenicId: Int?, api: AccountContextServing) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if self.scenicId != scenicId {
|
||||
spots = []
|
||||
}
|
||||
self.scenicId = scenicId
|
||||
loadState = .loading
|
||||
|
||||
do {
|
||||
let response = try await api.scenicSpotListAll(scenicId: scenicId)
|
||||
guard !Task.isCancelled else { return }
|
||||
spots = response.list
|
||||
loadState = .loaded
|
||||
} catch is CancellationError {
|
||||
loadState = .idle
|
||||
} catch {
|
||||
guard !Task.isCancelled else { return }
|
||||
spots = []
|
||||
loadState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空景点上下文,通常在退出登录或没有当前景区时调用。
|
||||
func reset() {
|
||||
scenicId = nil
|
||||
spots = []
|
||||
loadState = .idle
|
||||
}
|
||||
}
|
||||
111
suixinkan_ios/App/State/SessionBootstrapper.swift
Normal file
111
suixinkan_ios/App/State/SessionBootstrapper.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// SessionBootstrapper.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 登录态启动恢复器,负责冷启动时从缓存恢复并校验 token。
|
||||
final class SessionBootstrapper {
|
||||
private let tokenStore: SessionTokenStore
|
||||
private let snapshotStore: AccountSnapshotStore
|
||||
private var didAttemptRestore = false
|
||||
|
||||
/// 初始化启动恢复器,并注入 token 与账号快照存储。
|
||||
init(
|
||||
tokenStore: SessionTokenStore,
|
||||
snapshotStore: AccountSnapshotStore
|
||||
) {
|
||||
self.tokenStore = tokenStore
|
||||
self.snapshotStore = snapshotStore
|
||||
}
|
||||
|
||||
/// 创建使用默认存储服务的启动恢复器。
|
||||
convenience init() {
|
||||
self.init(
|
||||
tokenStore: SessionTokenStore(),
|
||||
snapshotStore: AccountSnapshotStore()
|
||||
)
|
||||
}
|
||||
|
||||
/// 从本地缓存恢复登录态并请求服务端校验。
|
||||
func restore(
|
||||
appSession: AppSession,
|
||||
accountContext: AccountContext,
|
||||
permissionContext: PermissionContext,
|
||||
profileAPI: UserProfileServing,
|
||||
accountContextAPI: AccountContextServing,
|
||||
toastCenter: ToastCenter
|
||||
) async {
|
||||
guard !didAttemptRestore, !appSession.isLoggedIn else { return }
|
||||
didAttemptRestore = true
|
||||
|
||||
guard let token = tokenStore.load() else {
|
||||
appSession.logout()
|
||||
return
|
||||
}
|
||||
|
||||
appSession.beginRestoring(token: token)
|
||||
let cachedSnapshot = restoreCachedSnapshot(to: accountContext)
|
||||
|
||||
do {
|
||||
try await AccountContextLoader().refresh(
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
profileAPI: profileAPI,
|
||||
accountContextAPI: accountContextAPI,
|
||||
cachedCurrentRoleId: cachedSnapshot?.currentRoleId,
|
||||
cachedCurrentScenicId: cachedSnapshot?.currentScenicId,
|
||||
cachedCurrentStoreId: cachedSnapshot?.currentStoreId
|
||||
)
|
||||
saveLatestSnapshot(from: accountContext, permissionContext: permissionContext)
|
||||
appSession.markLoggedIn(token: token)
|
||||
} catch {
|
||||
if APIError.isAuthenticationExpired(error) {
|
||||
try? tokenStore.clear()
|
||||
snapshotStore.clear()
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
appSession.logout()
|
||||
toastCenter.show("登录状态已失效,请重新登录")
|
||||
} else {
|
||||
appSession.markLoggedIn(token: token)
|
||||
toastCenter.show("网络异常,已使用本地登录状态")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将缓存快照恢复到账号上下文。
|
||||
private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? {
|
||||
guard let snapshot = snapshotStore.load() else { return nil }
|
||||
accountContext.applyLogin(profile: snapshot.profile)
|
||||
accountContext.replaceScopes(
|
||||
scenic: snapshot.scenicScopes,
|
||||
stores: snapshot.storeScopes,
|
||||
currentScenicId: snapshot.currentScenicId,
|
||||
currentStoreId: snapshot.currentStoreId
|
||||
)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// 保存服务端校验后的最新账号上下文。
|
||||
private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) {
|
||||
let existing = snapshotStore.load()
|
||||
snapshotStore.save(
|
||||
AccountSnapshot(
|
||||
profile: accountContext.profile,
|
||||
accountType: existing?.accountType,
|
||||
businessUserId: existing?.businessUserId,
|
||||
currentRoleId: permissionContext.currentRole?.id ?? existing?.currentRoleId,
|
||||
scenicScopes: accountContext.scenicScopes,
|
||||
storeScopes: accountContext.storeScopes,
|
||||
currentScenicId: accountContext.currentScenic?.id,
|
||||
currentStoreId: accountContext.currentStore?.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
182
suixinkan_ios/App/State/ToastCenter.swift
Normal file
182
suixinkan_ios/App/State/ToastCenter.swift
Normal file
@ -0,0 +1,182 @@
|
||||
//
|
||||
// ToastCenter.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||||
final class ToastCenter {
|
||||
var onChange: (() -> Void)?
|
||||
private(set) var message: String? { didSet { onChange?() } }
|
||||
|
||||
private let autoDismissNanoseconds: UInt64
|
||||
private var dismissTask: Task<Void, Never>?
|
||||
private var displayToken: UInt64 = 0
|
||||
private weak var overlayView: ToastOverlayView?
|
||||
|
||||
/// 创建 Toast 状态中心,默认 2.2 秒后自动隐藏。
|
||||
init(autoDismissNanoseconds: UInt64 = 2_200_000_000) {
|
||||
self.autoDismissNanoseconds = autoDismissNanoseconds
|
||||
}
|
||||
|
||||
/// 显示一条全局 Toast 文案。
|
||||
func show(_ message: String) {
|
||||
displayToken &+= 1
|
||||
self.message = message
|
||||
scheduleAutoDismiss(token: displayToken)
|
||||
}
|
||||
|
||||
/// 清除当前 Toast 文案。
|
||||
func dismiss() {
|
||||
displayToken &+= 1
|
||||
dismissTask?.cancel()
|
||||
dismissTask = nil
|
||||
message = nil
|
||||
}
|
||||
|
||||
/// 将全局 Toast 展示层挂载到指定窗口,通常只在应用根部调用一次。
|
||||
func attachToWindow(_ window: UIWindow) {
|
||||
if let overlayView, overlayView.superview === window {
|
||||
return
|
||||
}
|
||||
|
||||
let overlay = ToastOverlayView(center: self)
|
||||
overlay.frame = window.bounds
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
window.addSubview(overlay)
|
||||
overlayView = overlay
|
||||
}
|
||||
|
||||
/// 为当前 Toast 安排自动隐藏任务,并避免旧任务误清除新 Toast。
|
||||
private func scheduleAutoDismiss(token: UInt64) {
|
||||
dismissTask?.cancel()
|
||||
let delay = autoDismissNanoseconds
|
||||
dismissTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
guard let self, self.displayToken == token else { return }
|
||||
self.message = nil
|
||||
self.dismissTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 测试专用快照,业务代码不应读取 Toast 展示状态。
|
||||
var snapshotForTests: ToastSnapshot {
|
||||
ToastSnapshot(message: message)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 全局 Toast 测试快照,用于验证文案和自动隐藏行为。
|
||||
struct ToastSnapshot: Equatable {
|
||||
let message: String?
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 全局 Toast 展示层,负责把 Toast 展示到窗口顶部。
|
||||
final class ToastOverlayView: UIView {
|
||||
private weak var toastCenter: ToastCenter?
|
||||
private let bannerView = UIView()
|
||||
private let messageLabel = UILabel()
|
||||
|
||||
/// 使用 Toast 状态中心初始化展示层。
|
||||
init(center: ToastCenter) {
|
||||
self.toastCenter = center
|
||||
super.init(frame: .zero)
|
||||
isUserInteractionEnabled = false
|
||||
configureViews()
|
||||
center.onChange = { [weak self] in
|
||||
self?.refreshPresentation(animated: true)
|
||||
}
|
||||
refreshPresentation(animated: false)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
private func configureViews() {
|
||||
bannerView.backgroundColor = AppDesign.primary
|
||||
bannerView.isHidden = true
|
||||
bannerView.alpha = 0
|
||||
|
||||
messageLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
messageLabel.textColor = .white
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.numberOfLines = 2
|
||||
|
||||
bannerView.addSubview(messageLabel)
|
||||
addSubview(bannerView)
|
||||
|
||||
bannerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
messageLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
bannerView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
bannerView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
bannerView.topAnchor.constraint(equalTo: topAnchor),
|
||||
|
||||
messageLabel.leadingAnchor.constraint(equalTo: bannerView.leadingAnchor, constant: AppMetrics.Spacing.pageHorizontal),
|
||||
messageLabel.trailingAnchor.constraint(equalTo: bannerView.trailingAnchor, constant: -AppMetrics.Spacing.pageHorizontal),
|
||||
messageLabel.topAnchor.constraint(equalTo: bannerView.safeAreaLayoutGuide.topAnchor, constant: AppMetrics.Spacing.small),
|
||||
messageLabel.bottomAnchor.constraint(equalTo: bannerView.bottomAnchor, constant: -AppMetrics.Spacing.small)
|
||||
])
|
||||
}
|
||||
|
||||
private func refreshPresentation(animated: Bool) {
|
||||
guard let message = toastCenter?.message, !message.isEmpty else {
|
||||
hideBanner(animated: animated)
|
||||
return
|
||||
}
|
||||
|
||||
messageLabel.text = message
|
||||
showBanner(animated: animated)
|
||||
}
|
||||
|
||||
private func showBanner(animated: Bool) {
|
||||
guard bannerView.isHidden || bannerView.alpha < 1 else { return }
|
||||
|
||||
bannerView.isHidden = false
|
||||
guard animated else {
|
||||
bannerView.alpha = 1
|
||||
bannerView.transform = .identity
|
||||
return
|
||||
}
|
||||
|
||||
bannerView.alpha = 0
|
||||
bannerView.transform = CGAffineTransform(translationX: 0, y: -12)
|
||||
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut]) {
|
||||
self.bannerView.alpha = 1
|
||||
self.bannerView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
private func hideBanner(animated: Bool) {
|
||||
guard !bannerView.isHidden else { return }
|
||||
|
||||
guard animated else {
|
||||
bannerView.isHidden = true
|
||||
bannerView.alpha = 0
|
||||
return
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut], animations: {
|
||||
self.bannerView.alpha = 0
|
||||
self.bannerView.transform = CGAffineTransform(translationX: 0, y: -12)
|
||||
}, completion: { _ in
|
||||
self.bannerView.isHidden = true
|
||||
self.bannerView.transform = .identity
|
||||
})
|
||||
}
|
||||
}
|
||||
138
suixinkan_ios/App/ViewControllers/RootViewController.swift
Normal file
138
suixinkan_ios/App/ViewControllers/RootViewController.swift
Normal 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
|
||||
}
|
||||
}
|
||||
36
suixinkan_ios/AppDelegate.swift
Normal file
36
suixinkan_ios/AppDelegate.swift
Normal 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.
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
6
suixinkan_ios/Assets.xcassets/Contents.json
Normal file
6
suixinkan_ios/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
25
suixinkan_ios/Base.lproj/LaunchScreen.storyboard
Normal file
25
suixinkan_ios/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||
93
suixinkan_ios/Core/Core.md
Normal file
93
suixinkan_ios/Core/Core.md
Normal 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、一次性扫码结果和错误提示不落盘。
|
||||
- 头像、证件照等图片缓存交给 Kingfisher,Core 不保存图片 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` 加载网络图片。
|
||||
111
suixinkan_ios/Core/Design/AppContentUnavailableView.swift
Normal file
111
suixinkan_ios/Core/Design/AppContentUnavailableView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
suixinkan_ios/Core/Design/AppDesign.swift
Normal file
21
suixinkan_ios/Core/Design/AppDesign.swift
Normal 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)
|
||||
}
|
||||
68
suixinkan_ios/Core/Design/AppMetrics.swift
Normal file
68
suixinkan_ios/Core/Design/AppMetrics.swift
Normal 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
|
||||
}
|
||||
}
|
||||
266
suixinkan_ios/Core/Design/GlobalLoadingCenter.swift
Normal file
266
suixinkan_ios/Core/Design/GlobalLoadingCenter.swift
Normal 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
|
||||
105
suixinkan_ios/Core/Location/ForegroundLocationProvider.swift
Normal file
105
suixinkan_ios/Core/Location/ForegroundLocationProvider.swift
Normal 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:
|
||||
"已有定位请求正在执行"
|
||||
}
|
||||
}
|
||||
}
|
||||
166
suixinkan_ios/Core/Models/SharedBusinessModels.swift
Normal file
166
suixinkan_ios/Core/Models/SharedBusinessModels.swift
Normal 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 ""
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 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
|
||||
}
|
||||
}
|
||||
255
suixinkan_ios/Core/Networking/APIClient.swift
Normal file
255
suixinkan_ios/Core/Networking/APIClient.swift
Normal 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,并注入公共 Header、token 和请求体。
|
||||
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
|
||||
}
|
||||
}
|
||||
62
suixinkan_ios/Core/Networking/APIEnvelope.swift
Normal file
62
suixinkan_ios/Core/Networking/APIEnvelope.swift
Normal file
@ -0,0 +1,62 @@
|
||||
//
|
||||
// APIEnvelope.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 后端统一响应包裹实体,承载业务 code、msg 和真实 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?
|
||||
}
|
||||
62
suixinkan_ios/Core/Networking/APIEnvironment.swift
Normal file
62
suixinkan_ios/Core/Networking/APIEnvironment.swift
Normal 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
|
||||
}
|
||||
}
|
||||
61
suixinkan_ios/Core/Networking/APIError.swift
Normal file
61
suixinkan_ios/Core/Networking/APIError.swift
Normal file
@ -0,0 +1,61 @@
|
||||
//
|
||||
// APIError.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 网络层错误实体,统一转换 URL、HTTP、业务码、解析和网络异常。
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
55
suixinkan_ios/Core/Networking/APIRequest.swift
Normal file
55
suixinkan_ios/Core/Networking/APIRequest.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
83
suixinkan_ios/Core/Networking/ListPayload.swift
Normal file
83
suixinkan_ios/Core/Networking/ListPayload.swift
Normal 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 {
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
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
|
||||
}
|
||||
}
|
||||
305
suixinkan_ios/Core/Networking/Networking.md
Normal file
305
suixinkan_ios/Core/Networking/Networking.md
Normal 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 下默认测试环境,真机调试时需要注意接口环境。
|
||||
30
suixinkan_ios/Core/Push/PushAPI.swift
Normal file
30
suixinkan_ios/Core/Push/PushAPI.swift
Normal 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)]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
151
suixinkan_ios/Core/Push/PushNotificationManager.swift
Normal file
151
suixinkan_ios/Core/Push/PushNotificationManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
99
suixinkan_ios/Core/Push/PushPayload.swift
Normal file
99
suixinkan_ios/Core/Push/PushPayload.swift
Normal 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 ""
|
||||
}
|
||||
}
|
||||
}
|
||||
73
suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift
Normal file
73
suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift
Normal 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: " ")
|
||||
}
|
||||
}
|
||||
295
suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift
Normal file
295
suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
166
suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift
Normal file
166
suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造订阅 payload,type 与 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
|
||||
}
|
||||
}
|
||||
105
suixinkan_ios/Core/Storage/AccountSnapshotStore.swift
Normal file
105
suixinkan_ios/Core/Storage/AccountSnapshotStore.swift
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
50
suixinkan_ios/Core/Storage/AppPreferencesStore.swift
Normal file
50
suixinkan_ios/Core/Storage/AppPreferencesStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
100
suixinkan_ios/Core/Storage/SessionTokenStore.swift
Normal file
100
suixinkan_ios/Core/Storage/SessionTokenStore.swift
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
62
suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift
Normal file
62
suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
107
suixinkan_ios/Core/UI/RemoteImage.swift
Normal file
107
suixinkan_ios/Core/UI/RemoteImage.swift
Normal 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
|
||||
}
|
||||
}
|
||||
31
suixinkan_ios/Core/UI/UIColor+AppDesign.swift
Normal file
31
suixinkan_ios/Core/UI/UIColor+AppDesign.swift
Normal 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
|
||||
}
|
||||
111
suixinkan_ios/Core/UI/ViewControllerHelpers.swift
Normal file
111
suixinkan_ios/Core/UI/ViewControllerHelpers.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// ViewControllerHelpers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// ViewController 通用工具,封装 Toast、Loading 和 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
|
||||
}
|
||||
}
|
||||
156
suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift
Normal file
156
suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift
Normal 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 }
|
||||
}
|
||||
283
suixinkan_ios/Core/Upload/OSSUploadService.swift
Normal file
283
suixinkan_ios/Core/Upload/OSSUploadService.swift
Normal 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格式"
|
||||
}
|
||||
}
|
||||
}
|
||||
52
suixinkan_ios/Core/Upload/Upload.md
Normal file
52
suixinkan_ios/Core/Upload/Upload.md
Normal 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 保存,上传模块不直接读取或保存登录态。
|
||||
39
suixinkan_ios/Core/Upload/UploadAPI.swift
Normal file
39
suixinkan_ios/Core/Upload/UploadAPI.swift
Normal 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 {}
|
||||
121
suixinkan_ios/Core/Upload/UploadImageProcessors.swift
Normal file
121
suixinkan_ios/Core/Upload/UploadImageProcessors.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
100
suixinkan_ios/Core/Upload/UploadModels.swift
Normal file
100
suixinkan_ios/Core/Upload/UploadModels.swift
Normal 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 ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
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
|
||||
}
|
||||
}
|
||||
74
suixinkan_ios/Core/Validation/AppFormValidator.swift
Normal file
74
suixinkan_ios/Core/Validation/AppFormValidator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
76
suixinkan_ios/Features/Account/API/AccountContextAPI.swift
Normal file
76
suixinkan_ios/Features/Account/API/AccountContextAPI.swift
Normal 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)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
36
suixinkan_ios/Features/Account/Account.md
Normal file
36
suixinkan_ios/Features/Account/Account.md
Normal 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 做隔离。
|
||||
355
suixinkan_ios/Features/Account/Models/AccountContextModels.swift
Normal file
355
suixinkan_ios/Features/Account/Models/AccountContextModels.swift
Normal 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 ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
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
|
||||
}
|
||||
|
||||
/// 将 Double、Int 或数字字符串宽松解码为浮点数。
|
||||
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
|
||||
}
|
||||
}
|
||||
373
suixinkan_ios/Features/Assets/API/AssetsAPI.swift
Normal file
373
suixinkan_ios/Features/Assets/API/AssetsAPI.swift
Normal 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
|
||||
}
|
||||
}
|
||||
90
suixinkan_ios/Features/Assets/Assets.md
Normal file
90
suixinkan_ios/Features/Assets/Assets.md
Normal 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 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。
|
||||
1021
suixinkan_ios/Features/Assets/Models/AssetsModels.swift
Normal file
1021
suixinkan_ios/Features/Assets/Models/AssetsModels.swift
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 {}
|
||||
974
suixinkan_ios/Features/Assets/ViewModels/AssetsViewModels.swift
Normal file
974
suixinkan_ios/Features/Assets/ViewModels/AssetsViewModels.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 媒体库上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材或样片。
|
||||
@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
|
||||
}
|
||||
}
|
||||
42
suixinkan_ios/Features/Auth/API/AuthAPI.swift
Normal file
42
suixinkan_ios/Features/Auth/API/AuthAPI.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
63
suixinkan_ios/Features/Auth/Auth.md
Normal file
63
suixinkan_ios/Features/Auth/Auth.md
Normal 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`,不落盘。
|
||||
409
suixinkan_ios/Features/Auth/Models/AuthModels.swift
Normal file
409
suixinkan_ios/Features/Auth/Models/AuthModels.swift
Normal 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 ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
212
suixinkan_ios/Features/Auth/ViewModels/LoginViewModel.swift
Normal file
212
suixinkan_ios/Features/Auth/ViewModels/LoginViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
38
suixinkan_ios/Features/Home/Home.md
Normal file
38
suixinkan_ios/Features/Home/Home.md
Normal 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 的映射,并同步补充单元测试。
|
||||
85
suixinkan_ios/Features/Home/HomeIconCatalog.swift
Normal file
85
suixinkan_ios/Features/Home/HomeIconCatalog.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
104
suixinkan_ios/Features/Home/Models/HomeMenuItem.swift
Normal file
104
suixinkan_ios/Features/Home/Models/HomeMenuItem.swift
Normal 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
|
||||
}
|
||||
}
|
||||
503
suixinkan_ios/Features/Home/Routing/HomeMenuRouter.swift
Normal file
503
suixinkan_ios/Features/Home/Routing/HomeMenuRouter.swift
Normal 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: " ")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
115
suixinkan_ios/Features/Home/ViewModels/HomeViewModel.swift
Normal file
115
suixinkan_ios/Features/Home/ViewModels/HomeViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
48
suixinkan_ios/Features/Invite/API/InviteAPI.swift
Normal file
48
suixinkan_ios/Features/Invite/API/InviteAPI.swift
Normal 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))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
30
suixinkan_ios/Features/Invite/Invite.md
Normal file
30
suixinkan_ios/Features/Invite/Invite.md
Normal 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 模块只调用其公开服务协议,不持有钱包业务状态。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
邀请码、邀请记录、奖励记录和二维码都不落盘。后续如果需要分享图片,应在分享流程中临时生成。
|
||||
156
suixinkan_ios/Features/Invite/Models/InviteModels.swift
Normal file
156
suixinkan_ios/Features/Invite/Models/InviteModels.swift
Normal 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 ""
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
203
suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift
Normal file
203
suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
159
suixinkan_ios/Features/Live/API/LiveAPI.swift
Normal file
159
suixinkan_ios/Features/Live/API/LiveAPI.swift
Normal 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 {}
|
||||
27
suixinkan_ios/Features/Live/Live.md
Normal file
27
suixinkan_ios/Features/Live/Live.md
Normal 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 提交给创建相册接口。
|
||||
415
suixinkan_ios/Features/Live/Models/LiveModels.swift
Normal file
415
suixinkan_ios/Features/Live/Models/LiveModels.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
466
suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift
Normal file
466
suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift
Normal 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))"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
39
suixinkan_ios/Features/LocationReport/LocationReport.md
Normal file
39
suixinkan_ios/Features/LocationReport/LocationReport.md
Normal 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 和路由测试。测试失败时先修复问题,再继续迁移后续功能。
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
/// 将 String、Double 和 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
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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
|
||||
}()
|
||||
}
|
||||
59
suixinkan_ios/Features/Main/Main.md
Normal file
59
suixinkan_ios/Features/Main/Main.md
Normal 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
Reference in New Issue
Block a user