将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
15 KiB
Swift
358 lines
15 KiB
Swift
//
|
||
// OrderTailUploadViews.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import PhotosUI
|
||
import SwiftUI
|
||
import UniformTypeIdentifiers
|
||
|
||
/// 多点旅拍任务上传页面,负责选择打卡点和素材后提交到订单接口。
|
||
struct MultiTravelTaskUploadView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.ordersAPI) private var ordersAPI
|
||
@Environment(\.ossUploadService) private var uploadService
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
@StateObject private var viewModel: MultiTravelTaskUploadViewModel
|
||
@State private var showSpotPicker = false
|
||
@State private var showCloudSelection = false
|
||
@State private var pickerItems: [PhotosPickerItem] = []
|
||
|
||
/// 使用订单号初始化任务上传页面。
|
||
init(initialOrderNumber: String) {
|
||
_viewModel = StateObject(wrappedValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber))
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
orderSection
|
||
cloudFileSection
|
||
localFileSection
|
||
submitButton
|
||
}
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||
}
|
||
.background(Color(hex: 0xF5F7FA))
|
||
.navigationTitle("任务上传")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task {
|
||
await viewModel.loadSpots(api: ordersAPI)
|
||
}
|
||
.sheet(isPresented: $showCloudSelection) {
|
||
NavigationStack {
|
||
TaskCloudFileSelectionView { items in
|
||
viewModel.mergeCloudFiles(items)
|
||
showCloudSelection = false
|
||
}
|
||
}
|
||
}
|
||
.confirmationDialog("选择打卡点", isPresented: $showSpotPicker, titleVisibility: .visible) {
|
||
ForEach(viewModel.spots) { spot in
|
||
Button(spot.name.isEmpty ? "打卡点 \(spot.id)" : spot.name) {
|
||
viewModel.selectSpot(id: spot.id)
|
||
}
|
||
}
|
||
Button("取消", role: .cancel) {}
|
||
}
|
||
.onChange(of: pickerItems) { items in
|
||
Task { await importPickerItems(items) }
|
||
}
|
||
.alert("提示", isPresented: errorBinding) {
|
||
Button("知道了", role: .cancel) {}
|
||
} message: {
|
||
Text(viewModel.errorMessage ?? "")
|
||
}
|
||
}
|
||
|
||
/// 订单号和打卡点选择区域。
|
||
private var orderSection: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
Text("基础信息")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
|
||
TextField("关联订单号", text: $viewModel.orderNumber)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
Button(viewModel.isLoadingSpots ? "加载中..." : "刷新打卡点") {
|
||
Task { await viewModel.loadSpots(api: ordersAPI) }
|
||
}
|
||
.disabled(viewModel.isLoadingSpots)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
.frame(width: 104, height: 40)
|
||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
|
||
Button {
|
||
showSpotPicker = true
|
||
} label: {
|
||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||
Text(viewModel.selectedSpotName)
|
||
.lineLimit(1)
|
||
Spacer()
|
||
Image(systemName: "chevron.down")
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 40)
|
||
.background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(viewModel.spots.isEmpty)
|
||
}
|
||
|
||
if viewModel.spots.isEmpty && !viewModel.isLoadingSpots {
|
||
Text("暂无可选打卡点,请确认订单已完成核销。")
|
||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
/// 云盘素材选择区域。
|
||
private var cloudFileSection: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
HStack {
|
||
Text("云盘素材")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Spacer()
|
||
Button("选择云盘文件") {
|
||
showCloudSelection = true
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
|
||
if viewModel.selectedCloudFiles.isEmpty {
|
||
Text("未选择云盘文件")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
} else {
|
||
ForEach(viewModel.selectedCloudFiles) { file in
|
||
OrderTaskAttachmentRow(
|
||
title: file.fileName.isEmpty ? "文件 \(file.id)" : file.fileName,
|
||
subtitle: "云盘文件"
|
||
) {
|
||
viewModel.removeCloudFile(id: file.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
/// 本地素材选择区域。
|
||
private var localFileSection: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
HStack {
|
||
Text("本地素材")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Spacer()
|
||
PhotosPicker(selection: $pickerItems, maxSelectionCount: 9, matching: .any(of: [.images, .videos])) {
|
||
Text("选择图片/视频")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
}
|
||
}
|
||
|
||
if viewModel.selectedLocalFiles.isEmpty {
|
||
Text("未选择本地文件")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
} else {
|
||
ForEach(viewModel.selectedLocalFiles) { file in
|
||
OrderTaskAttachmentRow(title: file.fileName, subtitle: file.statusText) {
|
||
viewModel.removeLocalFile(id: file.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
}
|
||
|
||
/// 提交按钮。
|
||
private var submitButton: some View {
|
||
Button {
|
||
Task { await submit() }
|
||
} label: {
|
||
Text(viewModel.isSubmitting ? "保存中..." : "保存任务素材")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||
.background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||
}
|
||
.disabled(!viewModel.canSubmit)
|
||
}
|
||
|
||
private var errorBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { viewModel.errorMessage != nil },
|
||
set: { visible in
|
||
if !visible { viewModel.clearError() }
|
||
}
|
||
)
|
||
}
|
||
|
||
/// 导入本地图片或视频。
|
||
private func importPickerItems(_ items: [PhotosPickerItem]) async {
|
||
for item in items {
|
||
do {
|
||
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
|
||
viewModel.addLocalFile(data: data, fileName: Self.fileName(for: item))
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
pickerItems = []
|
||
}
|
||
|
||
/// 提交任务素材。
|
||
private func submit() async {
|
||
let success = await globalLoading.withLoading {
|
||
await viewModel.submit(api: ordersAPI, uploadService: uploadService, scenicId: accountContext.currentScenic?.id)
|
||
}
|
||
if success {
|
||
toastCenter.show("任务素材已保存")
|
||
dismiss()
|
||
} else if let message = viewModel.errorMessage {
|
||
toastCenter.show(message)
|
||
}
|
||
}
|
||
|
||
/// 根据 PhotosPicker 类型生成本地文件名。
|
||
private static func fileName(for item: PhotosPickerItem) -> String {
|
||
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) || $0.conforms(to: .video) }
|
||
return "\(UUID().uuidString).\(isVideo ? "mp4" : "jpg")"
|
||
}
|
||
}
|
||
|
||
/// 订单尾片入口页面,复用相册预览上传能力承接视频预告和尾片上传。
|
||
struct OrderTrailerView: View {
|
||
@State private var showAlbumTrailer = false
|
||
|
||
let orderNumber: String
|
||
let title: String
|
||
|
||
var body: some View {
|
||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Text("订单号:\(orderNumber.isEmpty ? "--" : orderNumber)")
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
Text("用于完成订单尾片素材的最终核对与提交,按“选择相册 -> 预览文件 -> 确认上传”流程操作。")
|
||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||
Text("提交流程")
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
trailerStep("1. 进入相册预览上传并选择目标相册")
|
||
trailerStep("2. 核对素材内容与顺序后确认上传")
|
||
trailerStep("3. 上传完成后返回订单页继续处理")
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(AppMetrics.Spacing.medium)
|
||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||
|
||
Button("打开相册预览上传") {
|
||
showAlbumTrailer = true
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||
|
||
Spacer()
|
||
}
|
||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||
.navigationTitle(title)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.sheet(isPresented: $showAlbumTrailer) {
|
||
NavigationStack {
|
||
AlbumTrailerEntryView()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 构建尾片流程步骤行。
|
||
private func trailerStep(_ text: String) -> some View {
|
||
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
|
||
Circle()
|
||
.fill(AppDesign.primary)
|
||
.frame(width: 6, height: 6)
|
||
.padding(.top, 6)
|
||
Text(text)
|
||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 订单任务素材附件行。
|
||
private struct OrderTaskAttachmentRow: View {
|
||
let title: String
|
||
let subtitle: String
|
||
let onDelete: () -> Void
|
||
|
||
var body: some View {
|
||
HStack(spacing: AppMetrics.Spacing.small) {
|
||
Image(systemName: "paperclip")
|
||
.foregroundStyle(AppDesign.primary)
|
||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||
.background(AppDesign.primarySoft, in: Circle())
|
||
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.lineLimit(1)
|
||
Text(subtitle)
|
||
.font(.system(size: AppMetrics.FontSize.caption))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Button(action: onDelete) {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundStyle(AppDesign.placeholder)
|
||
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||
}
|
||
.accessibilityLabel("删除附件")
|
||
}
|
||
.padding(AppMetrics.Spacing.small)
|
||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||
}
|
||
}
|