实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
252 lines
8.8 KiB
Swift
252 lines
8.8 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|