diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/Contents.json index de9adeb..27e32af 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_add_folder.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/Contents.json index 1d8a158..8af3381 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_add_photo.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/Contents.json index 8f4d468..306af79 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_add_video.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/Contents.json index 70404d5..4f7d5fb 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_current_path_arrow.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/Contents.json index 018d074..d27f5ca 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_delete.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/Contents.json index 7b088d9..17e9272 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_details.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/Contents.json index e48c961..a860d7f 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_download.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/Contents.json index 20f9690..e181ce7 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_filter.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/Contents.json index d2a3a80..269f83e 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_item_details.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/Contents.json index 66ee239..de501f3 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_move.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/Contents.json index ddd996f..336f798 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_play.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/Contents.json index 62995df..a6f5019 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_search.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/Contents.json index 8e3efb6..1626bda 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_share.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/Contents.json index 555634a..b7cf637 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_type_folder.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/Contents.json index 3640b79..ea93f75 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_type_photo.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/Contents.json index e0db243..1e1d609 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_file_type_video.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/Contents.json index 3bbcd7c..85023d0 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_folder_type.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/Contents.json index 2e384c1..e22aef5 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_go_home.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/Contents.json index 38d839e..a5ad7dc 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_list_sort.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/Contents.json index 35c7f97..1d3cc99 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_show_type_list.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/Contents.json index e6e3e60..bcae26e 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_show_type_table.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/Contents.json index 509e3bf..715f6c8 100644 --- a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/Contents.json +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/Contents.json @@ -4,6 +4,14 @@ "filename" : "icon_cloud_storage_transit.png", "idiom" : "universal", "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" } ], "info" : { diff --git a/suixinkan/Assets.xcassets/WildPhotographerReport/Contents.json b/suixinkan/Assets.xcassets/WildPhotographerReport/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/WildPhotographerReport/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/WildPhotographerReport/report_share_cover.imageset/Contents.json b/suixinkan/Assets.xcassets/WildPhotographerReport/report_share_cover.imageset/Contents.json new file mode 100644 index 0000000..cd649df --- /dev/null +++ b/suixinkan/Assets.xcassets/WildPhotographerReport/report_share_cover.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "report_share_cover.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/WildPhotographerReport/report_share_cover.imageset/report_share_cover.jpg b/suixinkan/Assets.xcassets/WildPhotographerReport/report_share_cover.imageset/report_share_cover.jpg new file mode 100644 index 0000000..c8380c0 Binary files /dev/null and b/suixinkan/Assets.xcassets/WildPhotographerReport/report_share_cover.imageset/report_share_cover.jpg differ diff --git a/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift b/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift index e31e7bf..1154007 100644 --- a/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift +++ b/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift @@ -61,6 +61,14 @@ enum MaterialMediaType: Int, CaseIterable, Sendable, Equatable { case .video: 1 } } + + /// 云盘文件类型,视频为 1、图片为 2。 + var cloudFileType: Int { + switch self { + case .image: 2 + case .video: 1 + } + } } /// 素材列表项,对齐 Android `MaterialItemEntity`。 diff --git a/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift b/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift index 7229dea..13d4caf 100644 --- a/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift +++ b/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift @@ -354,6 +354,48 @@ final class MaterialFormViewModel { notifyStateChange() } + /// 增加云盘素材文件,文件已在云盘中时直接使用其 URL。 + func addCloudMediaFiles(_ files: [CloudFile]) { + guard !files.isEmpty else { return } + let remaining = mediaType.maxCount - uploadedMediaList.count + guard remaining > 0 else { + onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张") + return + } + + var seenCloudIDs = importedCloudFileIDs + var accepted: [MaterialUploadedMediaItem] = [] + for file in files where accepted.count < remaining { + let fileURL = file.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard file.type == mediaType.cloudFileType, !fileURL.isEmpty, !seenCloudIDs.contains(file.id) else { + continue + } + seenCloudIDs.insert(file.id) + accepted.append( + MaterialUploadedMediaItem( + id: "cloud_\(file.id)", + data: Data(), + thumbnailData: nil, + previewURL: file.coverUrl.isEmpty ? fileURL : file.coverUrl, + fileName: file.name.isEmpty ? "cloud_file_\(file.id)" : file.name, + size: file.fileSize, + width: 0, + height: 0, + ossUrl: fileURL, + isUploading: false, + uploadProgress: 100 + ) + ) + } + + guard !accepted.isEmpty else { + onShowMessage?(mediaType == .video ? "请选择视频素材" : "请选择图片素材") + return + } + uploadedMediaList.append(contentsOf: accepted) + notifyStateChange() + } + /// 设置封面并立即上传。 func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async { coverImage = item @@ -376,6 +418,16 @@ final class MaterialFormViewModel { notifyStateChange() } + /// 已从云盘导入的文件 ID。 + var importedCloudFileIDs: Set { + Set( + uploadedMediaList.compactMap { item in + guard item.id.hasPrefix("cloud_") else { return nil } + return Int(item.id.dropFirst("cloud_".count)) + } + ) + } + /// 上传或编辑素材。 func submit(api: any MaterialManagementServing) async { guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { diff --git a/suixinkan/Features/Task/API/TaskAPI.swift b/suixinkan/Features/Task/API/TaskAPI.swift index 7154850..dd07a86 100644 --- a/suixinkan/Features/Task/API/TaskAPI.swift +++ b/suixinkan/Features/Task/API/TaskAPI.swift @@ -61,20 +61,6 @@ final class TaskAPI { ) } - /// 登记 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 字段的成功接口。 diff --git a/suixinkan/Features/Task/Models/TaskModels.swift b/suixinkan/Features/Task/Models/TaskModels.swift index 066105b..633a050 100644 --- a/suixinkan/Features/Task/Models/TaskModels.swift +++ b/suixinkan/Features/Task/Models/TaskModels.swift @@ -177,4 +177,14 @@ struct TaskFileItem: Sendable, Equatable, Hashable, Identifiable { return uploadFileName ?? "" } } + + /// 素材来源角标文案。 + var sourceBadgeText: String { + switch source { + case .cloud: + return "云盘" + case .upload: + return "本地" + } + } } diff --git a/suixinkan/Features/Task/ViewModels/CloudStoragePickForTaskViewModel.swift b/suixinkan/Features/Task/ViewModels/CloudStoragePickForTaskViewModel.swift index a6d9604..efa1cf4 100644 --- a/suixinkan/Features/Task/ViewModels/CloudStoragePickForTaskViewModel.swift +++ b/suixinkan/Features/Task/ViewModels/CloudStoragePickForTaskViewModel.swift @@ -25,9 +25,13 @@ final class CloudStoragePickForTaskViewModel { private var totalCount = 0 private let pageSize = 20 private let importedFileIDs: Set + private let allowedFileType: Int? - init(importedFileIDs: Set = []) { + /// 初始化云盘多选 ViewModel,可按云盘文件类型限制选择范围。 + init(importedFileIDs: Set = [], allowedFileType: Int? = nil) { self.importedFileIDs = importedFileIDs + self.allowedFileType = allowedFileType + self.filterType = allowedFileType ?? 0 } /// 当前文件夹 ID。 @@ -94,6 +98,10 @@ final class CloudStoragePickForTaskViewModel { return } guard file.isSelectableMedia else { return } + if let allowedFileType, file.type != allowedFileType { + onShowMessage?(allowedFileType == 1 ? "请选择视频素材" : "请选择图片素材") + return + } if isImported(file.id) { onShowMessage?("已选择") return diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index 5242e8c..3ce8c50 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -139,16 +139,6 @@ final class WildPhotographerReportSubmitViewModel { return "当前定位:\(reportLocation)" } - var locationSubtitleText: String { - if isUpdatingLocation { - return "正在更新定位,请稍候" - } - if locationConfirmed { - return "已获取最新定位,如不准确请点击更新定位" - } - return "如位置不准确,请点击更新定位" - } - init( homeViewModel: WildPhotographerReportHomeViewModel, locationProvider: any LocationProviding = LocationProvider.shared @@ -358,12 +348,13 @@ final class WildPhotographerReportSubmitViewModel { let address = snapshot.address.isEmpty ? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude) : snapshot.address + let reportAddress = Self.simplifiedReportLocation(address) locationSnapshot = HomeLocationSnapshot( latitude: snapshot.latitude, longitude: snapshot.longitude, - address: address + address: reportAddress ) - reportLocation = address + reportLocation = reportAddress locationConfirmed = true isUpdatingLocation = false notifyStateChange() @@ -393,6 +384,40 @@ final class WildPhotographerReportSubmitViewModel { return types.filter { seen.insert($0.value).inserted } } + private static func simplifiedReportLocation(_ address: String) -> String { + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + let detail = WildReportLocationParser.split(trimmed).detail + let simplified = removeAdministrativePrefix(from: detail) + return simplified.isEmpty ? trimmed : simplified + } + + private static func removeAdministrativePrefix(from address: String) -> String { + var text = address + removeLeadingRegion(from: &text, matches: [ + "北京市", "天津市", "上海市", "重庆市", + "内蒙古自治区", "广西壮族自治区", "西藏自治区", "宁夏回族自治区", "新疆维吾尔自治区" + ]) + removeLeadingRegion(from: &text, suffixes: ["省"], maxLength: 8) + removeLeadingRegion(from: &text, suffixes: ["自治州", "地区", "盟", "市"], maxLength: 12) + removeLeadingRegion(from: &text, suffixes: ["自治县", "新区", "区", "县", "旗", "市"], maxLength: 12) + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func removeLeadingRegion(from text: inout String, matches: [String]) { + guard let match = matches.first(where: { text.hasPrefix($0) }) else { return } + text.removeFirst(match.count) + } + + private static func removeLeadingRegion(from text: inout String, suffixes: [String], maxLength: Int) { + guard let match = suffixes.compactMap({ suffix -> String? in + guard let range = text.range(of: suffix) else { return nil } + let prefix = String(text[.. CGFloat { + guard + let contentView = keyboardAvoidingContentView, + let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue + else { + return 0 + } + + let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil) + guard keyboardFrame.minY < bounds.height else { return 0 } + + let contentHeight = contentView.bounds.height + let baseMinY = bounds.midY - contentHeight / 2 + let baseMaxY = bounds.midY + contentHeight / 2 + let desiredBottom = keyboardFrame.minY - keyboardAvoidingMinimumSpacing + let neededShift = max(0, baseMaxY - desiredBottom) + let maxShift = max(0, baseMinY - keyboardAvoidingMinimumSpacing) + return -min(neededShift, maxShift) + } +} diff --git a/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift index d456606..17ace25 100644 --- a/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift +++ b/suixinkan/UI/CooperationOrder/Views/CooperationOrderViews.swift @@ -630,7 +630,7 @@ final class CooperationAcquirerCell: UITableViewCell { } /// 修改获客员分成比例弹窗,对齐 Android `CommissionRateEditDialog`。 -final class CooperationCommissionRateDialogView: UIView { +final class CooperationCommissionRateDialogView: KeyboardAvoidingDialogView { var onCommissionRateChange: ((String) -> Void)? var onSmsCodeChange: ((String) -> Void)? @@ -654,8 +654,6 @@ final class CooperationCommissionRateDialogView: UIView { private let buttonStack = UIStackView() private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) - private var cardCenterYConstraint: Constraint? - private var keyboardObservers: [NSObjectProtocol] = [] override init(frame: CGRect) { super.init(frame: frame) @@ -667,10 +665,6 @@ final class CooperationCommissionRateDialogView: UIView { fatalError("init(coder:) has not been implemented") } - deinit { - unregisterKeyboardObservers() - } - func apply(commissionRate: String, smsCode: String, phone: String, countdown: Int) { rateField.text = commissionRate smsCodeField.text = smsCode @@ -690,7 +684,6 @@ final class CooperationCommissionRateDialogView: UIView { alpha = 0 parent.addSubview(self) layoutIfNeeded() - registerKeyboardObservers() rateField.focus() UIView.animate(withDuration: 0.2) { self.alpha = 1 @@ -699,7 +692,6 @@ final class CooperationCommissionRateDialogView: UIView { func dismiss() { endEditing(true) - unregisterKeyboardObservers() UIView.animate(withDuration: 0.2, animations: { self.alpha = 0 }, completion: { _ in @@ -708,6 +700,8 @@ final class CooperationCommissionRateDialogView: UIView { } private func setupUI() { + keyboardAvoidingContentView = cardView + dimView.backgroundColor = AppColor.overlayScrim dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) @@ -780,7 +774,7 @@ final class CooperationCommissionRateDialogView: UIView { } cardView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - cardCenterYConstraint = make.centerY.equalToSuperview().constraint + keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint } titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) @@ -815,67 +809,6 @@ final class CooperationCommissionRateDialogView: UIView { return row } - private func registerKeyboardObservers() { - unregisterKeyboardObservers() - let center = NotificationCenter.default - keyboardObservers = [ - center.addObserver( - forName: UIResponder.keyboardWillChangeFrameNotification, - object: nil, - queue: .main - ) { [weak self] notification in - self?.handleKeyboardNotification(notification) - }, - center.addObserver( - forName: UIResponder.keyboardWillHideNotification, - object: nil, - queue: .main - ) { [weak self] notification in - self?.handleKeyboardNotification(notification) - } - ] - } - - private func unregisterKeyboardObservers() { - keyboardObservers.forEach(NotificationCenter.default.removeObserver) - keyboardObservers.removeAll() - } - - private func handleKeyboardNotification(_ notification: Notification) { - layoutIfNeeded() - guard let userInfo = notification.userInfo else { return } - - let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25 - let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue - ?? UInt(UIView.AnimationCurve.easeInOut.rawValue) - let options = UIView.AnimationOptions(rawValue: curveRaw << 16) - - let offset = keyboardAvoidanceOffset(from: userInfo) - cardCenterYConstraint?.update(offset: offset) - UIView.animate(withDuration: duration, delay: 0, options: options) { - self.layoutIfNeeded() - } - } - - private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat { - guard - let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue - else { - return 0 - } - - let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil) - guard keyboardFrame.minY < bounds.height else { return 0 } - - let cardHeight = cardView.bounds.height - let baseMinY = bounds.midY - cardHeight / 2 - let baseMaxY = bounds.midY + cardHeight / 2 - let desiredBottom = keyboardFrame.minY - AppSpacing.md - let neededShift = max(0, baseMaxY - desiredBottom) - let maxShift = max(0, baseMinY - AppSpacing.md) - return -min(neededShift, maxShift) - } - @objc private func sendCodeTapped() { onSendSmsCode?() } diff --git a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift index b0bfbab..3fc757f 100644 --- a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift @@ -267,10 +267,26 @@ final class MaterialFormViewController: BaseViewController { sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in self?.presentPhotoPicker() }) + if target == .material { + sheet.addAction(UIAlertAction(title: "从云盘导入", style: .default) { [weak self] _ in + self?.openCloudPicker() + }) + } sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) present(sheet, animated: true) } + private func openCloudPicker() { + let controller = CloudStoragePickForTaskViewController( + importedFileIDs: viewModel.importedCloudFileIDs, + allowedFileType: viewModel.mediaType.cloudFileType + ) + controller.onConfirmed = { [weak self] files in + self?.viewModel.addCloudMediaFiles(files) + } + navigationController?.pushViewController(controller, animated: true) + } + private func presentPhotoPicker() { var configuration = PHPickerConfiguration(photoLibrary: .shared()) if pickTarget == .cover { diff --git a/suixinkan/UI/Orders/OrderDialogControllers.swift b/suixinkan/UI/Orders/OrderDialogControllers.swift index f9dd2f8..66cdcb7 100644 --- a/suixinkan/UI/Orders/OrderDialogControllers.swift +++ b/suixinkan/UI/Orders/OrderDialogControllers.swift @@ -220,7 +220,18 @@ private extension String { } /// 自定义订单退款弹窗视图,对齐 Android `OrderRefundDialog` / `DepositOrderRefundDialog`。 -final class OrderRefundDialogView: UIView, UITextViewDelegate { +final class OrderRefundDialogView: KeyboardAvoidingDialogView, UITextViewDelegate { + + /// 退款弹窗局部布局尺寸,保持内容紧凑但不牺牲基础点击区域。 + private enum Layout { + static let sectionGap: CGFloat = AppSpacing.lg + static let formGap: CGFloat = AppSpacing.md + static let controlHeight: CGFloat = AppSpacing.minTouchTarget + static let amountGroupHeight: CGFloat = 67 + static let remarkHeight: CGFloat = 88 + static let actionTop: CGFloat = 14 + static let bottomInset: CGFloat = AppSpacing.md + } /// 退款弹窗展示与校验配置,用于区分普通订单和店铺/定金订单。 struct Configuration { @@ -266,8 +277,6 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { private let configuration: Configuration private var selectedMode: OrderRefundMode - private var keyboardObservers: [NSObjectProtocol] = [] - private var cardCenterYConstraint: Constraint? private let dimView = UIView() private let cardView = UIView() @@ -304,16 +313,11 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { fatalError("init(coder:) has not been implemented") } - deinit { - unregisterKeyboardObservers() - } - func show(in parent: UIView) { frame = parent.bounds autoresizingMask = [.flexibleWidth, .flexibleHeight] alpha = 0 parent.addSubview(self) - registerKeyboardObservers() UIView.animate(withDuration: 0.2) { self.alpha = 1 } @@ -321,7 +325,6 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { func dismiss() { endEditing(true) - unregisterKeyboardObservers() UIView.animate(withDuration: 0.2, animations: { self.alpha = 0 }, completion: { _ in @@ -330,6 +333,9 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { } private func setupUI() { + keyboardAvoidingContentView = cardView + keyboardAvoidingMinimumSpacing = AppSpacing.sm + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.2) dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) @@ -422,7 +428,7 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { } cardView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - cardCenterYConstraint = make.centerY.equalToSuperview().constraint + keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint } titleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(AppSpacing.sm) @@ -442,18 +448,18 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } refundModeTitleLabel.snp.makeConstraints { make in - make.top.equalTo(orderNumberLabel.snp.bottom).offset(AppSpacing.xl) + make.top.equalTo(orderNumberLabel.snp.bottom).offset(Layout.sectionGap) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } modeButtonStack.snp.makeConstraints { make in make.top.equalTo(refundModeTitleLabel.snp.bottom).offset(AppSpacing.xs) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.height.equalTo(46) + make.height.equalTo(Layout.controlHeight) } amountGroupView.snp.makeConstraints { make in - make.top.equalTo(modeButtonStack.snp.bottom).offset(AppSpacing.xl) + make.top.equalTo(modeButtonStack.snp.bottom).offset(Layout.formGap) make.leading.trailing.equalToSuperview() - make.height.equalTo(73) + make.height.equalTo(Layout.amountGroupHeight) } amountTitleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: 0, right: AppSpacing.md)) @@ -461,20 +467,20 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { amountContainerView.snp.makeConstraints { make in make.top.equalTo(amountTitleLabel.snp.bottom).offset(AppSpacing.xs) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.height.equalTo(46) + make.height.equalTo(Layout.controlHeight) } amountField.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) make.centerY.equalToSuperview() } remarkTitleLabel.snp.makeConstraints { make in - make.top.equalTo(amountGroupView.snp.bottom).offset(AppSpacing.xl) + make.top.equalTo(amountGroupView.snp.bottom).offset(Layout.formGap) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } remarkContainerView.snp.makeConstraints { make in make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.height.equalTo(104) + make.height.equalTo(Layout.remarkHeight) } remarkTextView.snp.makeConstraints { make in make.edges.equalToSuperview() @@ -484,9 +490,9 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) } actionRow.snp.makeConstraints { make in - make.top.equalTo(remarkContainerView.snp.bottom).offset(20) + make.top.equalTo(remarkContainerView.snp.bottom).offset(Layout.actionTop) make.trailing.equalToSuperview().inset(AppSpacing.md) - make.bottom.equalToSuperview().inset(AppSpacing.lg) + make.bottom.equalToSuperview().inset(Layout.bottomInset) } [cancelButton, confirmButton].forEach { button in button.snp.makeConstraints { make in @@ -521,10 +527,10 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { let amountHidden = selectedMode == .full amountGroupView.isHidden = amountHidden amountGroupView.snp.updateConstraints { make in - make.height.equalTo(amountHidden ? 0 : 73) + make.height.equalTo(amountHidden ? 0 : Layout.amountGroupHeight) } remarkTitleLabel.snp.remakeConstraints { make in - make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(AppSpacing.xl) + make.top.equalTo((amountHidden ? modeButtonStack : amountGroupView).snp.bottom).offset(Layout.formGap) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } @@ -544,60 +550,6 @@ final class OrderRefundDialogView: UIView, UITextViewDelegate { button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal) } - private func registerKeyboardObservers() { - unregisterKeyboardObservers() - let center = NotificationCenter.default - keyboardObservers = [ - center.addObserver( - forName: UIResponder.keyboardWillChangeFrameNotification, - object: nil, - queue: .main - ) { [weak self] notification in - self?.handleKeyboardNotification(notification) - }, - center.addObserver( - forName: UIResponder.keyboardWillHideNotification, - object: nil, - queue: .main - ) { [weak self] notification in - self?.handleKeyboardNotification(notification) - } - ] - } - - private func unregisterKeyboardObservers() { - keyboardObservers.forEach(NotificationCenter.default.removeObserver) - keyboardObservers.removeAll() - } - - private func handleKeyboardNotification(_ notification: Notification) { - layoutIfNeeded() - guard let userInfo = notification.userInfo else { return } - let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25 - let curveRaw = (userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue - ?? UInt(UIView.AnimationCurve.easeInOut.rawValue) - let options = UIView.AnimationOptions(rawValue: curveRaw << 16) - let offset = keyboardAvoidanceOffset(from: userInfo) - cardCenterYConstraint?.update(offset: offset) - UIView.animate(withDuration: duration, delay: 0, options: options) { - self.layoutIfNeeded() - } - } - - private func keyboardAvoidanceOffset(from userInfo: [AnyHashable: Any]) -> CGFloat { - guard - let keyboardFrameValue = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue - else { - return 0 - } - let keyboardFrame = convert(keyboardFrameValue.cgRectValue, from: nil) - guard keyboardFrame.minY < bounds.height else { return 0 } - - let desiredBottom = keyboardFrame.minY - AppSpacing.sm - let overlap = cardView.frame.maxY - desiredBottom - return overlap > 0 ? -overlap : 0 - } - func textViewDidChange(_ textView: UITextView) { if textView.text.count > 255 { textView.text = String(textView.text.prefix(255)) diff --git a/suixinkan/UI/Orders/Views/OrderSourceEntryView.swift b/suixinkan/UI/Orders/Views/OrderSourceEntryView.swift index d8538ae..b511b5c 100644 --- a/suixinkan/UI/Orders/Views/OrderSourceEntryView.swift +++ b/suixinkan/UI/Orders/Views/OrderSourceEntryView.swift @@ -17,6 +17,7 @@ final class OrderSourceEntryView: UIControl { private let personRow = UIStackView() private let personNameLabel = UILabel() private let personPhoneLabel = UILabel() + private let personSpacerView = UIView() private let divider = UIView() private let leadContentView = OrderSourceLeadContentView() private var currentImages: [String] = [] @@ -83,10 +84,19 @@ final class OrderSourceEntryView: UIControl { personRow.spacing = 10 personNameLabel.font = .systemFont(ofSize: 13, weight: .medium) personNameLabel.textColor = AppColor.text333 + personNameLabel.lineBreakMode = .byTruncatingTail + personNameLabel.setContentHuggingPriority(.required, for: .horizontal) + personNameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) personPhoneLabel.font = .systemFont(ofSize: 13) personPhoneLabel.textColor = AppColor.text666 + personPhoneLabel.lineBreakMode = .byTruncatingTail + personPhoneLabel.setContentHuggingPriority(.required, for: .horizontal) + personPhoneLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + personSpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) + personSpacerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) personRow.addArrangedSubview(personNameLabel) personRow.addArrangedSubview(personPhoneLabel) + personRow.addArrangedSubview(personSpacerView) divider.backgroundColor = OrderTokens.sourceDivider diff --git a/suixinkan/UI/Orders/Views/OrderSourceLeadContentView.swift b/suixinkan/UI/Orders/Views/OrderSourceLeadContentView.swift index 49ca0c3..86ef8d0 100644 --- a/suixinkan/UI/Orders/Views/OrderSourceLeadContentView.swift +++ b/suixinkan/UI/Orders/Views/OrderSourceLeadContentView.swift @@ -15,6 +15,7 @@ final class OrderSourceLeadContentView: UIView { private let phoneRow = UIStackView() private let phoneTitleLabel = UILabel() private let phoneValueLabel = UILabel() + private let phoneSpacerView = UIView() private let remarkLabel = UILabel() private let imageRow = UIStackView() private let emptyImageLabel = UILabel() @@ -91,12 +92,20 @@ final class OrderSourceLeadContentView: UIView { phoneTitleLabel.text = "客户手机号" phoneTitleLabel.font = .systemFont(ofSize: 12) phoneTitleLabel.textColor = AppColor.text666 + phoneTitleLabel.setContentHuggingPriority(.required, for: .horizontal) + phoneTitleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) phoneValueLabel.font = .systemFont(ofSize: 13, weight: .medium) + phoneValueLabel.lineBreakMode = .byTruncatingTail + phoneValueLabel.setContentHuggingPriority(.required, for: .horizontal) + phoneValueLabel.setContentCompressionResistancePriority(.required, for: .horizontal) phoneValueLabel.addGestureRecognizer( UITapGestureRecognizer(target: self, action: #selector(phoneTapped)) ) + phoneSpacerView.setContentHuggingPriority(.defaultLow, for: .horizontal) + phoneSpacerView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) phoneRow.addArrangedSubview(phoneTitleLabel) phoneRow.addArrangedSubview(phoneValueLabel) + phoneRow.addArrangedSubview(phoneSpacerView) remarkLabel.textColor = AppColor.text333 remarkLabel.lineBreakMode = .byTruncatingTail diff --git a/suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift b/suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift index 627e1d0..3301aea 100644 --- a/suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift +++ b/suixinkan/UI/Payment/Views/PaymentSetAmountDialogView.swift @@ -7,7 +7,7 @@ import SnapKit import UIKit /// 设置收款金额弹窗,对齐 Android `SetAmountDialog`。 -final class PaymentSetAmountDialogView: UIView { +final class PaymentSetAmountDialogView: KeyboardAvoidingDialogView { var onConfirm: (() -> Void)? var onCancel: (() -> Void)? @@ -52,6 +52,8 @@ final class PaymentSetAmountDialogView: UIView { autoresizingMask = [.flexibleWidth, .flexibleHeight] alpha = 0 parent.addSubview(self) + layoutIfNeeded() + amountField.becomeFirstResponder() UIView.animate(withDuration: 0.2) { self.alpha = 1 } @@ -67,6 +69,8 @@ final class PaymentSetAmountDialogView: UIView { } private func setupUI() { + keyboardAvoidingContentView = cardView + dimView.backgroundColor = AppColor.overlayScrim dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) @@ -146,7 +150,7 @@ final class PaymentSetAmountDialogView: UIView { } cardView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.centerY.equalToSuperview() + keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint } titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) diff --git a/suixinkan/UI/Statistics/StatisticsPeriodButton.swift b/suixinkan/UI/Statistics/StatisticsPeriodButton.swift index 3efa9ae..21d96c0 100644 --- a/suixinkan/UI/Statistics/StatisticsPeriodButton.swift +++ b/suixinkan/UI/Statistics/StatisticsPeriodButton.swift @@ -17,6 +17,11 @@ final class StatisticsPeriodButton: UIButton { didSet { updateAppearance() } } + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } + override init(frame: CGRect) { super.init(frame: frame) setupUI() @@ -29,7 +34,7 @@ final class StatisticsPeriodButton: UIButton { private func setupUI() { titleLabel?.font = .app(.bodyMedium) - layer.cornerRadius = AppRadius.pill + layer.cornerCurve = .continuous clipsToBounds = true contentEdgeInsets = UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20) updateAppearance() diff --git a/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift b/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift index b6bc327..da7a451 100644 --- a/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift +++ b/suixinkan/UI/Task/CloudStoragePickForTaskViewController.swift @@ -23,8 +23,9 @@ final class CloudStoragePickForTaskViewController: BaseViewController { private let confirmButton = AppButton(title: "确定") private let bottomBar = UIView() - init(importedFileIDs: Set = []) { - viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs) + /// 初始化云盘多选页,可按云盘文件类型限制选择范围。 + init(importedFileIDs: Set = [], allowedFileType: Int? = nil) { + viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: importedFileIDs, allowedFileType: allowedFileType) super.init(nibName: nil, bundle: nil) } @@ -293,6 +294,7 @@ private final class CloudPickCell: UICollectionViewCell { imageView.image = nil folderIconView.isHidden = true badgeView.isHidden = true + contentView.alpha = 1 } func apply(file: CloudFile, isSelected: Bool, isImported: Bool) { @@ -302,6 +304,7 @@ private final class CloudPickCell: UICollectionViewCell { badgeView.isHidden = !isSelected if file.isFolder { + contentView.alpha = 1 imageView.image = nil folderIconView.isHidden = false return diff --git a/suixinkan/UI/Task/TaskAddViewController.swift b/suixinkan/UI/Task/TaskAddViewController.swift index 2c75f8a..a86bdd3 100644 --- a/suixinkan/UI/Task/TaskAddViewController.swift +++ b/suixinkan/UI/Task/TaskAddViewController.swift @@ -16,9 +16,11 @@ final class TaskAddViewController: BaseViewController { private let ossUploadService = NetworkServices.shared.ossUploadService private let scrollView = UIScrollView() + private let formCardView = UIView() private let contentStack = UIStackView() private let mediaSection = TaskAddMediaSectionView() private let prioritySection = TaskPrioritySectionView() + private let orderTitleLabel = UILabel() private let orderButton = TaskOrderSelectButton() private let nameField = TaskBorderTextField(placeholder: "请输入任务标题") private let detailsField = TaskBorderTextView(placeholder: "请输入任务描述(必填)") @@ -26,6 +28,7 @@ final class TaskAddViewController: BaseViewController { private let bottomBar = UIView() private var remarkDialog: TaskFileRemarkDialogView? + private var uploadTypeSheet: TaskUploadTypeSheetView? private var pickingMediaType: Int = 2 private var detailsPlaceholder = "请输入任务描述(必填)" @@ -34,37 +37,56 @@ final class TaskAddViewController: BaseViewController { } override func setupUI() { + view.backgroundColor = AppColor.pageBackground + scrollView.backgroundColor = AppColor.pageBackground + scrollView.keyboardDismissMode = .interactive + + formCardView.backgroundColor = .white + formCardView.layer.cornerRadius = AppRadius.lg + contentStack.axis = .vertical - contentStack.spacing = AppSpacing.sm + contentStack.spacing = AppSpacing.md + + orderTitleLabel.text = "关联订单(可选)" + orderTitleLabel.font = .app(.bodyMedium) + orderTitleLabel.textColor = AppColor.textPrimary bottomBar.backgroundColor = .white view.addSubview(scrollView) view.addSubview(bottomBar) - scrollView.addSubview(contentStack) + scrollView.addSubview(formCardView) + formCardView.addSubview(contentStack) contentStack.addArrangedSubview(mediaSection) contentStack.addArrangedSubview(prioritySection) + contentStack.addArrangedSubview(orderTitleLabel) contentStack.addArrangedSubview(orderButton) contentStack.addArrangedSubview(nameField) contentStack.addArrangedSubview(detailsField) + contentStack.setCustomSpacing(AppSpacing.xxs, after: orderTitleLabel) bottomBar.addSubview(saveButton) } override func setupConstraints() { bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(80) } saveButton.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.centerY.equalToSuperview() } scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalTo(bottomBar.snp.top) } - contentStack.snp.makeConstraints { make in + formCardView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(AppSpacing.md) make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2) } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppSpacing.md) + } } override func bindActions() { @@ -91,6 +113,7 @@ final class TaskAddViewController: BaseViewController { nameField.textField.addTarget(self, action: #selector(nameChanged), for: .editingChanged) detailsField.textView.delegate = self saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) + applyViewModel() } override func viewDidAppear(_ animated: Bool) { @@ -108,11 +131,15 @@ final class TaskAddViewController: BaseViewController { if nameField.textField.text != viewModel.taskName { nameField.textField.text = viewModel.taskName } - if detailsField.textView.textColor != AppColor.textTertiary, - detailsField.textView.text != viewModel.taskDetails { + if viewModel.taskDetails.isEmpty, !detailsField.textView.isFirstResponder { + detailsField.textView.text = detailsPlaceholder + detailsField.textView.textColor = AppColor.textTertiary + } else if detailsField.textView.text != viewModel.taskDetails { detailsField.textView.text = viewModel.taskDetails + detailsField.textView.textColor = AppColor.textPrimary } - saveButton.isEnabled = !viewModel.isUploading + saveButton.isEnabled = true + saveButton.alpha = viewModel.isUploading ? 0.6 : 1 if viewModel.showRemarkDialog, let file = viewModel.remarkTargetFile { showRemarkDialog(file: file) @@ -151,15 +178,30 @@ final class TaskAddViewController: BaseViewController { } private func presentLocalImportSheet() { - let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - sheet.addAction(UIAlertAction(title: "图片", style: .default) { [weak self] _ in + guard uploadTypeSheet == nil else { return } + let sheet = TaskUploadTypeSheetView(frame: view.bounds) + sheet.onChooseImage = { [weak self] in + self?.hideUploadTypeSheet() self?.presentMediaPicker(isImage: true) - }) - sheet.addAction(UIAlertAction(title: "视频", style: .default) { [weak self] _ in + } + sheet.onChooseVideo = { [weak self] in + self?.hideUploadTypeSheet() self?.presentMediaPicker(isImage: false) - }) - sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(sheet, animated: true) + } + sheet.onCancel = { [weak self] in + self?.hideUploadTypeSheet() + } + uploadTypeSheet = sheet + view.addSubview(sheet) + sheet.snp.makeConstraints { make in make.edges.equalToSuperview() } + sheet.show() + } + + private func hideUploadTypeSheet() { + uploadTypeSheet?.dismiss { [weak self] in + self?.uploadTypeSheet?.removeFromSuperview() + self?.uploadTypeSheet = nil + } } private func presentMediaPicker(isImage: Bool) { @@ -257,7 +299,6 @@ final class TaskAddViewController: BaseViewController { 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, diff --git a/suixinkan/UI/Task/Views/TaskAddViews.swift b/suixinkan/UI/Task/Views/TaskAddViews.swift index b440943..a31dad9 100644 --- a/suixinkan/UI/Task/Views/TaskAddViews.swift +++ b/suixinkan/UI/Task/Views/TaskAddViews.swift @@ -17,16 +17,17 @@ final class TaskAddMediaSectionView: UIView { private var files: [TaskFileItem] = [] private var collectionHeightConstraint: Constraint? + private let gridColumns = 3 + private let gridSpacing: CGFloat = 8 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 layout = UICollectionViewCompositionalLayout { [weak self] _, environment in + let columns = self?.gridColumns ?? 3 + let spacing = self?.gridSpacing ?? 8 + let availableWidth = environment.container.effectiveContentSize.width let totalSpacing = spacing * CGFloat(columns - 1) let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns)) let itemHeight = itemWidth / 1.77 @@ -83,8 +84,7 @@ final class TaskAddMediaSectionView: UIView { override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = .white - layer.cornerRadius = AppRadius.md + backgroundColor = .clear titleLabel.text = "添加图片" titleLabel.font = .app(.bodyMedium) @@ -93,11 +93,13 @@ final class TaskAddMediaSectionView: UIView { tipsLabel.text = "(点击图片或视频可添加备注)" tipsLabel.font = .app(.caption) tipsLabel.textColor = AppColor.textPrimary + tipsLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) cloudButton.setTitle("云盘导入", for: .normal) cloudButton.setTitleColor(AppColor.primary, for: .normal) cloudButton.titleLabel?.font = .app(.caption) cloudButton.addTarget(self, action: #selector(cloudTapped), for: .touchUpInside) + cloudButton.setContentCompressionResistancePriority(.required, for: .horizontal) collectionView.register(TaskAddMediaCell.self, forCellWithReuseIdentifier: TaskAddMediaCell.reuseIdentifier) collectionView.register(TaskAddAddMediaCell.self, forCellWithReuseIdentifier: TaskAddAddMediaCell.reuseIdentifier) @@ -109,20 +111,20 @@ final class TaskAddMediaSectionView: UIView { addSubview(collectionView) titleLabel.snp.makeConstraints { make in - make.top.leading.equalToSuperview().offset(AppSpacing.md) + make.top.leading.equalToSuperview() } tipsLabel.snp.makeConstraints { make in make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xxs) make.centerY.equalTo(titleLabel) + make.trailing.lessThanOrEqualTo(cloudButton.snp.leading).offset(-AppSpacing.xs) } cloudButton.snp.makeConstraints { make in - make.trailing.equalToSuperview().inset(AppSpacing.md) + make.trailing.equalToSuperview() 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) + make.leading.trailing.bottom.equalToSuperview() collectionHeightConstraint = make.height.equalTo(88).constraint } } @@ -138,9 +140,22 @@ final class TaskAddMediaSectionView: UIView { 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) + updateCollectionHeight(itemCount: files.count + 1) + } + + override func layoutSubviews() { + super.layoutSubviews() + updateCollectionHeight(itemCount: files.count + 1) + } + + private func updateCollectionHeight(itemCount: Int) { + let availableWidth = collectionView.bounds.width + guard availableWidth > 0 else { return } + let itemWidth = max(0, (availableWidth - gridSpacing * CGFloat(gridColumns - 1)) / CGFloat(gridColumns)) + let itemHeight = itemWidth / 1.77 + let rows = max(1, Int(ceil(Double(itemCount) / Double(gridColumns)))) + let height = itemHeight * CGFloat(rows) + gridSpacing * CGFloat(rows - 1) + collectionHeightConstraint?.update(offset: max(88, height)) } @objc private func cloudTapped() { @@ -161,6 +176,54 @@ extension TaskAddMediaSectionView: UICollectionViewDelegate { } } +/// 圆形上传进度视图,对齐 Android 上传素材 cell 的进度表现。 +final class TaskCircularProgressView: UIView { + + private let trackLayer = CAShapeLayer() + private let progressLayer = CAShapeLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + isHidden = true + layer.addSublayer(trackLayer) + layer.addSublayer(progressLayer) + trackLayer.fillColor = UIColor.clear.cgColor + trackLayer.strokeColor = UIColor.white.withAlphaComponent(0.35).cgColor + trackLayer.lineWidth = 3 + progressLayer.fillColor = UIColor.clear.cgColor + progressLayer.strokeColor = UIColor.white.cgColor + progressLayer.lineCap = .round + progressLayer.lineWidth = 3 + progressLayer.strokeEnd = 0 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + let radius = min(bounds.width, bounds.height) / 2 - progressLayer.lineWidth / 2 + let path = UIBezierPath( + arcCenter: CGPoint(x: bounds.midX, y: bounds.midY), + radius: radius, + startAngle: -.pi / 2, + endAngle: .pi * 1.5, + clockwise: true + ) + trackLayer.frame = bounds + progressLayer.frame = bounds + trackLayer.path = path.cgPath + progressLayer.path = path.cgPath + } + + /// 设置 0...1 的上传进度。 + func setProgress(_ progress: CGFloat) { + progressLayer.strokeEnd = min(1, max(0, progress)) + } +} + /// 素材缩略图 cell。 final class TaskAddMediaCell: UICollectionViewCell { @@ -170,9 +233,11 @@ final class TaskAddMediaCell: UICollectionViewCell { private let imageView = UIImageView() private let overlayView = UIView() + private let progressRingView = TaskCircularProgressView() private let progressLabel = UILabel() private let deleteButton = UIButton(type: .system) private let playIconView = UIImageView() + private let sourceBadgeLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) @@ -183,7 +248,7 @@ final class TaskAddMediaCell: UICollectionViewCell { imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true - overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35) + overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.2) overlayView.isHidden = true progressLabel.font = .app(.captionMedium) @@ -198,22 +263,45 @@ final class TaskAddMediaCell: UICollectionViewCell { playIconView.tintColor = .white playIconView.isHidden = true + sourceBadgeLabel.font = .app(.caption) + sourceBadgeLabel.textColor = .white + sourceBadgeLabel.textAlignment = .center + sourceBadgeLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5) + sourceBadgeLabel.layer.cornerRadius = AppRadius.xs + sourceBadgeLabel.clipsToBounds = true + sourceBadgeLabel.isHidden = true + contentView.addSubview(imageView) contentView.addSubview(overlayView) + contentView.addSubview(sourceBadgeLabel) contentView.addSubview(playIconView) + contentView.addSubview(progressRingView) contentView.addSubview(progressLabel) contentView.addSubview(deleteButton) imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } overlayView.snp.makeConstraints { make in make.edges.equalToSuperview() } + sourceBadgeLabel.snp.makeConstraints { make in + make.leading.bottom.equalToSuperview().inset(AppSpacing.xxs) + make.height.equalTo(18) + make.width.greaterThanOrEqualTo(34) + } playIconView.snp.makeConstraints { make in make.center.equalToSuperview() + make.width.height.equalTo(24) + } + progressRingView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-10) make.width.height.equalTo(28) } - progressLabel.snp.makeConstraints { make in make.center.equalToSuperview() } + progressLabel.snp.makeConstraints { make in + make.top.equalTo(progressRingView.snp.bottom).offset(2) + make.centerX.equalToSuperview() + } deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(AppSpacing.xxs) - make.width.height.equalTo(22) + make.width.height.equalTo(20) } } @@ -227,6 +315,7 @@ final class TaskAddMediaCell: UICollectionViewCell { onDelete = nil imageView.kf.cancelDownloadTask() imageView.image = nil + progressRingView.setProgress(0) } func apply(file: TaskFileItem) { @@ -237,12 +326,18 @@ final class TaskAddMediaCell: UICollectionViewCell { imageView.image = UIImage(systemName: "photo") imageView.tintColor = AppColor.textTertiary } - playIconView.isHidden = file.fileType != 1 + playIconView.isHidden = file.fileType != 1 || file.isUploading + deleteButton.isHidden = file.isUploading + sourceBadgeLabel.isHidden = file.isUploading + sourceBadgeLabel.text = file.sourceBadgeText if file.isUploading { overlayView.isHidden = false + progressRingView.isHidden = false + progressRingView.setProgress(CGFloat(file.uploadProgress) / 100) progressLabel.text = "\(file.uploadProgress)%" } else { overlayView.isHidden = true + progressRingView.isHidden = true progressLabel.text = nil } } @@ -302,11 +397,14 @@ final class TaskPrioritySectionView: UIView { private let twoHourButton = UIButton(type: .system) private let customButton = UIButton(type: .system) private let customField = UITextField() + private let customConfirmButton = UIButton(type: .system) + private let optionRow = UIStackView() + private let customRow = UIStackView() + private let stackView = UIStackView() override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = .white - layer.cornerRadius = AppRadius.md + backgroundColor = .clear titleLabel.text = "优先级" titleLabel.font = .app(.bodyMedium) @@ -324,32 +422,54 @@ final class TaskPrioritySectionView: UIView { customField.font = .app(.body) customField.textColor = AppColor.textPrimary customField.keyboardType = .numberPad - customField.borderStyle = .roundedRect - customField.isHidden = true + customField.backgroundColor = .white + customField.layer.cornerRadius = AppRadius.sm + customField.layer.borderWidth = 1 + customField.layer.borderColor = AppColor.border.cgColor + customField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: AppSpacing.sm, height: 1)) + customField.leftViewMode = .always 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 + customConfirmButton.setTitle("确定", for: .normal) + customConfirmButton.setTitleColor(.white, for: .normal) + customConfirmButton.titleLabel?.font = .app(.bodyMedium) + customConfirmButton.backgroundColor = AppColor.primary + customConfirmButton.layer.cornerRadius = AppRadius.xs + customConfirmButton.addTarget(self, action: #selector(customConfirmTapped), for: .touchUpInside) - addSubview(titleLabel) - addSubview(row) - addSubview(customField) + optionRow.addArrangedSubview(noneButton) + optionRow.addArrangedSubview(twoHourButton) + optionRow.addArrangedSubview(customButton) + optionRow.axis = .horizontal + optionRow.spacing = AppSpacing.xs + optionRow.distribution = .fillEqually - titleLabel.snp.makeConstraints { make in - make.top.leading.equalToSuperview().offset(AppSpacing.md) + customRow.addArrangedSubview(customField) + customRow.addArrangedSubview(customConfirmButton) + customRow.axis = .horizontal + customRow.spacing = AppSpacing.xs + customRow.alignment = .fill + customRow.isHidden = true + + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(optionRow) + stackView.addArrangedSubview(customRow) + stackView.axis = .vertical + stackView.spacing = AppSpacing.xs + + addSubview(stackView) + + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() } - row.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + optionRow.snp.makeConstraints { make in 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) + make.height.equalTo(46) + } + customConfirmButton.snp.makeConstraints { make in + make.width.equalTo(72) } } @@ -363,7 +483,7 @@ final class TaskPrioritySectionView: UIView { styleOption(noneButton, selected: urgentHour == 0) styleOption(twoHourButton, selected: urgentHour == 2) styleOption(customButton, selected: isCustom) - customField.isHidden = !isCustom + customRow.isHidden = !isCustom if isCustom { customField.text = "\(urgentHour)" } else { @@ -392,6 +512,9 @@ final class TaskPrioritySectionView: UIView { @objc private func customFieldChanged() { onCustomHourTextChange?(customField.text ?? "") } + @objc private func customConfirmTapped() { + onCustomHourTextChange?(customField.text ?? "") + } } /// 关联订单选择按钮。 @@ -443,7 +566,7 @@ final class TaskOrderSelectButton: UIControl { } /// 任务素材备注弹窗。 -final class TaskFileRemarkDialogView: UIView { +final class TaskFileRemarkDialogView: KeyboardAvoidingDialogView { var onCancel: (() -> Void)? var onConfirm: (() -> Void)? @@ -451,31 +574,46 @@ final class TaskFileRemarkDialogView: UIView { private let containerView = UIView() private let titleLabel = UILabel() private let previewView = UIImageView() + private let remarkTitleLabel = UILabel() private let textView = UITextView() + private let placeholderLabel = UILabel() private let countLabel = UILabel() private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) + private let buttonRow = UIStackView() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = AppColor.overlayScrim + keyboardAvoidingContentView = containerView containerView.backgroundColor = .white - containerView.layer.cornerRadius = AppRadius.md + containerView.layer.cornerRadius = AppRadius.lg - titleLabel.text = "备注信息" + titleLabel.text = "请填写剪辑备注" titleLabel.font = .app(.title) + titleLabel.textColor = AppColor.textPrimary previewView.contentMode = .scaleAspectFill previewView.clipsToBounds = true - previewView.layer.cornerRadius = AppRadius.sm + previewView.layer.cornerRadius = AppRadius.lg previewView.backgroundColor = AppColor.inputBackground + remarkTitleLabel.text = "备注信息" + remarkTitleLabel.font = .app(.caption) + remarkTitleLabel.textColor = AppColor.textPrimary + textView.font = .app(.body) + textView.textColor = AppColor.textPrimary textView.layer.borderWidth = 1 textView.layer.borderColor = AppColor.border.cgColor textView.layer.cornerRadius = AppRadius.sm textView.delegate = self + textView.textContainerInset = UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.xs, bottom: AppSpacing.sm, right: AppSpacing.xs) + + placeholderLabel.text = "请输入备注(最多50字)" + placeholderLabel.font = .app(.body) + placeholderLabel.textColor = AppColor.textTertiary countLabel.font = .app(.caption) countLabel.textColor = AppColor.textTertiary @@ -493,16 +631,25 @@ final class TaskFileRemarkDialogView: UIView { confirmButton.layer.cornerRadius = AppRadius.xs confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + buttonRow.axis = .horizontal + buttonRow.spacing = AppSpacing.xs + buttonRow.alignment = .center + buttonRow.distribution = .fill + buttonRow.addArrangedSubview(cancelButton) + buttonRow.addArrangedSubview(confirmButton) + addSubview(containerView) containerView.addSubview(titleLabel) containerView.addSubview(previewView) - containerView.addSubview(textView) + containerView.addSubview(remarkTitleLabel) containerView.addSubview(countLabel) - containerView.addSubview(cancelButton) - containerView.addSubview(confirmButton) + containerView.addSubview(textView) + textView.addSubview(placeholderLabel) + containerView.addSubview(buttonRow) containerView.snp.makeConstraints { make in - make.center.equalToSuperview() + make.centerX.equalToSuperview() + keyboardAvoidingCenterYConstraint = make.centerY.equalToSuperview().constraint make.leading.trailing.equalToSuperview().inset(AppSpacing.lg) } titleLabel.snp.makeConstraints { make in @@ -511,26 +658,35 @@ final class TaskFileRemarkDialogView: UIView { 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) + make.height.equalTo(previewView.snp.width) + } + remarkTitleLabel.snp.makeConstraints { make in + make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.md) + make.leading.equalToSuperview().offset(AppSpacing.md) + } + countLabel.snp.makeConstraints { make in + make.centerY.equalTo(remarkTitleLabel) + make.trailing.equalToSuperview().inset(AppSpacing.md) } textView.snp.makeConstraints { make in - make.top.equalTo(previewView.snp.bottom).offset(AppSpacing.sm) + make.top.equalTo(remarkTitleLabel.snp.bottom).offset(AppSpacing.xs) 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) + placeholderLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(AppSpacing.sm) + make.leading.equalToSuperview().offset(AppSpacing.sm + 3) + make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.sm) + } + buttonRow.snp.makeConstraints { make in + make.top.equalTo(textView.snp.bottom).offset(AppSpacing.md) + make.trailing.bottom.equalToSuperview().inset(AppSpacing.md) } 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) } @@ -544,6 +700,7 @@ final class TaskFileRemarkDialogView: UIView { func apply(file: TaskFileItem, text: String) { textView.text = text countLabel.text = "\(text.count)/50" + placeholderLabel.isHidden = !text.isEmpty let urlString = file.previewURLString if let url = URL(string: urlString), !urlString.isEmpty { previewView.kf.setImage(with: url) @@ -565,6 +722,7 @@ extension TaskFileRemarkDialogView: UITextViewDelegate { if textView.text.count > 50 { textView.text = String(textView.text.prefix(50)) } + placeholderLabel.isHidden = !textView.text.isEmpty countLabel.text = "\(textView.text.count)/50" } } @@ -625,3 +783,177 @@ final class TaskBorderTextView: UIView { fatalError("init(coder:) has not been implemented") } } + +/// 任务上传类型底部弹层,对齐 Android `AddFileBottomModalForTask`。 +final class TaskUploadTypeSheetView: UIView { + + var onChooseImage: (() -> Void)? + var onChooseVideo: (() -> Void)? + var onCancel: (() -> Void)? + + private let dimmingView = UIView() + private let panelView = UIView() + private let titleLabel = UILabel() + private let imageItem = TaskUploadTypeItemView( + title: "上传图片", + image: UIImage(named: "icon_cloud_storage_add_photo") ?? UIImage(systemName: "photo") + ) + private let videoItem = TaskUploadTypeItemView( + title: "上传视频", + image: UIImage(named: "icon_cloud_storage_add_video") ?? UIImage(systemName: "video") + ) + private let dividerView = UIView() + private let cancelButton = UIButton(type: .system) + private var panelBottomConstraint: Constraint? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + + dimmingView.backgroundColor = AppColor.overlayScrim + dimmingView.alpha = 0 + let tap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped)) + dimmingView.addGestureRecognizer(tap) + + panelView.backgroundColor = .white + panelView.layer.cornerRadius = AppRadius.sheetTop + panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + panelView.clipsToBounds = true + + titleLabel.text = "选择上传类型" + titleLabel.font = .app(.bodyMedium) + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + + imageItem.addTarget(self, action: #selector(imageTapped), for: .touchUpInside) + videoItem.addTarget(self, action: #selector(videoTapped), for: .touchUpInside) + + dividerView.backgroundColor = AppColor.border + + cancelButton.setTitle("取消", for: .normal) + cancelButton.titleLabel?.font = .app(.bodyMedium) + cancelButton.setTitleColor(UIColor(hex: 0x4B5563), for: .normal) + cancelButton.backgroundColor = AppColor.inputBackground + cancelButton.layer.cornerRadius = AppRadius.lg + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + + addSubview(dimmingView) + addSubview(panelView) + panelView.addSubview(titleLabel) + panelView.addSubview(imageItem) + panelView.addSubview(videoItem) + panelView.addSubview(dividerView) + panelView.addSubview(cancelButton) + + dimmingView.snp.makeConstraints { make in make.edges.equalToSuperview() } + panelView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + panelBottomConstraint = make.bottom.equalToSuperview().offset(260).constraint + } + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + imageItem.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(58) + } + videoItem.snp.makeConstraints { make in + make.top.equalTo(imageItem.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalTo(imageItem) + make.height.equalTo(imageItem) + } + dividerView.snp.makeConstraints { make in + make.top.equalTo(videoItem.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview() + make.height.equalTo(0.5) + } + cancelButton.snp.makeConstraints { make in + make.top.equalTo(dividerView.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(48) + make.bottom.equalTo(safeAreaLayoutGuide).inset(AppSpacing.md) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 展示底部弹层。 + func show() { + layoutIfNeeded() + panelBottomConstraint?.update(offset: 0) + UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) { + self.dimmingView.alpha = 1 + self.layoutIfNeeded() + } + } + + /// 关闭底部弹层。 + func dismiss(completion: (() -> Void)? = nil) { + panelBottomConstraint?.update(offset: panelView.bounds.height) + UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseIn]) { + self.dimmingView.alpha = 0 + self.layoutIfNeeded() + } completion: { _ in + completion?() + } + } + + @objc private func imageTapped() { + onChooseImage?() + } + + @objc private func videoTapped() { + onChooseVideo?() + } + + @objc private func cancelTapped() { + onCancel?() + } +} + +/// 上传类型弹层内的单个选项。 +final class TaskUploadTypeItemView: UIControl { + + private let iconView = UIImageView() + private let titleLabel = UILabel() + + init(title: String, image: UIImage?) { + super.init(frame: .zero) + backgroundColor = .white + layer.cornerRadius = AppRadius.sm + layer.borderWidth = 1 + layer.borderColor = AppColor.inputBackground.cgColor + + iconView.image = image + iconView.contentMode = .scaleAspectFit + iconView.tintColor = AppColor.primary + + titleLabel.text = title + titleLabel.font = .app(.bodyMedium) + titleLabel.textColor = .black + + addSubview(iconView) + addSubview(titleLabel) + + iconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(AppSpacing.md) + make.centerY.equalToSuperview() + make.width.height.equalTo(32) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.md) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index 39da0da..136df3b 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -156,7 +156,7 @@ final class WildPhotographerReportListViewController: BaseViewController { await MainActor.run { self.hideLoading() guard let payload else { return } - WeChatShareService.shareMiniProgram(payload) { [weak self] result in + WeChatShareService.shareMiniProgram(payload, previewImage: WildReportShareCover.image) { [weak self] result in self?.showWildReportShareResult(result) } } @@ -181,6 +181,13 @@ extension WildPhotographerReportListViewController: UIScrollViewDelegate { } } +/// 举报摄影师微信分享封面资源。 +private enum WildReportShareCover { + static var image: UIImage? { + UIImage(named: "report_share_cover") + } +} + /// 我的举报筛选栏,使用四段等宽胶囊按钮展示状态过滤。 private final class WildReportFilterTabBarView: UIView { var onSelect: ((WildReportListFilter) -> Void)? @@ -1228,7 +1235,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController { await MainActor.run { self.hideLoading() guard let payload else { return } - WeChatShareService.shareMiniProgram(payload) { [weak self] result in + WeChatShareService.shareMiniProgram(payload, previewImage: WildReportShareCover.image) { [weak self] result in self?.showWildReportShareResult(result) } } diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift index a5e8a65..170f1d3 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -330,9 +330,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { let title = WildReportUI.label(viewModel.locationTitleText, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textPrimary, lines: 2) title.adjustsFontSizeToFitWidth = true title.minimumScaleFactor = 0.78 - let subtitle = WildReportUI.label(viewModel.locationSubtitleText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0) textStack.addArrangedSubview(title) - textStack.addArrangedSubview(subtitle) let button = UIButton(type: .system) button.setTitle(viewModel.isUpdatingLocation ? "定位中" : "更新定位", for: .normal) diff --git a/suixinkanTests/CloudStoragePickForTaskViewModelTests.swift b/suixinkanTests/CloudStoragePickForTaskViewModelTests.swift index eaf77a2..ca0f369 100644 --- a/suixinkanTests/CloudStoragePickForTaskViewModelTests.swift +++ b/suixinkanTests/CloudStoragePickForTaskViewModelTests.swift @@ -73,6 +73,25 @@ final class CloudStoragePickForTaskViewModelTests: XCTestCase { XCTAssertEqual(Set(viewModel.selectedFileList.map(\.id)), Set([1, 2])) } + func testAllowedFileTypeRejectsMismatchedMedia() async throws { + let listJSON = try makeListJSON(files: [ + makeFileJSON(id: 1, name: "视频", type: 1), + makeFileJSON(id: 2, name: "图片", type: 2), + ]) + let api = makeAPI(responses: [listJSON]) + let viewModel = CloudStoragePickForTaskViewModel(allowedFileType: 2) + var message: String? + viewModel.onShowMessage = { message = $0 } + await viewModel.refresh(api: api) + + await viewModel.handleItemTap(viewModel.files[0], api: api) + await viewModel.handleItemTap(viewModel.files[1], api: api) + + XCTAssertEqual(message, "请选择图片素材") + XCTAssertFalse(viewModel.isSelected(1)) + XCTAssertTrue(viewModel.isSelected(2)) + } + private func makeAPI(responses: [Data]) -> TaskAPI { TaskAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: responses))) } diff --git a/suixinkanTests/MaterialManagementViewModelTests.swift b/suixinkanTests/MaterialManagementViewModelTests.swift index 95031ca..6613225 100644 --- a/suixinkanTests/MaterialManagementViewModelTests.swift +++ b/suixinkanTests/MaterialManagementViewModelTests.swift @@ -118,6 +118,45 @@ final class MaterialManagementViewModelTests: XCTestCase { XCTAssertNil(api.lastUploadRequest?.id) } + func testCloudMediaImportBuildsMaterialRequestWithoutUploadingAgain() async { + let api = MockMaterialAPI() + api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1) + let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) + var didSucceed = false + viewModel.onSubmitSuccess = { didSucceed = true } + + await viewModel.loadInitial(api: api) + viewModel.updateMaterialName("云盘素材") + viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点")) + viewModel.addCloudMediaFiles([ + CloudFile(id: 31, fileUrl: "https://cdn/cloud.jpg", coverUrl: "https://cdn/cover-thumb.jpg", name: "cloud.jpg", type: 2, fileSize: 88), + ]) + await viewModel.setCoverImage(makeUploadItem(fileName: "cover.jpg"), uploader: MockMaterialUploader(urls: ["https://cdn/cover.jpg"])) + await viewModel.submit(api: api) + + XCTAssertTrue(didSucceed) + XCTAssertEqual(viewModel.importedCloudFileIDs, Set([31])) + XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.originalName, "cloud.jpg") + XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.ossUrl, "https://cdn/cloud.jpg") + XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.size, 88) + } + + func testCloudMediaImportFiltersByCurrentMaterialTypeAndDeduplicates() { + let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + viewModel.addCloudMediaFiles([ + CloudFile(id: 1, fileUrl: "https://cdn/video.mp4", name: "video.mp4", type: 1), + CloudFile(id: 2, fileUrl: "https://cdn/image.jpg", name: "image.jpg", type: 2), + CloudFile(id: 2, fileUrl: "https://cdn/image.jpg", name: "image.jpg", type: 2), + ]) + + XCTAssertEqual(viewModel.uploadedMediaList.map(\.ossUrl), ["https://cdn/image.jpg"]) + XCTAssertEqual(viewModel.importedCloudFileIDs, Set([2])) + XCTAssertTrue(messages.isEmpty) + } + func testUploadDialogStaysHiddenAfterCompletionProgressCallback() async { let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) let uploader = MockMaterialUploader(urls: ["https://cdn/a.jpg"]) diff --git a/suixinkanTests/TaskAddViewModelTests.swift b/suixinkanTests/TaskAddViewModelTests.swift index b1d6479..b747cb1 100644 --- a/suixinkanTests/TaskAddViewModelTests.swift +++ b/suixinkanTests/TaskAddViewModelTests.swift @@ -30,6 +30,32 @@ final class TaskAddViewModelTests: XCTestCase { XCTAssertEqual(message, "请填写任务名称!") } + func testTextInputsAreTrimmedToAndroidLimits() { + let viewModel = TaskAddViewModel(appStore: appStore) + + viewModel.updateTaskName(String(repeating: "任", count: 25)) + viewModel.updateTaskDetails(String(repeating: "描", count: 210)) + viewModel.addCloudFiles([CloudFile(id: 1, name: "A", type: 2)]) + viewModel.presentRemarkDialog(for: viewModel.selectedFiles[0].id) + viewModel.updateRemarkDialogText(String(repeating: "注", count: 60)) + + XCTAssertEqual(viewModel.taskName.count, 20) + XCTAssertEqual(viewModel.taskDetails.count, 200) + XCTAssertEqual(viewModel.remarkDialogText.count, 50) + } + + func testUploadingStateBlocksSubmitButKeepsButtonActionUseful() async { + let viewModel = TaskAddViewModel(appStore: appStore) + viewModel.updateTaskName("任务") + viewModel.addUploadPlaceholder(fileName: "a.jpg", fileType: 2, uploadTaskId: "task1") + 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") @@ -50,6 +76,14 @@ final class TaskAddViewModelTests: XCTestCase { XCTAssertEqual(request.uploadFile[0].fileUrl, "https://cdn/a.jpg") } + func testTaskFileSourceBadgeTextMatchesAndroidLabels() { + let cloudItem = TaskFileItem(source: .cloud, cloudFile: CloudFile(id: 1, name: "云文件", type: 2)) + let uploadItem = TaskFileItem(source: .upload, uploadFileName: "a.jpg", uploadFileType: 2) + + XCTAssertEqual(cloudItem.sourceBadgeText, "云盘") + XCTAssertEqual(uploadItem.sourceBadgeText, "本地") + } + func testAddCloudFilesDedupesAndAutoRemarkSingleFile() { let viewModel = TaskAddViewModel(appStore: appStore) viewModel.addCloudFiles([CloudFile(id: 1, name: "A", type: 2)]) @@ -74,8 +108,12 @@ final class TaskAddViewModelTests: XCTestCase { viewModel.resetAllState() XCTAssertEqual(viewModel.taskName, "") + XCTAssertEqual(viewModel.taskDetails, "") XCTAssertNil(viewModel.selectedOrderNumber) + XCTAssertEqual(viewModel.urgentHour, 0) XCTAssertTrue(viewModel.selectedFiles.isEmpty) + XCTAssertFalse(viewModel.isUploading) + XCTAssertFalse(viewModel.showSuccessDialog) } private func makeClient() -> APIClient { diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index 7082d3a..2037a8f 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -701,6 +701,20 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription) } + func testSubmitLocationOmitsProvinceCityDistrict() { + let homeViewModel = WildPhotographerReportHomeViewModel() + let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) + + viewModel.applyResolvedLocation(HomeLocationSnapshot( + latitude: 32.40, + longitude: 119.41, + address: "江苏省扬州市邗江区瘦西湖路" + )) + + XCTAssertEqual(viewModel.locationTitleText, "当前定位:瘦西湖路") + XCTAssertEqual(viewModel.reportLocation, "瘦西湖路") + } + func testSubmitFailsWithoutEvidence() async { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) @@ -766,7 +780,7 @@ final class WildPhotographerReportTests: XCTestCase { viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")]) viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")]) viewModel.updateDescription("测试说明") - viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: "测试景区 · 测试点")) + viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: "江苏省扬州市邗江区瘦西湖路")) await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "测试景区") @@ -779,10 +793,13 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(api.submitRequests.first?.reportType, 2) XCTAssertEqual(api.submitRequests.first?.lat, 30.12) XCTAssertEqual(api.submitRequests.first?.lng, 120.34) + XCTAssertEqual(api.submitRequests.first?.locationName, "测试景区") + XCTAssertEqual(api.submitRequests.first?.locationAddress, "瘦西湖路") XCTAssertEqual(api.submitRequests.first?.evidences.count, 1) XCTAssertEqual(api.submitRequests.first?.evidences.first?.fileType, 1) XCTAssertEqual(api.submitRequests.first?.paymentScreenshots.count, 1) XCTAssertNil(api.submitRequests.first?.storeUserId) + XCTAssertEqual(viewModel.submitResult?.record.location, "瘦西湖路") } func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws {