新增订单长尾流程,并迁移排队、消息、结算与审核模块

将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent 311a70d610
commit c39c3d3c75
63 changed files with 9823 additions and 58 deletions

View 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))
}
}