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:
@ -28,6 +28,7 @@ struct RootView: View {
|
|||||||
@State private var paymentAPI: PaymentAPI
|
@State private var paymentAPI: PaymentAPI
|
||||||
@State private var walletAPI: WalletAPI
|
@State private var walletAPI: WalletAPI
|
||||||
@State private var scenicPermissionAPI: ScenicPermissionAPI
|
@State private var scenicPermissionAPI: ScenicPermissionAPI
|
||||||
|
@State private var taskAPI: TaskAPI
|
||||||
@State private var authSessionCoordinator: AuthSessionCoordinator
|
@State private var authSessionCoordinator: AuthSessionCoordinator
|
||||||
@State private var sessionBootstrapper: SessionBootstrapper
|
@State private var sessionBootstrapper: SessionBootstrapper
|
||||||
|
|
||||||
@ -49,6 +50,7 @@ struct RootView: View {
|
|||||||
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
|
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
|
||||||
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
||||||
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient))
|
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient))
|
||||||
|
_taskAPI = State(initialValue: TaskAPI(client: apiClient))
|
||||||
_authSessionCoordinator = State(
|
_authSessionCoordinator = State(
|
||||||
initialValue: AuthSessionCoordinator(
|
initialValue: AuthSessionCoordinator(
|
||||||
tokenStore: tokenStore,
|
tokenStore: tokenStore,
|
||||||
@ -87,6 +89,7 @@ struct RootView: View {
|
|||||||
.environment(paymentAPI)
|
.environment(paymentAPI)
|
||||||
.environment(walletAPI)
|
.environment(walletAPI)
|
||||||
.environment(scenicPermissionAPI)
|
.environment(scenicPermissionAPI)
|
||||||
|
.environment(taskAPI)
|
||||||
.environment(authSessionCoordinator)
|
.environment(authSessionCoordinator)
|
||||||
.task {
|
.task {
|
||||||
apiClient.bindAuthTokenProvider { appSession.token }
|
apiClient.bindAuthTokenProvider { appSession.token }
|
||||||
|
|||||||
@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
|
|
||||||
## 后续迁移
|
## 后续迁移
|
||||||
|
|
||||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
|
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。
|
||||||
|
|
||||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||||
|
|||||||
@ -29,6 +29,9 @@ enum HomeRoute: Hashable {
|
|||||||
case settings
|
case settings
|
||||||
case paymentCollection
|
case paymentCollection
|
||||||
case wallet
|
case wallet
|
||||||
|
case taskManagement
|
||||||
|
case taskCreate
|
||||||
|
case taskDetail(id: Int, summary: PhotographerTaskItem?)
|
||||||
case modulePlaceholder(uri: String, title: String)
|
case modulePlaceholder(uri: String, title: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -91,6 +91,10 @@ enum HomeMenuRouter {
|
|||||||
return .destination(.wallet)
|
return .destination(.wallet)
|
||||||
case "payment_collection", "payment_qr", "payment_code":
|
case "payment_collection", "payment_qr", "payment_code":
|
||||||
return .destination(.paymentCollection)
|
return .destination(.paymentCollection)
|
||||||
|
case "task_management", "task_management_editor":
|
||||||
|
return .destination(.taskManagement)
|
||||||
|
case "task_create":
|
||||||
|
return .destination(.taskCreate)
|
||||||
case "store", "more_functions":
|
case "store", "more_functions":
|
||||||
return .destination(.moreFunctions)
|
return .destination(.moreFunctions)
|
||||||
case "fly", "pilot_controller":
|
case "fly", "pilot_controller":
|
||||||
@ -106,9 +110,6 @@ enum HomeMenuRouter {
|
|||||||
"deposit_order",
|
"deposit_order",
|
||||||
"deposit_order_shooting_info",
|
"deposit_order_shooting_info",
|
||||||
"withdrawal_audit",
|
"withdrawal_audit",
|
||||||
"task_management",
|
|
||||||
"task_management_editor",
|
|
||||||
"task_create",
|
|
||||||
"schedule_management",
|
"schedule_management",
|
||||||
"checkin_points",
|
"checkin_points",
|
||||||
"pm",
|
"pm",
|
||||||
|
|||||||
@ -30,6 +30,12 @@ extension HomeRoute {
|
|||||||
PaymentCollectionView()
|
PaymentCollectionView()
|
||||||
case .wallet:
|
case .wallet:
|
||||||
WalletView()
|
WalletView()
|
||||||
|
case .taskManagement:
|
||||||
|
TaskManagementView()
|
||||||
|
case .taskCreate:
|
||||||
|
TaskCreateView()
|
||||||
|
case .taskDetail(let id, let summary):
|
||||||
|
TaskDetailView(taskId: id, summary: summary)
|
||||||
case let .modulePlaceholder(uri, title):
|
case let .modulePlaceholder(uri, title):
|
||||||
HomeMigrationModuleView(title: title, uri: uri)
|
HomeMigrationModuleView(title: title, uri: uri)
|
||||||
}
|
}
|
||||||
|
|||||||
148
suixinkan/Features/Tasks/API/TaskAPI.swift
Normal file
148
suixinkan/Features/Tasks/API/TaskAPI.swift
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
//
|
||||||
|
// TaskAPI.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// 任务服务协议,抽象任务列表、详情、发布和云盘选择接口以便测试替换。
|
||||||
|
@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
|
||||||
|
@Observable
|
||||||
|
final class TaskAPI: TaskServing {
|
||||||
|
@ObservationIgnored 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))")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
393
suixinkan/Features/Tasks/Models/TaskModels.swift
Normal file
393
suixinkan/Features/Tasks/Models/TaskModels.swift
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
//
|
||||||
|
// 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 AvailableOrderResponse: Decodable, Identifiable, Hashable {
|
||||||
|
var id: String { orderNumber }
|
||||||
|
let projectName: String
|
||||||
|
let orderNumber: String
|
||||||
|
let orderStatus: Int
|
||||||
|
let orderStatusLabel: String
|
||||||
|
let payTime: String
|
||||||
|
let userPhone: String
|
||||||
|
|
||||||
|
/// 可关联订单字段映射,兼容后端下划线命名。
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case projectName = "project_name"
|
||||||
|
case orderNumber = "order_number"
|
||||||
|
case orderStatus = "order_status"
|
||||||
|
case orderStatusLabel = "order_status_label"
|
||||||
|
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)
|
||||||
|
orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0
|
||||||
|
orderStatusLabel = try container.decodeLossyString(forKey: .orderStatusLabel)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘文件实体,表示发布任务时可选择的云盘文件或文件夹。
|
||||||
|
struct CloudDriveFile: Decodable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let parentFolderId: Int
|
||||||
|
let fileUrl: String
|
||||||
|
let coverUrl: String
|
||||||
|
let updatedAt: String
|
||||||
|
let name: String
|
||||||
|
let createdAt: String
|
||||||
|
let childNum: Int
|
||||||
|
let type: Int
|
||||||
|
let fileSize: Int64
|
||||||
|
|
||||||
|
/// 判断当前云盘项是否为文件夹。
|
||||||
|
var isFolder: Bool { type == 99 }
|
||||||
|
|
||||||
|
/// 判断当前云盘项是否为视频文件。
|
||||||
|
var isVideo: Bool {
|
||||||
|
type == 1 || Self.hasVideoExtension(name) || Self.hasVideoExtension(fileUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据文件扩展名判断是否是常见视频格式。
|
||||||
|
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 parentFolderId = "parent_folder_id"
|
||||||
|
case fileUrl = "file_url"
|
||||||
|
case coverUrl = "cover_url"
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
case name
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case childNum = "child_num"
|
||||||
|
case type
|
||||||
|
case fileSize = "file_size"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造本地路径根节点,供云盘选择器维护面包屑。
|
||||||
|
init(id: Int, name: String) {
|
||||||
|
self.id = id
|
||||||
|
parentFolderId = 0
|
||||||
|
fileUrl = ""
|
||||||
|
coverUrl = ""
|
||||||
|
updatedAt = ""
|
||||||
|
self.name = name
|
||||||
|
createdAt = ""
|
||||||
|
childNum = 0
|
||||||
|
type = 99
|
||||||
|
fileSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宽松解码云盘文件字段。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||||
|
parentFolderId = try container.decodeLossyInt(forKey: .parentFolderId) ?? 0
|
||||||
|
fileUrl = try container.decodeLossyString(forKey: .fileUrl)
|
||||||
|
coverUrl = try container.decodeLossyString(forKey: .coverUrl)
|
||||||
|
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
||||||
|
name = try container.decodeLossyString(forKey: .name)
|
||||||
|
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||||
|
childNum = try container.decodeLossyInt(forKey: .childNum) ?? 0
|
||||||
|
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||||
|
fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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/Features/Tasks/Tasks.md
Normal file
30
suixinkan/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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
281
suixinkan/Features/Tasks/Views/TaskCreateView.swift
Normal file
281
suixinkan/Features/Tasks/Views/TaskCreateView.swift
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
//
|
||||||
|
// TaskCreateView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import PhotosUI
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// 发布任务页面,负责填写任务信息、选择附件并提交任务。
|
||||||
|
struct TaskCreateView: View {
|
||||||
|
@Environment(AccountContext.self) private var accountContext
|
||||||
|
@Environment(TaskAPI.self) private var taskAPI
|
||||||
|
@Environment(OSSUploadService.self) private var ossUploadService
|
||||||
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
|
@State private var viewModel = TaskCreateViewModel()
|
||||||
|
@State private var showOrderSelection = false
|
||||||
|
@State private var showCloudSelection = false
|
||||||
|
@State private var pickerItems: [PhotosPickerItem] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
basicFormSection
|
||||||
|
orderSection
|
||||||
|
cloudFileSection
|
||||||
|
localFileSection
|
||||||
|
submitButton
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||||
|
}
|
||||||
|
.background(Color(hex: 0xF5F7FA))
|
||||||
|
.navigationTitle("发布任务")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
await viewModel.loadAvailableOrders(api: taskAPI, scenicId: accountContext.currentScenic?.id)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showOrderSelection) {
|
||||||
|
NavigationStack {
|
||||||
|
TaskOrderSelectionView(
|
||||||
|
orders: viewModel.availableOrders,
|
||||||
|
selectedOrder: viewModel.selectedOrder,
|
||||||
|
onSelect: { order in
|
||||||
|
viewModel.selectOrder(order)
|
||||||
|
showOrderSelection = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCloudSelection) {
|
||||||
|
NavigationStack {
|
||||||
|
TaskCloudFileSelectionView { items in
|
||||||
|
viewModel.mergeCloudFiles(items)
|
||||||
|
showCloudSelection = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: pickerItems) { _, items in
|
||||||
|
Task { await importPickerItems(items) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基础表单区域。
|
||||||
|
private var basicFormSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
Text("任务信息")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
|
||||||
|
TextField("任务名称", text: $viewModel.taskName)
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||||
|
|
||||||
|
TextField("紧急小时", text: $viewModel.urgentHourText)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||||
|
|
||||||
|
TextField("任务备注", text: $viewModel.remark, axis: .vertical)
|
||||||
|
.lineLimit(4, reservesSpace: true)
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 104)
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关联订单区域。
|
||||||
|
private var orderSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
HStack {
|
||||||
|
Text("关联订单")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Spacer()
|
||||||
|
Button(viewModel.selectedOrder == nil ? "选择订单" : "更换") {
|
||||||
|
showOrderSelection = true
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let order = viewModel.selectedOrder {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||||
|
Text(order.projectName.isEmpty ? "未命名项目" : order.projectName)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Text("订单号:\(order.orderNumber)")
|
||||||
|
Text("手机号:\(order.userPhone.isEmpty ? "暂无" : order.userPhone)")
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
Button("清除关联订单") {
|
||||||
|
viewModel.selectOrder(nil)
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||||
|
.foregroundStyle(Color(hex: 0xEF4444))
|
||||||
|
} else {
|
||||||
|
Text("可不选择订单,直接发布普通任务。")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘附件区域。
|
||||||
|
private var cloudFileSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
HStack {
|
||||||
|
Text("云盘附件")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Spacer()
|
||||||
|
Button("选择云盘文件") {
|
||||||
|
showCloudSelection = true
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.selectedCloudFiles.isEmpty {
|
||||||
|
Text("未选择云盘文件")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.selectedCloudFiles) { file in
|
||||||
|
TaskAttachmentRow(title: file.fileName, subtitle: "云盘文件") {
|
||||||
|
viewModel.removeCloudFile(id: file.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地附件区域。
|
||||||
|
private var localFileSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
HStack {
|
||||||
|
Text("本地附件")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Spacer()
|
||||||
|
PhotosPicker(selection: $pickerItems, maxSelectionCount: 9, matching: .any(of: [.images, .videos])) {
|
||||||
|
Text("选择图片/视频")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.selectedLocalFiles.isEmpty {
|
||||||
|
Text("未选择本地文件")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.selectedLocalFiles) { file in
|
||||||
|
TaskAttachmentRow(title: file.fileName, subtitle: file.statusText) {
|
||||||
|
viewModel.removeLocalFile(id: file.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交按钮。
|
||||||
|
private var submitButton: some View {
|
||||||
|
Button {
|
||||||
|
Task { await submit() }
|
||||||
|
} label: {
|
||||||
|
Text(viewModel.isSubmitting ? "提交中..." : "提交任务")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||||
|
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isSubmitting)
|
||||||
|
.opacity(viewModel.isSubmitting ? 0.6 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 导入 PhotosPicker 选择的本地文件。
|
||||||
|
private func importPickerItems(_ items: [PhotosPickerItem]) async {
|
||||||
|
for item in items {
|
||||||
|
do {
|
||||||
|
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
|
||||||
|
let fileName = Self.fileName(for: item)
|
||||||
|
viewModel.addLocalFile(data: data, fileName: fileName)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pickerItems = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交发布任务表单。
|
||||||
|
private func submit() async {
|
||||||
|
do {
|
||||||
|
let success = try await globalLoading.withLoading {
|
||||||
|
try await viewModel.submit(api: taskAPI, uploadService: ossUploadService, scenicId: accountContext.currentScenic?.id)
|
||||||
|
}
|
||||||
|
if success {
|
||||||
|
toastCenter.show("任务发布成功")
|
||||||
|
dismiss()
|
||||||
|
} else if let message = viewModel.errorMessage {
|
||||||
|
toastCenter.show(message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(viewModel.errorMessage ?? error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据选择项类型生成本地附件文件名。
|
||||||
|
private static func fileName(for item: PhotosPickerItem) -> String {
|
||||||
|
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) || $0.conforms(to: .video) }
|
||||||
|
return "\(UUID().uuidString).\(isVideo ? "mp4" : "jpg")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务附件行,展示文件名、状态和删除按钮。
|
||||||
|
private struct TaskAttachmentRow: View {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let onDelete: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Image(systemName: "paperclip")
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||||
|
.background(AppDesign.primarySoft, in: Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: onDelete) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("删除附件")
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.small)
|
||||||
|
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||||
|
}
|
||||||
|
}
|
||||||
186
suixinkan/Features/Tasks/Views/TaskDetailView.swift
Normal file
186
suixinkan/Features/Tasks/Views/TaskDetailView.swift
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
//
|
||||||
|
// TaskDetailView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 任务详情页面,展示任务基础信息、关联订单和任务结果。
|
||||||
|
struct TaskDetailView: View {
|
||||||
|
@Environment(TaskAPI.self) private var taskAPI
|
||||||
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
|
let taskId: Int
|
||||||
|
let summary: PhotographerTaskItem?
|
||||||
|
|
||||||
|
@State private var viewModel = TaskDetailViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
headerSection
|
||||||
|
orderSection
|
||||||
|
resultSection
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||||
|
}
|
||||||
|
.background(Color(hex: 0xF5F7FA))
|
||||||
|
.navigationTitle("任务详情")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.task {
|
||||||
|
await globalLoading.withOptionalLoading(viewModel.detail == nil) {
|
||||||
|
await viewModel.load(api: taskAPI, taskId: taskId)
|
||||||
|
}
|
||||||
|
if let message = viewModel.errorMessage {
|
||||||
|
toastCenter.show(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务头部信息区域。
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text(displayName)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
Spacer()
|
||||||
|
Text(displayStatus)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||||||
|
.background(AppDesign.primarySoft, in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskInfoRow(title: "任务备注", value: detail?.photogRemark ?? summary?.photogRemark ?? "暂无")
|
||||||
|
TaskInfoRow(title: "创建时间", value: detail?.createdAt ?? summary?.createdAt ?? "暂无")
|
||||||
|
TaskInfoRow(title: "紧急小时", value: detail.map { "\($0.urgentHour) 小时" } ?? "暂无")
|
||||||
|
TaskInfoRow(title: "编辑人员", value: detail?.editorName.nonEmpty ?? summary?.editorName.nonEmpty ?? "暂无")
|
||||||
|
TaskInfoRow(title: "编辑电话", value: detail?.editorPhone.nonEmpty ?? "暂无")
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关联订单区域。
|
||||||
|
private var orderSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
Text("关联订单")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
|
||||||
|
TaskInfoRow(title: "项目名称", value: detail?.order?.projectName.nonEmpty ?? summary?.orderUserNickname.nonEmpty ?? "暂无")
|
||||||
|
TaskInfoRow(title: "订单号", value: detail?.order?.orderNumber.nonEmpty ?? summary?.orderNumber.nonEmpty ?? "暂无")
|
||||||
|
TaskInfoRow(title: "手机号", value: detail?.order?.userPhone.nonEmpty ?? summary?.orderUserPhone.nonEmpty ?? "暂无")
|
||||||
|
TaskInfoRow(title: "支付时间", value: detail?.order?.payTime.nonEmpty ?? "暂无")
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务结果区域。
|
||||||
|
private var resultSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
Text("任务结果")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
|
||||||
|
if mediaItems.isEmpty {
|
||||||
|
Text("暂无任务结果文件")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.small)
|
||||||
|
} else {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 92), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) {
|
||||||
|
ForEach(mediaItems) { media in
|
||||||
|
TaskMediaThumbView(media: media)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前已加载的详情。
|
||||||
|
private var detail: TaskDetailResponse? {
|
||||||
|
viewModel.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示任务名称。
|
||||||
|
private var displayName: String {
|
||||||
|
detail?.name.nonEmpty ?? summary?.name.nonEmpty ?? "未命名任务"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示任务状态。
|
||||||
|
private var displayStatus: String {
|
||||||
|
detail?.taskStatusLabel.nonEmpty ?? summary?.statusName.nonEmpty ?? "未知"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 展示任务媒体列表。
|
||||||
|
private var mediaItems: [TaskMediaItem] {
|
||||||
|
if let detail {
|
||||||
|
return detail.taskResult
|
||||||
|
}
|
||||||
|
return summary?.media ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务信息行,展示左右结构的标题和值。
|
||||||
|
private struct TaskInfoRow: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.frame(width: 72, alignment: .leading)
|
||||||
|
Text(value.isEmpty ? "暂无" : value)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务媒体缩略图视图,使用 Kingfisher 加载网络图片。
|
||||||
|
private struct TaskMediaThumbView: View {
|
||||||
|
let media: TaskMediaItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
RemoteImage(urlString: media.displayURL) {
|
||||||
|
Image(systemName: media.isVideo ? "video.fill" : "photo")
|
||||||
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(hex: 0xEEF2F7))
|
||||||
|
}
|
||||||
|
.frame(height: 92)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||||
|
|
||||||
|
if media.isVideo {
|
||||||
|
Image(systemName: "play.circle.fill")
|
||||||
|
.font(.system(size: 20, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(AppMetrics.Spacing.xSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
/// 去除空白后返回非空字符串。
|
||||||
|
var nonEmpty: String? {
|
||||||
|
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return text.isEmpty ? nil : text
|
||||||
|
}
|
||||||
|
}
|
||||||
272
suixinkan/Features/Tasks/Views/TaskManagementView.swift
Normal file
272
suixinkan/Features/Tasks/Views/TaskManagementView.swift
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
//
|
||||||
|
// TaskManagementView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 任务管理页面,展示任务列表、筛选条件和发布任务入口。
|
||||||
|
struct TaskManagementView: View {
|
||||||
|
@Environment(AccountContext.self) private var accountContext
|
||||||
|
@Environment(RouterPath.self) private var router
|
||||||
|
@Environment(TaskAPI.self) private var taskAPI
|
||||||
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.globalLoading) private var globalLoading
|
||||||
|
|
||||||
|
@State private var viewModel = TaskManagementViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
filterSection
|
||||||
|
contentSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||||
|
}
|
||||||
|
.background(Color(hex: 0xF5F7FA))
|
||||||
|
.navigationTitle("任务管理")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
router.navigate(to: .home(.taskCreate))
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("发布任务")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable { await reload(showLoading: false) }
|
||||||
|
.task {
|
||||||
|
guard viewModel.tasks.isEmpty else { return }
|
||||||
|
await reload(showLoading: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 筛选区域,包含状态、搜索和日期筛选。
|
||||||
|
private var filterSection: some View {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Picker("任务状态", selection: $viewModel.selectedStatus) {
|
||||||
|
ForEach(TaskStatusFilter.allCases) { status in
|
||||||
|
Text(status.title).tag(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: viewModel.selectedStatus) { _, _ in
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
TextField("搜索任务名称", text: $viewModel.searchText)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.submitLabel(.search)
|
||||||
|
.onSubmit { Task { await reload(showLoading: true) } }
|
||||||
|
Button("搜索") {
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||||
|
|
||||||
|
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||||
|
TaskDatePickerRow(title: "开始日期", date: $viewModel.startDate)
|
||||||
|
TaskDatePickerRow(title: "结束日期", date: $viewModel.endDate)
|
||||||
|
HStack {
|
||||||
|
Button("清除日期") {
|
||||||
|
viewModel.startDate = nil
|
||||||
|
viewModel.endDate = nil
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("应用日期") {
|
||||||
|
Task { await reload(showLoading: true) }
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表内容区域,展示空状态、任务卡片和加载更多。
|
||||||
|
@ViewBuilder
|
||||||
|
private var contentSection: some View {
|
||||||
|
if accountContext.currentScenic?.id == nil {
|
||||||
|
TaskEmptyState(title: "缺少景区上下文", message: "请先在首页选择景区后再查看任务。")
|
||||||
|
} else if viewModel.tasks.isEmpty {
|
||||||
|
TaskEmptyState(title: "暂无任务", message: viewModel.errorMessage ?? "当前筛选条件下没有任务。")
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.tasks) { task in
|
||||||
|
Button {
|
||||||
|
router.navigate(to: .home(.taskDetail(id: task.id, summary: task)))
|
||||||
|
} label: {
|
||||||
|
TaskCardView(task: task)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.onAppear {
|
||||||
|
guard task.id == viewModel.tasks.last?.id else { return }
|
||||||
|
Task { await loadMore() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoadingMore {
|
||||||
|
Text("加载更多...")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.small)
|
||||||
|
} else if viewModel.hasMore {
|
||||||
|
Button("加载更多") {
|
||||||
|
Task { await loadMore() }
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新加载任务列表。
|
||||||
|
private func reload(showLoading: Bool) async {
|
||||||
|
do {
|
||||||
|
try await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
|
||||||
|
try await viewModel.reload(api: taskAPI, scenicId: accountContext.currentScenic?.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载下一页任务列表。
|
||||||
|
private func loadMore() async {
|
||||||
|
do {
|
||||||
|
try await viewModel.loadMore(api: taskAPI, scenicId: accountContext.currentScenic?.id)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务卡片视图,展示任务名称、状态、订单和时间信息。
|
||||||
|
private struct TaskCardView: View {
|
||||||
|
let task: PhotographerTaskItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||||
|
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||||
|
Text(task.name.isEmpty ? "未命名任务" : task.name)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
if !task.orderNumber.isEmpty {
|
||||||
|
Text("订单号:\(task.orderNumber)")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: AppMetrics.Spacing.small)
|
||||||
|
|
||||||
|
Text(task.statusName.isEmpty ? "\(task.taskStatus)" : task.statusName)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||||
|
.foregroundStyle(statusColor)
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||||||
|
.background(statusColor.opacity(0.12), in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !task.photogRemark.isEmpty {
|
||||||
|
Text(task.photogRemark)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Label(task.editorName.isEmpty ? "暂无编辑" : task.editorName, systemImage: "person.crop.circle")
|
||||||
|
Spacer()
|
||||||
|
Text(task.createdAt.isEmpty ? task.updatedAt : task.createdAt)
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.medium)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回任务状态对应的强调色。
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch task.taskStatus {
|
||||||
|
case 3:
|
||||||
|
AppDesign.success
|
||||||
|
case 4:
|
||||||
|
Color(hex: 0xEF4444)
|
||||||
|
case 1, 2:
|
||||||
|
AppDesign.warning
|
||||||
|
default:
|
||||||
|
AppDesign.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务日期筛选行,封装可选日期选择控件。
|
||||||
|
private struct TaskDatePickerRow: View {
|
||||||
|
let title: String
|
||||||
|
@Binding var date: Date?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
Spacer()
|
||||||
|
DatePicker(
|
||||||
|
title,
|
||||||
|
selection: Binding(
|
||||||
|
get: { date ?? Date() },
|
||||||
|
set: { date = $0 }
|
||||||
|
),
|
||||||
|
displayedComponents: .date
|
||||||
|
)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务空状态视图,用于缺少景区或无数据时展示。
|
||||||
|
private struct TaskEmptyState: View {
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Image(systemName: "tray")
|
||||||
|
.font(.system(size: 36, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.large)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||||
|
}
|
||||||
|
}
|
||||||
271
suixinkan/Features/Tasks/Views/TaskSelectionViews.swift
Normal file
271
suixinkan/Features/Tasks/Views/TaskSelectionViews.swift
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
//
|
||||||
|
// TaskSelectionViews.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 任务订单选择页面,供发布任务时关联已有订单。
|
||||||
|
struct TaskOrderSelectionView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let orders: [AvailableOrderResponse]
|
||||||
|
let selectedOrder: AvailableOrderResponse?
|
||||||
|
let onSelect: (AvailableOrderResponse?) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Button {
|
||||||
|
onSelect(nil)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Label("不关联订单", systemImage: selectedOrder == nil ? "checkmark.circle.fill" : "circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(orders) { order in
|
||||||
|
Button {
|
||||||
|
onSelect(order)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Image(systemName: selectedOrder?.orderNumber == order.orderNumber ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||||
|
Text(order.projectName.isEmpty ? "未命名项目" : order.projectName)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Text("订单号:\(order.orderNumber)")
|
||||||
|
Text("手机号:\(order.userPhone.isEmpty ? "暂无" : order.userPhone)")
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("选择订单")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("关闭") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务云盘文件选择页面,供发布任务时选择云盘附件。
|
||||||
|
struct TaskCloudFileSelectionView: View {
|
||||||
|
@Environment(TaskAPI.self) private var taskAPI
|
||||||
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let onConfirm: ([TaskCloudSelectionItem]) -> Void
|
||||||
|
|
||||||
|
@State private var viewModel = TaskCloudFileSelectionViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
filterBar
|
||||||
|
breadcrumbBar
|
||||||
|
fileList
|
||||||
|
confirmBar
|
||||||
|
}
|
||||||
|
.background(Color(hex: 0xF5F7FA))
|
||||||
|
.navigationTitle("选择云盘文件")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("关闭") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜索与类型筛选区域。
|
||||||
|
private var filterBar: some View {
|
||||||
|
VStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
TextField("搜索文件名", text: $viewModel.searchText)
|
||||||
|
.submitLabel(.search)
|
||||||
|
.onSubmit { Task { await reload() } }
|
||||||
|
Button("搜索") {
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
}
|
||||||
|
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||||
|
|
||||||
|
Picker("文件类型", selection: $viewModel.selectedFilter) {
|
||||||
|
ForEach(TaskCloudFileFilter.allCases) { filter in
|
||||||
|
Text(filter.title).tag(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: viewModel.selectedFilter) { _, _ in
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.background(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘路径面包屑区域。
|
||||||
|
private var breadcrumbBar: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||||
|
ForEach(Array(viewModel.path.enumerated()), id: \.element.id) { index, folder in
|
||||||
|
Button(folder.name) {
|
||||||
|
Task { await popToFolder(index) }
|
||||||
|
}
|
||||||
|
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||||
|
.foregroundStyle(index == viewModel.path.count - 1 ? AppDesign.textPrimary : AppDesign.primary)
|
||||||
|
if index < viewModel.path.count - 1 {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.small)
|
||||||
|
}
|
||||||
|
.background(Color(hex: 0xF8FAFC))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘文件列表区域。
|
||||||
|
private var fileList: some View {
|
||||||
|
List {
|
||||||
|
if viewModel.files.isEmpty {
|
||||||
|
Text(viewModel.errorMessage ?? "暂无文件")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.files) { file in
|
||||||
|
Button {
|
||||||
|
Task { await handleTap(file) }
|
||||||
|
} label: {
|
||||||
|
CloudFileRow(file: file, isSelected: viewModel.isSelected(file))
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
guard file.id == viewModel.files.last?.id else { return }
|
||||||
|
Task { await loadMore() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoadingMore {
|
||||||
|
Text("加载更多...")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 底部确认区域。
|
||||||
|
private var confirmBar: some View {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||||
|
Text("已选 \(viewModel.selectedFiles.count) 个文件")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
onConfirm(viewModel.makeSelectionItems())
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Text("确认")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 108, height: 44)
|
||||||
|
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||||
|
.background(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新加载当前云盘目录。
|
||||||
|
private func reload() async {
|
||||||
|
do {
|
||||||
|
try await viewModel.reload(api: taskAPI)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载下一页云盘文件。
|
||||||
|
private func loadMore() async {
|
||||||
|
do {
|
||||||
|
try await viewModel.loadMore(api: taskAPI)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理云盘列表点击,文件夹进入目录,文件切换选中。
|
||||||
|
private func handleTap(_ file: CloudDriveFile) async {
|
||||||
|
if file.isFolder {
|
||||||
|
do {
|
||||||
|
try await viewModel.enterFolder(file, api: taskAPI)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewModel.toggleSelection(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回到指定面包屑目录。
|
||||||
|
private func popToFolder(_ index: Int) async {
|
||||||
|
do {
|
||||||
|
try await viewModel.popToFolder(at: index, api: taskAPI)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 云盘文件列表行,展示文件或文件夹图标和选择状态。
|
||||||
|
private struct CloudFileRow: View {
|
||||||
|
let file: CloudDriveFile
|
||||||
|
let isSelected: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: AppMetrics.Spacing.small) {
|
||||||
|
Image(systemName: file.isFolder ? "folder.fill" : (file.isVideo ? "video.fill" : "photo.fill"))
|
||||||
|
.font(.system(size: 22, weight: .semibold))
|
||||||
|
.foregroundStyle(file.isFolder ? AppDesign.warning : AppDesign.primary)
|
||||||
|
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
|
||||||
|
Text(file.name.isEmpty ? "未命名文件" : file.name)
|
||||||
|
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(file.isFolder ? "\(file.childNum) 项" : "\(file.fileSize / 1024) KB")
|
||||||
|
.font(.system(size: AppMetrics.FontSize.caption))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if !file.isFolder {
|
||||||
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(isSelected ? AppDesign.primary : AppDesign.placeholder)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundStyle(AppDesign.placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
suixinkanTests/Fixtures/available_order_success.json
Normal file
14
suixinkanTests/Fixtures/available_order_success.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"project_name": "亲子跟拍",
|
||||||
|
"order_number": "ORD-TASK-001",
|
||||||
|
"order_status": "20",
|
||||||
|
"order_status_label": "已支付",
|
||||||
|
"pay_time": "2026-06-21 09:55:00",
|
||||||
|
"user_phone": "13800000000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
suixinkanTests/Fixtures/cloud_file_list_success.json
Normal file
33
suixinkanTests/Fixtures/cloud_file_list_success.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"total": "2",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "501",
|
||||||
|
"parent_folder_id": "0",
|
||||||
|
"file_url": "",
|
||||||
|
"cover_url": "",
|
||||||
|
"updated_at": "2026-06-20 10:00:00",
|
||||||
|
"name": "任务素材",
|
||||||
|
"created_at": "2026-06-20 09:00:00",
|
||||||
|
"child_num": "3",
|
||||||
|
"type": "99",
|
||||||
|
"file_size": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 502,
|
||||||
|
"parent_folder_id": 0,
|
||||||
|
"file_url": "https://cdn.example.com/cloud/photo.jpg",
|
||||||
|
"cover_url": "",
|
||||||
|
"updated_at": "2026-06-20 10:00:00",
|
||||||
|
"name": "photo.jpg",
|
||||||
|
"created_at": "2026-06-20 09:00:00",
|
||||||
|
"child_num": 0,
|
||||||
|
"type": 2,
|
||||||
|
"file_size": 2048
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
38
suixinkanTests/Fixtures/task_detail_success.json
Normal file
38
suixinkanTests/Fixtures/task_detail_success.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": "101",
|
||||||
|
"name": "游客精修任务",
|
||||||
|
"created_at": "2026-06-21 10:00:00",
|
||||||
|
"task_status": "2",
|
||||||
|
"task_status_label": "处理中",
|
||||||
|
"accepted_at": "2026-06-21 10:05:00",
|
||||||
|
"editor_name": "小李",
|
||||||
|
"editor_phone": "13900000000",
|
||||||
|
"urgent_hour": "24",
|
||||||
|
"photog_remark": "请优先处理亲子照片",
|
||||||
|
"order": {
|
||||||
|
"project_name": "亲子跟拍",
|
||||||
|
"order_number": "ORD-TASK-001",
|
||||||
|
"pay_time": "2026-06-21 09:55:00",
|
||||||
|
"user_phone": "13800000000"
|
||||||
|
},
|
||||||
|
"task_result": [
|
||||||
|
{
|
||||||
|
"id": 9101,
|
||||||
|
"photog_task_id": 101,
|
||||||
|
"user_id": 7001,
|
||||||
|
"file_name": "result.mp4",
|
||||||
|
"file_type": 1,
|
||||||
|
"file_url": "https://cdn.example.com/task/result.mp4",
|
||||||
|
"file_size": 4096,
|
||||||
|
"cover_url": "https://cdn.example.com/task/result-cover.jpg",
|
||||||
|
"duration": 12,
|
||||||
|
"remark": "",
|
||||||
|
"created_at": "2026-06-21 11:00:00",
|
||||||
|
"updated_at": "2026-06-21 11:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
26
suixinkanTests/Fixtures/task_list_page2_success.json
Normal file
26
suixinkanTests/Fixtures/task_list_page2_success.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"total": 3,
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 103,
|
||||||
|
"name": "补传照片",
|
||||||
|
"type": 1,
|
||||||
|
"order_number": "ORD-TASK-003",
|
||||||
|
"task_status": 3,
|
||||||
|
"status_name": "已完成",
|
||||||
|
"created_at": "2026-06-23 09:00:00",
|
||||||
|
"updated_at": "2026-06-23 09:30:00",
|
||||||
|
"photog_remark": "",
|
||||||
|
"editor_name": "小张",
|
||||||
|
"operate_time": "",
|
||||||
|
"order_user_nickname": "",
|
||||||
|
"order_user_phone": "",
|
||||||
|
"order_user_id": "",
|
||||||
|
"media": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
58
suixinkanTests/Fixtures/task_list_success.json
Normal file
58
suixinkanTests/Fixtures/task_list_success.json
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"total": "2",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "101",
|
||||||
|
"name": "游客精修任务",
|
||||||
|
"type": "1",
|
||||||
|
"order_number": "ORD-TASK-001",
|
||||||
|
"task_status": "2",
|
||||||
|
"status_name": "处理中",
|
||||||
|
"created_at": "2026-06-21 10:00:00",
|
||||||
|
"updated_at": "2026-06-21 10:30:00",
|
||||||
|
"photog_remark": "请优先处理亲子照片",
|
||||||
|
"editor_name": "小李",
|
||||||
|
"operate_time": "2026-06-21 10:31:00",
|
||||||
|
"order_user_nickname": "王女士",
|
||||||
|
"order_user_phone": "13800000000",
|
||||||
|
"order_user_id": "7001",
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"id": "9001",
|
||||||
|
"photog_task_id": "101",
|
||||||
|
"user_id": "7001",
|
||||||
|
"file_name": "cover.jpg",
|
||||||
|
"file_type": "2",
|
||||||
|
"file_url": "https://cdn.example.com/task/cover.jpg",
|
||||||
|
"file_size": "2048",
|
||||||
|
"cover_url": "",
|
||||||
|
"duration": "0",
|
||||||
|
"remark": "",
|
||||||
|
"created_at": "2026-06-21 10:01:00",
|
||||||
|
"updated_at": "2026-06-21 10:01:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 102,
|
||||||
|
"name": "短视频剪辑",
|
||||||
|
"type": 1,
|
||||||
|
"order_number": "ORD-TASK-002",
|
||||||
|
"task_status": 1,
|
||||||
|
"status_name": "待接收",
|
||||||
|
"created_at": "2026-06-22 09:00:00",
|
||||||
|
"updated_at": "2026-06-22 09:00:00",
|
||||||
|
"photog_remark": "",
|
||||||
|
"editor_name": "",
|
||||||
|
"operate_time": "",
|
||||||
|
"order_user_nickname": "",
|
||||||
|
"order_user_phone": "",
|
||||||
|
"order_user_id": "",
|
||||||
|
"media": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,14 +32,13 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply", title: ""), .destination(.permissionApply))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply", title: ""), .destination(.permissionApply))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply_status", title: ""), .destination(.permissionApplyStatus))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply_status", title: ""), .destination(.permissionApplyStatus))
|
||||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicapplication", title: ""), .destination(.scenicApplication))
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicapplication", title: ""), .destination(.scenicApplication))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management", title: ""), .destination(.taskManagement))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management_editor", title: ""), .destination(.taskManagement))
|
||||||
|
XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_create", title: ""), .destination(.taskCreate))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试已知但未迁移的首页路由会进入安全占位。
|
/// 测试已知但未迁移的首页路由会进入安全占位。
|
||||||
func testKnownUnmigratedRoutesResolveToPlaceholders() {
|
func testKnownUnmigratedRoutesResolveToPlaceholders() {
|
||||||
XCTAssertEqual(
|
|
||||||
HomeMenuRouter.resolve(uri: "task_management", title: ""),
|
|
||||||
.destination(.modulePlaceholder(uri: "task_management", title: "任务管理"))
|
|
||||||
)
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""),
|
HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""),
|
||||||
.destination(.modulePlaceholder(uri: "scenic_settlement", title: "景区结算"))
|
.destination(.modulePlaceholder(uri: "scenic_settlement", title: "景区结算"))
|
||||||
|
|||||||
145
suixinkanTests/TaskAPITests.swift
Normal file
145
suixinkanTests/TaskAPITests.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
//
|
||||||
|
// TaskAPITests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 任务 API 测试,覆盖请求参数、请求体和模型解码。
|
||||||
|
final class TaskAPITests: XCTestCase {
|
||||||
|
/// 测试任务列表接口使用正确 path 和 query。
|
||||||
|
func testTaskListUsesExpectedPathAndQuery() async throws {
|
||||||
|
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "task_list_success"))
|
||||||
|
let api = TaskAPI(client: APIClient(session: session))
|
||||||
|
|
||||||
|
_ = try await api.taskList(
|
||||||
|
scenicId: 88,
|
||||||
|
page: 0,
|
||||||
|
pageSize: 0,
|
||||||
|
taskStatus: -2,
|
||||||
|
taskName: " 精修 ",
|
||||||
|
startTime: "2026-06-01",
|
||||||
|
endTime: "2026-06-30"
|
||||||
|
)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/task/list")
|
||||||
|
let query = taskQueryItems(from: request)
|
||||||
|
XCTAssertEqual(query["scenic_id"], "88")
|
||||||
|
XCTAssertEqual(query["page"], "1")
|
||||||
|
XCTAssertEqual(query["page_size"], "1")
|
||||||
|
XCTAssertEqual(query["task_status"], "0")
|
||||||
|
XCTAssertEqual(query["task_name"], "精修")
|
||||||
|
XCTAssertEqual(query["start_time"], "2026-06-01")
|
||||||
|
XCTAssertEqual(query["end_time"], "2026-06-30")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试任务详情接口使用正确 path 和 id。
|
||||||
|
func testTaskDetailUsesExpectedPath() async throws {
|
||||||
|
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "task_detail_success"))
|
||||||
|
let api = TaskAPI(client: APIClient(session: session))
|
||||||
|
|
||||||
|
let detail = try await api.taskDetail(id: 101)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/task/info")
|
||||||
|
XCTAssertEqual(taskQueryItems(from: request)["id"], "101")
|
||||||
|
XCTAssertEqual(detail.urgentHour, 24)
|
||||||
|
XCTAssertEqual(detail.taskResult.first?.isVideo, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试可关联订单接口使用正确景区参数。
|
||||||
|
func testAvailableOrderUsesScenicId() async throws {
|
||||||
|
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "available_order_success"))
|
||||||
|
let api = TaskAPI(client: APIClient(session: session))
|
||||||
|
|
||||||
|
let orders = try await api.availableOrderList(scenicId: 88)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/task/available-order")
|
||||||
|
XCTAssertEqual(taskQueryItems(from: request)["scenic_id"], "88")
|
||||||
|
XCTAssertEqual(orders.first?.orderNumber, "ORD-TASK-001")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试发布任务接口提交正确 body。
|
||||||
|
func testAddTaskUsesExpectedBody() async throws {
|
||||||
|
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "empty_success"))
|
||||||
|
let api = TaskAPI(client: APIClient(session: session))
|
||||||
|
let request = AddTaskRequest(
|
||||||
|
scenicId: "88",
|
||||||
|
name: "任务",
|
||||||
|
orderNumber: "ORD001",
|
||||||
|
remark: "备注",
|
||||||
|
urgentHour: 12,
|
||||||
|
cloudFile: [CloudFileItem(fileId: 1, remark: "")],
|
||||||
|
uploadFile: [UploadFileItem(fileUrl: "https://cdn.example.com/a.jpg", fileName: "a.jpg", remark: "")]
|
||||||
|
)
|
||||||
|
|
||||||
|
try await api.addTask(request)
|
||||||
|
|
||||||
|
let urlRequest = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(urlRequest.url?.path, "/api/yf-handset-app/photog/task/create")
|
||||||
|
let body = try XCTUnwrap(urlRequest.httpBody)
|
||||||
|
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||||
|
XCTAssertEqual(object["scenic_id"] as? String, "88")
|
||||||
|
XCTAssertEqual(object["name"] as? String, "任务")
|
||||||
|
XCTAssertEqual(object["order_number"] as? String, "ORD001")
|
||||||
|
XCTAssertEqual(object["photog_remark"] as? String, "备注")
|
||||||
|
XCTAssertEqual(object["urgent_hour"] as? Int, 12)
|
||||||
|
XCTAssertEqual((object["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 1)
|
||||||
|
XCTAssertEqual((object["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/a.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试云盘文件列表接口使用正确 path 和 query。
|
||||||
|
func testCloudFileListUsesExpectedQuery() async throws {
|
||||||
|
let session = TaskRecordingURLSession(data: try TestFixture.data(named: "cloud_file_list_success"))
|
||||||
|
let api = TaskAPI(client: APIClient(session: session))
|
||||||
|
|
||||||
|
let payload = try await api.cloudFileList(parentFolderId: 501, name: "photo", type: 2, orderBy: 2, page: 0, pageSize: 0)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/cloud-driver/list")
|
||||||
|
let query = taskQueryItems(from: request)
|
||||||
|
XCTAssertEqual(query["parent_folder_id"], "501")
|
||||||
|
XCTAssertEqual(query["name"], "photo")
|
||||||
|
XCTAssertEqual(query["type"], "2")
|
||||||
|
XCTAssertEqual(query["page"], "1")
|
||||||
|
XCTAssertEqual(query["page_size"], "1")
|
||||||
|
XCTAssertEqual(payload.list.first?.isFolder, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务 API 测试用 URLSession,记录请求并返回固定数据。
|
||||||
|
private final class TaskRecordingURLSession: URLSessionProtocol {
|
||||||
|
let data: Data
|
||||||
|
private(set) var requests: [URLRequest] = []
|
||||||
|
|
||||||
|
/// 初始化测试 Session。
|
||||||
|
init(data: Data) {
|
||||||
|
self.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录请求并返回成功响应。
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
requests.append(request)
|
||||||
|
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
|
return (data, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从请求中提取 query 字典。
|
||||||
|
private func taskQueryItems(from request: URLRequest) -> [String: String] {
|
||||||
|
guard
|
||||||
|
let url = request.url,
|
||||||
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||||
|
item.value.map { (item.name, $0) }
|
||||||
|
})
|
||||||
|
}
|
||||||
323
suixinkanTests/TaskViewModelTests.swift
Normal file
323
suixinkanTests/TaskViewModelTests.swift
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
//
|
||||||
|
// TaskViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 任务 ViewModel 测试,覆盖列表、发布任务和云盘选择逻辑。
|
||||||
|
final class TaskViewModelTests: XCTestCase {
|
||||||
|
/// 测试无景区时清空任务列表且不请求接口。
|
||||||
|
func testTaskManagementClearsWithoutScenic() async throws {
|
||||||
|
let api = TaskMockService()
|
||||||
|
api.taskListResponses = [try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_success")]
|
||||||
|
let viewModel = TaskManagementViewModel()
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, scenicId: 88)
|
||||||
|
XCTAssertFalse(viewModel.tasks.isEmpty)
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, scenicId: nil)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.tasks.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.total, 0)
|
||||||
|
XCTAssertEqual(viewModel.page, 1)
|
||||||
|
XCTAssertEqual(api.taskListCalls.count, 1)
|
||||||
|
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试任务列表首屏和加载更多会更新分页。
|
||||||
|
func testTaskManagementReloadAndLoadMore() async throws {
|
||||||
|
let api = TaskMockService()
|
||||||
|
api.taskListResponses = [
|
||||||
|
try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_success"),
|
||||||
|
try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_page2_success")
|
||||||
|
]
|
||||||
|
let viewModel = TaskManagementViewModel()
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, scenicId: 88)
|
||||||
|
viewModel.total = 3
|
||||||
|
try await viewModel.loadMore(api: api, scenicId: 88)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.tasks.count, 3)
|
||||||
|
XCTAssertEqual(viewModel.page, 2)
|
||||||
|
XCTAssertEqual(api.taskListCalls.map(\.page), [1, 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试筛选参数会传给任务服务层。
|
||||||
|
func testTaskManagementUsesFilters() async throws {
|
||||||
|
let api = TaskMockService()
|
||||||
|
api.taskListResponses = [try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_success")]
|
||||||
|
let viewModel = TaskManagementViewModel()
|
||||||
|
viewModel.selectedStatus = .accepted
|
||||||
|
viewModel.searchText = " 亲子 "
|
||||||
|
viewModel.startDate = Self.date("2026-06-01")
|
||||||
|
viewModel.endDate = Self.date("2026-06-30")
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api, scenicId: 88)
|
||||||
|
|
||||||
|
let call = try XCTUnwrap(api.taskListCalls.first)
|
||||||
|
XCTAssertEqual(call.taskStatus, TaskStatusFilter.accepted.rawValue)
|
||||||
|
XCTAssertEqual(call.taskName, "亲子")
|
||||||
|
XCTAssertEqual(call.startTime, "2026-06-01")
|
||||||
|
XCTAssertEqual(call.endTime, "2026-06-30")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试任务详情加载成功后保存详情。
|
||||||
|
func testTaskDetailLoadsDetail() async throws {
|
||||||
|
let api = TaskMockService()
|
||||||
|
api.detailResponse = try TestFixture.payload(TaskDetailResponse.self, named: "task_detail_success")
|
||||||
|
let viewModel = TaskDetailViewModel()
|
||||||
|
|
||||||
|
await viewModel.load(api: api, taskId: 101)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.detailIds, [101])
|
||||||
|
XCTAssertEqual(viewModel.detail?.name, "游客精修任务")
|
||||||
|
XCTAssertNil(viewModel.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试发布任务校验必填项和景区。
|
||||||
|
func testTaskCreateValidation() async throws {
|
||||||
|
let api = TaskMockService()
|
||||||
|
let upload = TaskMockUploadService()
|
||||||
|
let viewModel = TaskCreateViewModel()
|
||||||
|
|
||||||
|
let noScenic = try await viewModel.submit(api: api, uploadService: upload, scenicId: nil)
|
||||||
|
XCTAssertFalse(noScenic)
|
||||||
|
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
|
||||||
|
|
||||||
|
viewModel.taskName = "任务"
|
||||||
|
let noRemark = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
|
||||||
|
XCTAssertFalse(noRemark)
|
||||||
|
XCTAssertEqual(viewModel.errorMessage, "请输入任务备注")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试发布任务会把云盘文件和本地上传文件写入请求。
|
||||||
|
func testTaskCreateUploadsLocalFilesBeforeSubmit() async throws {
|
||||||
|
let api = TaskMockService()
|
||||||
|
let upload = TaskMockUploadService()
|
||||||
|
upload.taskUploadURLs = ["https://cdn.example.com/task/a.jpg"]
|
||||||
|
let viewModel = TaskCreateViewModel()
|
||||||
|
viewModel.taskName = "任务"
|
||||||
|
viewModel.remark = "备注"
|
||||||
|
viewModel.urgentHourText = "6"
|
||||||
|
viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "")])
|
||||||
|
viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg")
|
||||||
|
|
||||||
|
let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
|
||||||
|
|
||||||
|
XCTAssertTrue(success)
|
||||||
|
XCTAssertEqual(upload.taskUploads.count, 1)
|
||||||
|
let request = try XCTUnwrap(api.addTaskRequests.first)
|
||||||
|
XCTAssertEqual(request.scenicId, "88")
|
||||||
|
XCTAssertEqual(request.name, "任务")
|
||||||
|
XCTAssertEqual(request.cloudFile.first?.fileId, 501)
|
||||||
|
XCTAssertEqual(request.uploadFile.first?.fileUrl, "https://cdn.example.com/task/a.jpg")
|
||||||
|
XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试本地文件上传失败时不会提交发布任务。
|
||||||
|
func testTaskCreateUploadFailureStopsSubmit() async {
|
||||||
|
let api = TaskMockService()
|
||||||
|
let upload = TaskMockUploadService()
|
||||||
|
upload.uploadError = APIError.networkFailed("上传失败")
|
||||||
|
let viewModel = TaskCreateViewModel()
|
||||||
|
viewModel.taskName = "任务"
|
||||||
|
viewModel.remark = "备注"
|
||||||
|
viewModel.addLocalFile(data: Data([1]), fileName: "local.jpg")
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
|
||||||
|
XCTFail("Expected upload failure")
|
||||||
|
} catch APIError.networkFailed(let message) {
|
||||||
|
XCTAssertEqual(message, "上传失败")
|
||||||
|
} catch {
|
||||||
|
XCTFail("Unexpected error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(api.addTaskRequests.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.selectedLocalFiles.first?.errorMessage, "网络请求失败:上传失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试云盘选择器进入文件夹、分页、选择文件和忽略文件夹选择。
|
||||||
|
func testCloudSelectionNavigationAndSelection() async throws {
|
||||||
|
let api = TaskMockService()
|
||||||
|
let firstPage = try TestFixture.payload(ListPayload<CloudDriveFile>.self, named: "cloud_file_list_success")
|
||||||
|
let secondPage = ListPayload(total: 3, list: [firstPage.list[1]])
|
||||||
|
api.cloudFileResponses = [firstPage, secondPage, firstPage]
|
||||||
|
let viewModel = TaskCloudFileSelectionViewModel()
|
||||||
|
|
||||||
|
try await viewModel.reload(api: api)
|
||||||
|
viewModel.total = 3
|
||||||
|
try await viewModel.loadMore(api: api)
|
||||||
|
XCTAssertEqual(viewModel.files.count, 3)
|
||||||
|
|
||||||
|
let folder = firstPage.list[0]
|
||||||
|
try await viewModel.enterFolder(folder, api: api)
|
||||||
|
XCTAssertEqual(viewModel.currentFolderId, folder.id)
|
||||||
|
|
||||||
|
viewModel.toggleSelection(folder)
|
||||||
|
XCTAssertTrue(viewModel.selectedFiles.isEmpty)
|
||||||
|
|
||||||
|
let file = firstPage.list[1]
|
||||||
|
viewModel.toggleSelection(file)
|
||||||
|
XCTAssertEqual(viewModel.makeSelectionItems().first?.id, file.id)
|
||||||
|
XCTAssertEqual(api.cloudFileCalls.map(\.page), [1, 2, 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造固定日期。
|
||||||
|
private static func date(_ text: String) -> Date {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.calendar = Calendar(identifier: .gregorian)
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
return formatter.date(from: text)!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 任务服务测试替身。
|
||||||
|
private final class TaskMockService: TaskServing {
|
||||||
|
struct TaskListCall {
|
||||||
|
let scenicId: Int
|
||||||
|
let page: Int
|
||||||
|
let pageSize: Int
|
||||||
|
let taskStatus: Int
|
||||||
|
let taskName: String?
|
||||||
|
let startTime: String?
|
||||||
|
let endTime: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CloudFileCall {
|
||||||
|
let parentFolderId: Int
|
||||||
|
let name: String
|
||||||
|
let type: Int
|
||||||
|
let orderBy: Int
|
||||||
|
let page: Int
|
||||||
|
let pageSize: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskListResponses: [ListPayload<PhotographerTaskItem>] = []
|
||||||
|
var detailResponse: TaskDetailResponse?
|
||||||
|
var availableOrders: [AvailableOrderResponse] = []
|
||||||
|
var cloudFileResponses: [ListPayload<CloudDriveFile>] = []
|
||||||
|
private(set) var taskListCalls: [TaskListCall] = []
|
||||||
|
private(set) var detailIds: [Int] = []
|
||||||
|
private(set) var availableOrderScenicIds: [Int] = []
|
||||||
|
private(set) var addTaskRequests: [AddTaskRequest] = []
|
||||||
|
private(set) var cloudFileCalls: [CloudFileCall] = []
|
||||||
|
|
||||||
|
/// 记录任务列表请求并返回下一份响应。
|
||||||
|
func taskList(
|
||||||
|
scenicId: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int,
|
||||||
|
taskStatus: Int,
|
||||||
|
taskName: String?,
|
||||||
|
startTime: String?,
|
||||||
|
endTime: String?
|
||||||
|
) async throws -> ListPayload<PhotographerTaskItem> {
|
||||||
|
taskListCalls.append(
|
||||||
|
TaskListCall(
|
||||||
|
scenicId: scenicId,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
taskStatus: taskStatus,
|
||||||
|
taskName: taskName,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return taskListResponses.isEmpty ? ListPayload(total: 0, list: []) : taskListResponses.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录任务详情请求并返回响应。
|
||||||
|
func taskDetail(id: Int) async throws -> TaskDetailResponse {
|
||||||
|
detailIds.append(id)
|
||||||
|
return detailResponse!
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录可关联订单请求并返回响应。
|
||||||
|
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||||
|
availableOrderScenicIds.append(scenicId)
|
||||||
|
return availableOrders
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录发布任务请求。
|
||||||
|
func addTask(_ request: AddTaskRequest) async throws {
|
||||||
|
addTaskRequests.append(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录云盘文件请求并返回下一份响应。
|
||||||
|
func cloudFileList(
|
||||||
|
parentFolderId: Int,
|
||||||
|
name: String,
|
||||||
|
type: Int,
|
||||||
|
orderBy: Int,
|
||||||
|
page: Int,
|
||||||
|
pageSize: Int
|
||||||
|
) async throws -> ListPayload<CloudDriveFile> {
|
||||||
|
cloudFileCalls.append(
|
||||||
|
CloudFileCall(
|
||||||
|
parentFolderId: parentFolderId,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
orderBy: orderBy,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return cloudFileResponses.isEmpty ? ListPayload(total: 0, list: []) : cloudFileResponses.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// OSS 上传服务测试替身。
|
||||||
|
private final class TaskMockUploadService: OSSUploadServing {
|
||||||
|
struct TaskUpload {
|
||||||
|
let data: Data
|
||||||
|
let fileName: String
|
||||||
|
let fileType: Int
|
||||||
|
let scenicId: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskUploadURLs: [String] = []
|
||||||
|
var uploadError: Error?
|
||||||
|
private(set) var taskUploads: [TaskUpload] = []
|
||||||
|
|
||||||
|
/// 模拟上传任务附件。
|
||||||
|
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
taskUploads.append(TaskUpload(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId))
|
||||||
|
onProgress(50)
|
||||||
|
if let uploadError {
|
||||||
|
throw uploadError
|
||||||
|
}
|
||||||
|
onProgress(100)
|
||||||
|
return taskUploadURLs.isEmpty ? "https://cdn.example.com/task/default.jpg" : taskUploadURLs.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟上传头像。
|
||||||
|
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
|
||||||
|
/// 模拟上传实名认证图片。
|
||||||
|
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
|
||||||
|
/// 模拟上传云盘文件。
|
||||||
|
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
|
||||||
|
/// 模拟上传相册文件。
|
||||||
|
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
|
||||||
|
/// 模拟上传打卡点图片。
|
||||||
|
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
|
||||||
|
/// 模拟上传景区申请图片。
|
||||||
|
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
|
||||||
|
/// 模拟上传银行卡照片。
|
||||||
|
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user