Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View 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))")
]
)
)
}
}

View 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 ""
}
/// IntDouble
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
}
}

View 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` 管理。
本轮不迁完整云盘资产管理,只保留发布任务所需的文件选择能力。云盘的新建文件夹、上传、删除、移动、重命名等能力后续应放在独立云盘模块中迁移。

View File

@ -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 {}

View File

@ -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
)
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}