Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
146
suixinkan_ios/Features/Tasks/API/TaskAPI.swift
Normal file
146
suixinkan_ios/Features/Tasks/API/TaskAPI.swift
Normal file
@ -0,0 +1,146 @@
|
||||
//
|
||||
// TaskAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 任务服务协议,抽象任务列表、详情、发布和云盘选择接口以便测试替换。
|
||||
@MainActor
|
||||
protocol TaskServing {
|
||||
/// 获取摄影师任务列表。
|
||||
func taskList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
taskStatus: Int,
|
||||
taskName: String?,
|
||||
startTime: String?,
|
||||
endTime: String?
|
||||
) async throws -> ListPayload<PhotographerTaskItem>
|
||||
|
||||
/// 获取任务详情。
|
||||
func taskDetail(id: Int) async throws -> TaskDetailResponse
|
||||
|
||||
/// 获取发布任务时可关联的订单列表。
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse]
|
||||
|
||||
/// 发布任务。
|
||||
func addTask(_ request: AddTaskRequest) async throws
|
||||
|
||||
/// 获取云盘文件列表,供发布任务选择附件。
|
||||
func cloudFileList(
|
||||
parentFolderId: Int,
|
||||
name: String,
|
||||
type: Int,
|
||||
orderBy: Int,
|
||||
page: Int,
|
||||
pageSize: Int
|
||||
) async throws -> ListPayload<CloudDriveFile>
|
||||
}
|
||||
|
||||
/// 任务 API,封装任务管理和发布任务相关接口。
|
||||
@MainActor
|
||||
final class TaskAPI: TaskServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化任务 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取摄影师任务列表。
|
||||
func taskList(
|
||||
scenicId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10,
|
||||
taskStatus: Int = 0,
|
||||
taskName: String? = nil,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil
|
||||
) async throws -> ListPayload<PhotographerTaskItem> {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"),
|
||||
URLQueryItem(name: "task_status", value: "\(max(taskStatus, 0))")
|
||||
]
|
||||
if let taskName = taskName?.trimmingCharacters(in: .whitespacesAndNewlines), !taskName.isEmpty {
|
||||
query.append(URLQueryItem(name: "task_name", value: taskName))
|
||||
}
|
||||
if let startTime, !startTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime, !endTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/task/list",
|
||||
queryItems: query
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取任务详情。
|
||||
func taskDetail(id: Int) async throws -> TaskDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/task/info",
|
||||
queryItems: [URLQueryItem(name: "id", value: "\(id)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取发布任务时可关联的订单列表。
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/task/available-order",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 发布任务。
|
||||
func addTask(_ request: AddTaskRequest) async throws {
|
||||
_ = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/task/create",
|
||||
body: request
|
||||
)
|
||||
) as EmptyPayload
|
||||
}
|
||||
|
||||
/// 获取云盘文件列表,供发布任务选择附件。
|
||||
func cloudFileList(
|
||||
parentFolderId: Int,
|
||||
name: String = "",
|
||||
type: Int = 0,
|
||||
orderBy: Int = 2,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ListPayload<CloudDriveFile> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/cloud-driver/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "parent_folder_id", value: "\(parentFolderId)"),
|
||||
URLQueryItem(name: "name", value: name),
|
||||
URLQueryItem(name: "type", value: "\(type)"),
|
||||
URLQueryItem(name: "order_by", value: "\(orderBy)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
291
suixinkan_ios/Features/Tasks/Models/TaskModels.swift
Normal file
291
suixinkan_ios/Features/Tasks/Models/TaskModels.swift
Normal file
@ -0,0 +1,291 @@
|
||||
//
|
||||
// TaskModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 摄影师任务列表实体,表示任务管理页中的一条任务记录。
|
||||
struct PhotographerTaskItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let type: Int
|
||||
let orderNumber: String
|
||||
let taskStatus: Int
|
||||
let statusName: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let photogRemark: String
|
||||
let editorName: String
|
||||
let operateTime: String
|
||||
let orderUserNickname: String
|
||||
let orderUserPhone: String
|
||||
let orderUserId: String
|
||||
let media: [TaskMediaItem]
|
||||
|
||||
/// 任务列表字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case type
|
||||
case orderNumber = "order_number"
|
||||
case taskStatus = "task_status"
|
||||
case statusName = "status_name"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case photogRemark = "photog_remark"
|
||||
case editorName = "editor_name"
|
||||
case operateTime = "operate_time"
|
||||
case orderUserNickname = "order_user_nickname"
|
||||
case orderUserPhone = "order_user_phone"
|
||||
case orderUserId = "order_user_id"
|
||||
case media
|
||||
}
|
||||
|
||||
/// 宽松解码任务列表字段,避免后端数字和字符串混用导致页面不可用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
taskStatus = try container.decodeLossyInt(forKey: .taskStatus) ?? 0
|
||||
statusName = try container.decodeLossyString(forKey: .statusName)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
||||
photogRemark = try container.decodeLossyString(forKey: .photogRemark)
|
||||
editorName = try container.decodeLossyString(forKey: .editorName)
|
||||
operateTime = try container.decodeLossyString(forKey: .operateTime)
|
||||
orderUserNickname = try container.decodeLossyString(forKey: .orderUserNickname)
|
||||
orderUserPhone = try container.decodeLossyString(forKey: .orderUserPhone)
|
||||
orderUserId = try container.decodeLossyString(forKey: .orderUserId)
|
||||
media = (try? container.decode([TaskMediaItem].self, forKey: .media)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务媒体实体,表示任务附件或任务结果中的图片、视频文件。
|
||||
struct TaskMediaItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let photogTaskId: Int
|
||||
let userId: Int
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let fileUrl: String
|
||||
let fileSize: Int64
|
||||
let coverUrl: String
|
||||
let duration: Int
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
/// 判断当前媒体是否应按视频展示。
|
||||
var isVideo: Bool {
|
||||
fileType == 1 || Self.hasVideoExtension(fileName) || Self.hasVideoExtension(fileUrl)
|
||||
}
|
||||
|
||||
/// 返回用于缩略图展示的 URL,优先使用封面图。
|
||||
var displayURL: String {
|
||||
coverUrl.isEmpty ? fileUrl : coverUrl
|
||||
}
|
||||
|
||||
/// 根据文件扩展名判断是否是常见视频格式。
|
||||
private static func hasVideoExtension(_ value: String) -> Bool {
|
||||
["mp4", "mov", "m4v", "avi"].contains(URL(string: value)?.pathExtension.lowercased() ?? URL(fileURLWithPath: value).pathExtension.lowercased())
|
||||
}
|
||||
|
||||
/// 任务媒体字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case photogTaskId = "photog_task_id"
|
||||
case userId = "user_id"
|
||||
case fileName = "file_name"
|
||||
case fileType = "file_type"
|
||||
case fileUrl = "file_url"
|
||||
case fileSize = "file_size"
|
||||
case coverUrl = "cover_url"
|
||||
case duration
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
/// 宽松解码任务媒体字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
photogTaskId = try container.decodeLossyInt(forKey: .photogTaskId) ?? 0
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
fileName = try container.decodeLossyString(forKey: .fileName)
|
||||
fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0
|
||||
fileUrl = try container.decodeLossyString(forKey: .fileUrl)
|
||||
fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0)
|
||||
coverUrl = try container.decodeLossyString(forKey: .coverUrl)
|
||||
duration = try container.decodeLossyInt(forKey: .duration) ?? 0
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务详情实体,表示任务详情页展示的完整任务信息。
|
||||
struct TaskDetailResponse: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let createdAt: String
|
||||
let taskStatus: Int
|
||||
let taskStatusLabel: String
|
||||
let acceptedAt: String
|
||||
let editorName: String
|
||||
let editorPhone: String
|
||||
let urgentHour: Int
|
||||
let photogRemark: String
|
||||
let order: TaskDetailOrder?
|
||||
let taskResult: [TaskMediaItem]
|
||||
|
||||
/// 任务详情字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case createdAt = "created_at"
|
||||
case taskStatus = "task_status"
|
||||
case taskStatusLabel = "task_status_label"
|
||||
case acceptedAt = "accepted_at"
|
||||
case editorName = "editor_name"
|
||||
case editorPhone = "editor_phone"
|
||||
case urgentHour = "urgent_hour"
|
||||
case photogRemark = "photog_remark"
|
||||
case order
|
||||
case taskResult = "task_result"
|
||||
}
|
||||
|
||||
/// 宽松解码任务详情字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
taskStatus = try container.decodeLossyInt(forKey: .taskStatus) ?? 0
|
||||
taskStatusLabel = try container.decodeLossyString(forKey: .taskStatusLabel)
|
||||
acceptedAt = try container.decodeLossyString(forKey: .acceptedAt)
|
||||
editorName = try container.decodeLossyString(forKey: .editorName)
|
||||
editorPhone = try container.decodeLossyString(forKey: .editorPhone)
|
||||
urgentHour = try container.decodeLossyInt(forKey: .urgentHour) ?? 0
|
||||
photogRemark = try container.decodeLossyString(forKey: .photogRemark)
|
||||
order = try? container.decode(TaskDetailOrder.self, forKey: .order)
|
||||
taskResult = (try? container.decode([TaskMediaItem].self, forKey: .taskResult)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务关联订单实体,表示任务详情中的订单摘要。
|
||||
struct TaskDetailOrder: Decodable, Hashable {
|
||||
let projectName: String
|
||||
let orderNumber: String
|
||||
let payTime: String
|
||||
let userPhone: String
|
||||
|
||||
/// 任务关联订单字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case orderNumber = "order_number"
|
||||
case payTime = "pay_time"
|
||||
case userPhone = "user_phone"
|
||||
}
|
||||
|
||||
/// 宽松解码订单摘要字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布任务请求实体,描述提交给后端的新任务内容。
|
||||
struct AddTaskRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let name: String
|
||||
let orderNumber: String?
|
||||
let remark: String
|
||||
let urgentHour: Int
|
||||
let cloudFile: [CloudFileItem]
|
||||
let uploadFile: [UploadFileItem]
|
||||
|
||||
/// 发布任务请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
case orderNumber = "order_number"
|
||||
case remark = "photog_remark"
|
||||
case urgentHour = "urgent_hour"
|
||||
case cloudFile = "cloud_file"
|
||||
case uploadFile = "upload_file"
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务云盘附件请求实体,表示发布任务时选择的云盘文件。
|
||||
struct CloudFileItem: Encodable, Equatable {
|
||||
let fileId: Int
|
||||
let remark: String
|
||||
|
||||
/// 云盘附件请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileId = "file_id"
|
||||
case remark
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务上传附件请求实体,表示发布任务时上传到 OSS 的本地文件。
|
||||
struct UploadFileItem: Encodable, Equatable {
|
||||
let fileUrl: String
|
||||
let fileName: String
|
||||
let remark: String
|
||||
|
||||
/// 上传附件请求字段映射,兼容后端下划线命名。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileUrl = "file_url"
|
||||
case fileName = "file_name"
|
||||
case remark
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
30
suixinkan_ios/Features/Tasks/Tasks.md
Normal file
30
suixinkan_ios/Features/Tasks/Tasks.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Tasks 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Tasks 模块负责首页“任务管理”和“发布任务”入口,包括任务列表、筛选、分页、任务详情、可关联订单、云盘附件选择和本地附件 OSS 上传。
|
||||
|
||||
任务状态只保存在本模块 ViewModel 内,不写入 `AppSession`、`AccountContext` 或 TabBar。当前景区 ID 从 `AccountContext.currentScenic` 读取,缺少景区时不请求任务接口。
|
||||
|
||||
## 数据来源
|
||||
|
||||
`TaskAPI` 封装任务接口:
|
||||
- `taskList` 获取任务列表。
|
||||
- `taskDetail` 获取任务详情。
|
||||
- `availableOrderList` 获取发布任务可关联订单。
|
||||
- `addTask` 提交发布任务。
|
||||
- `cloudFileList` 仅用于发布任务选择云盘附件,不代表完整云盘模块迁移。
|
||||
|
||||
任务本地附件上传复用 `OSSUploadService.uploadTaskFile`,上传成功后把最终 OSS URL 放入 `upload_file` 提交给发布任务接口。OSS STS、上传进度和本地文件数据不落盘。
|
||||
|
||||
## 页面流程
|
||||
|
||||
任务管理页支持状态筛选、任务名称搜索、开始/结束日期筛选、下拉刷新和加载更多。点击任务卡片进入任务详情页,详情接口失败时保留列表摘要信息作为兜底。
|
||||
|
||||
发布任务页支持填写任务名称、任务备注、紧急小时、选择关联订单、选择云盘文件和选择本地图片/视频。提交前先校验表单,再上传本地附件,最后调用发布任务接口。任意附件上传失败时不提交任务。
|
||||
|
||||
## 迁移边界
|
||||
|
||||
本轮不迁旧工程中把订单管理和核销订单拼成“待办任务”的逻辑;订单与核销已经归 `Features/Orders` 管理。
|
||||
|
||||
本轮不迁完整云盘资产管理,只保留发布任务所需的文件选择能力。云盘的新建文件夹、上传、删除、移动、重命名等能力后续应放在独立云盘模块中迁移。
|
||||
@ -0,0 +1,188 @@
|
||||
//
|
||||
// TaskViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 任务管理列表页。
|
||||
final class TaskManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = TaskManagementViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "任务管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "发布",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(createTask)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.tasks.count
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let task = viewModel.tasks[indexPath.row]
|
||||
cell.configure(title: task.name, subtitle: task.statusName, detail: task.createdAt)
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
let task = viewModel.tasks[indexPath.row]
|
||||
navigationController?.pushViewController(
|
||||
TaskDetailViewController(taskId: task.id, summary: task),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
try? await viewModel.reload(api: services.taskAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard indexPath.row >= viewModel.tasks.count - 2 else { return }
|
||||
Task { try? await viewModel.loadMore(api: services.taskAPI, scenicId: services.currentScenicId) }
|
||||
}
|
||||
|
||||
@objc private func createTask() {
|
||||
navigationController?.pushViewController(TaskCreateViewController(), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension TaskManagementViewModel: ViewModelBindable {}
|
||||
|
||||
/// 任务详情页。
|
||||
final class TaskDetailViewController: ModuleTableViewController {
|
||||
private let taskId: Int
|
||||
private let summary: PhotographerTaskItem?
|
||||
private let viewModel = TaskDetailViewModel()
|
||||
|
||||
init(taskId: Int, summary: PhotographerTaskItem?) {
|
||||
self.taskId = taskId
|
||||
self.summary = summary
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = summary?.name ?? "任务详情"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { [weak self] in
|
||||
self?.title = self?.viewModel.detail?.name ?? self?.summary?.name ?? "任务详情"
|
||||
}
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
guard let detail = viewModel.detail else { return summary == nil ? 0 : 4 }
|
||||
return 6
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
if let detail = viewModel.detail {
|
||||
switch indexPath.row {
|
||||
case 0: cell.configure(title: "任务名称", subtitle: detail.name)
|
||||
case 1: cell.configure(title: "状态", subtitle: detail.taskStatusLabel)
|
||||
case 2: cell.configure(title: "订单号", subtitle: detail.order?.orderNumber ?? "--")
|
||||
case 3: cell.configure(title: "备注", subtitle: detail.photogRemark)
|
||||
case 4: cell.configure(title: "创建时间", subtitle: detail.createdAt)
|
||||
default: cell.configure(title: "附件", subtitle: "\(detail.taskResult.count) 个")
|
||||
}
|
||||
} else if let summary {
|
||||
switch indexPath.row {
|
||||
case 0: cell.configure(title: "任务名称", subtitle: summary.name)
|
||||
case 1: cell.configure(title: "状态", subtitle: summary.statusName)
|
||||
case 2: cell.configure(title: "订单号", subtitle: summary.orderNumber)
|
||||
default: cell.configure(title: "创建时间", subtitle: summary.createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(api: services.taskAPI, taskId: taskId)
|
||||
}
|
||||
}
|
||||
|
||||
extension TaskDetailViewModel: ViewModelBindable {}
|
||||
|
||||
/// 发布任务页。
|
||||
final class TaskCreateViewController: ModuleTableViewController {
|
||||
private let viewModel = TaskCreateViewModel()
|
||||
private let nameField = UITextField()
|
||||
private let remarkField = UITextField()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "发布任务"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "提交",
|
||||
style: .done,
|
||||
target: self,
|
||||
action: #selector(submit)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
setupHeader()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
nameField.placeholder = "任务名称"
|
||||
nameField.borderStyle = .roundedRect
|
||||
remarkField.placeholder = "备注"
|
||||
remarkField.borderStyle = .roundedRect
|
||||
let stack = UIStackView(arrangedSubviews: [nameField, remarkField])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 96)
|
||||
stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
|
||||
stack.isLayoutMarginsRelativeArrangement = true
|
||||
tableView.tableHeaderView = stack
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int {
|
||||
viewModel.availableOrders.count
|
||||
}
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let order = viewModel.availableOrders[indexPath.row]
|
||||
let selected = viewModel.selectedOrder?.orderNumber == order.orderNumber
|
||||
cell.configure(title: order.orderNumber, subtitle: order.projectName, detail: selected ? "已选择" : nil)
|
||||
cell.accessoryType = selected ? .checkmark : .none
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
viewModel.selectedOrder = viewModel.availableOrders[indexPath.row]
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.loadAvailableOrders(api: services.taskAPI, scenicId: services.currentScenicId)
|
||||
}
|
||||
|
||||
@objc private func submit() {
|
||||
viewModel.taskName = nameField.text ?? ""
|
||||
viewModel.remark = remarkField.text ?? ""
|
||||
Task {
|
||||
do {
|
||||
let success = try await viewModel.submit(
|
||||
api: services.taskAPI,
|
||||
uploadService: services.ossUploadService,
|
||||
scenicId: services.currentScenicId
|
||||
)
|
||||
if success {
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
} catch {
|
||||
services.toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TaskCreateViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,138 @@
|
||||
//
|
||||
// TaskCloudFileSelectionViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 云盘文件类型筛选实体,表示发布任务选择附件时的过滤方式。
|
||||
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
|
||||
final class TaskCloudFileSelectionViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] { didSet { onChange?() } }
|
||||
var files: [CloudDriveFile] = [] { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var selectedFilter: TaskCloudFileFilter = .all { didSet { onChange?() } }
|
||||
var selectedFiles: [CloudDriveFile] = [] { didSet { onChange?() } }
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,250 @@
|
||||
//
|
||||
// TaskCreateViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 任务云盘选择项实体,表示发布任务表单中的云盘附件。
|
||||
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 {
|
||||
var onChange: (() -> Void)?
|
||||
var taskName = "" { didSet { onChange?() } }
|
||||
var remark = "" { didSet { onChange?() } }
|
||||
var urgentHourText = "0" { didSet { onChange?() } }
|
||||
var selectedOrder: AvailableOrderResponse? { didSet { onChange?() } }
|
||||
var availableOrders: [AvailableOrderResponse] = [] { didSet { onChange?() } }
|
||||
var selectedCloudFiles: [TaskCloudSelectionItem] = [] { didSet { onChange?() } }
|
||||
var selectedLocalFiles: [TaskLocalUploadItem] = [] { didSet { onChange?() } }
|
||||
var isLoadingOrders = false { didSet { onChange?() } }
|
||||
var isSubmitting = false { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
var didSubmitSuccessfully = false { didSet { onChange?() } }
|
||||
|
||||
/// 判断当前表单是否满足提交按钮启用条件。
|
||||
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,166 @@
|
||||
//
|
||||
// TaskManagementViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 任务状态筛选实体,表示任务管理页支持的任务状态。
|
||||
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
|
||||
final class TaskManagementViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var isLoadingMore = false { didSet { onChange?() } }
|
||||
var tasks: [PhotographerTaskItem] = [] { didSet { onChange?() } }
|
||||
var total = 0 { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var selectedStatus: TaskStatusFilter = .all { didSet { onChange?() } }
|
||||
var searchText = "" { didSet { onChange?() } }
|
||||
var startDate: Date? { didSet { onChange?() } }
|
||||
var endDate: Date? { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
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
|
||||
final class TaskDetailViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var isLoading = false { didSet { onChange?() } }
|
||||
var detail: TaskDetailResponse? { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
/// 加载任务详情;接口失败时保留外部传入的列表摘要。
|
||||
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