282 lines
10 KiB
Swift
282 lines
10 KiB
Swift
//
|
||
// PilotCertificationViewModel.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import Foundation
|
||
|
||
@MainActor
|
||
/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。
|
||
protocol PilotRealNameServing {
|
||
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 }
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|