Add Tasks module with home routing for task management flows.
Implement task list, detail, and create pages with API integration, cloud/local attachment support, and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,139 @@
|
||||
//
|
||||
// TaskCloudFileSelectionViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 云盘文件类型筛选实体,表示发布任务选择附件时的过滤方式。
|
||||
enum TaskCloudFileFilter: Int, CaseIterable, Identifiable {
|
||||
case all = 0
|
||||
case video = 1
|
||||
case image = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 返回筛选项展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
"全部"
|
||||
case .video:
|
||||
"视频"
|
||||
case .image:
|
||||
"图片"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘文件选择 ViewModel,负责目录浏览、筛选、分页和附件选择。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskCloudFileSelectionViewModel {
|
||||
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||||
var files: [CloudDriveFile] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var searchText = ""
|
||||
var selectedFilter: TaskCloudFileFilter = .all
|
||||
var selectedFiles: [CloudDriveFile] = []
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
/// 当前目录 ID。
|
||||
var currentFolderId: Int {
|
||||
path.last?.id ?? 0
|
||||
}
|
||||
|
||||
/// 判断当前目录是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
files.count < total
|
||||
}
|
||||
|
||||
/// 重新加载当前目录第一页。
|
||||
func reload(api: any TaskServing) async throws {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await requestFiles(api: api, page: 1)
|
||||
page = 1
|
||||
files = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
files = []
|
||||
total = 0
|
||||
page = 1
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载当前目录下一页。
|
||||
func loadMore(api: any TaskServing) async throws {
|
||||
guard hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
let nextPage = page + 1
|
||||
let payload = try await requestFiles(api: api, page: nextPage)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
files.append(contentsOf: payload.list)
|
||||
}
|
||||
|
||||
/// 进入指定文件夹并刷新列表。
|
||||
func enterFolder(_ folder: CloudDriveFile, api: any TaskServing) async throws {
|
||||
guard folder.isFolder else { return }
|
||||
path.append(folder)
|
||||
try await reload(api: api)
|
||||
}
|
||||
|
||||
/// 回到指定面包屑目录并刷新列表。
|
||||
func popToFolder(at index: Int, api: any TaskServing) async throws {
|
||||
guard path.indices.contains(index) else { return }
|
||||
path = Array(path.prefix(index + 1))
|
||||
try await reload(api: api)
|
||||
}
|
||||
|
||||
/// 切换文件选中状态,文件夹不会被选为附件。
|
||||
func toggleSelection(_ file: CloudDriveFile) {
|
||||
guard !file.isFolder else { return }
|
||||
if let index = selectedFiles.firstIndex(where: { $0.id == file.id }) {
|
||||
selectedFiles.remove(at: index)
|
||||
} else {
|
||||
selectedFiles.append(file)
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断指定云盘文件是否已选中。
|
||||
func isSelected(_ file: CloudDriveFile) -> Bool {
|
||||
selectedFiles.contains { $0.id == file.id }
|
||||
}
|
||||
|
||||
/// 将当前选中的云盘文件转换为发布任务表单附件。
|
||||
func makeSelectionItems() -> [TaskCloudSelectionItem] {
|
||||
selectedFiles.map {
|
||||
TaskCloudSelectionItem(id: $0.id, fileName: $0.name, fileType: $0.type, remark: "")
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装并发送云盘文件列表请求。
|
||||
private func requestFiles(api: any TaskServing, page: Int) async throws -> ListPayload<CloudDriveFile> {
|
||||
try await api.cloudFileList(
|
||||
parentFolderId: currentFolderId,
|
||||
name: searchText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
type: selectedFilter.rawValue,
|
||||
orderBy: 2,
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
)
|
||||
}
|
||||
}
|
||||
251
suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift
Normal file
251
suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift
Normal file
@ -0,0 +1,251 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
//
|
||||
// TaskManagementViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 任务状态筛选实体,表示任务管理页支持的任务状态。
|
||||
enum TaskStatusFilter: Int, CaseIterable, Identifiable {
|
||||
case all = 0
|
||||
case unaccepted = 1
|
||||
case accepted = 2
|
||||
case completed = 3
|
||||
case rejected = 4
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 返回状态筛选在页面上的展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
"全部"
|
||||
case .unaccepted:
|
||||
"待接收"
|
||||
case .accepted:
|
||||
"处理中"
|
||||
case .completed:
|
||||
"已完成"
|
||||
case .rejected:
|
||||
"已驳回"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务管理 ViewModel,负责任务列表、筛选、分页和错误状态。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskManagementViewModel {
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var tasks: [PhotographerTaskItem] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var selectedStatus: TaskStatusFilter = .all
|
||||
var searchText = ""
|
||||
var startDate: Date?
|
||||
var endDate: Date?
|
||||
var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
/// 判断任务列表是否还有下一页。
|
||||
var hasMore: Bool {
|
||||
tasks.count < total
|
||||
}
|
||||
|
||||
/// 首屏加载任务列表;无景区时清空旧数据且不请求接口。
|
||||
func reload(api: any TaskServing, scenicId: Int?) async throws {
|
||||
guard let scenicId else {
|
||||
resetList()
|
||||
errorMessage = "请先选择景区"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let payload = try await requestList(api: api, scenicId: scenicId, page: 1)
|
||||
page = 1
|
||||
tasks = payload.list
|
||||
total = payload.total
|
||||
} catch {
|
||||
resetList()
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页任务列表;最后一页或正在加载时不会重复请求。
|
||||
func loadMore(api: any TaskServing, scenicId: Int?) async throws {
|
||||
guard let scenicId, hasMore, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
errorMessage = nil
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
let nextPage = page + 1
|
||||
do {
|
||||
let payload = try await requestList(api: api, scenicId: scenicId, page: nextPage)
|
||||
page = nextPage
|
||||
total = payload.total
|
||||
tasks.append(contentsOf: payload.list)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空任务列表和分页状态。
|
||||
func resetList() {
|
||||
tasks = []
|
||||
total = 0
|
||||
page = 1
|
||||
isLoading = false
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
/// 组装并发送任务列表请求。
|
||||
private func requestList(api: any TaskServing, scenicId: Int, page: Int) async throws -> ListPayload<PhotographerTaskItem> {
|
||||
try await api.taskList(
|
||||
scenicId: scenicId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
taskStatus: selectedStatus.rawValue,
|
||||
taskName: trimmedSearchText,
|
||||
startTime: Self.requestDateString(from: startDate),
|
||||
endTime: Self.requestDateString(from: endDate)
|
||||
)
|
||||
}
|
||||
|
||||
/// 搜索关键字去除空白后为空则不传给后端。
|
||||
private var trimmedSearchText: String? {
|
||||
let text = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// 日期请求格式化器。
|
||||
private static let requestDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// 将可选日期格式化成请求字符串。
|
||||
private static func requestDateString(from date: Date?) -> String? {
|
||||
guard let date else { return nil }
|
||||
return requestDateFormatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务详情 ViewModel,负责详情加载和列表摘要兜底展示。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TaskDetailViewModel {
|
||||
var isLoading = false
|
||||
var detail: TaskDetailResponse?
|
||||
var errorMessage: String?
|
||||
|
||||
/// 加载任务详情;接口失败时保留外部传入的列表摘要。
|
||||
func load(api: any TaskServing, taskId: Int) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
detail = try await api.taskDetail(id: taskId)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user