Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,56 @@
//
// PilotCertificationAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
@MainActor
protocol PilotCertificationServing {
func flyerDetail() async throws -> FlyerDetailResponse
func flyerSendCode(phone: String) async throws
func flyerApply(_ request: FlyerApplyRequest) async throws
func flyerEdit(_ request: FlyerEditRequest) async throws
}
@MainActor
/// API `/api/app/flyer`
final class PilotCertificationAPI {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func flyerDetail() async throws -> FlyerDetailResponse {
try await client.send(APIRequest(method: .get, path: "/api/app/flyer/detail"))
}
///
func flyerSendCode(phone: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/flyer/send-code", body: FlyerSendCodeRequest(phone: phone))
)
}
///
func flyerApply(_ request: FlyerApplyRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/flyer/apply", body: request)
)
}
///
func flyerEdit(_ request: FlyerEditRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/flyer/edit", body: request)
)
}
}
extension PilotCertificationAPI: PilotCertificationServing {}

View File

@ -0,0 +1,310 @@
//
// PilotCertificationModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
enum PilotCertType: Int, CaseIterable, Identifiable {
case idCard = 1
case caac = 2
var id: Int { rawValue }
var title: String {
switch self {
case .idCard:
"身份证"
case .caac:
"民用无人机驾驶员执照"
}
}
}
///
struct FlyerDetailResponse: Decodable, Equatable {
let id: Int
let name: String
let accountId: String
let realnameStatus: Int
let status: Int
let statusName: String
let submitTime: String
let updatedAt: String
let auditPerson: String
let auditTime: String
let auditNote: String
let certificateType: Int
let certificateNo: String
let certificateStartDate: String
let certificateEndDate: String
let certificateImage: String
let droneModel: String
let droneSn: String
let contactPhone: String
let realnameStatusText: String
let certificationLogs: [FlyerCertificationLogItem]
enum CodingKeys: String, CodingKey {
case id
case name = "flyer_nickname"
case accountId = "account_id"
case realnameStatus = "realname_status"
case status
case statusName = "status_text"
case submitTime = "created_at"
case updatedAt = "updated_at"
case auditPerson = "reviewer"
case auditTime = "review_time"
case auditNote = "reject_reason"
case certificateType = "certificate_type"
case certificateNo = "certificate_no"
case certificateStartDate = "certificate_start_date"
case certificateEndDate = "certificate_end_date"
case certificateImage = "certificate_image"
case droneModel = "drone_model"
case droneSn = "drone_sn"
case contactPhone = "contact_phone"
case realnameStatusText = "realname_status_text"
case certificationLogs = "flyers_certification_logs"
}
init(
id: Int = 0,
name: String = "",
accountId: String = "",
realnameStatus: Int = 2,
status: Int = 0,
statusName: String = "",
submitTime: String = "",
updatedAt: String = "",
auditPerson: String = "",
auditTime: String = "",
auditNote: String = "",
certificateType: Int = 2,
certificateNo: String = "",
certificateStartDate: String = "",
certificateEndDate: String = "",
certificateImage: String = "",
droneModel: String = "",
droneSn: String = "",
contactPhone: String = "",
realnameStatusText: String = "",
certificationLogs: [FlyerCertificationLogItem] = []
) {
self.id = id
self.name = name
self.accountId = accountId
self.realnameStatus = realnameStatus
self.status = status
self.statusName = statusName
self.submitTime = submitTime
self.updatedAt = updatedAt
self.auditPerson = auditPerson
self.auditTime = auditTime
self.auditNote = auditNote
self.certificateType = certificateType
self.certificateNo = certificateNo
self.certificateStartDate = certificateStartDate
self.certificateEndDate = certificateEndDate
self.certificateImage = certificateImage
self.droneModel = droneModel
self.droneSn = droneSn
self.contactPhone = contactPhone
self.realnameStatusText = realnameStatusText
self.certificationLogs = certificationLogs
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
name = try container.pilotDecodeLossyString(forKey: .name)
accountId = try container.pilotDecodeLossyString(forKey: .accountId)
realnameStatus = try container.pilotDecodeLossyInt(forKey: .realnameStatus) ?? 2
status = try container.pilotDecodeLossyInt(forKey: .status) ?? 0
statusName = try container.pilotDecodeLossyString(forKey: .statusName)
submitTime = try container.pilotDecodeLossyString(forKey: .submitTime)
updatedAt = try container.pilotDecodeLossyString(forKey: .updatedAt)
auditPerson = try container.pilotDecodeLossyString(forKey: .auditPerson)
auditTime = try container.pilotDecodeLossyString(forKey: .auditTime)
auditNote = try container.pilotDecodeLossyString(forKey: .auditNote)
certificateType = try container.pilotDecodeLossyInt(forKey: .certificateType) ?? 2
certificateNo = try container.pilotDecodeLossyString(forKey: .certificateNo)
certificateStartDate = try container.pilotDecodeLossyString(forKey: .certificateStartDate)
certificateEndDate = try container.pilotDecodeLossyString(forKey: .certificateEndDate)
certificateImage = try container.pilotDecodeLossyString(forKey: .certificateImage)
droneModel = try container.pilotDecodeLossyString(forKey: .droneModel)
droneSn = try container.pilotDecodeLossyString(forKey: .droneSn)
contactPhone = try container.pilotDecodeLossyString(forKey: .contactPhone)
realnameStatusText = try container.pilotDecodeLossyString(forKey: .realnameStatusText)
certificationLogs = try container.decodeIfPresent([FlyerCertificationLogItem].self, forKey: .certificationLogs) ?? []
}
}
///
struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable {
let id: Int
let flyerId: Int
let operatorName: String
let action: Int
let actionText: String
let rejectReason: String
let remark: String
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case flyerId = "flyer_id"
case operatorName = "operator"
case action
case actionText = "action_text"
case rejectReason = "reject_reason"
case remark
case createdAt = "created_at"
}
init(
id: Int = 0,
flyerId: Int = 0,
operatorName: String = "",
action: Int = 0,
actionText: String = "",
rejectReason: String = "",
remark: String = "",
createdAt: String = ""
) {
self.id = id
self.flyerId = flyerId
self.operatorName = operatorName
self.action = action
self.actionText = actionText
self.rejectReason = rejectReason
self.remark = remark
self.createdAt = createdAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0
flyerId = try container.pilotDecodeLossyInt(forKey: .flyerId) ?? 0
operatorName = try container.pilotDecodeLossyString(forKey: .operatorName)
action = try container.pilotDecodeLossyInt(forKey: .action) ?? 0
actionText = try container.pilotDecodeLossyString(forKey: .actionText)
rejectReason = try container.pilotDecodeLossyString(forKey: .rejectReason)
remark = try container.pilotDecodeLossyString(forKey: .remark)
createdAt = try container.pilotDecodeLossyString(forKey: .createdAt)
}
}
///
struct FlyerSendCodeRequest: Encodable, Equatable {
let phone: String
}
///
struct FlyerApplyRequest: Encodable, Equatable {
let name: String
let realnameStatus: Int
let certificateType: Int
let certificateNo: String
let certificateStartDate: String
let certificateEndDate: String
let certificateImage: String
let droneModel: String
let droneSn: String
let contactPhone: String
let code: String
enum CodingKeys: String, CodingKey {
case name
case realnameStatus = "realname_status"
case certificateType = "certificate_type"
case certificateNo = "certificate_no"
case certificateStartDate = "certificate_start_date"
case certificateEndDate = "certificate_end_date"
case certificateImage = "certificate_image"
case droneModel = "drone_model"
case droneSn = "drone_sn"
case contactPhone = "contact_phone"
case code
}
}
///
struct FlyerEditRequest: Encodable, Equatable {
let id: Int
let name: String
let realnameStatus: Int
let certificateType: Int
let certificateNo: String
let certificateStartDate: String
let certificateEndDate: String
let certificateImage: String
let droneModel: String
let droneSn: String
let contactPhone: String
let code: String
enum CodingKeys: String, CodingKey {
case id
case name
case realnameStatus = "realname_status"
case certificateType = "certificate_type"
case certificateNo = "certificate_no"
case certificateStartDate = "certificate_start_date"
case certificateEndDate = "certificate_end_date"
case certificateImage = "certificate_image"
case droneModel = "drone_model"
case droneSn = "drone_sn"
case contactPhone = "contact_phone"
case code
}
}
///
enum PilotCertificationValidationError: LocalizedError, Equatable {
case message(String)
var errorDescription: String? {
switch self {
case .message(let message):
message
}
}
}
private extension KeyedDecodingContainer {
func pilotDecodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "true" : "false"
}
return ""
}
func pilotDecodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) { return intValue }
if let doubleValue = Double(text) { return Int(doubleValue) }
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
}

