Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user