// // TaskCreateViewModel.swift // suixinkan // // Created by Codex on 2026/6/23. // import Foundation import Combine /// 任务云盘选择项实体,表示发布任务表单中的云盘附件。 struct TaskCloudSelectionItem: Identifiable, Hashable { let id: Int let fileName: String let fileType: Int var remark: String } /// 任务本地上传项实体,表示发布任务表单中的本地图片或视频。 struct TaskLocalUploadItem: Identifiable, Hashable { let id: UUID let data: Data let fileName: String let fileType: Int var remark: String var uploadedURL: String? var progress: Int var errorMessage: String? /// 判断当前文件是否正在上传。 var isUploading: Bool { progress > 0 && progress < 100 && uploadedURL == nil && errorMessage == nil } /// 返回上传状态展示文案。 var statusText: String { if let errorMessage { return errorMessage } if uploadedURL != nil { return "已上传" } if progress > 0 { return "上传中 \(progress)%" } return "待上传" } } /// 发布任务 ViewModel,负责表单、附件上传和提交任务。 @MainActor final class TaskCreateViewModel: ObservableObject { @Published var taskName = "" @Published var remark = "" @Published var urgentHourText = "0" @Published var selectedOrder: AvailableOrderResponse? @Published var availableOrders: [AvailableOrderResponse] = [] @Published var selectedCloudFiles: [TaskCloudSelectionItem] = [] @Published var selectedLocalFiles: [TaskLocalUploadItem] = [] @Published var isLoadingOrders = false @Published var isSubmitting = false @Published var errorMessage: String? @Published var didSubmitSuccessfully = false /// 判断当前表单是否满足提交按钮启用条件。 var canSubmit: Bool { validateForm(scenicId: 1, shouldSetError: false) && !isSubmitting && !selectedLocalFiles.contains(where: \.isUploading) } /// 加载可关联订单列表。 func loadAvailableOrders(api: any TaskServing, scenicId: Int?) async { guard let scenicId else { availableOrders = [] return } isLoadingOrders = true defer { isLoadingOrders = false } do { availableOrders = try await api.availableOrderList(scenicId: scenicId) } catch { availableOrders = [] errorMessage = error.localizedDescription } } /// 选择或清除关联订单。 func selectOrder(_ order: AvailableOrderResponse?) { selectedOrder = order } /// 添加云盘附件,按文件 ID 去重。 func mergeCloudFiles(_ files: [TaskCloudSelectionItem]) { var next = selectedCloudFiles for file in files where !next.contains(where: { $0.id == file.id }) { next.append(file) } selectedCloudFiles = next } /// 移除指定云盘附件。 func removeCloudFile(id: Int) { selectedCloudFiles.removeAll { $0.id == id } } /// 添加本地附件数据。 func addLocalFile(data: Data, fileName: String) { let fileType = Self.fileType(for: fileName) selectedLocalFiles.append( TaskLocalUploadItem( id: UUID(), data: data, fileName: fileName, fileType: fileType, remark: "", uploadedURL: nil, progress: 0, errorMessage: nil ) ) } /// 移除指定本地附件。 func removeLocalFile(id: UUID) { selectedLocalFiles.removeAll { $0.id == id } } /// 提交任务;本地附件会先上传 OSS,成功后再提交最终 URL。 @discardableResult func submit(api: any TaskServing, uploadService: any OSSUploadServing, scenicId: Int?) async throws -> Bool { guard !isSubmitting else { return false } guard validateForm(scenicId: scenicId, shouldSetError: true) else { return false } guard let scenicId, let urgentHour = Int(urgentHourText.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } isSubmitting = true errorMessage = nil didSubmitSuccessfully = false defer { isSubmitting = false } do { let uploadedFiles = try await uploadLocalFiles(uploadService: uploadService, scenicId: scenicId) let request = AddTaskRequest( scenicId: "\(scenicId)", name: taskName.trimmingCharacters(in: .whitespacesAndNewlines), orderNumber: selectedOrder?.orderNumber, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines), urgentHour: urgentHour, cloudFile: selectedCloudFiles.map { CloudFileItem(fileId: $0.id, remark: $0.remark) }, uploadFile: uploadedFiles ) try await api.addTask(request) didSubmitSuccessfully = true resetForm() return true } catch { errorMessage = error.localizedDescription throw error } } /// 清空发布任务表单。 func resetForm() { taskName = "" remark = "" urgentHourText = "0" selectedOrder = nil selectedCloudFiles = [] selectedLocalFiles = [] } /// 校验表单并按需写入错误提示。 private func validateForm(scenicId: Int?, shouldSetError: Bool) -> Bool { func fail(_ message: String) -> Bool { if shouldSetError { errorMessage = message } return false } guard scenicId != nil else { return fail("请先选择景区") } guard !taskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return fail("请输入任务名称") } guard !remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return fail("请输入任务备注") } guard let urgentHour = Int(urgentHourText.trimmingCharacters(in: .whitespacesAndNewlines)), urgentHour >= 0 else { return fail("请输入正确的紧急小时") } guard !selectedLocalFiles.contains(where: \.isUploading) else { return fail("文件上传中,请稍后提交") } return true } /// 上传所有尚未上传的本地附件并返回提交请求需要的文件列表。 private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [UploadFileItem] { var uploadedFiles: [UploadFileItem] = [] for index in selectedLocalFiles.indices { if let uploadedURL = selectedLocalFiles[index].uploadedURL { uploadedFiles.append( UploadFileItem( fileUrl: uploadedURL, fileName: selectedLocalFiles[index].fileName, remark: selectedLocalFiles[index].remark ) ) continue } selectedLocalFiles[index].errorMessage = nil let fileID = selectedLocalFiles[index].id do { let url = try await uploadService.uploadTaskFile( data: selectedLocalFiles[index].data, fileName: selectedLocalFiles[index].fileName, fileType: selectedLocalFiles[index].fileType, scenicId: scenicId, onProgress: { [weak self] progress in Task { @MainActor in self?.updateLocalFileProgress(id: fileID, progress: progress) } } ) selectedLocalFiles[index].uploadedURL = url selectedLocalFiles[index].progress = 100 uploadedFiles.append( UploadFileItem( fileUrl: url, fileName: selectedLocalFiles[index].fileName, remark: selectedLocalFiles[index].remark ) ) } catch { selectedLocalFiles[index].progress = 0 selectedLocalFiles[index].errorMessage = error.localizedDescription throw error } } return uploadedFiles } /// 更新指定本地附件的上传进度。 private func updateLocalFileProgress(id: UUID, progress: Int) { guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return } selectedLocalFiles[index].progress = progress } /// 根据文件名推断后端需要的文件类型。 private static func fileType(for fileName: String) -> Int { let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2 } }