View File

@ -0,0 +1,17 @@
# PilotCertification 模块业务逻辑
## 模块职责
PilotCertification 负责首页 `pilot_cert` 入口的飞手认证申请。模块展示实名状态、飞手证件、无人机信息、联系方式和审核记录,并支持未提交或驳回状态下重新提交。
## 核心流程
- `PilotCertificationView` 从环境读取 `ProfileAPI``PilotCertificationAPI``OSSUploadService` 和当前景区。
- `PilotCertificationViewModel` 并行加载实名认证状态和飞手认证详情,单通道失败不清空另一通道数据。
- 用户选择证件图片后先保存在本地状态,提交时通过 OSS 上传并写入 `certificate_image`
- 未提交状态调用 `/api/app/flyer/apply`,驳回且有飞手记录 ID 时调用 `/api/app/flyer/edit`
- 验证码通过 `/api/app/flyer/send-code` 发送,成功后进入 60 秒本地倒计时。
## 边界
审核通过后页面只读。本模块不包含 DJI/飞控 SDK、无人机连接、飞行控制或横屏飞控流程。

View File

@ -0,0 +1,111 @@
//
// PilotCertificationViewController.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SnapKit
import UIKit
///
final class PilotCertificationViewController: ModuleTableViewController {
private let viewModel = PilotCertificationViewModel()
private let statusLabel = UILabel()
override func viewDidLoad() {
title = "飞手认证"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "提交",
style: .done,
target: self,
action: #selector(submit)
)
super.viewDidLoad()
setupHeader()
wireViewModel(viewModel) { [weak self] in self?.updateHeader() }
}
private func setupHeader() {
statusLabel.numberOfLines = 0
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.textColor = AppDesign.textSecondary
statusLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 80)
tableView.tableHeaderView = statusLabel
}
override func numberOfSections(in tableView: UITableView) -> Int { 2 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? formRows.count : 1
}
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 0 ? "认证信息" : "操作"
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier,
for: indexPath
) as! TitleSubtitleTableViewCell
if indexPath.section == 0 {
let row = formRows[indexPath.row]
cell.configure(title: row.title, subtitle: row.value)
} else {
cell.configure(title: "发送验证码", subtitle: viewModel.phone)
}
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if indexPath.section == 1 {
Task {
do {
try await viewModel.sendCode(api: services.pilotCertificationAPI)
showToast("验证码已发送")
} catch {
showToast(error.localizedDescription)
}
}
}
}
override func reloadContent() async {
await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI)
updateHeader()
}
@objc private func submit() {
Task {
do {
try await viewModel.submit(
api: services.pilotCertificationAPI,
uploader: services.ossUploadService,
scenicId: services.currentScenicId
)
services.toastCenter.show("提交成功")
} catch {
services.toastCenter.show(error.localizedDescription)
}
}
}
private var formRows: [(title: String, value: String)] {
[
("姓名", viewModel.name),
("证件号", viewModel.certNo),
("手机号", viewModel.phone),
("无人机型号", viewModel.droneModel),
("序列号", viewModel.droneSerialNo)
]
}
private func updateHeader() {
let status = viewModel.auditStatusText
statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")"
}
}
extension PilotCertificationViewModel: ViewModelBindable {}

View File

@ -0,0 +1,281 @@
//
// 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
}
}