Files
suixinkan_ios_new/suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift
汉秋 41dda3cc9b Add OperatingArea and PilotCertification modules with live push readiness.
Migrate operating area and pilot certification from home placeholders, extend Live with playback and push readiness flows, and add pilot certificate OSS upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 18:15:59 +08:00

283 lines
9.9 KiB
Swift
Raw 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 Observation
@MainActor
/// 便 ViewModel
protocol PilotRealNameServing {
func realNameInfo() async throws -> RealNameInfoResponse
}
extension ProfileAPI: PilotRealNameServing {}
@MainActor
@Observable
/// ViewModel
final class PilotCertificationViewModel {
private(set) var flyer: FlyerDetailResponse?
private(set) var realNameInfo: RealNameInfo?
var name = ""
var certType: PilotCertType = .caac
var certNo = ""
var certImageUrl = ""
var startDate = Date()
var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date()
var droneModel = ""
var droneSerialNo = ""
var phone = ""
var verifyCode = ""
private(set) var pendingCertificateImageData: Data?
private(set) var pendingCertificateFileName: String?
private(set) var uploadProgress: Int?
private(set) var loading = false
private(set) var sendingCode = false
private(set) var submitting = false
private(set) var countdown = 0
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
}
}