// // 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: 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 } }