// // OrderTailUploadViews.swift // suixinkan // // Created by Codex on 2026/6/25. // import PhotosUI import SwiftUI import UniformTypeIdentifiers /// 多点旅拍任务上传页面,负责选择打卡点和素材后提交到订单接口。 struct MultiTravelTaskUploadView: View { @Environment(AccountContext.self) private var accountContext @Environment(OrdersAPI.self) private var ordersAPI @Environment(OSSUploadService.self) private var uploadService @Environment(ToastCenter.self) private var toastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading @State private var viewModel: MultiTravelTaskUploadViewModel @State private var showSpotPicker = false @State private var showCloudSelection = false @State private var pickerItems: [PhotosPickerItem] = [] /// 使用订单号初始化任务上传页面。 init(initialOrderNumber: String) { _viewModel = State(initialValue: 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 { 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)) } }