// // TaskCreateView.swift // suixinkan // // Created by Codex on 2026/6/23. // import PhotosUI import SwiftUI import UniformTypeIdentifiers /// 发布任务页面,负责填写任务信息、选择附件并提交任务。 struct TaskCreateView: View { @Environment(AccountContext.self) private var accountContext @Environment(TaskAPI.self) private var taskAPI @Environment(OSSUploadService.self) private var ossUploadService @Environment(ToastCenter.self) private var toastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading @State private var viewModel = TaskCreateViewModel() @State private var showOrderSelection = false @State private var showCloudSelection = false @State private var pickerItems: [PhotosPickerItem] = [] var body: some View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { basicFormSection orderSection cloudFileSection localFileSection submitButton } .padding(AppMetrics.Spacing.pageHorizontal) } .background(Color(hex: 0xF5F7FA)) .navigationTitle("发布任务") .navigationBarTitleDisplayMode(.inline) .task { await viewModel.loadAvailableOrders(api: taskAPI, scenicId: accountContext.currentScenic?.id) } .sheet(isPresented: $showOrderSelection) { NavigationStack { TaskOrderSelectionView( orders: viewModel.availableOrders, selectedOrder: viewModel.selectedOrder, onSelect: { order in viewModel.selectOrder(order) showOrderSelection = false } ) } } .sheet(isPresented: $showCloudSelection) { NavigationStack { TaskCloudFileSelectionView { items in viewModel.mergeCloudFiles(items) showCloudSelection = false } } } .onChange(of: pickerItems) { _, items in Task { await importPickerItems(items) } } } /// 基础表单区域。 private var basicFormSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text("任务信息") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) TextField("任务名称", text: $viewModel.taskName) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) TextField("紧急小时", text: $viewModel.urgentHourText) .keyboardType(.numberPad) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) TextField("任务备注", text: $viewModel.remark, axis: .vertical) .lineLimit(4, reservesSpace: true) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 104) } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } /// 关联订单区域。 private var orderSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { HStack { Text("关联订单") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Spacer() Button(viewModel.selectedOrder == nil ? "选择订单" : "更换") { showOrderSelection = true } .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(AppDesign.primary) } if let order = viewModel.selectedOrder { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { Text(order.projectName.isEmpty ? "未命名项目" : order.projectName) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) Text("订单号:\(order.orderNumber)") Text("手机号:\(order.userPhone.isEmpty ? "暂无" : order.userPhone)") } .font(.system(size: AppMetrics.FontSize.footnote)) .foregroundStyle(AppDesign.textSecondary) Button("清除关联订单") { viewModel.selectOrder(nil) } .font(.system(size: AppMetrics.FontSize.footnote)) .foregroundStyle(Color(hex: 0xEF4444)) } else { Text("可不选择订单,直接发布普通任务。") .font(.system(size: AppMetrics.FontSize.subheadline)) .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 TaskAttachmentRow(title: 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 TaskAttachmentRow(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(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) } .disabled(viewModel.isSubmitting) .opacity(viewModel.isSubmitting ? 0.6 : 1) } /// 导入 PhotosPicker 选择的本地文件。 private func importPickerItems(_ items: [PhotosPickerItem]) async { for item in items { do { guard let data = try await item.loadTransferable(type: Data.self) else { continue } let fileName = Self.fileName(for: item) viewModel.addLocalFile(data: data, fileName: fileName) } catch { toastCenter.show(error.localizedDescription) } } pickerItems = [] } /// 提交发布任务表单。 private func submit() async { do { let success = try await globalLoading.withLoading { try await viewModel.submit(api: taskAPI, uploadService: ossUploadService, scenicId: accountContext.currentScenic?.id) } if success { toastCenter.show("任务发布成功") dismiss() } else if let message = viewModel.errorMessage { toastCenter.show(message) } } catch { toastCenter.show(viewModel.errorMessage ?? error.localizedDescription) } } /// 根据选择项类型生成本地附件文件名。 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")" } } /// 任务附件行,展示文件名、状态和删除按钮。 private struct TaskAttachmentRow: 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)) } }