diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 8fe4077..8d228aa 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -17,6 +17,7 @@ final class NetworkServices { let orderAPI: OrderAPI let homeAPI: HomeAPI let paymentAPI: PaymentAPI + let taskAPI: TaskAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -29,6 +30,7 @@ final class NetworkServices { orderAPI = OrderAPI(client: client) homeAPI = HomeAPI(client: client) paymentAPI = PaymentAPI(client: client) + taskAPI = TaskAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { @@ -46,6 +48,7 @@ final class NetworkServices { orderAPI = OrderAPI(client: apiClient) homeAPI = HomeAPI(client: apiClient) paymentAPI = PaymentAPI(client: apiClient) + taskAPI = TaskAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) } diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 7a2a7a0..a8fe147 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -39,6 +39,24 @@ final class OSSUploadService { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress) } + /// 上传任务素材,对齐 Android `moduleType = task_upload`。 + func uploadTaskFile( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + try await uploadFile( + data: data, + fileName: fileName, + fileType: fileType, + scenicId: scenicId, + moduleType: "task_upload", + onProgress: onProgress + ) + } + private func uploadFile( data: Data, fileName: String, @@ -125,6 +143,8 @@ enum OSSUploadPolicy { return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "bank_card": return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "task_upload": + return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" } diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index 226e30b..6a13062 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -48,6 +48,8 @@ enum HomeRouteHandler { return } showDeveloping(from: viewController) + case "task_management", "task_management_editor": + viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true) default: showDeveloping(from: viewController) } diff --git a/suixinkan/Features/Profile/ViewModels/ProfileEditViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileEditViewModel.swift new file mode 100644 index 0000000..996ee7d --- /dev/null +++ b/suixinkan/Features/Profile/ViewModels/ProfileEditViewModel.swift @@ -0,0 +1,102 @@ +// +// ProfileEditViewModel.swift +// suixinkan +// + +import Foundation + +/// 资料编辑页 ViewModel,负责头像与昵称的本地编辑与提交保存。 +final class ProfileEditViewModel { + + private let initialNickname: String + private let initialAvatarURL: String + + private(set) var editingNickname = "" + private(set) var pendingAvatarData: Data? + private(set) var pendingAvatarFileName: String? + private(set) var isSaving = false + private(set) var avatarUploadProgress: Int? + + var onStateChange: (() -> Void)? + + /// 当前展示用的远程头像 URL(未选择新图时使用初始值)。 + var displayAvatarURL: String { + initialAvatarURL + } + + /// 使用「我的」页当前展示的快照初始化编辑态。 + init(nickname: String, avatarURL: String) { + initialNickname = nickname + initialAvatarURL = avatarURL + editingNickname = nickname == "未设置昵称" ? "" : nickname + } + + func updateEditingNickname(_ value: String) { + editingNickname = value + notifyStateChange() + } + + func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws { + let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp) + pendingAvatarData = processed.data + pendingAvatarFileName = processed.fileName + notifyStateChange() + } + + /// 提交昵称与头像变更;无变更时不请求网络。 + func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws { + let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !nextNickname.isEmpty else { + throw ProfileValidationError.emptyNickname + } + + let nicknameChanged = nextNickname != initialNickname + let avatarChanged = pendingAvatarData != nil + guard nicknameChanged || avatarChanged else { return } + + guard !isSaving else { return } + isSaving = true + avatarUploadProgress = avatarChanged ? 1 : nil + notifyStateChange() + defer { + isSaving = false + avatarUploadProgress = nil + notifyStateChange() + } + + let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId) + if let uploadedAvatarURL { + try await api.updateUserAvatarURL(uploadedAvatarURL) + } + if nicknameChanged { + try await api.updateUserInfo(nickname: nextNickname) + } + + AppStore.shared.userName = nextNickname + if let uploadedAvatarURL { + AppStore.shared.avatar = uploadedAvatarURL + } + + clearPendingAvatar() + NotificationCenter.default.post(name: NotificationName.userProfileDidUpdate, object: nil) + } + + private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? { + guard let pendingAvatarData else { return nil } + let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg" + return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in + self?.avatarUploadProgress = progress + self?.notifyStateChange() + } + } + + private func clearPendingAvatar() { + pendingAvatarData = nil + pendingAvatarFileName = nil + avatarUploadProgress = nil + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift index 141be24..afe7f10 100644 --- a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift @@ -5,7 +5,7 @@ import Foundation -/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。 +/// 个人信息页 ViewModel,负责资料加载与账号相关操作。 final class ProfileViewModel { private(set) var userInfo: UserInfoResponse? @@ -13,11 +13,6 @@ final class ProfileViewModel { private(set) var bankCardInfo: BankCardInfo? private(set) var isLoading = false private(set) var isSaving = false - private(set) var isEditingProfile = false - private(set) var editingNickname = "" - private(set) var pendingAvatarData: Data? - private(set) var pendingAvatarFileName: String? - private(set) var avatarUploadProgress: Int? var onStateChange: (() -> Void)? @@ -85,12 +80,6 @@ final class ProfileViewModel { return label.isEmpty ? "审核中" : label } - func updateEditingNickname(_ value: String) { - editingNickname = value - notifyStateChange() - } - - /// 重新加载用户资料,摄影师额外拉取实名与银行卡信息。 func reload(api: ProfileAPI) async throws { guard !isLoading else { return } isLoading = true @@ -119,77 +108,21 @@ final class ProfileViewModel { } } - func beginEditing() { - editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname - isEditingProfile = true - notifyStateChange() - } - - func cancelEditing() { - editingNickname = "" - isEditingProfile = false - clearPendingAvatar() - notifyStateChange() - } - - func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws { - let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp) - pendingAvatarData = processed.data - pendingAvatarFileName = processed.fileName - if !isEditingProfile { - beginEditing() - } else { - notifyStateChange() - } - } - - func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws { - let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines) - guard !nextNickname.isEmpty else { - throw ProfileValidationError.emptyNickname - } - - let nicknameChanged = nextNickname != displayNickname - let avatarChanged = pendingAvatarData != nil - guard nicknameChanged || avatarChanged else { - cancelEditing() - return - } - - guard !isSaving else { return } - isSaving = true - avatarUploadProgress = avatarChanged ? 1 : nil - notifyStateChange() - defer { - isSaving = false - avatarUploadProgress = nil - notifyStateChange() - } - - let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId) - if let uploadedAvatarURL { - try await api.updateUserAvatarURL(uploadedAvatarURL) - } - if nicknameChanged { - try await api.updateUserInfo(nickname: nextNickname) - } - + /// 从 `AppStore` 同步头像与昵称,用于资料保存后的本地即时刷新。 + func applyLocalProfileUpdate(from store: AppStore = .shared) { let previous = userInfo ?? UserInfoResponse() + let nextNickname = store.userName.trimmingCharacters(in: .whitespacesAndNewlines) + let nextAvatar = store.avatar.trimmingCharacters(in: .whitespacesAndNewlines) userInfo = UserInfoResponse( - avatar: uploadedAvatarURL ?? previous.avatar, + avatar: nextAvatar.isEmpty ? previous.avatar : nextAvatar, realName: previous.realName, phone: previous.phone, - nickname: nextNickname, + nickname: nextNickname.isEmpty ? previous.nickname : nextNickname, roleName: previous.roleName, status: previous.status, statusName: previous.statusName ) - AppStore.shared.userName = nextNickname - if let uploadedAvatarURL { - AppStore.shared.avatar = uploadedAvatarURL - } - clearPendingAvatar() - cancelEditing() + notifyStateChange() } func updatePassword(_ password: String, api: ProfileAPI) async throws { @@ -215,21 +148,6 @@ final class ProfileViewModel { return "**** **** **** \(digits.suffix(4))" } - private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? { - guard let pendingAvatarData else { return nil } - let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg" - return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in - self?.avatarUploadProgress = progress - self?.notifyStateChange() - } - } - - private func clearPendingAvatar() { - pendingAvatarData = nil - pendingAvatarFileName = nil - avatarUploadProgress = nil - } - private func nonEmpty(_ value: String?) -> String? { let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return text.isEmpty ? nil : text diff --git a/suixinkan/Features/Task/API/TaskAPI.swift b/suixinkan/Features/Task/API/TaskAPI.swift new file mode 100644 index 0000000..7154850 --- /dev/null +++ b/suixinkan/Features/Task/API/TaskAPI.swift @@ -0,0 +1,81 @@ +// +// TaskAPI.swift +// suixinkan +// + +import Foundation + +@MainActor +/// 任务相关 API,封装创建任务、可选订单与云盘列表接口。 +final class TaskAPI { + private let client: APIClient + + init(client: APIClient) { + self.client = client + } + + /// 创建摄影师任务。 + func createTask(_ request: AddTaskRequest) async throws { + let _: EmptyResponse = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/task/create", + body: request + ) + ) + } + + /// 拉取当前景区可关联订单列表。 + func availableOrders(scenicId: Int) async throws -> [AvailableOrder] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/task/available-order", + queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))] + ) + ) + } + + /// 拉取云盘文件列表。 + func cloudFileList( + parentFolderId: Int, + name: String = "", + type: Int = 0, + orderBy: Int = 2, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> CloudFileListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/cloud-driver/list", + queryItems: [ + URLQueryItem(name: "parent_folder_id", value: String(parentFolderId)), + URLQueryItem(name: "name", value: name), + URLQueryItem(name: "type", value: String(type)), + URLQueryItem(name: "order_by", value: String(orderBy)), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)), + ] + ) + ) + } + + /// 登记 OSS 上传后的文件 URL。 + func registerUploadedFileURL(scenicId: Int, fileURL: String, folderId: Int = 0) async throws { + let _: EmptyResponse = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/album/file-upload-url", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "file_url", value: fileURL), + URLQueryItem(name: "folder_id", value: String(folderId)), + ] + ) + ) + } +} + +/// 空响应占位,用于无 data 字段的成功接口。 +private struct EmptyResponse: Decodable, Sendable {} diff --git a/suixinkan/Features/Task/Models/TaskModels.swift b/suixinkan/Features/Task/Models/TaskModels.swift new file mode 100644 index 0000000..030cd5a --- /dev/null +++ b/suixinkan/Features/Task/Models/TaskModels.swift @@ -0,0 +1,260 @@ +// +// TaskModels.swift +// suixinkan +// + +import Foundation + +/// 创建任务请求体,对齐 Android `AddTaskRequest`。 +struct AddTaskRequest: Encodable, Sendable, Equatable { + let scenicId: Int + let name: String + let orderNumber: String? + let remark: String + let urgentHour: Int + let cloudFile: [TaskCloudFileItem] + let uploadFile: [TaskUploadFileItem] + + 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" + } +} + +/// 云盘文件引用,对齐 Android `CloudFileItem`。 +struct TaskCloudFileItem: Encodable, Sendable, Equatable { + let fileId: Int + let remark: String + + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + case remark + } +} + +/// 本地上传文件引用,对齐 Android `UploadFileItem`。 +struct TaskUploadFileItem: Encodable, Sendable, Equatable { + let fileUrl: String + let fileName: String + let remark: String + + enum CodingKeys: String, CodingKey { + case fileUrl = "file_url" + case fileName = "file_name" + case remark + } +} + +/// 可选关联订单,对齐 Android `AvailableOrderResponse`。 +struct AvailableOrder: Decodable, Sendable, Equatable, Hashable { + let projectName: String + let orderNumber: String + let orderStatus: Int + let orderStatusLabel: String + let payTime: String + let userId: Int + 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 userId = "user_id" + case userPhone = "user_phone" + } + + init( + projectName: String = "", + orderNumber: String = "", + orderStatus: Int = 0, + orderStatusLabel: String = "", + payTime: String = "", + userId: Int = 0, + userPhone: String = "" + ) { + self.projectName = projectName + self.orderNumber = orderNumber + self.orderStatus = orderStatus + self.orderStatusLabel = orderStatusLabel + self.payTime = payTime + self.userId = userId + self.userPhone = userPhone + } +} + +/// 云盘文件/文件夹实体,对齐 Android `CloudFileEntity`。 +struct CloudFile: Decodable, Sendable, Equatable, 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 + + 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 = 0, + parentFolderId: Int = 0, + fileUrl: String = "", + coverUrl: String = "", + updatedAt: String = "", + name: String = "", + createdAt: String = "", + childNum: Int = 0, + type: Int = 0, + fileSize: Int64 = 0 + ) { + self.id = id + self.parentFolderId = parentFolderId + self.fileUrl = fileUrl + self.coverUrl = coverUrl + self.updatedAt = updatedAt + self.name = name + self.createdAt = createdAt + self.childNum = childNum + self.type = type + self.fileSize = fileSize + } + + /// 是否为文件夹。 + var isFolder: Bool { type == 99 } + + /// 是否为图片。 + var isImage: Bool { type == 2 } + + /// 是否为视频。 + var isVideo: Bool { type == 1 } + + /// 是否为可导入的媒体文件。 + var isSelectableMedia: Bool { isImage || isVideo } +} + +/// 云盘列表响应。 +struct CloudFileListResponse: Decodable, Sendable, Equatable { + let total: Int + let list: [CloudFile] + + init(total: Int = 0, list: [CloudFile] = []) { + self.total = total + self.list = list + } +} + +/// 云盘路径节点,用于面包屑导航。 +struct CloudPathItem: Sendable, Equatable, Hashable { + let id: Int + let name: String +} + +/// 任务素材来源。 +enum TaskFileSource: String, Sendable, Equatable { + case cloud + case upload +} + +/// 任务素材项,对齐 Android `TaskFileItem`。 +struct TaskFileItem: Sendable, Equatable, Hashable, Identifiable { + let id: String + let source: TaskFileSource + let cloudFile: CloudFile? + let uploadFileName: String? + let uploadFileType: Int + let uploadTaskId: String? + var uploadFileUrl: String? + var uploadCoverUrl: String? + var remark: String + var isUploading: Bool + var uploadProgress: Int + + init( + id: String? = nil, + source: TaskFileSource, + cloudFile: CloudFile? = nil, + uploadFileUrl: String? = nil, + uploadFileName: String? = nil, + uploadFileType: Int = 0, + uploadCoverUrl: String? = nil, + remark: String = "", + uploadTaskId: String? = nil, + isUploading: Bool = false, + uploadProgress: Int = 0 + ) { + if let id { + self.id = id + } else if source == .cloud, let cloudFile { + self.id = "cloud_\(cloudFile.id)" + } else if let uploadTaskId { + self.id = "upload_\(uploadTaskId)" + } else { + self.id = UUID().uuidString + } + self.source = source + self.cloudFile = cloudFile + self.uploadFileUrl = uploadFileUrl + self.uploadFileName = uploadFileName + self.uploadFileType = uploadFileType + self.uploadCoverUrl = uploadCoverUrl + self.remark = remark + self.uploadTaskId = uploadTaskId + self.isUploading = isUploading + self.uploadProgress = uploadProgress + } + + /// 展示用缩略图 URL。 + var previewURLString: String { + switch source { + case .cloud: + let cover = cloudFile?.coverUrl ?? "" + if !cover.isEmpty { return cover } + return cloudFile?.fileUrl ?? "" + case .upload: + if uploadFileType == 1 { + return uploadCoverUrl ?? uploadFileUrl ?? "" + } + return uploadFileUrl ?? "" + } + } + + /// 文件类型:1=视频,2=图片。 + var fileType: Int { + switch source { + case .cloud: + return cloudFile?.type ?? 0 + case .upload: + return uploadFileType + } + } + + /// 展示文件名。 + var displayName: String { + switch source { + case .cloud: + return cloudFile?.name ?? "" + case .upload: + return uploadFileName ?? "" + } + } +} diff --git a/suixinkan/Features/Task/ViewModels/CloudStoragePickForTaskViewModel.swift b/suixinkan/Features/Task/ViewModels/CloudStoragePickForTaskViewModel.swift new file mode 100644 index 0000000..a6d9604 --- /dev/null +++ b/suixinkan/Features/Task/ViewModels/CloudStoragePickForTaskViewModel.swift @@ -0,0 +1,164 @@ +// +// CloudStoragePickForTaskViewModel.swift +// suixinkan +// + +import Foundation + +/// 任务云盘多选 ViewModel,对齐 Android 任务模式云盘浏览与多选逻辑。 +final class CloudStoragePickForTaskViewModel { + + private(set) var pathStack: [CloudPathItem] = [CloudPathItem(id: 0, name: "全部文件")] + private(set) var files: [CloudFile] = [] + private(set) var selectedFiles: [Int: CloudFile] = [:] + private(set) var searchText = "" + private(set) var filterType = 0 + private(set) var sortType = 2 + private(set) var isLoading = false + private(set) var isRefreshing = false + private(set) var canLoadMore = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + private var currentPage = 1 + private var totalCount = 0 + private let pageSize = 20 + private let importedFileIDs: Set + + init(importedFileIDs: Set = []) { + self.importedFileIDs = importedFileIDs + } + + /// 当前文件夹 ID。 + var currentFolderID: Int { + pathStack.last?.id ?? 0 + } + + /// 已选文件列表。 + var selectedFileList: [CloudFile] { + Array(selectedFiles.values) + } + + /// 文件是否已在任务页导入。 + func isImported(_ fileID: Int) -> Bool { + importedFileIDs.contains(fileID) + } + + /// 文件是否已选中。 + func isSelected(_ fileID: Int) -> Bool { + selectedFiles[fileID] != nil + } + + /// 更新搜索词。 + func updateSearchText(_ text: String) { + searchText = text + notifyStateChange() + } + + /// 更新类型筛选。 + func updateFilterType(_ type: Int) { + filterType = type + notifyStateChange() + } + + /// 更新排序方式。 + func updateSortType(_ type: Int) { + sortType = type + notifyStateChange() + } + + /// 首次或刷新加载。 + func refresh(api: TaskAPI) async { + currentPage = 1 + isRefreshing = true + notifyStateChange() + await loadPage(api: api, reset: true) + isRefreshing = false + notifyStateChange() + } + + /// 加载更多。 + func loadMore(api: TaskAPI) async { + guard canLoadMore, !isLoading else { return } + currentPage += 1 + await loadPage(api: api, reset: false) + } + + /// 点击文件或文件夹。 + func handleItemTap(_ file: CloudFile, api: TaskAPI) async { + if file.isFolder { + pathStack.append(CloudPathItem(id: file.id, name: file.name)) + searchText = "" + await refresh(api: api) + return + } + guard file.isSelectableMedia else { return } + if isImported(file.id) { + onShowMessage?("已选择") + return + } + if selectedFiles[file.id] != nil { + selectedFiles.removeValue(forKey: file.id) + } else { + selectedFiles[file.id] = file + } + notifyStateChange() + } + + /// 跳转面包屑路径。 + func navigateToPathIndex(_ index: Int, api: TaskAPI) async { + guard index >= 0, index < pathStack.count else { return } + pathStack = Array(pathStack.prefix(index + 1)) + searchText = "" + await refresh(api: api) + } + + /// 返回上级目录。 + func navigateBack(api: TaskAPI) async -> Bool { + guard pathStack.count > 1 else { return false } + pathStack.removeLast() + searchText = "" + await refresh(api: api) + return true + } + + private func loadPage(api: TaskAPI, reset: Bool) async { + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + + do { + let response = try await api.cloudFileList( + parentFolderId: currentFolderID, + name: searchText, + type: filterType, + orderBy: sortType, + page: currentPage, + pageSize: pageSize + ) + totalCount = response.total + if reset { + files = response.list + } else { + files.append(contentsOf: response.list) + } + canLoadMore = files.count < totalCount + } catch is CancellationError { + return + } catch { + if reset { + files = [] + } + canLoadMore = false + onShowMessage?(error.localizedDescription) + } + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Task/ViewModels/TaskAddViewModel.swift b/suixinkan/Features/Task/ViewModels/TaskAddViewModel.swift new file mode 100644 index 0000000..90ff396 --- /dev/null +++ b/suixinkan/Features/Task/ViewModels/TaskAddViewModel.swift @@ -0,0 +1,287 @@ +// +// TaskAddViewModel.swift +// suixinkan +// + +import Foundation + +/// 添加任务 ViewModel,对齐 Android `TaskAddViewModel` 表单与提交逻辑。 +final class TaskAddViewModel { + + private(set) var taskName = "" + private(set) var taskDetails = "" + private(set) var selectedOrderNumber: String? + private(set) var urgentHour = 0 + private(set) var selectedFiles: [TaskFileItem] = [] + private(set) var isUploading = false + private(set) var showSuccessDialog = false + private(set) var showRemarkDialog = false + private(set) var remarkDialogText = "" + private(set) var remarkTargetFileID: String? + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + private let appStore: AppStore + private var pendingRemarkFileID: String? + + init(appStore: AppStore = .shared) { + self.appStore = appStore + } + + /// 更新任务名称。 + func updateTaskName(_ value: String) { + taskName = String(value.prefix(20)) + notifyStateChange() + } + + /// 更新任务描述。 + func updateTaskDetails(_ value: String) { + taskDetails = String(value.prefix(200)) + notifyStateChange() + } + + /// 设置关联订单号。 + func setSelectedOrderNumber(_ orderNumber: String?) { + let trimmed = orderNumber?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + selectedOrderNumber = trimmed.isEmpty ? nil : trimmed + notifyStateChange() + } + + /// 设置优先级小时数。 + func setUrgentHour(_ hour: Int) { + urgentHour = max(0, hour) + notifyStateChange() + } + + /// 更新自定义优先级小时文本。 + func updateCustomUrgentHourText(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let hour = Int(trimmed), hour > 0 else { + urgentHour = 1 + notifyStateChange() + return + } + urgentHour = hour + notifyStateChange() + } + + /// 合并云盘文件,去重后追加。 + func addCloudFiles(_ files: [CloudFile]) { + let existingIDs = Set(selectedFiles.compactMap { $0.cloudFile?.id }) + let newItems = files + .filter { !existingIDs.contains($0.id) } + .map { + TaskFileItem(source: .cloud, cloudFile: $0) + } + guard !newItems.isEmpty else { return } + selectedFiles.append(contentsOf: newItems) + if newItems.count == 1 { + pendingRemarkFileID = newItems[0].id + } + notifyStateChange() + } + + /// 若云盘单选后需要自动弹备注,返回目标文件 ID 并清空 pending。 + func consumePendingRemarkFileID() -> String? { + defer { pendingRemarkFileID = nil } + return pendingRemarkFileID + } + + /// 添加本地上传占位项。 + @discardableResult + func addUploadPlaceholder(fileName: String, fileType: Int, uploadTaskId: String) -> TaskFileItem { + let item = TaskFileItem( + source: .upload, + uploadFileName: fileName, + uploadFileType: fileType, + uploadTaskId: uploadTaskId, + isUploading: true, + uploadProgress: 0 + ) + selectedFiles.append(item) + isUploading = true + notifyStateChange() + return item + } + + /// 更新上传进度。 + func updateUploadProgress(uploadTaskId: String, progress: Int) { + selectedFiles = selectedFiles.map { item in + guard item.uploadTaskId == uploadTaskId else { return item } + var updated = item + updated.uploadProgress = progress + updated.isUploading = progress < 100 + return updated + } + notifyStateChange() + } + + /// 上传成功后更新文件 URL。 + func markUploadSucceeded(uploadTaskId: String, fileURL: String, coverURL: String? = nil, autoRemarkWhenSingle: Bool) { + selectedFiles = selectedFiles.map { item in + guard item.uploadTaskId == uploadTaskId else { return item } + var updated = item + updated.uploadFileUrl = fileURL + updated.uploadCoverUrl = coverURL + updated.isUploading = false + updated.uploadProgress = 100 + return updated + } + isUploading = selectedFiles.contains(where: \.isUploading) + if autoRemarkWhenSingle, let item = selectedFiles.first(where: { $0.uploadTaskId == uploadTaskId }) { + pendingRemarkFileID = item.id + } + notifyStateChange() + } + + /// 上传失败时移除占位项。 + func markUploadFailed(uploadTaskId: String) { + selectedFiles.removeAll { $0.uploadTaskId == uploadTaskId } + isUploading = selectedFiles.contains(where: \.isUploading) + notifyStateChange() + } + + /// 删除素材。 + func removeTaskFile(id: String) { + selectedFiles.removeAll { $0.id == id } + isUploading = selectedFiles.contains(where: \.isUploading) + notifyStateChange() + } + + /// 打开备注弹窗。 + func presentRemarkDialog(for fileID: String) { + guard let file = selectedFiles.first(where: { $0.id == fileID }) else { return } + remarkTargetFileID = fileID + remarkDialogText = file.remark + showRemarkDialog = true + notifyStateChange() + } + + /// 更新备注弹窗文本。 + func updateRemarkDialogText(_ text: String) { + remarkDialogText = String(text.prefix(50)) + notifyStateChange() + } + + /// 保存备注。 + func saveRemarkDialog() { + guard let fileID = remarkTargetFileID else { return } + selectedFiles = selectedFiles.map { item in + guard item.id == fileID else { return item } + var updated = item + updated.remark = remarkDialogText + return updated + } + hideRemarkDialog() + } + + /// 关闭备注弹窗。 + func hideRemarkDialog() { + showRemarkDialog = false + remarkTargetFileID = nil + remarkDialogText = "" + notifyStateChange() + } + + /// 当前备注弹窗对应文件。 + var remarkTargetFile: TaskFileItem? { + guard let fileID = remarkTargetFileID else { return nil } + return selectedFiles.first { $0.id == fileID } + } + + /// 校验并提交任务。 + func validateAndSubmit(api: TaskAPI) async { + if isUploading { + onShowMessage?("文件正在上传中,请稍后添加!") + return + } + let trimmedName = taskName.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedName.isEmpty { + onShowMessage?("请填写任务名称!") + return + } + if trimmedName.count > 20 { + onShowMessage?("任务名称不能超过20字!") + return + } + if taskDetails.count > 200 { + onShowMessage?("备注信息不能超过200字!") + return + } + + let request = buildAddTaskRequest(name: trimmedName) + do { + try await api.createTask(request) + showSuccessDialog = true + notifyStateChange() + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription) + } + } + + /// 构建创建任务请求体。 + func buildAddTaskRequest(name: String? = nil) -> AddTaskRequest { + let cloudItems = selectedFiles + .filter { $0.source == .cloud } + .compactMap { item -> TaskCloudFileItem? in + guard let fileId = item.cloudFile?.id else { return nil } + return TaskCloudFileItem(fileId: fileId, remark: item.remark) + } + let uploadItems = selectedFiles + .filter { $0.source == .upload } + .compactMap { item -> TaskUploadFileItem? in + guard let url = item.uploadFileUrl, !url.isEmpty else { return nil } + return TaskUploadFileItem( + fileUrl: url, + fileName: item.uploadFileName ?? "", + remark: item.remark + ) + } + return AddTaskRequest( + scenicId: appStore.currentScenicId, + name: name ?? taskName, + orderNumber: selectedOrderNumber, + remark: taskDetails, + urgentHour: urgentHour, + cloudFile: cloudItems, + uploadFile: uploadItems + ) + } + + /// 成功弹窗选择继续添加。 + func handleContinueAdding() { + resetAllState() + } + + /// 关闭成功弹窗。 + func dismissSuccessDialog() { + showSuccessDialog = false + notifyStateChange() + } + + /// 重置全部表单状态。 + func resetAllState() { + taskName = "" + taskDetails = "" + selectedOrderNumber = nil + urgentHour = 0 + selectedFiles = [] + isUploading = false + showSuccessDialog = false + hideRemarkDialog() + pendingRemarkFileID = nil + notifyStateChange() + } + + /// 已导入云盘文件 ID,供云盘页禁用重复选择。 + var importedCloudFileIDs: Set { + Set(selectedFiles.compactMap { $0.cloudFile?.id }) + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Task/ViewModels/TaskOrderSelectViewModel.swift b/suixinkan/Features/Task/ViewModels/TaskOrderSelectViewModel.swift new file mode 100644 index 0000000..7e35514 --- /dev/null +++ b/suixinkan/Features/Task/ViewModels/TaskOrderSelectViewModel.swift @@ -0,0 +1,70 @@ +// +// TaskOrderSelectViewModel.swift +// suixinkan +// + +import Foundation + +/// 任务关联订单选择 ViewModel,对齐 Android `OrderSelectViewModel`。 +final class TaskOrderSelectViewModel { + + private(set) var orders: [AvailableOrder] = [] + private(set) var selectedOrderNumber: String? + private(set) var isLoading = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + private let appStore: AppStore + + init(appStore: AppStore = .shared, initialOrderNumber: String? = nil) { + self.appStore = appStore + let trimmed = initialOrderNumber?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + selectedOrderNumber = trimmed.isEmpty ? nil : trimmed + } + + /// 拉取可选订单列表。 + func loadOrders(api: TaskAPI) async { + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + + let scenicId = appStore.currentScenicId + guard scenicId > 0 else { + orders = [] + onShowMessage?("请先选择景区") + return + } + + do { + orders = try await api.availableOrders(scenicId: scenicId) + } catch is CancellationError { + return + } catch { + orders = [] + onShowMessage?(error.localizedDescription) + } + } + + /// 切换订单选中状态,再次点击同一项则取消。 + func toggleSelection(orderNumber: String) { + if selectedOrderNumber == orderNumber { + selectedOrderNumber = nil + } else { + selectedOrderNumber = orderNumber + } + notifyStateChange() + } + + /// 确认选择并返回订单号。 + func confirmedOrderNumber() -> String? { + selectedOrderNumber + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index 1723149..d9ce76e 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -185,8 +185,8 @@ final class HomeViewController: BaseViewController { ) } cell.cardView.onSubmitTask = { [weak self] in - self?.showToast("功能开发中") - } + self?.navigationController?.pushViewController(TaskAddViewController(), animated: true) + } cell.cardView.onToggleOnline = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() } diff --git a/suixinkan/UI/Profile/ProfileEditViewController.swift b/suixinkan/UI/Profile/ProfileEditViewController.swift new file mode 100644 index 0000000..7c5be54 --- /dev/null +++ b/suixinkan/UI/Profile/ProfileEditViewController.swift @@ -0,0 +1,195 @@ +// +// ProfileEditViewController.swift +// suixinkan +// + +import PhotosUI +import SnapKit +import UIKit + +/// 资料编辑页,支持修改头像与昵称并保存到服务端。 +final class ProfileEditViewController: BaseViewController { + + private let viewModel: ProfileEditViewModel + private let profileAPI = NetworkServices.shared.profileAPI + private let ossUploadService = NetworkServices.shared.ossUploadService + + private let avatarContainer = UIView() + private let avatarImageView = UIImageView() + private let avatarHintLabel = UILabel() + private let nicknameCard = UIView() + private let nicknameTitleLabel = UILabel() + private let nicknameField = UITextField() + private let saveButton = AppButton(title: "保存", style: .primary) + + /// 使用「我的」页当前展示的资料快照进入编辑。 + init(nickname: String, avatarURL: String) { + viewModel = ProfileEditViewModel(nickname: nickname, avatarURL: avatarURL) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "编辑资料" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackgroundSoft + + avatarImageView.layer.cornerRadius = 48 + avatarImageView.clipsToBounds = true + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.isUserInteractionEnabled = true + + avatarHintLabel.text = "点击更换头像" + avatarHintLabel.font = .systemFont(ofSize: 14) + avatarHintLabel.textColor = AppColor.textTertiary + avatarHintLabel.textAlignment = .center + + avatarContainer.addSubview(avatarImageView) + avatarContainer.addSubview(avatarHintLabel) + view.addSubview(avatarContainer) + + nicknameCard.backgroundColor = .white + nicknameCard.layer.cornerRadius = AppRadius.sm + view.addSubview(nicknameCard) + + nicknameTitleLabel.text = "昵称" + nicknameTitleLabel.font = .systemFont(ofSize: 14) + nicknameTitleLabel.textColor = AppColor.textTertiary + + nicknameField.font = .systemFont(ofSize: 16) + nicknameField.textColor = AppColor.text333 + nicknameField.placeholder = "请输入昵称" + nicknameField.clearButtonMode = .whileEditing + nicknameField.returnKeyType = .done + + nicknameCard.addSubview(nicknameTitleLabel) + nicknameCard.addSubview(nicknameField) + + view.addSubview(saveButton) + } + + override func setupConstraints() { + avatarContainer.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(32) + make.centerX.equalToSuperview() + } + avatarImageView.snp.makeConstraints { make in + make.top.centerX.equalToSuperview() + make.width.height.equalTo(96) + } + avatarHintLabel.snp.makeConstraints { make in + make.top.equalTo(avatarImageView.snp.bottom).offset(12) + make.leading.trailing.bottom.equalToSuperview() + } + + nicknameCard.snp.makeConstraints { make in + make.top.equalTo(avatarContainer.snp.bottom).offset(32) + make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + } + nicknameTitleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().inset(16) + } + nicknameField.snp.makeConstraints { make in + make.top.equalTo(nicknameTitleLabel.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview().inset(16) + make.height.equalTo(44) + } + + saveButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + make.height.equalTo(48) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + + avatarImageView.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(pickAvatar)) + ) + nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged) + saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) + + applyViewModel() + } + + private func applyViewModel() { + nicknameField.text = viewModel.editingNickname + + if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) { + avatarImageView.image = image + } else { + avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL) + } + + saveButton.isEnabled = !viewModel.isSaving + avatarImageView.alpha = viewModel.isSaving ? 0.6 : 1 + nicknameField.isEnabled = !viewModel.isSaving + } + + @objc private func nicknameChanged() { + viewModel.updateEditingNickname(nicknameField.text ?? "") + } + + @objc private func pickAvatar() { + guard !viewModel.isSaving else { return } + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = 1 + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + + @objc private func saveTapped() { + nicknameField.resignFirstResponder() + Task { await saveProfile() } + } + + private func saveProfile() async { + let scenicId = AppStore.shared.currentScenicId + guard scenicId > 0 else { + showToast("当前景区信息缺失") + return + } + showLoading() + defer { hideLoading() } + do { + try await viewModel.saveProfile( + api: profileAPI, + uploader: ossUploadService, + scenicId: scenicId + ) + showToast("保存成功") + navigationController?.popViewController(animated: true) + } catch { + showToast(error.localizedDescription) + } + } +} + +extension ProfileEditViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } + provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in + guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return } + Task { @MainActor in + do { + try self.viewModel.prepareAvatarImage(data: data) + } catch { + self.showToast(error.localizedDescription) + } + } + } + } +} diff --git a/suixinkan/UI/Profile/ProfileViewController.swift b/suixinkan/UI/Profile/ProfileViewController.swift index 79e8068..20775e8 100644 --- a/suixinkan/UI/Profile/ProfileViewController.swift +++ b/suixinkan/UI/Profile/ProfileViewController.swift @@ -3,7 +3,6 @@ // suixinkan // -import PhotosUI import SnapKit import UIKit @@ -12,7 +11,6 @@ final class ProfileViewController: BaseViewController { private let viewModel = ProfileViewModel() private let profileAPI = NetworkServices.shared.profileAPI - private let ossUploadService = NetworkServices.shared.ossUploadService private let scrollView = UIScrollView() private let contentStack = UIStackView() @@ -35,7 +33,7 @@ final class ProfileViewController: BaseViewController { private let logoutButton = UIButton(type: .system) override func setupNavigationBar() { - title = "个人信息" + title = "我的" } override func setupUI() { @@ -87,18 +85,18 @@ final class ProfileViewController: BaseViewController { withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside) realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside) - nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged) - - let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar)) - avatarImageView.isUserInteractionEnabled = true - avatarImageView.addGestureRecognizer(avatarTap) - NotificationCenter.default.addObserver( self, selector: #selector(handleAccountDidSwitch), name: NotificationName.accountDidSwitch, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleProfileDidUpdate), + name: NotificationName.userProfileDidUpdate, + object: nil + ) } override func viewWillAppear(_ animated: Bool) { @@ -191,19 +189,9 @@ final class ProfileViewController: BaseViewController { } private func applyViewModel() { - nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : viewModel.displayNickname - nicknameField.isEnabled = viewModel.isEditingProfile + nicknameField.text = viewModel.displayNickname uidLabel.text = "UID:\(viewModel.displayUID)" - - if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) { - avatarImageView.image = image - } else { - avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL) - } - - editButton.setTitle(viewModel.isEditingProfile ? "完成" : "编辑", for: .normal) - editButton.isEnabled = !viewModel.isSaving - avatarImageView.alpha = viewModel.isEditingProfile ? 1 : 1 + avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL) nameRow.configure(value: viewModel.displayRealName) accountRow.configure( @@ -289,26 +277,17 @@ final class ProfileViewController: BaseViewController { Task { await reloadProfile(showGlobalLoading: true) } } - @objc private func nicknameChanged() { - viewModel.updateEditingNickname(nicknameField.text ?? "") + @objc private func handleProfileDidUpdate() { + viewModel.applyLocalProfileUpdate() + applyViewModel() } @objc private func editTapped() { - if viewModel.isEditingProfile { - Task { await saveProfile() } - } else { - viewModel.beginEditing() - } - } - - @objc private func pickAvatar() { - guard viewModel.isEditingProfile else { return } - var configuration = PHPickerConfiguration(photoLibrary: .shared()) - configuration.filter = .images - configuration.selectionLimit = 1 - let picker = PHPickerViewController(configuration: configuration) - picker.delegate = self - present(picker, animated: true) + let editVC = ProfileEditViewController( + nickname: viewModel.displayNickname, + avatarURL: viewModel.displayAvatarURL + ) + navigationController?.pushViewController(editVC, animated: true) } @objc private func accountSwitchTapped() { @@ -382,26 +361,6 @@ final class ProfileViewController: BaseViewController { } } - private func saveProfile() async { - let scenicId = AppStore.shared.currentScenicId - guard scenicId > 0 else { - showToast("当前景区信息缺失") - return - } - showLoading() - defer { hideLoading() } - do { - try await viewModel.saveProfile( - api: profileAPI, - uploader: ossUploadService, - scenicId: scenicId - ) - showToast("保存成功") - } catch { - showToast(error.localizedDescription) - } - } - private func updatePassword(_ password: String) async { showLoading() defer { hideLoading() } @@ -413,20 +372,3 @@ final class ProfileViewController: BaseViewController { } } } - -extension ProfileViewController: PHPickerViewControllerDelegate { - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - picker.dismiss(animated: true) - guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } - provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in - guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return } - Task { @MainActor in - do { - try self.viewModel.prepareAvatarImage(data: data) - } catch { - self.showToast(error.localizedDescription) - } - } - } - } -} diff --git a/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift b/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift new file mode 100644 index 0000000..b6bc327 --- /dev/null +++ b/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift @@ -0,0 +1,323 @@ +// +// CloudStoragePickForTaskViewController.swift +// suixinkan +// + +import Kingfisher +import SnapKit +import UIKit + +/// 任务云盘多选页,对齐 Android `CloudStorageListForTaskScreen`。 +final class CloudStoragePickForTaskViewController: BaseViewController { + + var onConfirmed: (([CloudFile]) -> Void)? + + private let viewModel: CloudStoragePickForTaskViewModel + private let taskAPI = NetworkServices.shared.taskAPI + + private let searchField = UITextField() + private let pathScrollView = UIScrollView() + private let pathStack = UIStackView() + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private let confirmButton = AppButton(title: "确定") + private let bottomBar = UIView() + + init(importedFileIDs: Set = []) { + viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "云盘导入" + } + + override func setupUI() { + searchField.placeholder = "搜索文件" + searchField.borderStyle = .roundedRect + searchField.font = .app(.body) + searchField.returnKeyType = .search + searchField.delegate = self + + pathStack.axis = .horizontal + pathStack.spacing = AppSpacing.xs + pathScrollView.showsHorizontalScrollIndicator = false + pathScrollView.addSubview(pathStack) + pathStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalToSuperview() + } + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) + collectionView.backgroundColor = AppColor.pageBackground + collectionView.register(CloudPickCell.self, forCellWithReuseIdentifier: CloudPickCell.reuseIdentifier) + collectionView.delegate = self + + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + [weak self] collectionView, indexPath, fileID in + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: CloudPickCell.reuseIdentifier, + for: indexPath + ) as! CloudPickCell + if let file = self?.viewModel.files.first(where: { $0.id == fileID }) { + cell.apply( + file: file, + isSelected: self?.viewModel.isSelected(fileID) == true, + isImported: self?.viewModel.isImported(fileID) == true + ) + } + return cell + } + + bottomBar.backgroundColor = .white + view.addSubview(searchField) + view.addSubview(pathScrollView) + view.addSubview(collectionView) + view.addSubview(bottomBar) + bottomBar.addSubview(confirmButton) + } + + override func setupConstraints() { + searchField.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(40) + } + pathScrollView.snp.makeConstraints { make in + make.top.equalTo(searchField.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(28) + } + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + confirmButton.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.md) + } + collectionView.snp.makeConstraints { make in + make.top.equalTo(pathScrollView.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.refresh(api: taskAPI) } + } + + @MainActor + private func applyViewModel() { + rebuildPathButtons() + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(viewModel.files.map(\.id)) + dataSource.apply(snapshot, animatingDifferences: false) + if viewModel.isLoading && viewModel.files.isEmpty { + showLoading() + } else { + hideLoading() + } + } + + private func rebuildPathButtons() { + pathStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for (index, item) in viewModel.pathStack.enumerated() { + let button = UIButton(type: .system) + button.setTitle(item.name, for: .normal) + button.titleLabel?.font = .app(.caption) + button.tag = index + button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside) + pathStack.addArrangedSubview(button) + if index < viewModel.pathStack.count - 1 { + let arrow = UILabel() + arrow.text = ">" + arrow.font = .app(.caption) + arrow.textColor = AppColor.textTertiary + pathStack.addArrangedSubview(arrow) + } + } + } + + @objc private func pathTapped(_ sender: UIButton) { + Task { await viewModel.navigateToPathIndex(sender.tag, api: taskAPI) } + } + + @objc private func confirmTapped() { + onConfirmed?(viewModel.selectedFileList) + navigationController?.popViewController(animated: true) + } + + private func makeLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { _, environment in + let columns = 3 + let spacing: CGFloat = 8 + let inset = AppSpacing.md * 2 + let availableWidth = environment.container.effectiveContentSize.width - inset + let totalSpacing = spacing * CGFloat(columns - 1) + let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns)) + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(itemWidth), + heightDimension: .absolute(itemWidth) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(itemWidth) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + repeatingSubitem: item, + count: columns + ) + group.interItemSpacing = .fixed(spacing) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = spacing + section.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: AppSpacing.md, + bottom: AppSpacing.md, + trailing: AppSpacing.md + ) + return section + } + } +} + +extension CloudStoragePickForTaskViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let fileID = dataSource.itemIdentifier(for: indexPath), + let file = viewModel.files.first(where: { $0.id == fileID }) else { + return + } + Task { await viewModel.handleItemTap(file, api: taskAPI) } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let frameHeight = scrollView.frame.size.height + if offsetY > contentHeight - frameHeight - 120 { + Task { await viewModel.loadMore(api: taskAPI) } + } + } +} + +extension CloudStoragePickForTaskViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + viewModel.updateSearchText(textField.text ?? "") + textField.resignFirstResponder() + Task { await viewModel.refresh(api: taskAPI) } + return true + } +} + +/// 云盘选择 cell。 +private final class CloudPickCell: UICollectionViewCell { + + static let reuseIdentifier = "CloudPickCell" + + private let imageView = UIImageView() + private let nameLabel = UILabel() + private let badgeView = UIView() + private let folderIconView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.backgroundColor = .white + contentView.layer.cornerRadius = AppRadius.sm + contentView.clipsToBounds = true + + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + + folderIconView.image = UIImage(systemName: "folder.fill") + folderIconView.tintColor = AppColor.primary + folderIconView.contentMode = .scaleAspectFit + + nameLabel.font = .app(.caption) + nameLabel.textColor = AppColor.textPrimary + nameLabel.numberOfLines = 2 + nameLabel.textAlignment = .center + + badgeView.backgroundColor = AppColor.primary + badgeView.layer.cornerRadius = 10 + badgeView.isHidden = true + + contentView.addSubview(imageView) + contentView.addSubview(folderIconView) + contentView.addSubview(nameLabel) + contentView.addSubview(badgeView) + + imageView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(contentView.snp.width) + } + folderIconView.snp.makeConstraints { make in + make.center.equalTo(imageView) + make.width.height.equalTo(36) + } + nameLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(AppSpacing.xxs) + make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.xxs) + } + badgeView.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(AppSpacing.xxs) + make.width.height.equalTo(20) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.kf.cancelDownloadTask() + imageView.image = nil + folderIconView.isHidden = true + badgeView.isHidden = true + } + + func apply(file: CloudFile, isSelected: Bool, isImported: Bool) { + nameLabel.text = file.name + contentView.layer.borderWidth = isSelected ? 2 : 0 + contentView.layer.borderColor = AppColor.primary.cgColor + badgeView.isHidden = !isSelected + + if file.isFolder { + imageView.image = nil + folderIconView.isHidden = false + return + } + folderIconView.isHidden = true + let preview = file.coverUrl.isEmpty ? file.fileUrl : file.coverUrl + if let url = URL(string: preview), !preview.isEmpty { + imageView.kf.setImage(with: url) + } else { + imageView.image = UIImage(systemName: file.isVideo ? "video" : "photo") + imageView.tintColor = AppColor.textTertiary + } + if isImported { + contentView.alpha = 0.5 + } else { + contentView.alpha = 1 + } + } +} diff --git a/suixinkan/UI/Task/TaskAddViewController.swift b/suixinkan/UI/Task/TaskAddViewController.swift new file mode 100644 index 0000000..2c75f8a --- /dev/null +++ b/suixinkan/UI/Task/TaskAddViewController.swift @@ -0,0 +1,366 @@ +// +// TaskAddViewController.swift +// suixinkan +// + +import PhotosUI +import SnapKit +import UIKit +import UniformTypeIdentifiers + +/// 添加任务页,对齐 Android `TaskAddScreen`。 +final class TaskAddViewController: BaseViewController { + + private let viewModel = TaskAddViewModel() + private let taskAPI = NetworkServices.shared.taskAPI + private let ossUploadService = NetworkServices.shared.ossUploadService + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let mediaSection = TaskAddMediaSectionView() + private let prioritySection = TaskPrioritySectionView() + private let orderButton = TaskOrderSelectButton() + private let nameField = TaskBorderTextField(placeholder: "请输入任务标题") + private let detailsField = TaskBorderTextView(placeholder: "请输入任务描述(必填)") + private let saveButton = AppButton(title: "保存") + private let bottomBar = UIView() + + private var remarkDialog: TaskFileRemarkDialogView? + private var pickingMediaType: Int = 2 + private var detailsPlaceholder = "请输入任务描述(必填)" + + override func setupNavigationBar() { + title = "添加任务" + } + + override func setupUI() { + contentStack.axis = .vertical + contentStack.spacing = AppSpacing.sm + + bottomBar.backgroundColor = .white + view.addSubview(scrollView) + view.addSubview(bottomBar) + scrollView.addSubview(contentStack) + + contentStack.addArrangedSubview(mediaSection) + contentStack.addArrangedSubview(prioritySection) + contentStack.addArrangedSubview(orderButton) + contentStack.addArrangedSubview(nameField) + contentStack.addArrangedSubview(detailsField) + bottomBar.addSubview(saveButton) + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + saveButton.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.md) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.md) + make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + + mediaSection.onCloudImport = { [weak self] in self?.openCloudPicker() } + mediaSection.onLocalImport = { [weak self] in self?.presentLocalImportSheet() } + mediaSection.onFileTap = { [weak self] file in self?.viewModel.presentRemarkDialog(for: file.id) } + mediaSection.onDeleteFile = { [weak self] file in self?.confirmDelete(file: file) } + + prioritySection.onUrgentHourChange = { [weak self] hour in + self?.viewModel.setUrgentHour(hour) + } + prioritySection.onCustomHourTextChange = { [weak self] text in + self?.viewModel.updateCustomUrgentHourText(text) + } + + orderButton.addTarget(self, action: #selector(openOrderSelect), for: .touchUpInside) + nameField.textField.addTarget(self, action: #selector(nameChanged), for: .editingChanged) + detailsField.textView.delegate = self + saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if let fileID = viewModel.consumePendingRemarkFileID() { + viewModel.presentRemarkDialog(for: fileID) + } + } + + @MainActor + private func applyViewModel() { + mediaSection.apply(files: viewModel.selectedFiles) + prioritySection.apply(urgentHour: viewModel.urgentHour) + orderButton.apply(orderNumber: viewModel.selectedOrderNumber) + if nameField.textField.text != viewModel.taskName { + nameField.textField.text = viewModel.taskName + } + if detailsField.textView.textColor != AppColor.textTertiary, + detailsField.textView.text != viewModel.taskDetails { + detailsField.textView.text = viewModel.taskDetails + } + saveButton.isEnabled = !viewModel.isUploading + + if viewModel.showRemarkDialog, let file = viewModel.remarkTargetFile { + showRemarkDialog(file: file) + } else { + hideRemarkDialog() + } + + if viewModel.showSuccessDialog { + presentSuccessDialog() + } + } + + @objc private func nameChanged() { + viewModel.updateTaskName(nameField.textField.text ?? "") + } + + @objc private func openOrderSelect() { + let controller = TaskOrderSelectViewController(initialOrderNumber: viewModel.selectedOrderNumber) + controller.onConfirmed = { [weak self] orderNumber in + self?.viewModel.setSelectedOrderNumber(orderNumber) + } + navigationController?.pushViewController(controller, animated: true) + } + + private func openCloudPicker() { + let controller = CloudStoragePickForTaskViewController( + importedFileIDs: viewModel.importedCloudFileIDs + ) + controller.onConfirmed = { [weak self] files in + self?.viewModel.addCloudFiles(files) + if let fileID = self?.viewModel.consumePendingRemarkFileID() { + self?.viewModel.presentRemarkDialog(for: fileID) + } + } + navigationController?.pushViewController(controller, animated: true) + } + + private func presentLocalImportSheet() { + let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + sheet.addAction(UIAlertAction(title: "图片", style: .default) { [weak self] _ in + self?.presentMediaPicker(isImage: true) + }) + sheet.addAction(UIAlertAction(title: "视频", style: .default) { [weak self] _ in + self?.presentMediaPicker(isImage: false) + }) + sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(sheet, animated: true) + } + + private func presentMediaPicker(isImage: Bool) { + pickingMediaType = isImage ? 2 : 1 + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = isImage ? .images : .videos + configuration.selectionLimit = 50 + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + + private func confirmDelete(file: TaskFileItem) { + let alert = UIAlertController(title: "删除确认", message: "确定要删除此文件吗?", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in + self?.viewModel.removeTaskFile(id: file.id) + }) + present(alert, animated: true) + } + + @objc private func saveTapped() { + showLoading() + Task { + await viewModel.validateAndSubmit(api: taskAPI) + hideLoading() + } + } + + private func presentSuccessDialog() { + guard viewModel.showSuccessDialog else { return } + let alert = UIAlertController( + title: "添加成功", + message: "任务已成功添加。是否继续添加新任务?", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "继续添加", style: .default) { [weak self] _ in + self?.viewModel.handleContinueAdding() + }) + alert.addAction(UIAlertAction(title: "返回", style: .cancel) { [weak self] _ in + self?.viewModel.dismissSuccessDialog() + self?.navigationController?.popViewController(animated: true) + }) + present(alert, animated: true) + viewModel.dismissSuccessDialog() + } + + private func showRemarkDialog(file: TaskFileItem) { + if remarkDialog == nil { + let dialog = TaskFileRemarkDialogView(frame: view.bounds) + dialog.onCancel = { [weak self] in + self?.viewModel.hideRemarkDialog() + } + dialog.onConfirm = { [weak self] in + guard let self, let dialog = self.remarkDialog else { return } + self.viewModel.updateRemarkDialogText(dialog.inputText) + self.viewModel.saveRemarkDialog() + } + remarkDialog = dialog + view.addSubview(dialog) + dialog.snp.makeConstraints { make in make.edges.equalToSuperview() } + } + remarkDialog?.apply(file: file, text: viewModel.remarkDialogText) + } + + private func hideRemarkDialog() { + remarkDialog?.removeFromSuperview() + remarkDialog = nil + } + + private func uploadPickedResults(_ results: [PHPickerResult], isImage: Bool) { + let scenicId = AppStore.shared.currentScenicId + guard scenicId > 0 else { + showToast("请先选择景区") + return + } + let totalCount = results.count + for (index, result) in results.enumerated() { + let uploadTaskId = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let placeholder = viewModel.addUploadPlaceholder( + fileName: "upload_\(uploadTaskId).\(isImage ? "jpg" : "mp4")", + fileType: isImage ? 2 : 1, + uploadTaskId: uploadTaskId + ) + Task { + do { + let payload = try await TaskMediaLoader.load(from: result, isImage: isImage) + let fileURL = try await ossUploadService.uploadTaskFile( + data: payload.data, + fileName: payload.fileName, + fileType: isImage ? 2 : 1, + scenicId: scenicId + ) { [weak self] progress in + Task { @MainActor in + self?.viewModel.updateUploadProgress(uploadTaskId: uploadTaskId, progress: progress) + } + } + try await taskAPI.registerUploadedFileURL(scenicId: scenicId, fileURL: fileURL, folderId: 0) + await MainActor.run { + self.viewModel.markUploadSucceeded( + uploadTaskId: uploadTaskId, + fileURL: fileURL, + autoRemarkWhenSingle: totalCount == 1 + ) + if totalCount == 1, let fileID = self.viewModel.consumePendingRemarkFileID() { + self.viewModel.presentRemarkDialog(for: fileID) + } + } + } catch { + await MainActor.run { + self.viewModel.markUploadFailed(uploadTaskId: uploadTaskId) + self.showToast(error.localizedDescription) + } + } + _ = placeholder + _ = index + } + } + } +} + +extension TaskAddViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard !results.isEmpty else { return } + uploadPickedResults(results, isImage: pickingMediaType == 2) + } +} + +extension TaskAddViewController: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + if textView.textColor == AppColor.textTertiary { + textView.text = "" + textView.textColor = AppColor.textPrimary + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + textView.text = detailsPlaceholder + textView.textColor = AppColor.textTertiary + viewModel.updateTaskDetails("") + } + } + + func textViewDidChange(_ textView: UITextView) { + guard textView.textColor != AppColor.textTertiary else { return } + viewModel.updateTaskDetails(textView.text) + } +} + +/// 从 PHPicker 结果加载上传数据。 +enum TaskMediaLoader { + struct Payload { + let data: Data + let fileName: String + } + + static func load(from result: PHPickerResult, isImage: Bool) async throws -> Payload { + let provider = result.itemProvider + if isImage { + return try await loadImage(from: provider) + } + return try await loadVideo(from: provider) + } + + private static func loadImage(from provider: NSItemProvider) async throws -> Payload { + try await withCheckedThrowingContinuation { continuation in + if provider.canLoadObject(ofClass: UIImage.self) { + provider.loadObject(ofClass: UIImage.self) { object, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { + continuation.resume(throwing: OSSUploadError.emptyFile) + return + } + continuation.resume(returning: Payload(data: data, fileName: "image_\(UUID().uuidString).jpg")) + } + return + } + continuation.resume(throwing: OSSUploadError.unsupportedFileType) + } + } + + private static func loadVideo(from provider: NSItemProvider) async throws -> Payload { + let typeIdentifier = UTType.movie.identifier + return try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let url, let data = try? Data(contentsOf: url) else { + continuation.resume(throwing: OSSUploadError.emptyFile) + return + } + let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension + continuation.resume(returning: Payload(data: data, fileName: "video_\(UUID().uuidString).\(ext)")) + } + } + } +} diff --git a/suixinkan/UI/Task/TaskOrderSelectViewController.swift b/suixinkan/UI/Task/TaskOrderSelectViewController.swift new file mode 100644 index 0000000..f7d47ad --- /dev/null +++ b/suixinkan/UI/Task/TaskOrderSelectViewController.swift @@ -0,0 +1,225 @@ +// +// TaskOrderSelectViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 任务关联订单选择页,对齐 Android `OrderSelectScreen`。 +final class TaskOrderSelectViewController: BaseViewController { + + var onConfirmed: ((String?) -> Void)? + + private let viewModel: TaskOrderSelectViewModel + private let taskAPI = NetworkServices.shared.taskAPI + + private let tableView = UITableView(frame: .zero, style: .plain) + private let confirmButton = AppButton(title: "确定") + private let emptyLabel = UILabel() + private let bottomBar = UIView() + + init(initialOrderNumber: String? = nil) { + viewModel = TaskOrderSelectViewModel(initialOrderNumber: initialOrderNumber) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "订单列表" + } + + override func setupUI() { + tableView.backgroundColor = AppColor.pageBackground + tableView.separatorStyle = .none + tableView.register(TaskOrderCell.self, forCellReuseIdentifier: TaskOrderCell.reuseIdentifier) + tableView.dataSource = self + tableView.delegate = self + + emptyLabel.text = "无有效订单" + emptyLabel.font = .app(.body) + emptyLabel.textColor = AppColor.textPrimary + emptyLabel.textAlignment = .center + emptyLabel.isHidden = true + + bottomBar.backgroundColor = .white + view.addSubview(tableView) + view.addSubview(emptyLabel) + view.addSubview(bottomBar) + bottomBar.addSubview(confirmButton) + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + confirmButton.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.md) + } + tableView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.loadOrders(api: taskAPI) } + } + + @MainActor + private func applyViewModel() { + emptyLabel.isHidden = viewModel.isLoading || !viewModel.orders.isEmpty + tableView.reloadData() + if viewModel.isLoading { + showLoading() + } else { + hideLoading() + } + } + + @objc private func confirmTapped() { + onConfirmed?(viewModel.confirmedOrderNumber()) + navigationController?.popViewController(animated: true) + } +} + +extension TaskOrderSelectViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.orders.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: TaskOrderCell.reuseIdentifier, + for: indexPath + ) as! TaskOrderCell + let order = viewModel.orders[indexPath.row] + cell.apply( + order: order, + isSelected: viewModel.selectedOrderNumber == order.orderNumber + ) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + viewModel.toggleSelection(orderNumber: viewModel.orders[indexPath.row].orderNumber) + } +} + +/// 订单选择卡片 cell。 +private final class TaskOrderCell: UITableViewCell { + + static let reuseIdentifier = "TaskOrderCell" + + private let cardView = UIView() + private let projectRow = TaskInfoRowView(label: "项目名称:") + private let statusRow = TaskInfoRowView(label: "订单状态:") + private let numberRow = TaskInfoRowView(label: "订单编号:") + private let payTimeRow = TaskInfoRowView(label: "付款时间:") + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppRadius.sm + cardView.layer.borderWidth = 1 + cardView.layer.borderColor = AppColor.border.cgColor + + contentView.addSubview(cardView) + cardView.addSubview(projectRow) + cardView.addSubview(statusRow) + cardView.addSubview(numberRow) + cardView.addSubview(payTimeRow) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)) + } + projectRow.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + statusRow.snp.makeConstraints { make in + make.top.equalTo(projectRow.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalTo(projectRow) + } + numberRow.snp.makeConstraints { make in + make.top.equalTo(statusRow.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalTo(projectRow) + } + payTimeRow.snp.makeConstraints { make in + make.top.equalTo(numberRow.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(order: AvailableOrder, isSelected: Bool) { + projectRow.apply(value: order.projectName.isEmpty ? "--" : order.projectName) + statusRow.apply(value: order.orderStatusLabel.isEmpty ? "--" : order.orderStatusLabel) + numberRow.apply(value: order.orderNumber) + payTimeRow.apply(value: order.payTime.isEmpty ? "--" : order.payTime) + cardView.layer.borderColor = isSelected ? AppColor.danger.cgColor : AppColor.border.cgColor + cardView.layer.borderWidth = isSelected ? 2 : 1 + } +} + +/// 订单信息行。 +private final class TaskInfoRowView: UIView { + private let labelView = UILabel() + private let valueView = UILabel() + + init(label: String) { + super.init(frame: .zero) + labelView.text = label + labelView.font = .app(.body) + labelView.textColor = AppColor.textSecondary + valueView.font = .app(.body) + valueView.textColor = AppColor.textPrimary + valueView.numberOfLines = 0 + + addSubview(labelView) + addSubview(valueView) + labelView.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + make.width.equalTo(80) + } + valueView.snp.makeConstraints { make in + make.leading.equalTo(labelView.snp.trailing).offset(AppSpacing.xs) + make.trailing.top.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(value: String) { + valueView.text = value + } +} diff --git a/suixinkan/UI/Task/Views/TaskAddViews.swift b/suixinkan/UI/Task/Views/TaskAddViews.swift new file mode 100644 index 0000000..b440943 --- /dev/null +++ b/suixinkan/UI/Task/Views/TaskAddViews.swift @@ -0,0 +1,627 @@ +// +// TaskAddViews.swift +// suixinkan +// + +import Kingfisher +import SnapKit +import UIKit + +/// 添加任务素材网格区。 +final class TaskAddMediaSectionView: UIView { + + var onCloudImport: (() -> Void)? + var onLocalImport: (() -> Void)? + var onFileTap: ((TaskFileItem) -> Void)? + var onDeleteFile: ((TaskFileItem) -> Void)? + + private var files: [TaskFileItem] = [] + private var collectionHeightConstraint: Constraint? + + private enum Section { case main } + private enum Item: Hashable { case add; case file(String) } + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewCompositionalLayout { _, environment in + let columns = 3 + let spacing: CGFloat = 8 + let inset = AppSpacing.md * 2 + let availableWidth = environment.container.effectiveContentSize.width - inset + let totalSpacing = spacing * CGFloat(columns - 1) + let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns)) + let itemHeight = itemWidth / 1.77 + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(itemWidth), + heightDimension: .absolute(itemHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(itemHeight) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + repeatingSubitem: item, + count: columns + ) + group.interItemSpacing = .fixed(spacing) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = spacing + return section + } + let view = UICollectionView(frame: .zero, collectionViewLayout: layout) + view.backgroundColor = .clear + view.isScrollEnabled = false + return view + }() + + private lazy var dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + [weak self] collectionView, indexPath, item in + switch item { + case .add: + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier, + for: indexPath + ) as! TaskAddAddMediaCell + return cell + case .file(let id): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TaskAddMediaCell.reuseIdentifier, + for: indexPath + ) as! TaskAddMediaCell + if let file = self?.files.first(where: { $0.id == id }) { + cell.apply(file: file) + cell.onDelete = { self?.onDeleteFile?(file) } + } + return cell + } + } + + private let titleLabel = UILabel() + private let tipsLabel = UILabel() + private let cloudButton = UIButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = AppRadius.md + + titleLabel.text = "添加图片" + titleLabel.font = .app(.bodyMedium) + titleLabel.textColor = AppColor.textPrimary + + tipsLabel.text = "(点击图片或视频可添加备注)" + tipsLabel.font = .app(.caption) + tipsLabel.textColor = AppColor.textPrimary + + cloudButton.setTitle("云盘导入", for: .normal) + cloudButton.setTitleColor(AppColor.primary, for: .normal) + cloudButton.titleLabel?.font = .app(.caption) + cloudButton.addTarget(self, action: #selector(cloudTapped), for: .touchUpInside) + + collectionView.register(TaskAddMediaCell.self, forCellWithReuseIdentifier: TaskAddMediaCell.reuseIdentifier) + collectionView.register(TaskAddAddMediaCell.self, forCellWithReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier) + collectionView.delegate = self + + addSubview(titleLabel) + addSubview(tipsLabel) + addSubview(cloudButton) + addSubview(collectionView) + + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(AppSpacing.md) + } + tipsLabel.snp.makeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xxs) + make.centerY.equalTo(titleLabel) + } + cloudButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.md) + make.centerY.equalTo(titleLabel) + } + collectionView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalToSuperview().inset(AppSpacing.md) + collectionHeightConstraint = make.height.equalTo(88).constraint + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(files: [TaskFileItem]) { + self.files = files + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(files.map { Item.file($0.id) } + [.add]) + dataSource.apply(snapshot, animatingDifferences: false) + collectionView.layoutIfNeeded() + let height = max(88, collectionView.collectionViewLayout.collectionViewContentSize.height) + collectionHeightConstraint?.update(offset: height) + } + + @objc private func cloudTapped() { + onCloudImport?() + } +} + +extension TaskAddMediaSectionView: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .add: + onLocalImport?() + case .file(let id): + guard let file = files.first(where: { $0.id == id }) else { return } + onFileTap?(file) + } + } +} + +/// 素材缩略图 cell。 +final class TaskAddMediaCell: UICollectionViewCell { + + static let reuseIdentifier = "TaskAddMediaCell" + + var onDelete: (() -> Void)? + + private let imageView = UIImageView() + private let overlayView = UIView() + private let progressLabel = UILabel() + private let deleteButton = UIButton(type: .system) + private let playIconView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.backgroundColor = AppColor.inputBackground + contentView.layer.cornerRadius = AppRadius.sm + contentView.clipsToBounds = true + + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + + overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35) + overlayView.isHidden = true + + progressLabel.font = .app(.captionMedium) + progressLabel.textColor = .white + progressLabel.textAlignment = .center + + deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + deleteButton.tintColor = .white + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + + playIconView.image = UIImage(systemName: "play.circle.fill") + playIconView.tintColor = .white + playIconView.isHidden = true + + contentView.addSubview(imageView) + contentView.addSubview(overlayView) + contentView.addSubview(playIconView) + contentView.addSubview(progressLabel) + contentView.addSubview(deleteButton) + + imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } + overlayView.snp.makeConstraints { make in make.edges.equalToSuperview() } + playIconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.height.equalTo(28) + } + progressLabel.snp.makeConstraints { make in make.center.equalToSuperview() } + deleteButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(AppSpacing.xxs) + make.width.height.equalTo(22) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + onDelete = nil + imageView.kf.cancelDownloadTask() + imageView.image = nil + } + + func apply(file: TaskFileItem) { + let urlString = file.previewURLString + if let url = URL(string: urlString), !urlString.isEmpty { + imageView.kf.setImage(with: url) + } else { + imageView.image = UIImage(systemName: "photo") + imageView.tintColor = AppColor.textTertiary + } + playIconView.isHidden = file.fileType != 1 + if file.isUploading { + overlayView.isHidden = false + progressLabel.text = "\(file.uploadProgress)%" + } else { + overlayView.isHidden = true + progressLabel.text = nil + } + } + + @objc private func deleteTapped() { + onDelete?() + } +} + +/// 本地导入占位 cell。 +final class TaskAddAddMediaCell: UICollectionViewCell { + + static let reuseIdentifier = "TaskAddAddMediaCell" + + private let iconView = UIImageView() + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.backgroundColor = AppColor.inputBackground + contentView.layer.cornerRadius = AppRadius.sm + + iconView.image = UIImage(systemName: "plus") + iconView.tintColor = AppColor.textTabInactive + + titleLabel.text = "本地导入" + titleLabel.font = .app(.caption) + titleLabel.textColor = AppColor.textTabInactive + + contentView.addSubview(iconView) + contentView.addSubview(titleLabel) + iconView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-8) + make.width.height.equalTo(24) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(iconView.snp.bottom).offset(2) + make.centerX.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 任务优先级选择区。 +final class TaskPrioritySectionView: UIView { + + var onUrgentHourChange: ((Int) -> Void)? + var onCustomHourTextChange: ((String) -> Void)? + + private let titleLabel = UILabel() + private let noneButton = UIButton(type: .system) + private let twoHourButton = UIButton(type: .system) + private let customButton = UIButton(type: .system) + private let customField = UITextField() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = AppRadius.md + + titleLabel.text = "优先级" + titleLabel.font = .app(.bodyMedium) + titleLabel.textColor = AppColor.textPrimary + + configureOptionButton(noneButton, title: "不加急") + configureOptionButton(twoHourButton, title: "加急(2小时)") + configureOptionButton(customButton, title: "自定义") + + noneButton.addTarget(self, action: #selector(noneTapped), for: .touchUpInside) + twoHourButton.addTarget(self, action: #selector(twoHourTapped), for: .touchUpInside) + customButton.addTarget(self, action: #selector(customTapped), for: .touchUpInside) + + customField.placeholder = "请输入小时数" + customField.font = .app(.body) + customField.textColor = AppColor.textPrimary + customField.keyboardType = .numberPad + customField.borderStyle = .roundedRect + customField.isHidden = true + customField.addTarget(self, action: #selector(customFieldChanged), for: .editingChanged) + + let row = UIStackView(arrangedSubviews: [noneButton, twoHourButton, customButton]) + row.axis = .horizontal + row.spacing = AppSpacing.xs + row.distribution = .fillEqually + + addSubview(titleLabel) + addSubview(row) + addSubview(customField) + + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(AppSpacing.md) + } + row.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(42) + } + customField.snp.makeConstraints { make in + make.top.equalTo(row.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(40) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(urgentHour: Int) { + let isCustom = urgentHour > 0 && urgentHour != 2 + styleOption(noneButton, selected: urgentHour == 0) + styleOption(twoHourButton, selected: urgentHour == 2) + styleOption(customButton, selected: isCustom) + customField.isHidden = !isCustom + if isCustom { + customField.text = "\(urgentHour)" + } else { + customField.text = "" + } + } + + private func configureOptionButton(_ button: UIButton, title: String) { + button.setTitle(title, for: .normal) + button.titleLabel?.font = .app(.caption) + button.layer.cornerRadius = AppRadius.xs + button.clipsToBounds = true + } + + private func styleOption(_ button: UIButton, selected: Bool) { + button.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground + button.setTitleColor(selected ? .white : AppColor.textSecondary, for: .normal) + } + + @objc private func noneTapped() { onUrgentHourChange?(0) } + @objc private func twoHourTapped() { onUrgentHourChange?(2) } + @objc private func customTapped() { + onUrgentHourChange?(1) + onCustomHourTextChange?("1") + } + @objc private func customFieldChanged() { + onCustomHourTextChange?(customField.text ?? "") + } +} + +/// 关联订单选择按钮。 +final class TaskOrderSelectButton: UIControl { + + private let titleLabel = UILabel() + private let arrowView = UIImageView(image: UIImage(systemName: "chevron.down")) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = AppRadius.sm + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + + titleLabel.font = .app(.bodyMedium) + titleLabel.textColor = AppColor.textTabInactive + titleLabel.text = "关联订单(可选)" + + arrowView.tintColor = AppColor.textTabInactive + + addSubview(titleLabel) + addSubview(arrowView) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(AppSpacing.sm) + make.centerY.equalToSuperview() + } + arrowView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.sm) + make.centerY.equalToSuperview() + } + snp.makeConstraints { make in make.height.equalTo(44) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(orderNumber: String?) { + if let orderNumber, !orderNumber.isEmpty { + titleLabel.text = orderNumber + titleLabel.textColor = AppColor.textPrimary + } else { + titleLabel.text = "关联订单(可选)" + titleLabel.textColor = AppColor.textTabInactive + } + } +} + +/// 任务素材备注弹窗。 +final class TaskFileRemarkDialogView: UIView { + + var onCancel: (() -> Void)? + var onConfirm: (() -> Void)? + + private let containerView = UIView() + private let titleLabel = UILabel() + private let previewView = UIImageView() + private let textView = UITextView() + private let countLabel = UILabel() + private let cancelButton = UIButton(type: .system) + private let confirmButton = UIButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = AppColor.overlayScrim + + containerView.backgroundColor = .white + containerView.layer.cornerRadius = AppRadius.md + + titleLabel.text = "备注信息" + titleLabel.font = .app(.title) + + previewView.contentMode = .scaleAspectFill + previewView.clipsToBounds = true + previewView.layer.cornerRadius = AppRadius.sm + previewView.backgroundColor = AppColor.inputBackground + + textView.font = .app(.body) + textView.layer.borderWidth = 1 + textView.layer.borderColor = AppColor.border.cgColor + textView.layer.cornerRadius = AppRadius.sm + textView.delegate = self + + countLabel.font = .app(.caption) + countLabel.textColor = AppColor.textTertiary + countLabel.text = "0/50" + + cancelButton.setTitle("取消", for: .normal) + cancelButton.setTitleColor(AppColor.textSecondary, for: .normal) + cancelButton.backgroundColor = AppColor.inputBackground + cancelButton.layer.cornerRadius = AppRadius.xs + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + + confirmButton.setTitle("确定", for: .normal) + confirmButton.setTitleColor(.white, for: .normal) + confirmButton.backgroundColor = AppColor.primary + confirmButton.layer.cornerRadius = AppRadius.xs + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + + addSubview(containerView) + containerView.addSubview(titleLabel) + containerView.addSubview(previewView) + containerView.addSubview(textView) + containerView.addSubview(countLabel) + containerView.addSubview(cancelButton) + containerView.addSubview(confirmButton) + + containerView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(AppSpacing.lg) + } + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(AppSpacing.md) + } + previewView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(160) + } + textView.snp.makeConstraints { make in + make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(80) + } + countLabel.snp.makeConstraints { make in + make.top.equalTo(textView.snp.bottom).offset(AppSpacing.xxs) + make.trailing.equalTo(textView) + } + cancelButton.snp.makeConstraints { make in + make.top.equalTo(countLabel.snp.bottom).offset(AppSpacing.md) + make.leading.bottom.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(36) + make.width.equalTo(72) + } + confirmButton.snp.makeConstraints { make in + make.centerY.equalTo(cancelButton) + make.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(36) + make.width.equalTo(72) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(file: TaskFileItem, text: String) { + textView.text = text + countLabel.text = "\(text.count)/50" + let urlString = file.previewURLString + if let url = URL(string: urlString), !urlString.isEmpty { + previewView.kf.setImage(with: url) + } else { + previewView.image = UIImage(systemName: "photo") + previewView.tintColor = AppColor.textTertiary + } + } + + @objc private func cancelTapped() { onCancel?() } + @objc private func confirmTapped() { onConfirm?() } + + /// 当前输入的备注文本。 + var inputText: String { textView.text ?? "" } +} + +extension TaskFileRemarkDialogView: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + if textView.text.count > 50 { + textView.text = String(textView.text.prefix(50)) + } + countLabel.text = "\(textView.text.count)/50" + } +} + +/// 带边框的单行输入框。 +final class TaskBorderTextField: UIView { + + let textField = UITextField() + + init(placeholder: String) { + super.init(frame: .zero) + backgroundColor = .white + layer.cornerRadius = AppRadius.sm + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + + textField.placeholder = placeholder + textField.font = .app(.body) + textField.textColor = AppColor.textPrimary + addSubview(textField) + textField.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)) + } + snp.makeConstraints { make in make.height.equalTo(44) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 带边框的多行输入框。 +final class TaskBorderTextView: UIView { + + let textView = UITextView() + + init(placeholder: String) { + super.init(frame: .zero) + backgroundColor = .white + layer.cornerRadius = AppRadius.sm + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + + textView.font = .app(.body) + textView.textColor = AppColor.textPrimary + textView.text = placeholder + textView.textColor = AppColor.textTertiary + addSubview(textView) + textView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.xs) + } + snp.makeConstraints { make in make.height.equalTo(80) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/suixinkanTests/CloudStoragePickForTaskViewModelTests.swift b/suixinkanTests/CloudStoragePickForTaskViewModelTests.swift new file mode 100644 index 0000000..eaf77a2 --- /dev/null +++ b/suixinkanTests/CloudStoragePickForTaskViewModelTests.swift @@ -0,0 +1,108 @@ +// +// CloudStoragePickForTaskViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 任务云盘多选 ViewModel 测试。 +final class CloudStoragePickForTaskViewModelTests: XCTestCase { + + func testHandleItemTapTogglesMediaSelection() async throws { + let listJSON = try makeListJSON(files: [ + makeFileJSON(id: 5, name: "图片", type: 2), + ]) + let api = makeAPI(responses: [listJSON, listJSON]) + let viewModel = CloudStoragePickForTaskViewModel() + await viewModel.refresh(api: api) + let file = try XCTUnwrap(viewModel.files.first) + + await viewModel.handleItemTap(file, api: api) + XCTAssertTrue(viewModel.isSelected(5)) + + await viewModel.handleItemTap(file, api: api) + XCTAssertFalse(viewModel.isSelected(5)) + } + + func testHandleItemTapShowsImportedMessage() async throws { + let listJSON = try makeListJSON(files: [ + makeFileJSON(id: 5, name: "图片", type: 2), + ]) + let api = makeAPI(responses: [listJSON]) + let viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: [5]) + var message: String? + viewModel.onShowMessage = { message = $0 } + await viewModel.refresh(api: api) + let file = try XCTUnwrap(viewModel.files.first) + + await viewModel.handleItemTap(file, api: api) + + XCTAssertEqual(message, "已选择") + XCTAssertFalse(viewModel.isSelected(5)) + } + + func testNavigateToFolderUpdatesPathStack() async throws { + let rootJSON = try makeListJSON(files: [ + makeFileJSON(id: 8, name: "文件夹", type: 99), + ]) + let childJSON = try makeListJSON(files: []) + let api = makeAPI(responses: [rootJSON, childJSON]) + let viewModel = CloudStoragePickForTaskViewModel() + await viewModel.refresh(api: api) + let folder = try XCTUnwrap(viewModel.files.first) + + await viewModel.handleItemTap(folder, api: api) + + XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 8]) + } + + func testSelectedFileListReturnsAllChosenFiles() async throws { + let listJSON = try makeListJSON(files: [ + makeFileJSON(id: 1, name: "A", type: 2), + makeFileJSON(id: 2, name: "B", type: 2), + ]) + let api = makeAPI(responses: [listJSON, listJSON, listJSON]) + let viewModel = CloudStoragePickForTaskViewModel() + await viewModel.refresh(api: api) + + await viewModel.handleItemTap(viewModel.files[0], api: api) + await viewModel.handleItemTap(viewModel.files[1], api: api) + + XCTAssertEqual(Set(viewModel.selectedFileList.map(\.id)), Set([1, 2])) + } + + private func makeAPI(responses: [Data]) -> TaskAPI { + TaskAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: responses))) + } + + private func makeListJSON(files: [[String: Any]]) throws -> Data { + let listData = try JSONSerialization.data(withJSONObject: files) + let listObject = try JSONSerialization.jsonObject(with: listData) + let envelope: [String: Any] = [ + "code": 100000, + "msg": "success", + "data": [ + "total": files.count, + "list": listObject, + ], + ] + return try JSONSerialization.data(withJSONObject: envelope) + } + + private func makeFileJSON(id: Int, name: String, type: Int) -> [String: Any] { + [ + "id": id, + "parent_folder_id": 0, + "file_url": "https://cdn/\(id).jpg", + "cover_url": "", + "updated_at": "", + "name": name, + "created_at": "", + "child_num": 0, + "type": type, + "file_size": 100, + ] + } +} diff --git a/suixinkanTests/ProfileEditViewModelTests.swift b/suixinkanTests/ProfileEditViewModelTests.swift new file mode 100644 index 0000000..1edbbf4 --- /dev/null +++ b/suixinkanTests/ProfileEditViewModelTests.swift @@ -0,0 +1,96 @@ +// +// ProfileEditViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 资料编辑页 ViewModel 测试。 +final class ProfileEditViewModelTests: XCTestCase { + + func testInitPrefillsNicknameWhenSet() { + let viewModel = ProfileEditViewModel(nickname: "测试昵称", avatarURL: "https://example.com/a.jpg") + XCTAssertEqual(viewModel.editingNickname, "测试昵称") + } + + func testInitClearsPlaceholderNickname() { + let viewModel = ProfileEditViewModel(nickname: "未设置昵称", avatarURL: "") + XCTAssertEqual(viewModel.editingNickname, "") + } + + func testSaveProfileRejectsEmptyNickname() async { + let viewModel = ProfileEditViewModel(nickname: "未设置昵称", avatarURL: "") + let session = MockURLSession(responses: []) + let api = ProfileAPI(client: APIClient(environment: .testing, session: session)) + + do { + try await viewModel.saveProfile( + api: api, + uploader: MockOSSUploadService(), + scenicId: 1 + ) + XCTFail("空昵称应抛出校验错误") + } catch { + guard let validationError = error as? ProfileValidationError else { + return XCTFail("期望 ProfileValidationError,实际为 \(error)") + } + XCTAssertEqual(validationError, .emptyNickname) + } + } + + func testSaveProfilePostsNotificationAndUpdatesAppStore() async throws { + let viewModel = ProfileEditViewModel(nickname: "旧昵称", avatarURL: "") + viewModel.updateEditingNickname("新昵称") + + let expectation = expectation(forNotification: NotificationName.userProfileDidUpdate, object: nil) + + let responseData = try TestJSON.envelope(data: EmptyPayload()) + let session = MockURLSession(responses: [responseData]) + let api = ProfileAPI(client: APIClient(environment: .testing, session: session)) + + try await viewModel.saveProfile( + api: api, + uploader: MockOSSUploadService(), + scenicId: 1 + ) + + await fulfillment(of: [expectation], timeout: 1) + XCTAssertEqual(AppStore.shared.userName, "新昵称") + } +} + +/// 测试用 OSS 上传替身,直接返回固定 URL。 +@MainActor +private struct MockOSSUploadService: OSSUploadServing { + func uploadUserAvatar( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + onProgress(100) + return "https://cdn.example.com/avatar.jpg" + } + + func uploadRealNameImage( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + onProgress(100) + return "https://cdn.example.com/realname.jpg" + } + + func uploadBankCardImage( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + onProgress(100) + return "https://cdn.example.com/bank.jpg" + } +} diff --git a/suixinkanTests/ProfileViewModelTests.swift b/suixinkanTests/ProfileViewModelTests.swift index 3e968ef..fdc91aa 100644 --- a/suixinkanTests/ProfileViewModelTests.swift +++ b/suixinkanTests/ProfileViewModelTests.swift @@ -44,9 +44,17 @@ final class ProfileViewModelTests: XCTestCase { } } - func testBeginEditingPrefillsNickname() { + func testApplyLocalProfileUpdateSyncsNicknameAndAvatar() { + AppStore.shared.userName = "全局昵称" + AppStore.shared.avatar = "https://cdn.example.com/new.jpg" + let viewModel = ProfileViewModel() - viewModel.beginEditing() - XCTAssertTrue(viewModel.isEditingProfile) + viewModel.applyLocalProfileUpdate() + + XCTAssertEqual(viewModel.displayNickname, "全局昵称") + XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/new.jpg") + + AppStore.shared.userName = "" + AppStore.shared.avatar = "" } } diff --git a/suixinkanTests/TaskAddViewModelTests.swift b/suixinkanTests/TaskAddViewModelTests.swift new file mode 100644 index 0000000..b1d6479 --- /dev/null +++ b/suixinkanTests/TaskAddViewModelTests.swift @@ -0,0 +1,84 @@ +// +// TaskAddViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 添加任务 ViewModel 测试。 +@MainActor +final class TaskAddViewModelTests: XCTestCase { + + private var appStore: AppStore! + private var defaults: UserDefaults! + + override func setUp() { + defaults = UserDefaults(suiteName: "TaskAddViewModelTests")! + defaults.removePersistentDomain(forName: "TaskAddViewModelTests") + appStore = AppStore(defaults: defaults) + appStore.currentScenicId = 10 + } + + func testValidateRequiresTaskName() async { + let viewModel = TaskAddViewModel(appStore: appStore) + var message: String? + viewModel.onShowMessage = { message = $0 } + + await viewModel.validateAndSubmit(api: TaskAPI(client: makeClient())) + + XCTAssertEqual(message, "请填写任务名称!") + } + + func testBuildAddTaskRequestSplitsCloudAndUploadFiles() { + let viewModel = TaskAddViewModel(appStore: appStore) + viewModel.updateTaskName("任务A") + viewModel.updateTaskDetails("描述") + viewModel.setSelectedOrderNumber("NO001") + viewModel.setUrgentHour(2) + viewModel.addCloudFiles([CloudFile(id: 1, name: "云文件", type: 2)]) + viewModel.addUploadPlaceholder(fileName: "a.jpg", fileType: 2, uploadTaskId: "task1") + viewModel.markUploadSucceeded(uploadTaskId: "task1", fileURL: "https://cdn/a.jpg", autoRemarkWhenSingle: false) + + let request = viewModel.buildAddTaskRequest() + + XCTAssertEqual(request.name, "任务A") + XCTAssertEqual(request.orderNumber, "NO001") + XCTAssertEqual(request.urgentHour, 2) + XCTAssertEqual(request.cloudFile.count, 1) + XCTAssertEqual(request.uploadFile.count, 1) + XCTAssertEqual(request.uploadFile[0].fileUrl, "https://cdn/a.jpg") + } + + func testAddCloudFilesDedupesAndAutoRemarkSingleFile() { + let viewModel = TaskAddViewModel(appStore: appStore) + viewModel.addCloudFiles([CloudFile(id: 1, name: "A", type: 2)]) + XCTAssertEqual(viewModel.selectedFiles.count, 1) + XCTAssertEqual(viewModel.consumePendingRemarkFileID(), viewModel.selectedFiles[0].id) + + viewModel.addCloudFiles([ + CloudFile(id: 1, name: "A", type: 2), + CloudFile(id: 2, name: "B", type: 2), + CloudFile(id: 3, name: "C", type: 2), + ]) + XCTAssertEqual(viewModel.selectedFiles.count, 3) + XCTAssertNil(viewModel.consumePendingRemarkFileID()) + } + + func testResetAllStateClearsForm() { + let viewModel = TaskAddViewModel(appStore: appStore) + viewModel.updateTaskName("任务") + viewModel.setSelectedOrderNumber("NO001") + viewModel.addCloudFiles([CloudFile(id: 1, type: 2)]) + + viewModel.resetAllState() + + XCTAssertEqual(viewModel.taskName, "") + XCTAssertNil(viewModel.selectedOrderNumber) + XCTAssertTrue(viewModel.selectedFiles.isEmpty) + } + + private func makeClient() -> APIClient { + APIClient(environment: .testing, session: MockURLSession(responses: [])) + } +} diff --git a/suixinkanTests/TaskModelsTests.swift b/suixinkanTests/TaskModelsTests.swift new file mode 100644 index 0000000..48376a6 --- /dev/null +++ b/suixinkanTests/TaskModelsTests.swift @@ -0,0 +1,77 @@ +// +// TaskModelsTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 任务模型编解码测试。 +final class TaskModelsTests: XCTestCase { + + func testAddTaskRequestEncodesSnakeCaseFields() throws { + let request = AddTaskRequest( + scenicId: 10, + name: "测试任务", + orderNumber: "NO001", + remark: "描述", + urgentHour: 2, + cloudFile: [TaskCloudFileItem(fileId: 1, remark: "云备注")], + uploadFile: [TaskUploadFileItem(fileUrl: "https://cdn/a.jpg", fileName: "a.jpg", remark: "")] + ) + let data = try JSONEncoder().encode(request) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + + XCTAssertEqual(json?["scenic_id"] as? Int, 10) + XCTAssertEqual(json?["name"] as? String, "测试任务") + XCTAssertEqual(json?["order_number"] as? String, "NO001") + XCTAssertEqual(json?["photog_remark"] as? String, "描述") + XCTAssertEqual(json?["urgent_hour"] as? Int, 2) + } + + func testAvailableOrderDecodesSnakeCaseFields() throws { + let json = """ + { + "project_name": "航拍项目", + "order_number": "NO001", + "order_status": 1, + "order_status_label": "已付款", + "pay_time": "2024-01-01 10:00:00", + "user_id": 100, + "user_phone": "13800138000" + } + """.data(using: .utf8)! + + let order = try JSONDecoder().decode(AvailableOrder.self, from: json) + XCTAssertEqual(order.projectName, "航拍项目") + XCTAssertEqual(order.orderNumber, "NO001") + XCTAssertEqual(order.orderStatusLabel, "已付款") + } + + func testCloudFileListResponseDecodesList() throws { + let json = """ + { + "total": 1, + "list": [ + { + "id": 5, + "parent_folder_id": 0, + "file_url": "https://cdn/a.jpg", + "cover_url": "", + "updated_at": "", + "name": "图片1", + "created_at": "", + "child_num": 0, + "type": 2, + "file_size": 100 + } + ] + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(CloudFileListResponse.self, from: json) + XCTAssertEqual(response.total, 1) + XCTAssertEqual(response.list[0].id, 5) + XCTAssertTrue(response.list[0].isImage) + } +} diff --git a/suixinkanTests/TaskOrderSelectViewModelTests.swift b/suixinkanTests/TaskOrderSelectViewModelTests.swift new file mode 100644 index 0000000..0e105e1 --- /dev/null +++ b/suixinkanTests/TaskOrderSelectViewModelTests.swift @@ -0,0 +1,58 @@ +// +// TaskOrderSelectViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 任务订单选择 ViewModel 测试。 +final class TaskOrderSelectViewModelTests: XCTestCase { + + private var appStore: AppStore! + private var defaults: UserDefaults! + + override func setUp() { + defaults = UserDefaults(suiteName: "TaskOrderSelectViewModelTests")! + defaults.removePersistentDomain(forName: "TaskOrderSelectViewModelTests") + appStore = AppStore(defaults: defaults) + appStore.currentScenicId = 10 + } + + func testToggleSelectionSelectsAndDeselects() { + let viewModel = TaskOrderSelectViewModel(appStore: appStore) + viewModel.toggleSelection(orderNumber: "NO001") + XCTAssertEqual(viewModel.selectedOrderNumber, "NO001") + viewModel.toggleSelection(orderNumber: "NO001") + XCTAssertNil(viewModel.selectedOrderNumber) + } + + func testLoadOrdersPopulatesList() async throws { + let json = """ + { + "code": 100000, + "msg": "success", + "data": [ + { + "project_name": "项目A", + "order_number": "NO001", + "order_status": 1, + "order_status_label": "已付款", + "pay_time": "2024-01-01", + "user_id": 1, + "user_phone": "13800138000" + } + ] + } + """.data(using: .utf8)! + let session = MockURLSession(responses: [json]) + let api = TaskAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = TaskOrderSelectViewModel(appStore: appStore) + + await viewModel.loadOrders(api: api) + + XCTAssertEqual(viewModel.orders.count, 1) + XCTAssertEqual(viewModel.orders[0].orderNumber, "NO001") + } +}