From f88a85a807a6823abb293abac122f238d5fb03d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 10 Jul 2026 14:51:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E4=B8=8E=E5=BC=B9=E7=AA=97=E4=BA=A4=E4=BA=92=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../Contents.json | 8 + .../WildPhotographerReport/Contents.json | 6 + .../report_share_cover.imageset/Contents.json | 12 + .../report_share_cover.jpg | Bin 0 -> 67712 bytes .../Models/MaterialManagementModels.swift | 8 + .../MaterialManagementViewModels.swift | 52 ++ suixinkan/Features/Task/API/TaskAPI.swift | 14 - .../Features/Task/Models/TaskModels.swift | 10 + .../CloudStoragePickForTaskViewModel.swift | 10 +- .../WildPhotographerReportViewModels.swift | 49 +- .../Common/KeyboardAvoidingDialogView.swift | 105 +++++ .../Views/CooperationOrderViews.swift | 75 +-- .../MaterialFormViewController.swift | 16 + .../UI/Orders/OrderDialogControllers.swift | 102 ++-- .../Orders/Views/OrderSourceEntryView.swift | 10 + .../Views/OrderSourceLeadContentView.swift | 9 + .../Views/PaymentSetAmountDialogView.swift | 8 +- .../Statistics/StatisticsPeriodButton.swift | 7 +- ...loudStoragePickForTaskViewController.swift | 7 +- suixinkan/UI/Task/TaskAddViewController.swift | 71 ++- suixinkan/UI/Task/Views/TaskAddViews.swift | 444 +++++++++++++++--- ...PhotographerReportListViewController.swift | 11 +- ...otographerReportSubmitViewController.swift | 2 - ...loudStoragePickForTaskViewModelTests.swift | 19 + .../MaterialManagementViewModelTests.swift | 39 ++ suixinkanTests/TaskAddViewModelTests.swift | 38 ++ .../WildPhotographerReportTests.swift | 19 +- 48 files changed, 1065 insertions(+), 254 deletions(-) create mode 100644 suixinkan/Assets.xcassets/WildPhotographerReport/Contents.json create mode 100644 suixinkan/Assets.xcassets/WildPhotographerReport/report_share_cover.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/WildPhotographerReport/report_share_cover.imageset/report_share_cover.jpg create mode 100644 suixinkan/UI/Common/KeyboardAvoidingDialogView.swift 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 0000000000000000000000000000000000000000..c8380c03ebd2f5b44f5264111720841dc097b1b0 GIT binary patch literal 67712 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 literal 0 HcmV?d00001 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 {