新增运营区域与飞手认证模块,并完善直播推流就绪流程
将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 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