新增运营区域与飞手认证模块,并完善直播推流就绪流程
将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,282 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user