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>
This commit is contained in:
2026-06-25 18:15:59 +08:00
parent fdf4659048
commit 41dda3cc9b
33 changed files with 3455 additions and 37 deletions

View File

@ -0,0 +1,422 @@
//
// PilotCertificationViews.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Combine
import PhotosUI
import SwiftUI
import UIKit
/// /
struct PilotCertificationView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(ProfileAPI.self) private var profileAPI
@Environment(PilotCertificationAPI.self) private var pilotAPI
@Environment(OSSUploadService.self) private var ossUploadService
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel = PilotCertificationViewModel()
@State private var selectedImageItem: PhotosPickerItem?
private let countdownTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.auditStatus != 0 {
auditStatusCard
}
realNameCard
certificateCard
droneCard
contactCard
if let message = viewModel.statusMessage, !message.isEmpty {
messageBanner(message)
}
}
.padding(AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
.padding(.bottom, viewModel.isReadOnly ? 0 : 88)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("飞手认证")
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
if !viewModel.isReadOnly {
submitBar
}
}
.task {
await reload(showLoading: true)
}
.onReceive(countdownTimer) { _ in
viewModel.tickCountdown()
}
.onChange(of: selectedImageItem) { _, item in
guard let item else { return }
Task { await loadImage(item) }
}
}
private var auditStatusCard: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text(viewModel.auditStatusText)
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(auditColor)
Spacer()
Image(systemName: auditIcon)
.foregroundStyle(auditColor)
}
PilotInfoRow(title: "提交时间", value: viewModel.flyer?.submitTime.nonEmptyPilotView ?? "--")
PilotInfoRow(title: "审核时间", value: viewModel.latestAuditLog?.createdAt.nonEmptyPilotView ?? viewModel.flyer?.auditTime.nonEmptyPilotView ?? "--")
PilotInfoRow(title: "审核人", value: viewModel.latestAuditLog?.operatorName.nonEmptyPilotView ?? viewModel.flyer?.auditPerson.nonEmptyPilotView ?? "--")
PilotInfoRow(title: "审核备注", value: viewModel.latestAuditLog?.rejectReason.nonEmptyPilotView ?? viewModel.flyer?.auditNote.nonEmptyPilotView ?? "--")
}
.padding(AppMetrics.Spacing.medium)
.background(auditColor.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(auditColor.opacity(0.35), lineWidth: 1))
}
private var realNameCard: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("实名状态")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text(viewModel.isRealNameVerified ? "已完成实名认证" : "飞手认证前建议先完成实名认证")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Text(viewModel.isRealNameVerified ? "已认证" : "未认证")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.frame(height: 28)
.background(viewModel.isRealNameVerified ? AppDesign.success : AppDesign.warning, in: Capsule())
}
if !viewModel.isRealNameVerified {
NavigationLink {
RealNameAuthView()
} label: {
Label("去认证", systemImage: "person.text.rectangle")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.borderedProminent)
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var certificateCard: some View {
PilotSectionCard(title: "飞手证件信息") {
PilotTextField(title: "飞手昵称", text: $viewModel.name, placeholder: "请输入飞手昵称", disabled: viewModel.isReadOnly)
VStack(alignment: .leading, spacing: 8) {
Text("证件类型")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
Picker("证件类型", selection: $viewModel.certType) {
ForEach(PilotCertType.allCases) { type in
Text(type.title).tag(type)
}
}
.pickerStyle(.menu)
.disabled(viewModel.isReadOnly)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.frame(height: 48)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
}
PilotTextField(title: "证件号码", text: $viewModel.certNo, placeholder: "请输入证件号码", disabled: viewModel.isReadOnly)
certificateImagePicker
HStack(spacing: AppMetrics.Spacing.small) {
PilotDateField(title: "起始日期", date: $viewModel.startDate, disabled: viewModel.isReadOnly)
PilotDateField(title: "截至日期", date: $viewModel.endDate, disabled: viewModel.isReadOnly)
}
}
}
private var certificateImagePicker: some View {
VStack(alignment: .leading, spacing: 8) {
Text("证件图片")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
ZStack {
if let data = viewModel.pendingCertificateImageData,
let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else if !viewModel.certImageUrl.isEmpty {
RemoteImage(urlString: viewModel.certImageUrl, contentMode: .fill) {
certificatePlaceholder("证件图片加载中")
}
} else {
certificatePlaceholder("点击选择证件图片")
}
}
.frame(maxWidth: .infinity)
.frame(height: 150)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(hex: 0xE2E8F0), style: StrokeStyle(lineWidth: 1, dash: [5, 4])))
if !viewModel.isReadOnly {
PhotosPicker(selection: $selectedImageItem, matching: .images) {
Label(viewModel.pendingCertificateImageData == nil && viewModel.certImageUrl.isEmpty ? "选择图片" : "更换图片", systemImage: "photo")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: 42)
}
.buttonStyle(.bordered)
}
if let progress = viewModel.uploadProgress {
ProgressView(value: Double(progress), total: 100)
}
}
}
private var droneCard: some View {
PilotSectionCard(title: "绑定无人机信息") {
PilotTextField(title: "无人机型号", text: $viewModel.droneModel, placeholder: "请输入无人机型号", disabled: viewModel.isReadOnly)
PilotTextField(title: "无人机序列号", text: $viewModel.droneSerialNo, placeholder: "请输入无人机序列号", disabled: viewModel.isReadOnly)
}
}
private var contactCard: some View {
PilotSectionCard(title: "联系信息") {
HStack(alignment: .bottom, spacing: AppMetrics.Spacing.small) {
PilotTextField(title: "手机号码", text: $viewModel.phone, placeholder: "请输入手机号", keyboard: .phonePad, disabled: viewModel.isReadOnly)
if !viewModel.isReadOnly {
Button {
Task { await sendCode() }
} label: {
Text(codeButtonText)
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.frame(width: 96, height: 48)
}
.buttonStyle(.bordered)
.disabled(!viewModel.canSendCode)
}
}
if !viewModel.isReadOnly {
PilotTextField(title: "验证码", text: $viewModel.verifyCode, placeholder: "请输入验证码", keyboard: .numberPad, disabled: false)
}
}
}
private var submitBar: some View {
VStack(spacing: 0) {
Divider()
Button {
Task { await submit() }
} label: {
HStack(spacing: 8) {
if viewModel.submitting {
ProgressView()
.tint(.white)
}
Text(viewModel.auditStatus == 3 ? "重新提交" : "提交认证")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
.background(viewModel.validationMessage == nil ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(viewModel.validationMessage != nil || viewModel.submitting)
.padding(AppMetrics.Spacing.medium)
.background(.white)
}
}
private var auditColor: Color {
switch viewModel.auditStatus {
case 2:
AppDesign.success
case 3:
Color(hex: 0xDC2626)
case 1:
AppDesign.warning
default:
AppDesign.textSecondary
}
}
private var auditIcon: String {
switch viewModel.auditStatus {
case 2:
"checkmark.seal.fill"
case 3:
"xmark.seal.fill"
case 1:
"clock.badge.checkmark.fill"
default:
"paperplane.fill"
}
}
private var codeButtonText: String {
if viewModel.countdown > 0 { return "\(viewModel.countdown)s" }
return viewModel.sendingCode ? "发送中" : "获取验证码"
}
private func certificatePlaceholder(_ text: String) -> some View {
VStack(spacing: 8) {
Image(systemName: "photo.badge.plus")
.font(.system(size: 34, weight: .semibold))
Text(text)
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
}
.foregroundStyle(AppDesign.primary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppDesign.primarySoft)
}
private func messageBanner(_ text: String) -> some View {
HStack(spacing: 8) {
Image(systemName: text.contains("") ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
Text(text)
.font(.system(size: AppMetrics.FontSize.caption))
.fixedSize(horizontal: false, vertical: true)
}
.foregroundStyle(text.contains("") ? AppDesign.success : AppDesign.warning)
.padding(AppMetrics.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading)
.background((text.contains("") ? AppDesign.success : AppDesign.warning).opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
}
private func reload(showLoading: Bool) async {
await globalLoading.withOptionalLoading(showLoading) {
await viewModel.load(api: pilotAPI, realNameAPI: profileAPI)
}
if let message = viewModel.statusMessage, !message.isEmpty {
toastCenter.show(message)
}
}
private func loadImage(_ item: PhotosPickerItem) async {
do {
if let data = try await item.loadTransferable(type: Data.self) {
viewModel.prepareCertificateImage(data: data, fileName: "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg")
}
} catch {
toastCenter.show(error.localizedDescription)
}
selectedImageItem = nil
}
private func sendCode() async {
do {
try await viewModel.sendCode(api: pilotAPI)
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func submit() async {
do {
try await globalLoading.withLoading {
try await viewModel.submit(api: pilotAPI, uploader: ossUploadService, scenicId: accountContext.currentScenic?.id)
}
toastCenter.show(viewModel.statusMessage ?? "提交成功")
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
private struct PilotSectionCard<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
Text(title)
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
content()
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
}
private struct PilotTextField: View {
let title: String
@Binding var text: String
let placeholder: String
var keyboard: UIKeyboardType = .default
let disabled: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
TextField(placeholder, text: $text)
.keyboardType(keyboard)
.disabled(disabled)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.appInputFieldStyle(cornerRadius: 8, minHeight: 48)
}
}
}
private struct PilotDateField: View {
let title: String
@Binding var date: Date
let disabled: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
DatePicker(title, selection: $date, displayedComponents: .date)
.labelsHidden()
.disabled(disabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.frame(height: 48)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct PilotInfoRow: View {
let title: String
let value: String
var body: some View {
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.frame(width: 72, alignment: .leading)
Text(value)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
}
}
}
private extension String {
var nonEmptyPilotView: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}