From ffb16eca290f72843f9d672d894429a9e0cc7c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 23 Jun 2026 15:26:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=BB=E5=8A=A1=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E5=B9=B6=E6=8E=A5=E5=85=A5=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=AE=A1=E7=90=86=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。 Co-authored-by: Cursor --- suixinkan/App/RootView.swift | 3 + suixinkan/Features/Home/Home.md | 2 +- .../Features/Home/Models/HomeMenuItem.swift | 3 + .../Home/Routing/HomeMenuRouter.swift | 7 +- .../Home/Views/HomeSupportViews.swift | 6 + suixinkan/Features/Tasks/API/TaskAPI.swift | 148 +++++++ .../Features/Tasks/Models/TaskModels.swift | 393 ++++++++++++++++++ suixinkan/Features/Tasks/Tasks.md | 30 ++ .../TaskCloudFileSelectionViewModel.swift | 139 +++++++ .../ViewModels/TaskCreateViewModel.swift | 251 +++++++++++ .../ViewModels/TaskManagementViewModel.swift | 167 ++++++++ .../Features/Tasks/Views/TaskCreateView.swift | 281 +++++++++++++ .../Features/Tasks/Views/TaskDetailView.swift | 186 +++++++++ .../Tasks/Views/TaskManagementView.swift | 272 ++++++++++++ .../Tasks/Views/TaskSelectionViews.swift | 271 ++++++++++++ .../Fixtures/available_order_success.json | 14 + .../Fixtures/cloud_file_list_success.json | 33 ++ .../Fixtures/task_detail_success.json | 38 ++ .../Fixtures/task_list_page2_success.json | 26 ++ .../Fixtures/task_list_success.json | 58 +++ suixinkanTests/HomeMenuRouterTests.swift | 7 +- suixinkanTests/TaskAPITests.swift | 145 +++++++ suixinkanTests/TaskViewModelTests.swift | 323 ++++++++++++++ 23 files changed, 2795 insertions(+), 8 deletions(-) create mode 100644 suixinkan/Features/Tasks/API/TaskAPI.swift create mode 100644 suixinkan/Features/Tasks/Models/TaskModels.swift create mode 100644 suixinkan/Features/Tasks/Tasks.md create mode 100644 suixinkan/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift create mode 100644 suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift create mode 100644 suixinkan/Features/Tasks/ViewModels/TaskManagementViewModel.swift create mode 100644 suixinkan/Features/Tasks/Views/TaskCreateView.swift create mode 100644 suixinkan/Features/Tasks/Views/TaskDetailView.swift create mode 100644 suixinkan/Features/Tasks/Views/TaskManagementView.swift create mode 100644 suixinkan/Features/Tasks/Views/TaskSelectionViews.swift create mode 100644 suixinkanTests/Fixtures/available_order_success.json create mode 100644 suixinkanTests/Fixtures/cloud_file_list_success.json create mode 100644 suixinkanTests/Fixtures/task_detail_success.json create mode 100644 suixinkanTests/Fixtures/task_list_page2_success.json create mode 100644 suixinkanTests/Fixtures/task_list_success.json create mode 100644 suixinkanTests/TaskAPITests.swift create mode 100644 suixinkanTests/TaskViewModelTests.swift diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index f6adcae..3d3b8b0 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -28,6 +28,7 @@ struct RootView: View { @State private var paymentAPI: PaymentAPI @State private var walletAPI: WalletAPI @State private var scenicPermissionAPI: ScenicPermissionAPI + @State private var taskAPI: TaskAPI @State private var authSessionCoordinator: AuthSessionCoordinator @State private var sessionBootstrapper: SessionBootstrapper @@ -49,6 +50,7 @@ struct RootView: View { _paymentAPI = State(initialValue: PaymentAPI(client: apiClient)) _walletAPI = State(initialValue: WalletAPI(client: apiClient)) _scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient)) + _taskAPI = State(initialValue: TaskAPI(client: apiClient)) _authSessionCoordinator = State( initialValue: AuthSessionCoordinator( tokenStore: tokenStore, @@ -87,6 +89,7 @@ struct RootView: View { .environment(paymentAPI) .environment(walletAPI) .environment(scenicPermissionAPI) + .environment(taskAPI) .environment(authSessionCoordinator) .task { apiClient.bindAuthTokenProvider { appSession.token } diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index 7437541..975c313 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -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 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index 721df55..e55f27f 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -29,6 +29,9 @@ enum HomeRoute: Hashable { case settings case paymentCollection case wallet + case taskManagement + case taskCreate + case taskDetail(id: Int, summary: PhotographerTaskItem?) case modulePlaceholder(uri: String, title: String) } diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 8efe706..8dc7ba4 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -91,6 +91,10 @@ enum HomeMenuRouter { return .destination(.wallet) case "payment_collection", "payment_qr", "payment_code": return .destination(.paymentCollection) + case "task_management", "task_management_editor": + return .destination(.taskManagement) + case "task_create": + return .destination(.taskCreate) case "store", "more_functions": return .destination(.moreFunctions) case "fly", "pilot_controller": @@ -106,9 +110,6 @@ enum HomeMenuRouter { "deposit_order", "deposit_order_shooting_info", "withdrawal_audit", - "task_management", - "task_management_editor", - "task_create", "schedule_management", "checkin_points", "pm", diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 88123ea..b35bfee 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -30,6 +30,12 @@ extension HomeRoute { PaymentCollectionView() case .wallet: WalletView() + case .taskManagement: + TaskManagementView() + case .taskCreate: + TaskCreateView() + case .taskDetail(let id, let summary): + TaskDetailView(taskId: id, summary: summary) case let .modulePlaceholder(uri, title): HomeMigrationModuleView(title: title, uri: uri) } diff --git a/suixinkan/Features/Tasks/API/TaskAPI.swift b/suixinkan/Features/Tasks/API/TaskAPI.swift new file mode 100644 index 0000000..475dc91 --- /dev/null +++ b/suixinkan/Features/Tasks/API/TaskAPI.swift @@ -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 + + /// 获取任务详情。 + 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 +} + +/// 任务 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 { + 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 { + 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))") + ] + ) + ) + } +} diff --git a/suixinkan/Features/Tasks/Models/TaskModels.swift b/suixinkan/Features/Tasks/Models/TaskModels.swift new file mode 100644 index 0000000..a02dcc5 --- /dev/null +++ b/suixinkan/Features/Tasks/Models/TaskModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Tasks/Tasks.md b/suixinkan/Features/Tasks/Tasks.md new file mode 100644 index 0000000..db54a84 --- /dev/null +++ b/suixinkan/Features/Tasks/Tasks.md @@ -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` 管理。 + +本轮不迁完整云盘资产管理,只保留发布任务所需的文件选择能力。云盘的新建文件夹、上传、删除、移动、重命名等能力后续应放在独立云盘模块中迁移。 diff --git a/suixinkan/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift b/suixinkan/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift new file mode 100644 index 0000000..06bc760 --- /dev/null +++ b/suixinkan/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift @@ -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 { + try await api.cloudFileList( + parentFolderId: currentFolderId, + name: searchText.trimmingCharacters(in: .whitespacesAndNewlines), + type: selectedFilter.rawValue, + orderBy: 2, + page: page, + pageSize: pageSize + ) + } +} diff --git a/suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift b/suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift new file mode 100644 index 0000000..7e19b85 --- /dev/null +++ b/suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift @@ -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 + } +} diff --git a/suixinkan/Features/Tasks/ViewModels/TaskManagementViewModel.swift b/suixinkan/Features/Tasks/ViewModels/TaskManagementViewModel.swift new file mode 100644 index 0000000..fe6c66e --- /dev/null +++ b/suixinkan/Features/Tasks/ViewModels/TaskManagementViewModel.swift @@ -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 { + 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 + } + } +} diff --git a/suixinkan/Features/Tasks/Views/TaskCreateView.swift b/suixinkan/Features/Tasks/Views/TaskCreateView.swift new file mode 100644 index 0000000..06fd9e0 --- /dev/null +++ b/suixinkan/Features/Tasks/Views/TaskCreateView.swift @@ -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)) + } +} diff --git a/suixinkan/Features/Tasks/Views/TaskDetailView.swift b/suixinkan/Features/Tasks/Views/TaskDetailView.swift new file mode 100644 index 0000000..50cdecb --- /dev/null +++ b/suixinkan/Features/Tasks/Views/TaskDetailView.swift @@ -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 + } +} diff --git a/suixinkan/Features/Tasks/Views/TaskManagementView.swift b/suixinkan/Features/Tasks/Views/TaskManagementView.swift new file mode 100644 index 0000000..032a9e2 --- /dev/null +++ b/suixinkan/Features/Tasks/Views/TaskManagementView.swift @@ -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)) + } +} diff --git a/suixinkan/Features/Tasks/Views/TaskSelectionViews.swift b/suixinkan/Features/Tasks/Views/TaskSelectionViews.swift new file mode 100644 index 0000000..11bdd5f --- /dev/null +++ b/suixinkan/Features/Tasks/Views/TaskSelectionViews.swift @@ -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) + } + } + } +} diff --git a/suixinkanTests/Fixtures/available_order_success.json b/suixinkanTests/Fixtures/available_order_success.json new file mode 100644 index 0000000..6216940 --- /dev/null +++ b/suixinkanTests/Fixtures/available_order_success.json @@ -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" + } + ] +} diff --git a/suixinkanTests/Fixtures/cloud_file_list_success.json b/suixinkanTests/Fixtures/cloud_file_list_success.json new file mode 100644 index 0000000..d70add7 --- /dev/null +++ b/suixinkanTests/Fixtures/cloud_file_list_success.json @@ -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 + } + ] + } +} diff --git a/suixinkanTests/Fixtures/task_detail_success.json b/suixinkanTests/Fixtures/task_detail_success.json new file mode 100644 index 0000000..fee0619 --- /dev/null +++ b/suixinkanTests/Fixtures/task_detail_success.json @@ -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" + } + ] + } +} diff --git a/suixinkanTests/Fixtures/task_list_page2_success.json b/suixinkanTests/Fixtures/task_list_page2_success.json new file mode 100644 index 0000000..b259f03 --- /dev/null +++ b/suixinkanTests/Fixtures/task_list_page2_success.json @@ -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": [] + } + ] + } +} diff --git a/suixinkanTests/Fixtures/task_list_success.json b/suixinkanTests/Fixtures/task_list_success.json new file mode 100644 index 0000000..798e509 --- /dev/null +++ b/suixinkanTests/Fixtures/task_list_success.json @@ -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": [] + } + ] + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index f823f02..0930a20 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -32,14 +32,13 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply", title: ""), .destination(.permissionApply)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply_status", title: ""), .destination(.permissionApplyStatus)) 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() { - XCTAssertEqual( - HomeMenuRouter.resolve(uri: "task_management", title: ""), - .destination(.modulePlaceholder(uri: "task_management", title: "任务管理")) - ) XCTAssertEqual( HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.modulePlaceholder(uri: "scenic_settlement", title: "景区结算")) diff --git a/suixinkanTests/TaskAPITests.swift b/suixinkanTests/TaskAPITests.swift new file mode 100644 index 0000000..699b5c6 --- /dev/null +++ b/suixinkanTests/TaskAPITests.swift @@ -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) } + }) +} diff --git a/suixinkanTests/TaskViewModelTests.swift b/suixinkanTests/TaskViewModelTests.swift new file mode 100644 index 0000000..361eede --- /dev/null +++ b/suixinkanTests/TaskViewModelTests.swift @@ -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.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.self, named: "task_list_success"), + try TestFixture.payload(ListPayload.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.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.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] = [] + var detailResponse: TaskDetailResponse? + var availableOrders: [AvailableOrderResponse] = [] + var cloudFileResponses: [ListPayload] = [] + 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 { + 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 { + 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 { "" } +}