// // 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 } }