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:
@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user