Files
suixinkan_ios_new/suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

282 lines
10 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// PilotCertificationViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import Combine
@MainActor
/// 便 ViewModel
protocol PilotRealNameServing {
func realNameInfo() async throws -> RealNameInfoResponse
}
extension ProfileAPI: PilotRealNameServing {}
@MainActor
/// ViewModel
final class PilotCertificationViewModel: ObservableObject {
@Published private(set) var flyer: FlyerDetailResponse?
@Published private(set) var realNameInfo: RealNameInfo?
@Published var name = ""
@Published var certType: PilotCertType = .caac
@Published var certNo = ""
@Published var certImageUrl = ""
@Published var startDate = Date()
@Published var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date()
@Published var droneModel = ""
@Published var droneSerialNo = ""
@Published var phone = ""
@Published var verifyCode = ""
@Published private(set) var pendingCertificateImageData: Data?
@Published private(set) var pendingCertificateFileName: String?
@Published private(set) var uploadProgress: Int?
@Published private(set) var loading = false
@Published private(set) var sendingCode = false
@Published private(set) var submitting = false
@Published private(set) var countdown = 0
@Published var statusMessage: String?
///
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 }
}
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
}
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
}
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
)
}
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
)
}
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
}()
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)
}
}
private struct PilotEmptyRealNameServing: PilotRealNameServing {
let info: 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
}
}