Add order tail flows and migrate queue, message, settlement, and audit modules.
Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
357
suixinkan/Features/Orders/Views/OrderTailUploadViews.swift
Normal file
357
suixinkan/Features/Orders/Views/OrderTailUploadViews.swift
Normal file
@ -0,0 +1,357 @@
|
||||
//
|
||||
// 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<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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user