Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
291 lines
11 KiB
Swift
291 lines
11 KiB
Swift
//
|
||
// PilotCertificationViewModel.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import Foundation
|
||
|
||
@MainActor
|
||
/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。
|
||
protocol PilotRealNameServing {
|
||
/// realNameInfo相关逻辑。
|
||
func realNameInfo() async throws -> RealNameInfoResponse
|
||
}
|
||
|
||
extension ProfileAPI: PilotRealNameServing {}
|
||
|
||
@MainActor
|
||
/// 飞手认证 ViewModel,管理审核状态、表单、验证码、证件图上传和提交。
|
||
final class PilotCertificationViewModel {
|
||
var onChange: (() -> Void)?
|
||
private(set) var flyer: FlyerDetailResponse? { didSet { onChange?() } }
|
||
private(set) var realNameInfo: RealNameInfo? { didSet { onChange?() } }
|
||
var name = "" { didSet { onChange?() } }
|
||
var certType: PilotCertType = .caac { didSet { onChange?() } }
|
||
var certNo = "" { didSet { onChange?() } }
|
||
var certImageUrl = "" { didSet { onChange?() } }
|
||
var startDate = Date() { didSet { onChange?() } }
|
||
var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date() { didSet { onChange?() } }
|
||
var droneModel = "" { didSet { onChange?() } }
|
||
var droneSerialNo = "" { didSet { onChange?() } }
|
||
var phone = "" { didSet { onChange?() } }
|
||
var verifyCode = "" { didSet { onChange?() } }
|
||
private(set) var pendingCertificateImageData: Data? { didSet { onChange?() } }
|
||
private(set) var pendingCertificateFileName: String? { didSet { onChange?() } }
|
||
private(set) var uploadProgress: Int? { didSet { onChange?() } }
|
||
private(set) var loading = false { didSet { onChange?() } }
|
||
private(set) var sendingCode = false { didSet { onChange?() } }
|
||
private(set) var submitting = false { didSet { onChange?() } }
|
||
private(set) var countdown = 0 { didSet { onChange?() } }
|
||
var statusMessage: String? { didSet { onChange?() } }
|
||
|
||
/// 加载实名状态和飞手认证详情,单通道失败不清空另一通道数据。
|
||
func load(api: any PilotCertificationServing, realNameAPI: any PilotRealNameServing) async {
|
||
guard !loading else { return }
|
||
loading = true
|
||
defer { loading = false }
|
||
|
||
async let realNameTask = realNameAPI.realNameInfo()
|
||
async let flyerTask = api.flyerDetail()
|
||
var messages: [String] = []
|
||
|
||
do {
|
||
let response = try await realNameTask
|
||
realNameInfo = response.realNameInfo
|
||
} catch {
|
||
messages.append(error.localizedDescription)
|
||
}
|
||
|
||
do {
|
||
apply(try await flyerTask)
|
||
} catch {
|
||
messages.append(error.localizedDescription)
|
||
}
|
||
|
||
statusMessage = messages.isEmpty ? nil : messages.joined(separator: "\n")
|
||
}
|
||
|
||
/// 选择待上传证件图。
|
||
func prepareCertificateImage(data: Data, fileName: String) {
|
||
pendingCertificateImageData = data
|
||
pendingCertificateFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
? "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg"
|
||
: fileName
|
||
certImageUrl = ""
|
||
uploadProgress = nil
|
||
}
|
||
|
||
/// 发送验证码,成功后进入 60 秒倒计时。
|
||
func sendCode(api: any PilotCertificationServing) async throws {
|
||
guard canSendCode else {
|
||
throw PilotCertificationValidationError.message("请输入有效的手机号码")
|
||
}
|
||
sendingCode = true
|
||
defer { sendingCode = false }
|
||
|
||
try await api.flyerSendCode(phone: AppFormValidator.normalizedPhoneNumber(phone))
|
||
countdown = 60
|
||
statusMessage = "验证码已发送"
|
||
}
|
||
|
||
/// 倒计时递减,由页面定时器驱动。
|
||
func tickCountdown() {
|
||
if countdown > 0 {
|
||
countdown -= 1
|
||
}
|
||
}
|
||
|
||
/// 提交飞手认证,必要时先上传证件图。
|
||
func submit(api: any PilotCertificationServing, uploader: any OSSUploadServing, scenicId: Int?) async throws {
|
||
if let validationMessage {
|
||
throw PilotCertificationValidationError.message(validationMessage)
|
||
}
|
||
|
||
submitting = true
|
||
defer { submitting = false }
|
||
|
||
try await uploadPendingCertificateImage(uploader: uploader, scenicId: scenicId)
|
||
if auditStatus == 3, flyerId > 0 {
|
||
try await api.flyerEdit(makeEditRequest())
|
||
statusMessage = "修改已提交"
|
||
} else {
|
||
try await api.flyerApply(makeApplyRequest())
|
||
statusMessage = "认证申请已提交"
|
||
}
|
||
await load(api: api, realNameAPI: PilotEmptyRealNameServing(info: realNameInfo))
|
||
}
|
||
|
||
/// 审核状态编码。
|
||
var auditStatus: Int {
|
||
flyer?.status ?? 0
|
||
}
|
||
|
||
/// 飞手认证记录 ID。
|
||
var flyerId: Int {
|
||
flyer?.id ?? 0
|
||
}
|
||
|
||
/// 审核通过后表单只读。
|
||
var isReadOnly: Bool {
|
||
auditStatus == 2
|
||
}
|
||
|
||
/// 实名是否已通过。
|
||
var isRealNameVerified: Bool {
|
||
realNameInfo?.verified == true || flyer?.realnameStatus == 2
|
||
}
|
||
|
||
/// 是否允许发送验证码。
|
||
var canSendCode: Bool {
|
||
!sendingCode && countdown == 0 && (auditStatus == 0 || auditStatus == 3) && AppFormValidator.isValidMainlandPhoneNumber(phone)
|
||
}
|
||
|
||
/// 当前表单校验错误。
|
||
var validationMessage: String? {
|
||
if isReadOnly { return "认证已通过,无需重复提交" }
|
||
if auditStatus != 0 && auditStatus != 3 { return "认证审核中,请等待审核结果" }
|
||
if name.trimmedForPilot.isEmpty { return "请输入飞手昵称" }
|
||
if certNo.trimmedForPilot.isEmpty { return "请输入证件号码" }
|
||
if certImageUrl.trimmedForPilot.isEmpty && pendingCertificateImageData == nil { return "请选择证件图片" }
|
||
if endDate < startDate { return "截至日期不能早于起始日期" }
|
||
if droneModel.trimmedForPilot.isEmpty { return "请输入无人机型号" }
|
||
if droneSerialNo.trimmedForPilot.isEmpty { return "请输入无人机序列号" }
|
||
if phone.trimmedForPilot.isEmpty { return "请输入手机号码" }
|
||
if !AppFormValidator.isValidMainlandPhoneNumber(phone) { return "请输入有效的手机号码" }
|
||
if verifyCode.trimmedForPilot.isEmpty { return "请输入验证码" }
|
||
return nil
|
||
}
|
||
|
||
/// 审核状态展示文本。
|
||
var auditStatusText: String {
|
||
flyer?.statusName.trimmedForPilot.nonEmptyForPilot ?? Self.statusName(for: auditStatus)
|
||
}
|
||
|
||
/// 最近审核日志。
|
||
var latestAuditLog: FlyerCertificationLogItem? {
|
||
flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 }
|
||
}
|
||
|
||
/// apply 业务逻辑。
|
||
private func apply(_ flyer: FlyerDetailResponse) {
|
||
self.flyer = flyer
|
||
name = flyer.name
|
||
certType = PilotCertType(rawValue: flyer.certificateType) ?? .caac
|
||
certNo = flyer.certificateNo
|
||
certImageUrl = flyer.certificateImage
|
||
startDate = Self.date(from: flyer.certificateStartDate) ?? startDate
|
||
endDate = Self.date(from: flyer.certificateEndDate) ?? endDate
|
||
droneModel = flyer.droneModel
|
||
droneSerialNo = flyer.droneSn
|
||
phone = flyer.contactPhone
|
||
pendingCertificateImageData = nil
|
||
pendingCertificateFileName = nil
|
||
uploadProgress = nil
|
||
}
|
||
|
||
/// upload待处理Certificate图片相关逻辑。
|
||
private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws {
|
||
guard let data = pendingCertificateImageData else { return }
|
||
guard let scenicId, scenicId > 0 else {
|
||
throw PilotCertificationValidationError.message("请先选择景区")
|
||
}
|
||
uploadProgress = 1
|
||
defer { uploadProgress = nil }
|
||
certImageUrl = try await uploader.uploadPilotCertificateImage(
|
||
data: data,
|
||
fileName: pendingCertificateFileName ?? "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg",
|
||
scenicId: scenicId
|
||
) { [weak self] progress in
|
||
Task { @MainActor in
|
||
self?.uploadProgress = progress
|
||
}
|
||
}
|
||
pendingCertificateImageData = nil
|
||
pendingCertificateFileName = nil
|
||
}
|
||
|
||
/// 创建ApplyRequest实例。
|
||
private func makeApplyRequest() -> FlyerApplyRequest {
|
||
FlyerApplyRequest(
|
||
name: name.trimmedForPilot,
|
||
realnameStatus: isRealNameVerified ? 1 : 2,
|
||
certificateType: certType.rawValue,
|
||
certificateNo: certNo.trimmedForPilot,
|
||
certificateStartDate: Self.dateFormatter.string(from: startDate),
|
||
certificateEndDate: Self.dateFormatter.string(from: endDate),
|
||
certificateImage: certImageUrl.trimmedForPilot,
|
||
droneModel: droneModel.trimmedForPilot,
|
||
droneSn: droneSerialNo.trimmedForPilot,
|
||
contactPhone: AppFormValidator.normalizedPhoneNumber(phone),
|
||
code: verifyCode.trimmedForPilot
|
||
)
|
||
}
|
||
|
||
/// 创建EditRequest实例。
|
||
private func makeEditRequest() -> FlyerEditRequest {
|
||
let apply = makeApplyRequest()
|
||
return FlyerEditRequest(
|
||
id: flyerId,
|
||
name: apply.name,
|
||
realnameStatus: apply.realnameStatus,
|
||
certificateType: apply.certificateType,
|
||
certificateNo: apply.certificateNo,
|
||
certificateStartDate: apply.certificateStartDate,
|
||
certificateEndDate: apply.certificateEndDate,
|
||
certificateImage: apply.certificateImage,
|
||
droneModel: apply.droneModel,
|
||
droneSn: apply.droneSn,
|
||
contactPhone: apply.contactPhone,
|
||
code: apply.code
|
||
)
|
||
}
|
||
|
||
/// statusName相关逻辑。
|
||
private static func statusName(for status: Int) -> String {
|
||
switch status {
|
||
case 1: "审核中"
|
||
case 2: "已通过"
|
||
case 3: "审核失败"
|
||
default: "未提交"
|
||
}
|
||
}
|
||
|
||
private static let dateFormatter: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.calendar = Calendar(identifier: .gregorian)
|
||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
return formatter
|
||
}()
|
||
|
||
/// date 业务逻辑。
|
||
private static func date(from value: String) -> Date? {
|
||
let text = String(value.prefix(10))
|
||
guard !text.isEmpty else { return nil }
|
||
return dateFormatter.date(from: text)
|
||
}
|
||
}
|
||
|
||
/// PilotEmptyRealName服务协议,定义模块对外能力。
|
||
private struct PilotEmptyRealNameServing: PilotRealNameServing {
|
||
let info: RealNameInfo?
|
||
|
||
/// realNameInfo相关逻辑。
|
||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||
RealNameInfoResponse(realNameInfo: info)
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var trimmedForPilot: String {
|
||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
|
||
var nonEmptyForPilot: String? {
|
||
let value = trimmedForPilot
|
||
return value.isEmpty ? nil : value
|
||
}
|
||
}
|