Files
suixinkan_ios_new/suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift
汉秋 ffb16eca29 新增任务模块,并接入首页任务管理路由
实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:26:13 +08:00

252 lines
8.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// TaskCreateViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import Foundation
import Observation
///
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
@Observable
final class TaskCreateViewModel {
var taskName = ""
var remark = ""
var urgentHourText = "0"
var selectedOrder: AvailableOrderResponse?
var availableOrders: [AvailableOrderResponse] = []
var selectedCloudFiles: [TaskCloudSelectionItem] = []
var selectedLocalFiles: [TaskLocalUploadItem] = []
var isLoadingOrders = false
var isSubmitting = false
var errorMessage: String?
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
}
}