From f20ec7f06ce691ebe3bafd63376e503815c0b0ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Thu, 9 Jul 2026 22:37:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BA=91=E7=9B=98?= =?UTF-8?q?=E4=B8=8E=E6=B6=88=E6=81=AF=E4=B8=AD=E5=BF=83=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suixinkan.xcodeproj/project.pbxproj | 8 +- suixinkan/App/NetworkServices.swift | 6 + .../CloudDriveIcons/Contents.json | 6 + .../Contents.json | 13 + .../icon_cloud_storage_add_folder.png | Bin 0 -> 3660 bytes .../Contents.json | 13 + .../icon_cloud_storage_add_photo.png | Bin 0 -> 3958 bytes .../Contents.json | 13 + .../icon_cloud_storage_add_video.png | Bin 0 -> 3497 bytes .../Contents.json | 13 + .../icon_cloud_storage_current_path_arrow.png | Bin 0 -> 1526 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_delete.png | Bin 0 -> 1778 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_details.png | Bin 0 -> 2383 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_download.png | Bin 0 -> 2296 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_filter.png | Bin 0 -> 1236 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_item_details.png | Bin 0 -> 727 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_move.png | Bin 0 -> 1775 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_play.png | Bin 0 -> 1728 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_search.png | Bin 0 -> 2266 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_share.png | Bin 0 -> 2152 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_type_folder.png | Bin 0 -> 3525 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_type_photo.png | Bin 0 -> 3422 bytes .../Contents.json | 13 + .../icon_cloud_storage_file_type_video.png | Bin 0 -> 3163 bytes .../Contents.json | 13 + .../icon_cloud_storage_folder_type.png | Bin 0 -> 3189 bytes .../Contents.json | 13 + .../icon_cloud_storage_go_home.png | Bin 0 -> 1575 bytes .../Contents.json | 13 + .../icon_cloud_storage_list_sort.png | Bin 0 -> 1458 bytes .../Contents.json | 13 + .../icon_cloud_storage_show_type_list.png | Bin 0 -> 1352 bytes .../Contents.json | 13 + .../icon_cloud_storage_show_type_table.png | Bin 0 -> 1044 bytes .../Contents.json | 13 + .../icon_cloud_storage_transit.png | Bin 0 -> 2446 bytes .../MessageCenter/Contents.json | 6 + .../ic_msg1.imageset/Contents.json | 12 + .../ic_msg1.imageset/ic_msg1.png | Bin 0 -> 3960 bytes .../ic_msg2.imageset/Contents.json | 12 + .../ic_msg2.imageset/ic_msg2.png | Bin 0 -> 3233 bytes .../ic_msg3.imageset/Contents.json | 12 + .../ic_msg3.imageset/ic_msg3.png | Bin 0 -> 3784 bytes .../ic_msg4.imageset/Contents.json | 12 + .../ic_msg4.imageset/ic_msg4.png | Bin 0 -> 4048 bytes .../ic_msg5.imageset/Contents.json | 12 + .../ic_msg5.imageset/ic_msg5.png | Bin 0 -> 3838 bytes .../ic_msg8.imageset/Contents.json | 12 + .../ic_msg8.imageset/ic_msg8.png | Bin 0 -> 3703 bytes .../ic_msg999.imageset/Contents.json | 12 + .../ic_msg999.imageset/ic_msg999.png | Bin 0 -> 3774 bytes suixinkan/Core/Upload/OSSUploadService.swift | 30 +- .../CloudDrive/API/CloudDriveAPI.swift | 144 ++++ .../CloudDrive/Models/CloudDriveModels.swift | 165 +++++ .../Services/CloudTransferJSONStore.swift | 44 ++ .../Services/CloudTransferManager.swift | 300 ++++++++ .../Services/CloudTransferModels.swift | 97 +++ .../Services/CloudTransferWorkers.swift | 113 +++ .../ViewModels/CloudDriveListViewModel.swift | 353 +++++++++ .../Home/Services/HomeRouteHandler.swift | 4 + .../MessageCenter/API/MessageCenterAPI.swift | 67 ++ .../Models/MessageCenterModels.swift | 301 ++++++++ .../ViewModels/MessageCenterViewModels.swift | 158 ++++ .../Features/Task/Models/TaskModels.swift | 80 -- .../CloudDriveActionSheetViewController.swift | 137 ++++ .../CloudDriveDetailSheetViewController.swift | 149 ++++ .../UI/CloudDrive/CloudDriveFileCell.swift | 189 +++++ .../UI/CloudDrive/CloudDriveFormatter.swift | 62 ++ .../CloudDriveListViewController.swift | 529 ++++++++++++++ .../CloudDrivePreviewViewController.swift | 75 ++ .../CloudDriveTransferViewController.swift | 234 ++++++ .../MessageCenterViewController.swift | 309 ++++++++ .../MessageDetailViewController.swift | 169 +++++ .../ProfileSpaceSettingsViewController.swift | 90 ++- suixinkanTests/CloudDriveAPITests.swift | 71 ++ suixinkanTests/CloudDriveViewModelTests.swift | 139 ++++ .../CloudTransferManagerTests.swift | 185 +++++ suixinkanTests/MessageCenterAPITests.swift | 83 +++ .../MessageCenterViewModelTests.swift | 171 +++++ 应用启动流程与接口数据存储梳理.md | 682 ++++++++++++++++++ 91 files changed, 5437 insertions(+), 89 deletions(-) create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/icon_cloud_storage_add_folder.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/icon_cloud_storage_add_photo.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/icon_cloud_storage_add_video.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/icon_cloud_storage_current_path_arrow.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/icon_cloud_storage_file_delete.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/icon_cloud_storage_file_details.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/icon_cloud_storage_file_download.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/icon_cloud_storage_file_filter.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/icon_cloud_storage_file_item_details.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/icon_cloud_storage_file_move.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/icon_cloud_storage_file_play.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/icon_cloud_storage_file_search.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/icon_cloud_storage_file_share.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/icon_cloud_storage_file_type_folder.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/icon_cloud_storage_file_type_photo.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/icon_cloud_storage_file_type_video.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/icon_cloud_storage_folder_type.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/icon_cloud_storage_go_home.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/icon_cloud_storage_list_sort.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/icon_cloud_storage_show_type_list.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/icon_cloud_storage_show_type_table.png create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/icon_cloud_storage_transit.png create mode 100644 suixinkan/Assets.xcassets/MessageCenter/Contents.json create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/ic_msg1.png create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/ic_msg2.png create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/ic_msg3.png create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/ic_msg4.png create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/ic_msg5.png create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/ic_msg8.png create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/ic_msg999.png create mode 100644 suixinkan/Features/CloudDrive/API/CloudDriveAPI.swift create mode 100644 suixinkan/Features/CloudDrive/Models/CloudDriveModels.swift create mode 100644 suixinkan/Features/CloudDrive/Services/CloudTransferJSONStore.swift create mode 100644 suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift create mode 100644 suixinkan/Features/CloudDrive/Services/CloudTransferModels.swift create mode 100644 suixinkan/Features/CloudDrive/Services/CloudTransferWorkers.swift create mode 100644 suixinkan/Features/CloudDrive/ViewModels/CloudDriveListViewModel.swift create mode 100644 suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift create mode 100644 suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift create mode 100644 suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift create mode 100644 suixinkan/UI/CloudDrive/CloudDriveActionSheetViewController.swift create mode 100644 suixinkan/UI/CloudDrive/CloudDriveDetailSheetViewController.swift create mode 100644 suixinkan/UI/CloudDrive/CloudDriveFileCell.swift create mode 100644 suixinkan/UI/CloudDrive/CloudDriveFormatter.swift create mode 100644 suixinkan/UI/CloudDrive/CloudDriveListViewController.swift create mode 100644 suixinkan/UI/CloudDrive/CloudDrivePreviewViewController.swift create mode 100644 suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift create mode 100644 suixinkan/UI/MessageCenter/MessageCenterViewController.swift create mode 100644 suixinkan/UI/MessageCenter/MessageDetailViewController.swift create mode 100644 suixinkanTests/CloudDriveAPITests.swift create mode 100644 suixinkanTests/CloudDriveViewModelTests.swift create mode 100644 suixinkanTests/CloudTransferManagerTests.swift create mode 100644 suixinkanTests/MessageCenterAPITests.swift create mode 100644 suixinkanTests/MessageCenterViewModelTests.swift create mode 100644 应用启动流程与接口数据存储梳理.md diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index ebf15b7..0f14a8b 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -392,8 +392,8 @@ INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版"; INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡"; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码和排队打卡点小程序码到相册"; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证和银行卡照片"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码、排队打卡点小程序码和云盘图片视频到相册"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证、银行卡照片和云盘上传素材"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; @@ -456,8 +456,8 @@ INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版"; INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡"; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码和排队打卡点小程序码到相册"; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证和银行卡照片"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码、排队打卡点小程序码和云盘图片视频到相册"; + INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证、银行卡照片和云盘上传素材"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 5e10ff0..c4b223f 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -27,6 +27,8 @@ final class NetworkServices { let materialManagementAPI: MaterialManagementAPI let punchPointAPI: PunchPointAPI let liveAPI: LiveAPI + let cloudDriveAPI: CloudDriveAPI + let messageCenterAPI: MessageCenterAPI let wildPhotographerReportAPI: WildPhotographerReportAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -50,6 +52,8 @@ final class NetworkServices { materialManagementAPI = MaterialManagementAPI(client: client) punchPointAPI = PunchPointAPI(client: client) liveAPI = LiveAPI(client: client) + cloudDriveAPI = CloudDriveAPI(client: client) + messageCenterAPI = MessageCenterAPI(client: client) wildPhotographerReportAPI = WildPhotographerReportAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) @@ -78,6 +82,8 @@ final class NetworkServices { materialManagementAPI = MaterialManagementAPI(client: apiClient) punchPointAPI = PunchPointAPI(client: apiClient) liveAPI = LiveAPI(client: apiClient) + cloudDriveAPI = CloudDriveAPI(client: apiClient) + messageCenterAPI = MessageCenterAPI(client: apiClient) wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..de9adeb --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_add_folder.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/icon_cloud_storage_add_folder.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/icon_cloud_storage_add_folder.png new file mode 100644 index 0000000000000000000000000000000000000000..e55b6f3956f9950bf024c9e19d6da25524b65c71 GIT binary patch literal 3660 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 QjsO4v07*qoM6N<$f|0vL(EtDd literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..8f4d468 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_add_video.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/icon_cloud_storage_add_video.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/icon_cloud_storage_add_video.png new file mode 100644 index 0000000000000000000000000000000000000000..705f827fd2b3b805a7994ebf8b3fcd2d52bb466e GIT binary patch literal 3497 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 Xv0QI6ni(@S00000NkvXXu0mjfT@-7C literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..70404d5 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_current_path_arrow.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/icon_cloud_storage_current_path_arrow.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/icon_cloud_storage_current_path_arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..7beffde8bf23853a76d06c079ddd63aeb0e3ad03 GIT binary patch literal 1526 zcmV4Tx02q~(kg-a`P!xv0)!Au3p%k~ED%+Fp`~ zy6fiP>LxC}faqf=2oB;CIEkRM=O&b7aqz%@&&SW<=iCE@cU!5TREPc`4y|_6*z4>Y zHojr(D)XFcc)WnQx6G@_AE(dZ z;Yrz{omeGNvDbsIJDBRg>@!Rpj1Kz?4e3hd%)>R60000uWmrjOO-%qQ0000800D<- z00aO40096102%-Q00002paK8{000010000mpaTE|000010000m00000`2S8e000C@ zNkl5rQ9r_~VC&M?8W^ z1o22|5fVZ1gY{@46_t2|WLuS3MeC7BDhN%uO}ni-&O~o=O|I45nY$9%Np9{v-^`r( zzB6a`&YcBFl~e>$5lBVgUq*n43|=;W`6y6e4-hg3fIgu>wzYj~uBol9?Go`DPhTaT z#(Z0zFONHIpE+@0o|$G3CR77{=R%;Lyp0)b;?3sx-VaK}tl#B;VkFa`Vj zI3Imc#PpoJf|dc3iy|%Iu))wkG&DJ?;B5XY)N;!_n@bf_<*%@CV`icr>)s}fxAFq9AYE!vWn2q2vCjo%E~CYFJmU83t&QCJ*)ea zofmoxlukqb{F>;6O^Pv{hR8@KM+5+DM-lZO$M|z^Qcx!%;bC}L-#Hp?LGpdd6ah>` zbK_GzMmnJglCOfHzlzk~W9@rdtU1}!J_)z0zH;WZ(b=^Y9fWP1^dX?Ev%zCRNfi*2 z306`<gt_qG{*QB_q$Ty-s}T zRccXE74MMN*L=Kvd)l6KN1l z=7<1B9th=sZtViyT`7}16Cp(aJ5~WO&SJW!n~|~H z*|EyjkL+0G(qZYxDnbHEOG~wWY2GqSn-fT$|8!&;kUZIZ{o=_=iy*0wLIU~@_7&p+ z??Gf_NB#qgyp!ZvB!mPg8r_0h$V}#lE3HJK8(KWtc@CFu3JE~+>?DpaUvO`;=6cns z3w(tWHvfi;d;L8~^|S07*qoM6N<$g4s;1wg3PC literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..018d074 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_delete.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/icon_cloud_storage_file_delete.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/icon_cloud_storage_file_delete.png new file mode 100644 index 0000000000000000000000000000000000000000..f360a159e62883c8192e1ef65c469c1444b21763 GIT binary patch literal 1778 zcma)+X*|>m7svlIV;Pe{?uaa7Npf-RTbD3~tW(TIWScOuGcIPz$VA2(5*kYxt~SDF zD3!q_`xYVDv+t&ntw!?b&GWo{UVP8_o^#%v7r$h?OBQgLI1B&)xRs?D_PAUA0?Kn- z9XD$%001QKk4D>Bq0usS;i10%L3jWV%gM=PShv_AsQ-?bRw%|rg@ttvG@~9TJqw&D zh6>0kIX}$OzH2(}k(E@pzBIF@pxQ`T@m7Qi%>AaHOs`p1$o3(yn_~Mr`e&CXAqhm+ z=X8g}pE3d!QA4Ba8xo6x-43+k&brNuW!LguE$`l}gt52FJV`EtE z$1>*nglaav_OXb_AeWlFwYr=_4)PQX9&Mr;u0e%dv{nBA5q(1|lO<8i1cj4%)8swY z_%7hqsR~&$D9f`*+kg@tLChb_Kipl2L|^1lRb5xqpF^R<(Y&ePX&YVUcEgvLF9){{ zm--6!earmI6u~JJxdB6P$!Yigr&t4X72Ov$Z$%Cz(gbtQw7w`_C;rwpC#9i-*Wkb_ z(h&ofqxV{)XiEjANkiD7M`p`YNz`qW>At68-}(F~*1j7nRW$Uos$TXx@%9~^HF1yK zG=Ble`vdiQ2&iMLuUS^Q^{6i1v^zDvp@C^7=9r8SGL7NA9n2>#ITNB;Dlc&_#}DK` z9tM%K(QCTcr!Zo><)(xmXNc)XLq-c^L^96@{0J6n3Vx#T68_AEf||rnNmOXFG1v>?bKZP z&#tb(O=Rj<&y#jsBWj?i%stoz1I=*j70&Nfbxt`}9dCyVy4=b>8HX^shmfo?-mn|w zk(z4rgdQVab{`RmXd0;tDgiWNowzMQUQqRqO7RF8D)3i@=CcIAy(OS2kcLb5x>v^H zlNcvc4cKR#TMbfZa!iPzw!IIXSsu)_>u*$KTyr>5(LFx6@UF8HzCpW{{Jj240P-}L z2G)hApVP{#Ql@@^!!dh1JLEfEYZIA~4hz@F7KD@!Uc)&``bTXarXGR{`9%>CjrVI0 zV_qT0-u>KmY$_BD~r+e=IdbixRpgBYUPg{EW5W5+UDngZ8d>l3xc- za!?s)-aFAUf$OcU#v#0IqhjyXn^}xA@krJ_35UYPA~zZC(jk0Oc5hgTq3$3rF=Z2& z5SsGb>2xWe!9@$mLH+;Z+7rCcNIoN9oNQtG1j`ep(|PgPS+WVXeOXYFbkh;B8gWi$ zw=#>#HE;tqVOU;E{klaG4jg=_x=WR&3POe0wKGQNb!$tic++d7K&*MWxc4^6W?tO5 zYW#zq1BS9NrvR`IlFaXnt4Y-h4X|0=fx9SLGXL>@;61UE#eWoDn9VGXmL;-hI`Z)t zZ;5|+YdsVS{6fPi(yxtRCJXc1(3R?7m+e7kR=Ac=qgp13D-sdkCp4})H$kb8k1$O{ z-!kJ);%E}AxddQ#JUDNe9s4}P3MJ+C1f}!)_oof1{lR~AUitc#B@7ipDs+}S#ZEQo zRDqgZUNK!$4Q>Nt`MGVYgO?~saC^}!fUwR5mJd3xNaNn0YQMVY++FA^MvVI=3e6n* z94jb@j*%#BdGhk)oWwR~7pmn)o=jimB+^Ms+B7(tgo6(Yn)n=d%&P~5O|=&9KirwCF}yM;Jdu_w@v`NX;fOiA>bWzV7} z)fy;B-mS5u%MMzRsY|}vhje8XcQbYdIP6)99383%-o(tD#3d0L>@=oHzk=1u{E}Iv HDK73mhp{?K literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..7b088d9 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_details.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/icon_cloud_storage_file_details.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/icon_cloud_storage_file_details.png new file mode 100644 index 0000000000000000000000000000000000000000..a332db04d1818304e97a66c93be10a048047d885 GIT binary patch literal 2383 zcmV-V39$BwP)4Tx02q~(kg-a`P!xv0)!Au3p%k~ED%+Fp`~ zy6fiP>LxC}faqf=2oB;CIEkRM=O&b7aqz%@&&SW<=iCE@cU!5TREPc`4y|_6*z4>Y zHojr(D)XFcc)WnQx6G@_AE(dZ z;Yrz{omeGNvDbsIJDBRg>@!Rpj1Kz?4e3hd%)>R60000uWmrjOO-%qQ0000800D<- z00aO40096102%-Q00002paK8{000010000;paTE|000010000;00000v-xDz000M~ zNklVx#91nE;neej{+&r6Xg z(bnpNsL%(WgyMrp6+wJ3G<`D^($K0^L5f;}RZttTY1(A7$&TMQvvKd{2-Ar=56!BqjIKo)S%cmZJ!VHV*W!daxA0EDNr0<2*G zsTX7eM86IH_9GBzADAa7n-H=mC70327}`AsI47W6e+2JpAnR_sZh+JXy2_$Fihp_! zxEVy)j6kjMPe${m(C~4D6VfhY!HE1*4@UI;L0|N-%^)Qk_*%{P%1{(EZ z@Rof2f9tOqAjR=w1Yi_oKeXm0#5k_3UgBUcauB242jF`e0z-uKR69V!vIR}NhbDF* z?z6WY%LZbz3>#-=`7Xg#09?WM#z;G$bGnl4-2l7ss#@AZ_e2ri-|8Yc0LMB(o)#6CfBt`+(~8 zR_?44uKa1&>`Jq9H7aG3mf2C88z5sQb!5*dsYpv|gciM+oXd^I$m|A4WNW#khr2v2 zrV*N>o!%?aL~M}-P^mBWS~;^!n5XeCLcTQ!n+mRR$N~ta_glbfSA^YcqnCfT>(5`Z z)3QzJ)Z0>#Hf(^}lH%ON(}|tSJBz zesktM)BFR+l0T8sxFHvS|Xt4p}b})B5eG;en@JvPx3U@gl zyijJ!9wA#(xL3Z?mcs%8qK(}nnJsY2UO~17(UE|rB%ibd4Vn*afEKX*t~>)|WVRH> z28bJhbR_Ue)Rx8ha*Mq8twEfa%mh{D19vtM#s$T%22oq00QuLT3SgEawgxRf@p+}T zuv;w?@BM4ga_bdW8zAGnG?~~{mLfIqa0*IbnR>nxV#zr}%7p|7; zT)^Vh_cTwLjiW=E58T-R(d`ZYUq!C0BYY_gk2##x{A&>BF-vY6Ao>K-?ae1~ya{)) zJXZHE8&Q@#8N~=qrKT|(AUaGMpTI2HiPVkp#&?&BaDZqYa$BZNdzv>6l77b^cB%2V zMZW#Wuuby^)BF-;woC{%KuWvZ(yP=j&HN_9uyRzu&|+Ec>@F6_G ztNlVKu5WFCE(y(hSS?<2G)o8dsMHofC5P{TvV&

qG}go9Wc}e!sAdy4A=n125|P)0uIHlNrPBGcQ+nJGiS(NK@FWuU)7{= zs8?YeeN!$%U~6k9iT9cT3RkAjlJU5dIT1w9wADm39ghQ!;c92Xl9;XwAXcn+rpz3h z+JPwFynCQkb=RB3biy-sC9WU&6^2jwq8p6ZI~57#tw#+L6cJ$K{WkY(>6a1o{BdgSH)k3uSti@-PWUhVsWv5Cw5# zjK+xY$4$5$#T^%JD$s5|sjZ*Q@Np9`n@Ack`e4(|*f=V6LHJuy=sH~4Da)V7VNGU_ ziVg+g&*N~{cUrLX5@+B_WTN%3g+L2|76MI%z`vys59M1k?)m@#002ovPDHLkV1gdI BSakpZ literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..e48c961 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_download.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/icon_cloud_storage_file_download.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/icon_cloud_storage_file_download.png new file mode 100644 index 0000000000000000000000000000000000000000..8ec83a101e92e91390fcd5c27fc72e18093aa8b7 GIT binary patch literal 2296 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 Rvk3qI002ovPDHLkV1mYrI~4!` literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..ddd996f --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_play.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/icon_cloud_storage_file_play.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/icon_cloud_storage_file_play.png new file mode 100644 index 0000000000000000000000000000000000000000..b68df508ce9ab01bf1e3ee3dcd54934bc78321f4 GIT binary patch literal 1728 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 literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..8e3efb6 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_share.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/icon_cloud_storage_file_share.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/icon_cloud_storage_file_share.png new file mode 100644 index 0000000000000000000000000000000000000000..f38a604897cdb0b96ea9f1132b5558edc998d6de GIT binary patch literal 2152 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 Z;D2Z?K^bZmpWXlf002ovPDHLkV1h3#-Q55H literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..38d839e --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_list_sort.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/icon_cloud_storage_list_sort.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/icon_cloud_storage_list_sort.png new file mode 100644 index 0000000000000000000000000000000000000000..a73b03389b1771fafd21d7be66163af28c8c0ad9 GIT binary patch literal 1458 zcmV;j1x@;iP)4Tx02q~(kg-a`P!xv0)8s9K+m3huJJYK-UTjtf|kF)3S z@TBa>POOrs*zd#F9n5rL{u!nY#)tg{528xuI<`FP0000uWmrjOO-%qQ0000800D<- z00aO40096102%-Q00002paK8{000010000mpaTE|000010000m00000`2S8e000C9 zNkld8P)cnAwd@P(t{7Z z6j2e;549ExPolW_QPG>Mt1F2$79G)5!vZKuNEFsgazr8dc?F*DU|YYLT=)VtH? zz4J2-E*P=l3RI7L$_#XL)Y~=_wxtk}&zSqB95_3L z(vg+fmO>uy+Y$|wGe}@g5)yMrhjf$ZNOf6GAyrjfqcE#T zbp?aLn21dx9ZM-hc%0IpbOH`#p_V`w04{UM$HWY9wn6n5 zGlgBTN}jk924N8<;7^z&e~?}Sn=9XDYFueTXFhT->(E%A&qb^iOPAel>wWfg$XLf% zDm8$LifV6^Y1h%&O8dwYv9UmSIfr~-&dNJIJ)OdJlkfmGss8M!!Y`q>wZ^fqL&~yZ z3SXA%TmHB=-2GVuQD^|`RsU=T{4@fV;cH^uh)|i($>P8lF7_2Z=nHp`PDm3p+zI59 z#4Zkdah7vY*fXi)kA1M*F`ocO|CkF0*olA>0Ve`Z1e^#s5wIZwLgjGjR{s&K7b|V> zCk%m=X>;giVA@hbs1}MbIOhhQk7aKKG7u^Ng_*`H;wGQpiZOOBzAIiLM24!uTwh@~ z2et$uvg2Xwl(2h9>Od$M_#lERGynpk`aCaZ#a&H_;YOZiiGa)MV9EY)Ml@x6Ys;l~ zqP9Y7Ko7RLa#v7Sz)uJN^|1;Kf$>mFLnj>%;@tP4K>9p~Y{-@{2#YWYS@w}mA-w}S+r;l$^!!RM z7io{%)Q)3_m>BAqOrhUcfBSIP@&JAfrEJ^oHJr0+2srcQ!n*s6zp5AIb*x@+s{>(jna`a?_u}|H!8EKsPRQ^O7=i=nF2#4MzHS#Z-sn zo9w(|vMq)5gv-=DHTb)J9MdI( M07*qoM6N<$f(>z`r2qf` literal 0 HcmV?d00001 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 new file mode 100644 index 0000000..35c7f97 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_show_type_list.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/icon_cloud_storage_show_type_list.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/icon_cloud_storage_show_type_list.png new file mode 100644 index 0000000000000000000000000000000000000000..f280ea12606071294e883e62c2436b6cb062f3ac GIT binary patch literal 1352 zcmV-O1-JT%P)4Tx02q~(kg-a`P!xv0)aLrEtDCs^0-}$hAUKFm;3R_1o|{mT#lZvrJs&@ZpK}is-fg9VQXTq(IJDYLW3RJs zls_m_VTK|bwi~I2X>K7k>bWYw&#QjnWl3l8zp8Y-$d&#ns<|qRC9*1Pp2SLXSGXF= zQo^U2{gf{?J1O5JGdosG_+EIy$+4f~esJt&do$?`cDJREi{^>2Xw&>-W;K~I*Bx@b+`GmXpclzGEjSpSBxtITt*;qe0I-ZHNyf1Eyt zhbLu+c4C!8#a<7-?qI3|v(GSbFgoln5nD>S0hX-yU_<@BAqAA3H60SH<3oS$iOA#$pEW|?p07Xvx zDD)6LKO>@6k}4@A$~C8m9H?9&@hmI^y?l@_f0NtUdv9)cVzQga$qvlToA-O~&6~HI z+2763kl*|YxT-*adZ$vUaI@K5-)gn?LcbP0ZsPATW<#OS+gvVJH+5`Ke77*ro5>W` zf09TfW;2;gLxv1XL5z(?V;hD^bOW6>v7saNdi@y2R}_#ku-$<`;0ES~)OkGa!5W8) z#o~U%{UBopr65LB{uBm7$TrPDf@f^>Qgs1>At!wpv3y`1p2B$=n{5Dqcmle%-3dhS zjZT=y`pq<+x&eyW$?`YREoNumlMZhQ%c0v1^t5O{ozxA?c34b@f9~Mx2^0MMHN|1v!Wut;RM`DwQUHTt+Wj77IcZj^p8Q z_==*prj<%X9HDl;mW@l{7m9Qd{V|Obpc5&l)9HA*TwaaG)&9XmqtTDqY<6Dax>5Dj z*>;2F^ZBW6?0s9m0)7RSNd?#`Bq=eDs)K?!kv)8eJlTm(gU#;5aeDy1D~YcsBI15S zpo!t(;pvWa8VMLStB&gEG?E{K&sF%2*!a3uA+Cs#2A$rAu`hJ`90KU->dXXAIo-?WJEk$ol37!67DLoX-oX+AD zdP>8x4;clL$>b_IFBA&uh9qP=QeRtxE|sA3^SF=t*`=Fr?pMICz;dVnJB0+DuGMOr z@IgC@zSZ1-_-B3w$LRe?B=SagJ5A^`<0~E%qMZZcETQ!5b{c2x4t(jv)rz4knu2}| z37&xdxq`K){if4cB!xbTbq-;CjoxnTLxw7!gw|Mqmi!%oMo?y_HV8Uxpvd9|-D+$D z0Of0A87P7d*I`PS-lurA*)2HRK<{A(=ZPZ!$8;KX{G_WTc0Z%R-)S(4Tx02q~(kg-a`P!xv0)aLrEtDCs^0-}$hAUKFm;3R_1o|{mT#lZvrJs&@ZpK}is-fg9VQXTq(IJDYLW3RJs zls_m_VTK|bwi~I2X>K7k>bWYw&#QjnWl3l8zp8Y-$d&#ns<|qRC9*1Pp2SLXSGXF= zQo^U2{gf{?J1O5JGdosG_+EIy$+4f~esJt&do$?`cDJREi{^>2Xw&>-W;K~I*Bx@b+`GmXpclzGEjSpSBxtITt*;qe0I-ZHNyf1Eyt zhbLu+c4C!8#a<7-?qI3|v(GSbFgoln5nD>Y5H%NxMbe19g++dVBB;3{aHaScv{NeuF_pEI5YS5f8)A77@S=h! z))p4_Vq*$}NqiG_WwYE|?gK6&v#{*U-n{vEa~F0qH6=qB5C(*SNitC2g4b%b6~}ST z27|#;p-^x*yO8}3_L`+q>D2fAcY5x+?ktug_}Rr;roA`=vHP&Q#oB1M+xKA@HZcS{ z0bn^{@)Uy-Jm_}28>G;Hk9Eu)h_RDbI0rbFh$KC1um!+)EesHIj}o0XY!@I`3_X!Z zZvh8j3rNBINDLEi$|FWGl_)%0Kuq5gH)ebrswr~eH#0#{eIs~@d~{HdmRM9haHP__8UO*`*?nUS%w3$ zjWu9Pzc!m#x3S(3sMqT)LcfGq#f(h`VKL~3LcSSPxGo8Bo=%->%dB}b8E!oy@Xmn=@h0_DCZ1>{b-6;L{05t6$q z9mt(>E1-11A|!Y9|8_tf%2G3G;!Qb?!zeu40i0hR(*#o(Izhe~_&3Pxz1Fbf#9QtG zTL4azd#F5!s-AJ$0p4Tx02q~(kg-a`P!xv0)aLrEtDCs^0-}$hAUKFm;3R_1o|{mT#lZvrJs&@ZpK}is-fg9VQXTq(IJDYLW3RJs zls_m_VTK|bwi~I2X>K7k>bWYw&#QjnWl3l8zp8Y-$d&#ns<|qRC9*1Pp2SLXSGXF= zQo^U2{gf{?J1O5JGdosG_+EIy$+4f~esJt&do$?`cDJREi{^>2Xw&>-W;K~I*Bx@b+`GmXpclzGEjSpSBxtITt*;qe0I-ZHNyf1Eyt zhbLu+c4C!8#a<7-?qI3|v(GSbFgoln5nD>#Fz1QP<9)-N_ju& z8y;$>ypQ}lt*xzNyrUYDqb04jwl;Nmc=(0M$;m1JR3f(=s({i=wlN0(CBQeq*9`b> zWOhXhd{h9JmzS?c$$cnT1kH!$LeU5h5HbX*1CF+}wf$mOsAa$v6%}bHA%J@UyP3CH zOPbYv1cnAIempfbwfDk>3rd>~rUBR0)umj!c5U^<#6%rHx6opt=~nGA===T=;NL7? zzWk3KGEIXJse3}5m~sZs%lwEO3KFw+ySLWB6zmyV1Sn~#QeOx zytaXXfrsJR4QpX6yC5ws?I#44L$RKi!s%;NK8B?)vhKg#+1WW@R7v?W0$#Oh)xz}j z^wad>+L?sJQQ7ZIW_2wsE!T~7G91#y#l`b7GBQ5%OiqUNj08WW{DrienxCKlTYrCl z$X%MYcOk=^Idk3v>^2JHbr^@zTYH0dHMxc=R9?`K);jBQx!#2Fj1g0UkC`2S31T*RIWErrSm7Ymnh^pXQ!9@`U1Z z&>21reHM$Mpb$u` zUNBv1J<^Nf-+GU9O-)S?*pf>2oe(0Q0Jgrlx!I+%j*gDOk&%&IVLB*;n2=e8yW zUY%t{6PYH_yEYpuk~c;&+<+NA#l&^&imRYCG$$wLXOuc*Q?dDAtdb0ehBNPKC|eov z78)BH$C><&qL*mgqJDE3)|GND%EI|1YAgzxO}0Y}??xL9mdlgsz|;i@io5SO5Exdz zi(#|_)NF4hRLE`YK*$=n6XAfMnfhNyn;I)Y7)u0L&hoeqI@F+R8H!q-T;X4wFo1h| zdtc$x+W}z9dj*zXXDUK;XpXzU?&u^+Xh-3f6uNX*<>!LC6>K4$g|kHXFgPHyk`}*^n~? ze@CDz)P}s6vbq7&LMIf`OCLe&bE)$zlpI0br(H$=pV}RG>2IJ(t3_?H5wevtb)H53 zS!3hVyTm%&=eS3p)i$dwBd8YMJy!W=ix0yz(bgSDPfyPi5*JBWJ$7k(T91|My^gZk z0%4*ZouQlMHrCcM2~)T5`cH8iVb9Re&;}jjY{-Ze>kt^bCEN{o-n@C2iTBuSEsx=S zgkxMZ9Qig?31Yv*^4q0cH()u+7}f_!Tb->XOEdM0$T?P4R`!fC0s|cqbX|fxx4$@` zu4IME52zMVas-`@YB;O58?Yj}^hLoBirWbQ*vRlXsvqjUa!N}}WqG%6*f*be5#PUG zJ{_jBZ(%?^tH}o63vJVYJ<<;H)>hG00Q5WjOFH7Ubai!gAgtflR+r$drz zYHG6i*R+#*OG4B&o7G3>ANOVQ+c416`h)%{3hU2bG$x-vk*Dpaln?i9YbMnd!{@0l z4~N~L3v0)@qq5ug)}9|=wZIjnGCuI{iTVvQl_gkkVckt!aOv>XJ8(!R={g@3^lLMr z1Eb_a4;DWhK^FFw;zZjC(|*!f-n;~PYgXRRMD=+neS<9Qt%R5UIDo$*?VirP`EmZ% z0pV+1MKsdWaAMLur-FilZUn4@IU$_&=-NCwI{JQBSC^p|$8bn{dq`IgSyFT{MYr7J z^_PrBVcXc)*v4>I4-GBs1&pO1a&hx4YhxCmSH~gfhv;>Z8F@#0dwW=CMp(cCu3M`7 zX*`dmhY|j|w=?45%`pB1%N_=7s0%YeDux}>fgF{Um2c6=m*mUS5mAQ)%7{W*n~J}o z?d*|FIS&nIX(Ocy3k%O`L8n|>wmzeJG0b<-;fK*-ixuagX$yOScy8@E*_CONvK6xY zSI;Xm%v(#24mSt+ta`kspD;W@#g+m4O428OVP0gVXYJ-*9TI{*Lx M07*qoM6N<$g7IdME&u=k literal 0 HcmV?d00001 diff --git a/suixinkan/Assets.xcassets/MessageCenter/Contents.json b/suixinkan/Assets.xcassets/MessageCenter/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/MessageCenter/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/Contents.json b/suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/Contents.json new file mode 100644 index 0000000..03317ba --- /dev/null +++ b/suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_msg1.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/ic_msg1.png b/suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/ic_msg1.png new file mode 100644 index 0000000000000000000000000000000000000000..6268f066fd2b66787650f8482c53efba0b2c5282 GIT binary patch literal 3960 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 SXBHa(0000 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 GMgIqr(97xo literal 0 HcmV?d00001 diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/Contents.json b/suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/Contents.json new file mode 100644 index 0000000..53c6a05 --- /dev/null +++ b/suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_msg3.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/ic_msg3.png b/suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/ic_msg3.png new file mode 100644 index 0000000000000000000000000000000000000000..57abbf6a62d8eb1a50796558feffebcdce191df4 GIT binary patch literal 3784 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 Ai~s-t literal 0 HcmV?d00001 diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/Contents.json b/suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/Contents.json new file mode 100644 index 0000000..3ca86f0 --- /dev/null +++ b/suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_msg8.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/ic_msg8.png b/suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/ic_msg8.png new file mode 100644 index 0000000000000000000000000000000000000000..1c2cf3eb567e639eb2133a59eaa80809aa0fd6d3 GIT binary patch literal 3703 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 Rn&IDZqkq{*r(D}H>VJ(}o>~9^ literal 0 HcmV?d00001 diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/Contents.json b/suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/Contents.json new file mode 100644 index 0000000..ec5fd2a --- /dev/null +++ b/suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_msg999.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/ic_msg999.png b/suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/ic_msg999.png new file mode 100644 index 0000000000000000000000000000000000000000..f80058d6eb5f9f6416f077d7725f8f9bb1212ec5 GIT binary patch literal 3774 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 JuIwhB@*m;z(7gZv literal 0 HcmV?d00001 diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 6a4a192..bb97d3a 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -179,6 +179,24 @@ final class OSSUploadService { ) } + /// 上传相册云盘文件,对齐 Android `cloud_driver` / `cloud_driver_test` 路径。 + func uploadCloudDriveFile( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + try await uploadFile( + data: data, + fileName: fileName, + fileType: fileType, + scenicId: scenicId, + moduleType: "cloud_driver", + onProgress: onProgress + ) + } + /// 上传举报摄影师附件,对齐举报提交接口的证据与支付截图。 func uploadWildReportAttachment( data: Data, @@ -280,7 +298,7 @@ enum OSSUploadPolicy { formatter.timeZone = timeZone formatter.dateFormat = "yyyyMMdd" let currentDate = formatter.string(from: date) - let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "") + let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "").lowercased() let safeName = sanitizedFileName(fileName) switch moduleType { case "user_avatar": @@ -307,6 +325,16 @@ enum OSSUploadPolicy { return "live_covers/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "alive_album": return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "cloud_driver": + let userId = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) + let ownerId = userId.isEmpty ? String(scenicId) : userId + let month = String(currentDate.dropFirst(4).prefix(2)) + #if DEBUG + let root = "cloud_driver_test" + #else + let root = "cloud_driver" + #endif + return "\(root)/\(ownerId)/\(String(currentDate.prefix(4)))/\(month)/\(safeName)" case "photog_report": return "photog_report/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: diff --git a/suixinkan/Features/CloudDrive/API/CloudDriveAPI.swift b/suixinkan/Features/CloudDrive/API/CloudDriveAPI.swift new file mode 100644 index 0000000..69a1bb3 --- /dev/null +++ b/suixinkan/Features/CloudDrive/API/CloudDriveAPI.swift @@ -0,0 +1,144 @@ +// +// CloudDriveAPI.swift +// suixinkan +// + +import Foundation + +/// 云盘接口协议,便于 ViewModel 注入测试替身。 +@MainActor +protocol CloudDriveServing { + /// 拉取云盘文件列表。 + func cloudFileList( + parentFolderId: Int, + name: String, + type: Int, + orderBy: Int, + page: Int, + pageSize: Int + ) async throws -> CloudFileListResponse + + /// 创建云盘文件夹。 + func createCloudFolder(parentFolderId: Int, name: String) async throws + + /// 登记上传到 OSS 后的云盘文件。 + func uploadCloudFile(parentFolderId: Int, fileUrl: String, fileName: String) async throws + + /// 删除云盘文件或文件夹。 + func deleteCloudFiles(_ request: CloudFileDeleteRequest) async throws + + /// 移动云盘文件或文件夹。 + func moveCloudFiles(_ request: CloudFileMoveRequest) async throws + + /// 检查是否允许上传云盘文件。 + func checkUploadPermission() async throws -> CloudUploadPermissionResponse + + /// 修改云盘文件夹名称。 + func modifyCloudFolderName(fileId: Int, name: String) async throws + + /// 修改云盘文件名称。 + func modifyCloudFileName(fileId: Int, fileName: String) async throws +} + +@MainActor +/// 云盘 API,路径与 Android `NetworkApi` 的 cloud-driver 区域保持一致。 +final class CloudDriveAPI: CloudDriveServing { + private let client: APIClient + + init(client: APIClient) { + self.client = client + } + + func cloudFileList( + parentFolderId: Int, + name: String = "", + type: Int = 0, + orderBy: Int = 2, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> CloudFileListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/cloud-driver/list", + queryItems: [ + URLQueryItem(name: "parent_folder_id", value: String(parentFolderId)), + URLQueryItem(name: "name", value: name), + URLQueryItem(name: "type", value: String(type)), + URLQueryItem(name: "order_by", value: String(orderBy)), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)), + ] + ) + ) + } + + func createCloudFolder(parentFolderId: Int, name: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/cloud-driver/folder-create", + body: CloudFolderCreateRequest(parentFolderId: parentFolderId, name: name) + ) + ) + } + + func uploadCloudFile(parentFolderId: Int, fileUrl: String, fileName: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/cloud-driver/file-upload", + body: CloudFileUploadRequest(parentFolderId: parentFolderId, fileUrl: fileUrl, fileName: fileName) + ) + ) + } + + func deleteCloudFiles(_ request: CloudFileDeleteRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/cloud-driver/delete", + body: request + ) + ) + } + + func moveCloudFiles(_ request: CloudFileMoveRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/cloud-driver/move", + body: request + ) + ) + } + + func checkUploadPermission() async throws -> CloudUploadPermissionResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/cloud-driver/check-upload-permission" + ) + ) + } + + func modifyCloudFolderName(fileId: Int, name: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/cloud-driver/folder-edit", + body: CloudFolderModifyRequest(fileId: fileId, name: name) + ) + ) + } + + func modifyCloudFileName(fileId: Int, fileName: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/cloud-driver/file-edit", + body: CloudFileModifyRequest(fileId: fileId, fileName: fileName) + ) + ) + } +} diff --git a/suixinkan/Features/CloudDrive/Models/CloudDriveModels.swift b/suixinkan/Features/CloudDrive/Models/CloudDriveModels.swift new file mode 100644 index 0000000..9d33ace --- /dev/null +++ b/suixinkan/Features/CloudDrive/Models/CloudDriveModels.swift @@ -0,0 +1,165 @@ +// +// CloudDriveModels.swift +// suixinkan +// + +import Foundation + +/// 云盘文件/文件夹实体,对齐 Android `CloudFileEntity`。 +struct CloudFile: Codable, Sendable, Equatable, Hashable { + let id: Int + let parentFolderId: Int + let fileUrl: String + let coverUrl: String + let updatedAt: String + let name: String + let createdAt: String + let childNum: Int + let type: Int + let fileSize: Int64 + + enum CodingKeys: String, CodingKey { + case id + case parentFolderId = "parent_folder_id" + case fileUrl = "file_url" + case coverUrl = "cover_url" + case updatedAt = "updated_at" + case name + case createdAt = "created_at" + case childNum = "child_num" + case type + case fileSize = "file_size" + } + + init( + id: Int = 0, + parentFolderId: Int = 0, + fileUrl: String = "", + coverUrl: String = "", + updatedAt: String = "", + name: String = "", + createdAt: String = "", + childNum: Int = 0, + type: Int = 0, + fileSize: Int64 = 0 + ) { + self.id = id + self.parentFolderId = parentFolderId + self.fileUrl = fileUrl + self.coverUrl = coverUrl + self.updatedAt = updatedAt + self.name = name + self.createdAt = createdAt + self.childNum = childNum + self.type = type + self.fileSize = fileSize + } + + /// 是否为文件夹。 + var isFolder: Bool { type == 99 } + + /// 是否为图片。 + var isImage: Bool { type == 2 } + + /// 是否为视频。 + var isVideo: Bool { type == 1 } + + /// 是否为可导入的媒体文件。 + var isSelectableMedia: Bool { isImage || isVideo } +} + +/// 云盘列表响应。 +struct CloudFileListResponse: Codable, Sendable, Equatable { + let total: Int + let list: [CloudFile] + + init(total: Int = 0, list: [CloudFile] = []) { + self.total = total + self.list = list + } +} + +/// 云盘路径节点,用于面包屑导航。 +struct CloudPathItem: Codable, Sendable, Equatable, Hashable { + let id: Int + let name: String +} + +/// 云盘上传权限响应。 +struct CloudUploadPermissionResponse: Decodable, Sendable, Equatable { + let canUpload: Bool + let reason: String + + enum CodingKeys: String, CodingKey { + case canUpload = "can_upload" + case reason + } +} + +/// 云盘删除请求体。 +struct CloudFileDeleteRequest: Encodable, Sendable, Equatable { + let list: [CloudFileOperationItem] +} + +/// 云盘移动请求体。 +struct CloudFileMoveRequest: Encodable, Sendable, Equatable { + let targetFolderId: Int + let list: [CloudFileOperationItem] + + enum CodingKeys: String, CodingKey { + case targetFolderId = "target_folder_id" + case list + } +} + +/// 云盘删除/移动文件项。 +struct CloudFileOperationItem: Codable, Sendable, Equatable { + let id: Int + let type: Int +} + +/// 云盘创建文件夹请求体。 +struct CloudFolderCreateRequest: Encodable, Sendable, Equatable { + let parentFolderId: Int + let name: String + + enum CodingKeys: String, CodingKey { + case parentFolderId = "parent_folder_id" + case name + } +} + +/// 云盘文件上传登记请求体。 +struct CloudFileUploadRequest: Encodable, Sendable, Equatable { + let parentFolderId: Int + let fileUrl: String + let fileName: String + + enum CodingKeys: String, CodingKey { + case parentFolderId = "parent_folder_id" + case fileUrl = "file_url" + case fileName = "file_name" + } +} + +/// 云盘文件夹改名请求体。 +struct CloudFolderModifyRequest: Encodable, Sendable, Equatable { + let fileId: Int + let name: String + + enum CodingKeys: String, CodingKey { + case fileId = "id" + case name + } +} + +/// 云盘文件改名请求体。 +struct CloudFileModifyRequest: Encodable, Sendable, Equatable { + let fileId: Int + let fileName: String + + enum CodingKeys: String, CodingKey { + case fileId = "id" + case fileName = "file_name" + } +} diff --git a/suixinkan/Features/CloudDrive/Services/CloudTransferJSONStore.swift b/suixinkan/Features/CloudDrive/Services/CloudTransferJSONStore.swift new file mode 100644 index 0000000..55f3d4c --- /dev/null +++ b/suixinkan/Features/CloudDrive/Services/CloudTransferJSONStore.swift @@ -0,0 +1,44 @@ +// +// CloudTransferJSONStore.swift +// suixinkan +// + +import Foundation + +/// JSON 文件实现的云盘传输任务存储。 +final class CloudTransferJSONStore: CloudTransferTaskStoring { + private let fileURL: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + /// 初始化本地 JSON 存储。 + init(fileURL: URL? = nil) { + if let fileURL { + self.fileURL = fileURL + } else { + let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + self.fileURL = baseURL + .appendingPathComponent("suixinkan", isDirectory: true) + .appendingPathComponent("cloud_transfer_tasks.json") + } + } + + func loadTasks() -> [CloudTransferTask] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + return (try? decoder.decode([CloudTransferTask].self, from: data)) ?? [] + } + + func saveTasks(_ tasks: [CloudTransferTask]) { + do { + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try encoder.encode(tasks) + try data.write(to: fileURL, options: [.atomic]) + } catch { + #if DEBUG + print("CloudTransferJSONStore save failed: \(error)") + #endif + } + } +} diff --git a/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift b/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift new file mode 100644 index 0000000..bd295cb --- /dev/null +++ b/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift @@ -0,0 +1,300 @@ +// +// CloudTransferManager.swift +// suixinkan +// + +import Foundation + +@MainActor +/// 云盘上传/下载传输管理器,负责本地任务、进度和服务端登记。 +final class CloudTransferManager { + static let shared = CloudTransferManager( + store: CloudTransferJSONStore(), + uploader: NetworkServices.shared.ossUploadService, + api: NetworkServices.shared.cloudDriveAPI, + downloader: CloudURLSessionDownloader(), + photoSaver: CloudPhotoLibrarySaver(), + scenicIdProvider: { AppStore.shared.currentScenicId } + ) + + private(set) var uploadTasks: [CloudTransferTask] = [] + private(set) var downloadTasks: [CloudTransferTask] = [] + + var onTasksChange: (() -> Void)? + var onUploadCompleted: ((Int) -> Void)? + var onShowMessage: ((String) -> Void)? + + private let store: CloudTransferTaskStoring + private let uploader: any CloudDriveUploading + private let api: any CloudDriveServing + private let downloader: any CloudFileDownloading + private let photoSaver: any CloudPhotoSaving + private let scenicIdProvider: () -> Int + private var runningTasks: [String: Task] = [:] + + /// 初始化传输管理器。 + init( + store: CloudTransferTaskStoring, + uploader: any CloudDriveUploading, + api: any CloudDriveServing, + downloader: any CloudFileDownloading, + photoSaver: any CloudPhotoSaving, + scenicIdProvider: @escaping () -> Int + ) { + self.store = store + self.uploader = uploader + self.api = api + self.downloader = downloader + self.photoSaver = photoSaver + self.scenicIdProvider = scenicIdProvider + let tasks = store.loadTasks() + uploadTasks = tasks.filter { $0.kind == .upload } + downloadTasks = tasks.filter { $0.kind == .download } + } + + /// 添加上传任务并立即开始。 + func addUploadTask( + localFileURL: URL, + fileName: String, + fileType: Int, + fileSize: Int64, + parentFolderId: Int + ) { + let task = CloudTransferTask( + kind: .upload, + fileName: fileName, + fileType: fileType, + fileSize: fileSize, + parentFolderId: parentFolderId, + localPath: localFileURL.path, + status: .pending + ) + uploadTasks.insert(task, at: 0) + persistAndNotify() + startUpload(task.id) + onShowMessage?("已添加上传任务") + } + + /// 添加下载任务并立即开始。 + func addDownloadTask(fileURL: String, fileName: String, fileType: Int, fileSize: Int64 = 0) { + let task = CloudTransferTask( + kind: .download, + fileName: fileName, + fileType: fileType, + fileSize: fileSize, + remoteURL: fileURL, + status: .pending + ) + downloadTasks.insert(task, at: 0) + persistAndNotify() + startDownload(task.id) + onShowMessage?("已添加下载任务,可在传输管理查看") + } + + /// 暂停上传任务。 + func pauseUpload(id: String) { + runningTasks[id]?.cancel() + runningTasks[id] = nil + updateUploadTask(id: id) { task in + task.status = .paused + task.updatedAt = Date().timeIntervalSince1970 + } + } + + /// 继续上传任务。 + func resumeUpload(id: String) { + startUpload(id) + } + + /// 取消上传任务。 + func cancelUpload(id: String) { + runningTasks[id]?.cancel() + runningTasks[id] = nil + if let task = uploadTasks.first(where: { $0.id == id }), !task.localPath.isEmpty { + try? FileManager.default.removeItem(atPath: task.localPath) + } + uploadTasks.removeAll { $0.id == id } + persistAndNotify() + } + + /// 暂停下载任务。 + func pauseDownload(id: String) { + runningTasks[id]?.cancel() + runningTasks[id] = nil + updateDownloadTask(id: id) { task in + task.status = .paused + task.updatedAt = Date().timeIntervalSince1970 + } + } + + /// 继续下载任务。 + func resumeDownload(id: String) { + startDownload(id) + } + + /// 取消下载任务。 + func cancelDownload(id: String) { + runningTasks[id]?.cancel() + runningTasks[id] = nil + downloadTasks.removeAll { $0.id == id } + persistAndNotify() + } + + private func startUpload(_ id: String) { + guard runningTasks[id] == nil, + let index = uploadTasks.firstIndex(where: { $0.id == id }) else { return } + + uploadTasks[index].status = .uploading + uploadTasks[index].errorMessage = "" + uploadTasks[index].progress = max(uploadTasks[index].progress, 1) + uploadTasks[index].updatedAt = Date().timeIntervalSince1970 + let seed = uploadTasks[index] + persistAndNotify() + + runningTasks[id] = Task { [weak self] in + guard let self else { return } + do { + guard FileManager.default.fileExists(atPath: seed.localPath) else { + throw CloudTransferError.localFileMissing + } + let data = try Data(contentsOf: URL(fileURLWithPath: seed.localPath)) + try Task.checkCancellation() + let uploadedURL = try await uploader.uploadCloudDriveFile( + data: data, + fileName: seed.fileName, + fileType: seed.fileType, + scenicId: scenicIdProvider() + ) { [weak self] progress in + Task { @MainActor in + self?.updateUploadProgress(id: id, progress: progress) + } + } + try Task.checkCancellation() + try await api.uploadCloudFile( + parentFolderId: seed.parentFolderId, + fileUrl: uploadedURL, + fileName: seed.fileName + ) + try? FileManager.default.removeItem(atPath: seed.localPath) + runningTasks[id] = nil + uploadTasks.removeAll { $0.id == id } + persistAndNotify() + onUploadCompleted?(seed.parentFolderId) + } catch is CancellationError { + runningTasks[id] = nil + markUploadPaused(id: id) + } catch { + runningTasks[id] = nil + markUploadFailed(id: id, message: error.localizedDescription) + } + } + } + + private func startDownload(_ id: String) { + guard runningTasks[id] == nil, + let index = downloadTasks.firstIndex(where: { $0.id == id }) else { return } + + downloadTasks[index].status = .downloading + downloadTasks[index].errorMessage = "" + downloadTasks[index].progress = max(downloadTasks[index].progress, 1) + downloadTasks[index].updatedAt = Date().timeIntervalSince1970 + let seed = downloadTasks[index] + persistAndNotify() + + runningTasks[id] = Task { [weak self] in + guard let self else { return } + do { + let localURL = try await downloader.downloadFile( + remoteURL: seed.remoteURL, + fileName: seed.fileName + ) { [weak self] progress in + Task { @MainActor in + self?.updateDownloadProgress(id: id, progress: progress) + } + } + try Task.checkCancellation() + try await photoSaver.saveToPhotoLibrary(fileURL: localURL, fileType: seed.fileType) + try? FileManager.default.removeItem(at: localURL) + runningTasks[id] = nil + updateDownloadTask(id: id) { task in + task.status = .completed + task.progress = 100 + task.updatedAt = Date().timeIntervalSince1970 + } + } catch is CancellationError { + runningTasks[id] = nil + markDownloadPaused(id: id) + } catch { + runningTasks[id] = nil + markDownloadFailed(id: id, message: error.localizedDescription) + } + } + } + + private func updateUploadProgress(id: String, progress: Int) { + updateUploadTask(id: id) { task in + task.progress = max(task.progress, progress.coerceIn(0 ... 100)) + task.updatedAt = Date().timeIntervalSince1970 + } + } + + private func updateDownloadProgress(id: String, progress: Int) { + updateDownloadTask(id: id) { task in + task.progress = max(task.progress, progress.coerceIn(0 ... 100)) + task.updatedAt = Date().timeIntervalSince1970 + } + } + + private func markUploadPaused(id: String) { + updateUploadTask(id: id) { task in + task.status = .paused + task.updatedAt = Date().timeIntervalSince1970 + } + } + + private func markDownloadPaused(id: String) { + updateDownloadTask(id: id) { task in + task.status = .paused + task.updatedAt = Date().timeIntervalSince1970 + } + } + + private func markUploadFailed(id: String, message: String) { + updateUploadTask(id: id) { task in + task.status = .failed + task.errorMessage = message + task.updatedAt = Date().timeIntervalSince1970 + } + } + + private func markDownloadFailed(id: String, message: String) { + updateDownloadTask(id: id) { task in + task.status = .failed + task.errorMessage = message + task.updatedAt = Date().timeIntervalSince1970 + } + } + + private func updateUploadTask(id: String, mutation: (inout CloudTransferTask) -> Void) { + guard let index = uploadTasks.firstIndex(where: { $0.id == id }) else { return } + mutation(&uploadTasks[index]) + persistAndNotify() + } + + private func updateDownloadTask(id: String, mutation: (inout CloudTransferTask) -> Void) { + guard let index = downloadTasks.firstIndex(where: { $0.id == id }) else { return } + mutation(&downloadTasks[index]) + persistAndNotify() + } + + private func persistAndNotify() { + store.saveTasks(uploadTasks + downloadTasks) + onTasksChange?() + } +} + +private extension Comparable { + func coerceIn(_ range: ClosedRange) -> Self { + min(max(self, range.lowerBound), range.upperBound) + } +} diff --git a/suixinkan/Features/CloudDrive/Services/CloudTransferModels.swift b/suixinkan/Features/CloudDrive/Services/CloudTransferModels.swift new file mode 100644 index 0000000..cd6d4a0 --- /dev/null +++ b/suixinkan/Features/CloudDrive/Services/CloudTransferModels.swift @@ -0,0 +1,97 @@ +// +// CloudTransferModels.swift +// suixinkan +// + +import Foundation + +/// 云盘传输任务类型。 +enum CloudTransferKind: String, Codable, Sendable { + case upload + case download +} + +/// 云盘传输任务状态。 +enum CloudTransferStatus: String, Codable, Sendable { + case pending + case uploading + case downloading + case paused + case failed + case completed + + /// 展示文案。 + var title: String { + switch self { + case .pending: + "等待中" + case .uploading: + "上传中" + case .downloading: + "下载中" + case .paused: + "已暂停" + case .failed: + "失败" + case .completed: + "已完成" + } + } +} + +/// 云盘本地传输任务记录。 +struct CloudTransferTask: Codable, Sendable, Equatable, Hashable, Identifiable { + let id: String + let kind: CloudTransferKind + let fileName: String + let fileType: Int + let fileSize: Int64 + let parentFolderId: Int + let localPath: String + let remoteURL: String + var status: CloudTransferStatus + var progress: Int + var errorMessage: String + var createdAt: TimeInterval + var updatedAt: TimeInterval + + /// 创建传输任务。 + init( + id: String = UUID().uuidString.replacingOccurrences(of: "-", with: ""), + kind: CloudTransferKind, + fileName: String, + fileType: Int, + fileSize: Int64, + parentFolderId: Int = 0, + localPath: String = "", + remoteURL: String = "", + status: CloudTransferStatus = .pending, + progress: Int = 0, + errorMessage: String = "", + createdAt: TimeInterval = Date().timeIntervalSince1970, + updatedAt: TimeInterval = Date().timeIntervalSince1970 + ) { + self.id = id + self.kind = kind + self.fileName = fileName + self.fileType = fileType + self.fileSize = fileSize + self.parentFolderId = parentFolderId + self.localPath = localPath + self.remoteURL = remoteURL + self.status = status + self.progress = progress + self.errorMessage = errorMessage + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +/// 云盘传输任务持久化协议。 +protocol CloudTransferTaskStoring: AnyObject { + /// 读取全部本地任务。 + func loadTasks() -> [CloudTransferTask] + + /// 保存全部本地任务。 + func saveTasks(_ tasks: [CloudTransferTask]) +} diff --git a/suixinkan/Features/CloudDrive/Services/CloudTransferWorkers.swift b/suixinkan/Features/CloudDrive/Services/CloudTransferWorkers.swift new file mode 100644 index 0000000..15e1274 --- /dev/null +++ b/suixinkan/Features/CloudDrive/Services/CloudTransferWorkers.swift @@ -0,0 +1,113 @@ +// +// CloudTransferWorkers.swift +// suixinkan +// + +import Foundation +import Photos + +/// 云盘 OSS 上传协议,便于传输管理注入测试替身。 +@MainActor +protocol CloudDriveUploading { + /// 上传云盘文件并返回远程 URL。 + func uploadCloudDriveFile( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String +} + +extension OSSUploadService: CloudDriveUploading {} + +/// 云盘下载协议。 +protocol CloudFileDownloading { + /// 下载远程文件并返回本地临时文件 URL。 + func downloadFile( + remoteURL: String, + fileName: String, + onProgress: @escaping @Sendable (Int) -> Void + ) async throws -> URL +} + +/// URLSession 实现的云盘下载器。 +struct CloudURLSessionDownloader: CloudFileDownloading { + func downloadFile( + remoteURL: String, + fileName: String, + onProgress: @escaping @Sendable (Int) -> Void + ) async throws -> URL { + guard let url = URL(string: remoteURL) else { + throw CloudTransferError.invalidURL + } + onProgress(1) + let (temporaryURL, _) = try await URLSession.shared.download(from: url) + try Task.checkCancellation() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cloud_drive_downloads", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let destinationURL = directory.appendingPathComponent(CloudTransferFileName.sanitized(fileName)) + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + try FileManager.default.moveItem(at: temporaryURL, to: destinationURL) + onProgress(100) + return destinationURL + } +} + +/// 云盘下载后的系统相册保存协议。 +protocol CloudPhotoSaving { + /// 将图片或视频保存到系统相册。 + func saveToPhotoLibrary(fileURL: URL, fileType: Int) async throws +} + +/// Photos 框架实现的相册保存器。 +struct CloudPhotoLibrarySaver: CloudPhotoSaving { + func saveToPhotoLibrary(fileURL: URL, fileType: Int) async throws { + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + guard status == .authorized || status == .limited else { + throw CloudTransferError.photoPermissionDenied + } + + try await PHPhotoLibrary.shared().performChanges { + if fileType == 1 { + PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL) + } else { + PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL) + } + } + } +} + +/// 云盘传输错误。 +enum CloudTransferError: LocalizedError, Equatable { + case invalidURL + case localFileMissing + case photoPermissionDenied + + var errorDescription: String? { + switch self { + case .invalidURL: + "文件地址不存在" + case .localFileMissing: + "本地文件不存在" + case .photoPermissionDenied: + "请允许访问相册后再下载" + } + } +} + +/// 云盘传输文件名工具。 +enum CloudTransferFileName { + /// 清理文件名中不适合落盘的字符。 + static func sanitized(_ fileName: String) -> String { + let trimmed = fileName.trimmingCharacters(in: .whitespacesAndNewlines) + var unsafe = CharacterSet.controlCharacters + unsafe.formUnion(CharacterSet(charactersIn: "/\\")) + let value = trimmed.unicodeScalars.reduce(into: "") { result, scalar in + result += unsafe.contains(scalar) ? "_" : String(scalar) + } + return value.isEmpty ? "cloud_file" : value + } +} diff --git a/suixinkan/Features/CloudDrive/ViewModels/CloudDriveListViewModel.swift b/suixinkan/Features/CloudDrive/ViewModels/CloudDriveListViewModel.swift new file mode 100644 index 0000000..504f424 --- /dev/null +++ b/suixinkan/Features/CloudDrive/ViewModels/CloudDriveListViewModel.swift @@ -0,0 +1,353 @@ +// +// CloudDriveListViewModel.swift +// suixinkan +// + +import Foundation + +/// 云盘展示模式。 +enum CloudDriveDisplayMode: Sendable, Equatable { + case grid + case list +} + +/// 云盘文件排序方式。 +enum CloudDriveSortType: Int, CaseIterable, Sendable { + case createdAscending = 1 + case createdDescending = 2 + + /// Android 对齐文案。 + var title: String { + switch self { + case .createdAscending: + "创建时间顺序" + case .createdDescending: + "创建时间倒序" + } + } +} + +/// 云盘文件类型筛选。 +enum CloudDriveFilterType: Int, CaseIterable, Sendable { + case all = 0 + case video = 1 + case image = 2 + case folder = 99 + + /// Android 对齐文案。 + var title: String { + switch self { + case .all: + "全部类型" + case .video: + "视频" + case .image: + "图片" + case .folder: + "文件夹" + } + } +} + +private struct CloudDriveCacheKey: Hashable { + let folderId: Int + let name: String + let type: Int + let orderBy: Int +} + +private struct CloudDriveFolderCache { + let list: [CloudFile] + let total: Int + let page: Int +} + +/// 相册云盘列表 ViewModel,对齐 Android `CloudStorageListViewModel`。 +final class CloudDriveListViewModel { + private(set) var pathStack: [CloudPathItem] = [CloudPathItem(id: 0, name: "云盘")] + private(set) var files: [CloudFile] = [] + private(set) var searchText = "" + private(set) var filterType: CloudDriveFilterType = .all + private(set) var sortType: CloudDriveSortType = .createdDescending + private(set) var displayMode: CloudDriveDisplayMode = .grid + private(set) var isLoading = false + private(set) var isRefreshing = false + private(set) var canLoadMore = false + private(set) var currentControlFile: CloudFile? + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + private var currentPage = 1 + private var totalCount = 0 + private let pageSize = 20 + private var folderCache: [CloudDriveCacheKey: CloudDriveFolderCache] = [:] + private var dirtyFolders: Set = [] + + /// 当前文件夹 ID。 + var currentFolderID: Int { + pathStack.last?.id ?? 0 + } + + /// 更新搜索词。 + func updateSearchText(_ text: String) { + searchText = text + notifyStateChange() + } + + /// 切换宫格/列表。 + func toggleDisplayMode() { + displayMode = displayMode == .grid ? .list : .grid + notifyStateChange() + } + + /// 选择排序方式。 + func chooseSortType(_ type: CloudDriveSortType, api: any CloudDriveServing) async { + sortType = type + await refresh(api: api, showLoading: true) + } + + /// 选择类型筛选。 + func chooseFilterType(_ type: CloudDriveFilterType, api: any CloudDriveServing) async { + filterType = type + await refresh(api: api, showLoading: true) + } + + /// 首次加载或刷新。 + func refresh(api: any CloudDriveServing, showLoading: Bool = false) async { + currentPage = 1 + isRefreshing = true + notifyStateChange() + await loadPage(api: api, reset: true, showLoading: showLoading) + isRefreshing = false + notifyStateChange() + } + + /// 加载更多。 + func loadMoreIfNeeded(lastVisibleIndex: Int, api: any CloudDriveServing) async { + guard canLoadMore, !isLoading, lastVisibleIndex >= files.count - 6 else { return } + currentPage += 1 + await loadPage(api: api, reset: false, showLoading: false) + } + + /// 点击文件或文件夹。返回非文件夹供 UI 打开预览。 + func handleFileTap(_ file: CloudFile, api: any CloudDriveServing) async -> CloudFile? { + if file.isFolder { + searchText = "" + pathStack.append(CloudPathItem(id: file.id, name: file.name)) + await refresh(api: api, showLoading: true) + return nil + } + guard file.isSelectableMedia else { return nil } + return file + } + + /// 设置当前操作文件。 + func setControlFile(_ file: CloudFile) { + currentControlFile = file + notifyStateChange() + } + + /// 返回上级目录;返回 false 时由 UI 执行 pop。 + func navigateBack(api: any CloudDriveServing) async -> Bool { + guard pathStack.count > 1 else { return false } + pathStack.removeLast() + await restoreOrReload(api: api) + return true + } + + /// 回到云盘根目录。 + func goHome(api: any CloudDriveServing) async { + pathStack = [pathStack.first ?? CloudPathItem(id: 0, name: "云盘")] + await restoreOrReload(api: api) + } + + /// 点击面包屑路径。 + func navigateToPathIndex(_ index: Int, api: any CloudDriveServing) async { + guard index >= 0, index < pathStack.count else { return } + pathStack = Array(pathStack.prefix(index + 1)) + await restoreOrReload(api: api) + } + + /// 上传前检查权限。 + func checkCanUpload(api: any CloudDriveServing) async -> Bool { + do { + let response = try await api.checkUploadPermission() + if !response.canUpload { + onShowMessage?(response.reason) + } + return response.canUpload + } catch { + onShowMessage?(error.localizedDescription) + return false + } + } + + /// 创建文件夹。 + func addFolder(name: String, api: any CloudDriveServing) async { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + onShowMessage?("名称不能为空") + return + } + guard pathStack.count < 5 else { + onShowMessage?("不能创建更深的文件夹") + return + } + await performLoading { + try await api.createCloudFolder(parentFolderId: currentFolderID, name: trimmedName) + invalidateFolderCache(currentFolderID) + await refresh(api: api, showLoading: false) + } + } + + /// 删除当前操作文件。 + func deleteCurrentFile(api: any CloudDriveServing) async { + guard let file = currentControlFile else { return } + await performLoading { + try await api.deleteCloudFiles( + CloudFileDeleteRequest(list: [CloudFileOperationItem(id: file.id, type: file.type)]) + ) + invalidateFolderCache(currentFolderID) + await refresh(api: api, showLoading: false) + } + } + + /// 移动当前操作文件。 + func moveCurrentFile(targetFolderId: Int, api: any CloudDriveServing) async { + guard let file = currentControlFile else { return } + await performLoading { + try await api.moveCloudFiles( + CloudFileMoveRequest( + targetFolderId: targetFolderId, + list: [CloudFileOperationItem(id: file.id, type: file.type)] + ) + ) + invalidateFolderCache(currentFolderID) + invalidateFolderCache(targetFolderId) + await refresh(api: api, showLoading: false) + } + } + + /// 保存当前操作文件的名称。 + func saveCurrentFileName(_ name: String, api: any CloudDriveServing) async { + guard let file = currentControlFile else { return } + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + onShowMessage?("名称不能为空") + return + } + guard trimmedName != file.name else { return } + await performLoading { + if file.isFolder { + try await api.modifyCloudFolderName(fileId: file.id, name: trimmedName) + } else { + try await api.modifyCloudFileName(fileId: file.id, fileName: trimmedName) + } + invalidateFolderCache(currentFolderID) + await refresh(api: api, showLoading: false) + } + } + + /// 上传完成后标记目录需要刷新。 + func handleUploadCompleted(parentFolderId: Int, api: any CloudDriveServing) async { + if parentFolderId == currentFolderID { + invalidateFolderCache(parentFolderId) + await refresh(api: api, showLoading: false) + } else { + invalidateFolderCache(parentFolderId) + } + } + + /// 当前目录可移动目标,与 Android 一致:根目录 + 当前列表内文件夹。 + func moveTargetFolders() -> [CloudFile] { + let root = CloudFile(id: 0, name: "云盘", type: 99) + let folders = files.filter { $0.isFolder } + return ([root] + folders).reduce(into: [CloudFile]()) { result, item in + guard !result.contains(where: { $0.id == item.id }) else { return } + result.append(item) + } + } + + private func loadPage(api: any CloudDriveServing, reset: Bool, showLoading: Bool) async { + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + + do { + let response = try await api.cloudFileList( + parentFolderId: currentFolderID, + name: searchText, + type: filterType.rawValue, + orderBy: sortType.rawValue, + page: currentPage, + pageSize: pageSize + ) + totalCount = response.total + files = reset ? response.list : files + response.list + canLoadMore = files.count < totalCount + folderCache[currentKey()] = CloudDriveFolderCache(list: files, total: totalCount, page: currentPage) + } catch is CancellationError { + return + } catch { + if reset { + files = [] + } + canLoadMore = false + onShowMessage?(error.localizedDescription) + } + } + + private func restoreOrReload(api: any CloudDriveServing) async { + if dirtyFolders.remove(currentFolderID) != nil { + await refresh(api: api, showLoading: true) + return + } + if let cached = folderCache[currentKey()] { + files = cached.list + totalCount = cached.total + currentPage = cached.page + canLoadMore = files.count < totalCount + notifyStateChange() + } else { + await refresh(api: api, showLoading: true) + } + } + + private func performLoading(_ operation: () async throws -> Void) async { + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + try await operation() + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription) + } + } + + private func currentKey() -> CloudDriveCacheKey { + CloudDriveCacheKey( + folderId: currentFolderID, + name: searchText, + type: filterType.rawValue, + orderBy: sortType.rawValue + ) + } + + private func invalidateFolderCache(_ folderId: Int) { + folderCache = folderCache.filter { $0.key.folderId != folderId } + dirtyFolders.insert(folderId) + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index c86ad1a..b763d4e 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -37,8 +37,12 @@ enum HomeRouteHandler { selectTab(.statistics, from: viewController) case "system_settings": viewController.navigationController?.pushViewController(SettingViewController(), animated: true) + case "message_center": + viewController.navigationController?.pushViewController(MessageCenterViewController(), animated: true) case "space_settings": viewController.navigationController?.pushViewController(ProfileSpaceSettingsViewController(), animated: true) + case "cloud_management": + viewController.navigationController?.pushViewController(CloudDriveListViewController(), animated: true) case "pilot_cert": let controller = RealNameAuthViewController() viewController.navigationController?.pushViewController(controller, animated: true) diff --git a/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift b/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift new file mode 100644 index 0000000..3910772 --- /dev/null +++ b/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift @@ -0,0 +1,67 @@ +// +// MessageCenterAPI.swift +// suixinkan +// + +import Foundation + +/// 消息中心服务协议,抽象列表、已读和删除接口。 +@MainActor +protocol MessageCenterServing { + /// 拉取消息列表。 + func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse + + /// 标记消息已读。 + func markAsRead(messageId: Int) async throws + + /// 删除消息。 + func delete(messageId: Int) async throws +} + +/// 消息中心 API,对齐 Android `NetworkApi` 的 msg 区域。 +@MainActor +final class MessageCenterAPI: MessageCenterServing { + private let client: APIClient + + /// 初始化消息中心 API。 + init(client: APIClient) { + self.client = client + } + + /// GET /api/app/msg/list + func list(lastId: Int = 0, limit: Int = 20, unread: Int = 0) async throws -> MessageListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/msg/list", + queryItems: [ + URLQueryItem(name: "last_id", value: String(max(lastId, 0))), + URLQueryItem(name: "limit", value: String(max(limit, 1))), + URLQueryItem(name: "unread", value: String(max(unread, 0))), + ] + ) + ) + } + + /// POST /api/app/msg/read + func markAsRead(messageId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/msg/read", + queryItems: [URLQueryItem(name: "id", value: String(messageId))] + ) + ) + } + + /// POST /api/app/msg/delete + func delete(messageId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/msg/delete", + body: MessageDeleteRequest(id: messageId) + ) + ) + } +} diff --git a/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift b/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift new file mode 100644 index 0000000..cb81c0c --- /dev/null +++ b/suixinkan/Features/MessageCenter/Models/MessageCenterModels.swift @@ -0,0 +1,301 @@ +// +// MessageCenterModels.swift +// suixinkan +// + +import Foundation + +/// 可解码的 JSON 值,用于承接消息 `extra_data` 原始结构。 +enum MessageJSONValue: Decodable, Hashable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: MessageJSONValue]) + case array([MessageJSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([MessageJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: MessageJSONValue].self)) + } + } +} + +/// 消息中心列表响应,对齐 Android `MsgResponse`。 +struct MessageListResponse: Decodable, Equatable, Sendable { + let hasMore: Bool + let lastId: Int + let items: [MessageItem] + let isRefresh: Bool + + enum CodingKeys: String, CodingKey { + case hasMore = "has_more" + case lastId = "last_id" + case items + case isRefresh = "is_refresh" + } + + init(hasMore: Bool = false, lastId: Int = 0, items: [MessageItem] = [], isRefresh: Bool = false) { + self.hasMore = hasMore + self.lastId = lastId + self.items = items + self.isRefresh = isRefresh + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hasMore = try container.decodeLossyBool(forKey: .hasMore) ?? false + lastId = try container.decodeLossyInt(forKey: .lastId) ?? 0 + items = (try? container.decodeIfPresent([MessageItem].self, forKey: .items)) ?? [] + isRefresh = try container.decodeLossyBool(forKey: .isRefresh) ?? false + } +} + +/// 单条消息实体,对齐 Android `MsgEntity`。 +struct MessageItem: Decodable, Hashable, Sendable { + let id: Int + let receiverId: Int + let receiverType: String + let type: Int + let typeName: String + let title: String + let content: String + let pushAt: String + let isRead: Bool + let readAt: String + let pushChannel: Int + let pushChannelName: String + let extraData: [String: MessageJSONValue]? + let createdAt: String + let updatedAt: String + + enum CodingKeys: String, CodingKey { + case id + case receiverId = "receiver_id" + case receiverType = "receiver_type" + case type + case typeName = "type_name" + case title + case content + case pushAt = "push_at" + case isRead = "is_read" + case readAt = "read_at" + case pushChannel = "push_channel" + case pushChannelName = "push_channel_name" + case extraData = "extra_data" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + init( + id: Int = 0, + receiverId: Int = 0, + receiverType: String = "", + type: Int = 0, + typeName: String = "", + title: String = "", + content: String = "", + pushAt: String = "", + isRead: Bool = false, + readAt: String = "", + pushChannel: Int = 0, + pushChannelName: String = "", + extraData: [String: MessageJSONValue]? = nil, + createdAt: String = "", + updatedAt: String = "" + ) { + self.id = id + self.receiverId = receiverId + self.receiverType = receiverType + self.type = type + self.typeName = typeName + self.title = title + self.content = content + self.pushAt = pushAt + self.isRead = isRead + self.readAt = readAt + self.pushChannel = pushChannel + self.pushChannelName = pushChannelName + self.extraData = extraData + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + receiverId = try container.decodeLossyInt(forKey: .receiverId) ?? 0 + receiverType = try container.decodeLossyString(forKey: .receiverType) + type = try container.decodeLossyInt(forKey: .type) ?? 0 + typeName = try container.decodeLossyString(forKey: .typeName) + title = try container.decodeLossyString(forKey: .title) + content = try container.decodeLossyString(forKey: .content) + pushAt = try container.decodeLossyString(forKey: .pushAt) + isRead = try container.decodeLossyBool(forKey: .isRead) ?? false + readAt = try container.decodeLossyString(forKey: .readAt) + pushChannel = try container.decodeLossyInt(forKey: .pushChannel) ?? 0 + pushChannelName = try container.decodeLossyString(forKey: .pushChannelName) + extraData = try? container.decodeIfPresent([String: MessageJSONValue].self, forKey: .extraData) + createdAt = try container.decodeLossyString(forKey: .createdAt) + updatedAt = try container.decodeLossyString(forKey: .updatedAt) + } + + /// Android 列表标题使用 `type_name`。 + var displayTitle: String { + typeName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Android 列表优先展示 `push_at`,否则格式化 `created_at`。 + var displayTime: String { + let pushTime = pushAt.trimmingCharacters(in: .whitespacesAndNewlines) + if !pushTime.isEmpty { return pushTime } + return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt + } + + /// 返回已读状态的消息副本。 + func markedRead() -> MessageItem { + MessageItem( + id: id, + receiverId: receiverId, + receiverType: receiverType, + type: type, + typeName: typeName, + title: title, + content: content, + pushAt: pushAt, + isRead: true, + readAt: readAt, + pushChannel: pushChannel, + pushChannelName: pushChannelName, + extraData: extraData, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} + +/// 消息删除请求体,对齐 Android `MsgDeleteRequest`。 +struct MessageDeleteRequest: Encodable, Sendable { + let id: Int +} + +/// 消息中心日期显示工具,对齐 Android `DateUtils.formatDateTime` 的用户可见规则。 +enum MessageDateFormatter { + private static let calendar = Calendar.current + + static func formatDateTime(_ input: String?) -> String? { + guard let input = input?.trimmingCharacters(in: .whitespacesAndNewlines), !input.isEmpty else { return nil } + guard let date = parseDate(input) else { return nil } + + let today = Date() + let startOfInput = calendar.startOfDay(for: date) + let startOfToday = calendar.startOfDay(for: today) + let dayDifference = calendar.dateComponents([.day], from: startOfInput, to: startOfToday).day ?? 0 + let inputComponents = calendar.dateComponents([.year, .month, .day, .weekday], from: date) + let todayComponents = calendar.dateComponents([.year, .month], from: today) + + if dayDifference == 0 { + return "今天" + } + if (1 ... 6).contains(dayDifference), let weekday = inputComponents.weekday { + return weekdayName(weekday) + } + if inputComponents.year == todayComponents.year, inputComponents.month == todayComponents.month { + return "本月\(inputComponents.day ?? 0)日" + } + if inputComponents.year == todayComponents.year { + return "\(inputComponents.month ?? 0)月\(inputComponents.day ?? 0)日" + } + return "\(inputComponents.year ?? 0)年\(inputComponents.month ?? 0)月\(inputComponents.day ?? 0)日" + } + + private static func parseDate(_ input: String) -> Date? { + if let timestamp = Double(input) { + let seconds = timestamp > 1_000_000_000_000 ? timestamp / 1000 : timestamp + return Date(timeIntervalSince1970: seconds) + } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd"] { + formatter.dateFormat = format + if let date = formatter.date(from: input) { + return date + } + } + return nil + } + + private static func weekdayName(_ weekday: Int) -> String { + switch weekday { + case 1: return "周日" + case 2: return "周一" + case 3: return "周二" + case 4: return "周三" + case 5: return "周四" + case 6: return "周五" + case 7: return "周六" + default: return "" + } + } +} + +private extension KeyedDecodingContainer { + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { return intValue } + if let doubleValue = Double(text) { return Int(doubleValue) } + } + return nil + } + + func decodeLossyBool(forKey key: Key) throws -> Bool? { + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value != 0 + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["true", "1", "yes"].contains(text) { return true } + if ["false", "0", "no"].contains(text) { return false } + } + return nil + } +} diff --git a/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift new file mode 100644 index 0000000..1cffdb2 --- /dev/null +++ b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModels.swift @@ -0,0 +1,158 @@ +// +// MessageCenterViewModels.swift +// suixinkan +// + +import Foundation + +/// 消息中心列表页 ViewModel,负责分页、刷新和未读点击处理。 +final class MessageCenterViewModel { + private(set) var items: [MessageItem] = [] + private(set) var isLoading = false + private(set) var isRefreshing = false + private(set) var isLoadingMore = false + private(set) var canLoadMore = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onOpenDetail: ((MessageItem) -> Void)? + + private let pageSize = 20 + private var lastId = 0 + + /// 首次加载消息列表。 + func loadInitial(api: any MessageCenterServing) async { + await load(reset: true, showLoading: true, api: api) + } + + /// 下拉刷新消息列表。 + func refresh(api: any MessageCenterServing) async { + guard !isLoading else { return } + isRefreshing = true + notifyStateChange() + await load(reset: true, showLoading: false, api: api) + } + + /// 滚动到底部附近时加载更多。 + func loadMoreIfNeeded(lastVisibleIndex: Int, api: any MessageCenterServing) async { + guard lastVisibleIndex >= items.count - 4 else { return } + await load(reset: false, showLoading: false, api: api) + } + + /// 点击消息:未读先标记已读,成功后进入详情;已读直接进入详情。 + func selectMessage(id: Int, api: any MessageCenterServing) async { + guard let item = items.first(where: { $0.id == id }) else { return } + if item.isRead { + onOpenDetail?(item) + return + } + + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + + do { + try await api.markAsRead(messageId: id) + let readItem = item.markedRead() + items = items.map { $0.id == id ? readItem : $0 } + onOpenDetail?(readItem) + } catch is CancellationError { + return + } catch { + onShowMessage?("标记已读失败") + } + } + + /// 删除后从本地列表移除消息。 + func removeMessage(id: Int) { + items.removeAll { $0.id == id } + notifyStateChange() + } + + private func load(reset: Bool, showLoading: Bool, api: any MessageCenterServing) async { + if reset { + lastId = 0 + canLoadMore = false + } else { + guard canLoadMore, !isLoading, !isLoadingMore else { return } + isLoadingMore = true + } + + isLoading = showLoading + notifyStateChange() + defer { + isLoading = false + isRefreshing = false + isLoadingMore = false + notifyStateChange() + } + + do { + let response = try await api.list(lastId: reset ? 0 : lastId, limit: pageSize, unread: 0) + lastId = response.lastId + items = reset ? response.items : items + response.items + canLoadMore = response.hasMore && response.lastId > 0 + } catch is CancellationError { + return + } catch { + if reset { + items = [] + lastId = 0 + canLoadMore = false + } + onShowMessage?(error.localizedDescription) + } + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 消息详情页 ViewModel,负责删除当前消息。 +final class MessageDetailViewModel { + private(set) var message: MessageItem + private(set) var isDeleting = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onDeleted: ((Int) -> Void)? + + /// 初始化消息详情 ViewModel。 + init(message: MessageItem) { + self.message = message + } + + /// 删除当前消息。 + func delete(api: any MessageCenterServing) async { + guard message.id > 0 else { + onShowMessage?("消息ID无效") + return + } + + isDeleting = true + notifyStateChange() + defer { + isDeleting = false + notifyStateChange() + } + + do { + try await api.delete(messageId: message.id) + onShowMessage?("删除成功") + onDeleted?(message.id) + } catch is CancellationError { + return + } catch { + let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + onShowMessage?("删除失败: \(detail.isEmpty ? "请稍后重试" : detail)") + } + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Task/Models/TaskModels.swift b/suixinkan/Features/Task/Models/TaskModels.swift index 030cd5a..066105b 100644 --- a/suixinkan/Features/Task/Models/TaskModels.swift +++ b/suixinkan/Features/Task/Models/TaskModels.swift @@ -89,86 +89,6 @@ struct AvailableOrder: Decodable, Sendable, Equatable, Hashable { } } -/// 云盘文件/文件夹实体,对齐 Android `CloudFileEntity`。 -struct CloudFile: Decodable, Sendable, Equatable, Hashable { - let id: Int - let parentFolderId: Int - let fileUrl: String - let coverUrl: String - let updatedAt: String - let name: String - let createdAt: String - let childNum: Int - let type: Int - let fileSize: Int64 - - enum CodingKeys: String, CodingKey { - case id - case parentFolderId = "parent_folder_id" - case fileUrl = "file_url" - case coverUrl = "cover_url" - case updatedAt = "updated_at" - case name - case createdAt = "created_at" - case childNum = "child_num" - case type - case fileSize = "file_size" - } - - init( - id: Int = 0, - parentFolderId: Int = 0, - fileUrl: String = "", - coverUrl: String = "", - updatedAt: String = "", - name: String = "", - createdAt: String = "", - childNum: Int = 0, - type: Int = 0, - fileSize: Int64 = 0 - ) { - self.id = id - self.parentFolderId = parentFolderId - self.fileUrl = fileUrl - self.coverUrl = coverUrl - self.updatedAt = updatedAt - self.name = name - self.createdAt = createdAt - self.childNum = childNum - self.type = type - self.fileSize = fileSize - } - - /// 是否为文件夹。 - var isFolder: Bool { type == 99 } - - /// 是否为图片。 - var isImage: Bool { type == 2 } - - /// 是否为视频。 - var isVideo: Bool { type == 1 } - - /// 是否为可导入的媒体文件。 - var isSelectableMedia: Bool { isImage || isVideo } -} - -/// 云盘列表响应。 -struct CloudFileListResponse: Decodable, Sendable, Equatable { - let total: Int - let list: [CloudFile] - - init(total: Int = 0, list: [CloudFile] = []) { - self.total = total - self.list = list - } -} - -/// 云盘路径节点,用于面包屑导航。 -struct CloudPathItem: Sendable, Equatable, Hashable { - let id: Int - let name: String -} - /// 任务素材来源。 enum TaskFileSource: String, Sendable, Equatable { case cloud diff --git a/suixinkan/UI/CloudDrive/CloudDriveActionSheetViewController.swift b/suixinkan/UI/CloudDrive/CloudDriveActionSheetViewController.swift new file mode 100644 index 0000000..7cccdd0 --- /dev/null +++ b/suixinkan/UI/CloudDrive/CloudDriveActionSheetViewController.swift @@ -0,0 +1,137 @@ +// +// CloudDriveActionSheetViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 云盘底部操作弹层,用于同步 Android 云盘上传与文件操作菜单。 +final class CloudDriveActionSheetViewController: UIViewController { + /// 云盘底部弹层操作项。 + struct Item { + /// 操作文案。 + let title: String + /// 操作图标。 + let image: UIImage? + /// 是否展示为危险操作。 + let isDestructive: Bool + /// 点击后的业务动作。 + let handler: () -> Void + } + + private let sheetTitle: String? + private let items: [Item] + private let dimView = UIView() + private let panelView = UIView() + private let stackView = UIStackView() + + /// 初始化云盘底部弹层。 + init(title: String? = nil, items: [Item]) { + self.sheetTitle = title + self.items = items + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + } + + private func setupUI() { + view.backgroundColor = .clear + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35) + dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) + + panelView.backgroundColor = .white + panelView.layer.cornerRadius = 18 + panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + panelView.clipsToBounds = true + + stackView.axis = .vertical + stackView.spacing = 0 + + view.addSubview(dimView) + view.addSubview(panelView) + panelView.addSubview(stackView) + + if let sheetTitle { + let titleLabel = UILabel() + titleLabel.text = sheetTitle + titleLabel.font = .app(.bodyMedium) + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + titleLabel.snp.makeConstraints { make in + make.height.equalTo(54) + } + stackView.addArrangedSubview(titleLabel) + } + + for item in items { + stackView.addArrangedSubview(makeItemButton(item)) + } + + let spacer = UIView() + spacer.backgroundColor = AppColor.pageBackground + spacer.snp.makeConstraints { make in + make.height.equalTo(8) + } + stackView.addArrangedSubview(spacer) + + let cancelButton = UIButton(type: .system) + cancelButton.setTitle("取消", for: .normal) + cancelButton.setTitleColor(AppColor.textPrimary, for: .normal) + cancelButton.titleLabel?.font = .app(.body) + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + cancelButton.snp.makeConstraints { make in + make.height.equalTo(54) + } + stackView.addArrangedSubview(cancelButton) + } + + private func setupConstraints() { + dimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + panelView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + stackView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide) + } + } + + private func makeItemButton(_ item: Item) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(item.title, for: .normal) + button.setImage(item.image, for: .normal) + button.tintColor = item.isDestructive ? AppColor.danger : AppColor.textPrimary + button.setTitleColor(item.isDestructive ? AppColor.danger : AppColor.textPrimary, for: .normal) + button.titleLabel?.font = .app(.body) + button.contentHorizontalAlignment = .left + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.xl, bottom: 0, right: AppSpacing.xl) + button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.md) + button.addAction(UIAction { [weak self] _ in + self?.dismiss(animated: true) { + item.handler() + } + }, for: .touchUpInside) + button.snp.makeConstraints { make in + make.height.equalTo(54) + } + return button + } + + @objc private func cancelTapped() { + dismiss(animated: true) + } +} diff --git a/suixinkan/UI/CloudDrive/CloudDriveDetailSheetViewController.swift b/suixinkan/UI/CloudDrive/CloudDriveDetailSheetViewController.swift new file mode 100644 index 0000000..f4579ea --- /dev/null +++ b/suixinkan/UI/CloudDrive/CloudDriveDetailSheetViewController.swift @@ -0,0 +1,149 @@ +// +// CloudDriveDetailSheetViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 云盘文件详情底部表单,用于查看文件信息并修改名称。 +final class CloudDriveDetailSheetViewController: UIViewController { + private let file: CloudFile + private let onSave: (String) -> Void + private let dimView = UIView() + private let panelView = UIView() + private let titleLabel = UILabel() + private let nameTitleLabel = UILabel() + private let nameField = UITextField() + private let infoLabel = UILabel() + private let cancelButton = UIButton(type: .system) + private let saveButton = UIButton(type: .system) + + /// 初始化云盘文件详情表单。 + init(file: CloudFile, onSave: @escaping (String) -> Void) { + self.file = file + self.onSave = onSave + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupConstraints() + } + + private func setupUI() { + view.backgroundColor = .clear + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35) + dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cancelTapped))) + + panelView.backgroundColor = .white + panelView.layer.cornerRadius = 18 + panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + panelView.clipsToBounds = true + + titleLabel.text = "详情" + titleLabel.font = .app(.bodyMedium) + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + + nameTitleLabel.text = "文件名称" + nameTitleLabel.font = .app(.caption) + nameTitleLabel.textColor = AppColor.textSecondary + + nameField.text = file.name + nameField.font = .app(.body) + nameField.textColor = AppColor.textPrimary + nameField.clearButtonMode = .whileEditing + nameField.returnKeyType = .done + nameField.delegate = self + nameField.backgroundColor = AppColor.inputBackground + nameField.layer.cornerRadius = AppRadius.xs + nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: AppSpacing.sm, height: 1)) + nameField.leftViewMode = .always + + infoLabel.text = "\(file.createdAt) | \(CloudDriveFormatter.fileSize(file.fileSize))\n类型:\(CloudDriveFormatter.typeName(file.type))" + infoLabel.font = .app(.caption) + infoLabel.textColor = AppColor.textTertiary + infoLabel.numberOfLines = 0 + + cancelButton.setTitle("取消", for: .normal) + cancelButton.setTitleColor(AppColor.textSecondary, for: .normal) + cancelButton.titleLabel?.font = .app(.body) + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + + saveButton.setTitle("保存", for: .normal) + saveButton.setTitleColor(.white, for: .normal) + saveButton.titleLabel?.font = .app(.bodyMedium) + saveButton.backgroundColor = AppColor.primary + saveButton.layer.cornerRadius = AppRadius.xs + saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) + + view.addSubview(dimView) + view.addSubview(panelView) + [titleLabel, nameTitleLabel, nameField, infoLabel, cancelButton, saveButton].forEach(panelView.addSubview) + } + + private func setupConstraints() { + dimView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + panelView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(54) + } + nameTitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + nameField.snp.makeConstraints { make in + make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalTo(nameTitleLabel) + make.height.equalTo(42) + } + infoLabel.snp.makeConstraints { make in + make.top.equalTo(nameField.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.equalTo(nameTitleLabel) + } + cancelButton.snp.makeConstraints { make in + make.top.equalTo(infoLabel.snp.bottom).offset(AppSpacing.lg) + make.leading.equalToSuperview().offset(AppSpacing.md) + make.height.equalTo(44) + make.width.equalTo(saveButton) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md) + } + saveButton.snp.makeConstraints { make in + make.top.bottom.width.equalTo(cancelButton) + make.leading.equalTo(cancelButton.snp.trailing).offset(AppSpacing.md) + make.trailing.equalToSuperview().inset(AppSpacing.md) + } + } + + @objc private func cancelTapped() { + dismiss(animated: true) + } + + @objc private func saveTapped() { + let name = nameField.text ?? "" + dismiss(animated: true) { [onSave] in + onSave(name) + } + } +} + +extension CloudDriveDetailSheetViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/suixinkan/UI/CloudDrive/CloudDriveFileCell.swift b/suixinkan/UI/CloudDrive/CloudDriveFileCell.swift new file mode 100644 index 0000000..c2306e2 --- /dev/null +++ b/suixinkan/UI/CloudDrive/CloudDriveFileCell.swift @@ -0,0 +1,189 @@ +// +// CloudDriveFileCell.swift +// suixinkan +// + +import Kingfisher +import SnapKit +import UIKit + +/// 云盘文件 Cell,支持 Android 宫格与列表两种样式。 +final class CloudDriveFileCell: UICollectionViewCell { + static let reuseIdentifier = "CloudDriveFileCell" + + var onDetails: (() -> Void)? + + private let thumbnailView = UIImageView() + private let folderIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_folder_type", fallbackSystemName: "folder.fill")) + private let playIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_play", fallbackSystemName: "play.circle.fill")) + private let extensionLabel = CloudDrivePaddingLabel() + private let nameLabel = UILabel() + private let detailLabel = UILabel() + private let moreButton = UIButton(type: .system) + + private var gridConstraints: [Constraint] = [] + private var listConstraints: [Constraint] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = nil + folderIconView.isHidden = true + playIconView.isHidden = true + extensionLabel.isHidden = true + onDetails = nil + } + + /// 应用云盘文件数据。 + func apply(file: CloudFile, mode: CloudDriveDisplayMode) { + nameLabel.text = file.name + detailLabel.text = mode == .grid ? CloudDriveFormatter.typeName(file.type) : CloudDriveFormatter.fileDescription(file) + configureThumbnail(file) + gridConstraints.forEach { mode == .grid ? $0.activate() : $0.deactivate() } + listConstraints.forEach { mode == .list ? $0.activate() : $0.deactivate() } + contentView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0 + contentView.layer.borderWidth = mode == .grid ? 1 : 0 + contentView.backgroundColor = mode == .grid ? .white : .clear + thumbnailView.layer.cornerRadius = mode == .grid ? AppRadius.lg : 0 + nameLabel.textAlignment = mode == .grid ? .left : .left + } + + private func setupUI() { + contentView.clipsToBounds = true + contentView.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor + + thumbnailView.contentMode = .scaleAspectFill + thumbnailView.clipsToBounds = true + thumbnailView.backgroundColor = AppColor.warningBackground + + folderIconView.tintColor = AppColor.warning + folderIconView.contentMode = .scaleAspectFit + + playIconView.tintColor = .white + playIconView.contentMode = .scaleAspectFit + playIconView.isHidden = true + + extensionLabel.font = .app(.caption) + extensionLabel.textColor = .white + extensionLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5) + extensionLabel.layer.cornerRadius = AppRadius.xs + extensionLabel.clipsToBounds = true + extensionLabel.isHidden = true + + nameLabel.font = .app(.body) + nameLabel.textColor = AppColor.textPrimary + nameLabel.numberOfLines = 1 + + detailLabel.font = .app(.caption) + detailLabel.textColor = UIColor(hex: 0x4B5563) + detailLabel.numberOfLines = 1 + + moreButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_details", fallbackSystemName: "ellipsis"), for: .normal) + moreButton.tintColor = AppColor.textSecondary + moreButton.addTarget(self, action: #selector(moreTapped), for: .touchUpInside) + + contentView.addSubview(thumbnailView) + contentView.addSubview(folderIconView) + contentView.addSubview(playIconView) + contentView.addSubview(extensionLabel) + contentView.addSubview(nameLabel) + contentView.addSubview(detailLabel) + contentView.addSubview(moreButton) + } + + private func setupConstraints() { + thumbnailView.snp.makeConstraints { make in + gridConstraints.append(make.top.leading.trailing.equalToSuperview().constraint) + gridConstraints.append(make.height.equalTo(thumbnailView.snp.width).multipliedBy(0.56).constraint) + listConstraints.append(make.leading.centerY.equalToSuperview().constraint) + listConstraints.append(make.width.height.equalTo(48).constraint) + } + folderIconView.snp.makeConstraints { make in + make.center.equalTo(thumbnailView) + make.width.height.equalTo(42) + } + playIconView.snp.makeConstraints { make in + make.center.equalTo(thumbnailView) + make.width.height.equalTo(28) + } + extensionLabel.snp.makeConstraints { make in + make.top.leading.equalTo(thumbnailView).offset(AppSpacing.xs) + } + nameLabel.snp.makeConstraints { make in + gridConstraints.append(make.top.equalTo(thumbnailView.snp.bottom).offset(AppSpacing.xs).constraint) + gridConstraints.append(make.leading.equalToSuperview().offset(AppSpacing.sm).constraint) + listConstraints.append(make.top.equalToSuperview().offset(AppSpacing.sm).constraint) + listConstraints.append(make.leading.equalTo(thumbnailView.snp.trailing).offset(AppSpacing.sm).constraint) + make.trailing.equalTo(moreButton.snp.leading).offset(-AppSpacing.xs) + } + detailLabel.snp.makeConstraints { make in + make.top.equalTo(nameLabel.snp.bottom).offset(AppSpacing.xxs) + make.leading.equalTo(nameLabel) + make.trailing.equalTo(nameLabel) + } + moreButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppSpacing.xs) + make.centerY.equalTo(nameLabel.snp.bottom).offset(4) + make.width.height.equalTo(32) + } + listConstraints.forEach { $0.deactivate() } + } + + private func configureThumbnail(_ file: CloudFile) { + if file.isFolder { + thumbnailView.image = nil + thumbnailView.backgroundColor = AppColor.warningBackground + folderIconView.isHidden = false + extensionLabel.isHidden = true + return + } + folderIconView.isHidden = true + thumbnailView.backgroundColor = AppColor.inputBackground + let preview = file.isVideo ? file.coverUrl : file.fileUrl + if let url = URL(string: preview), !preview.isEmpty { + let placeholder = CloudDriveAsset.image( + named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo", + fallbackSystemName: file.isVideo ? "video" : "photo" + ) + thumbnailView.kf.setImage(with: url, placeholder: placeholder) + } else { + thumbnailView.image = CloudDriveAsset.image( + named: file.isVideo ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo", + fallbackSystemName: file.isVideo ? "video" : "photo" + ) + thumbnailView.tintColor = AppColor.textTertiary + } + playIconView.isHidden = !file.isVideo + extensionLabel.text = CloudDriveFormatter.fileExtension(file.fileUrl.isEmpty ? file.name : file.fileUrl) + extensionLabel.isHidden = false + } + + @objc private func moreTapped() { + onDetails?() + } +} + +/// 带内边距的标签。 +private final class CloudDrivePaddingLabel: UILabel { + var insets = UIEdgeInsets(top: 3, left: 5, bottom: 3, right: 5) + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: insets)) + } +} diff --git a/suixinkan/UI/CloudDrive/CloudDriveFormatter.swift b/suixinkan/UI/CloudDrive/CloudDriveFormatter.swift new file mode 100644 index 0000000..2db76db --- /dev/null +++ b/suixinkan/UI/CloudDrive/CloudDriveFormatter.swift @@ -0,0 +1,62 @@ +// +// CloudDriveFormatter.swift +// suixinkan +// + +import Foundation +import UIKit + +/// 云盘展示格式化工具。 +enum CloudDriveFormatter { + /// 文件类型名称。 + static func typeName(_ type: Int) -> String { + switch type { + case 1: + "视频" + case 2: + "图片" + case 99: + "文件夹" + default: + "文件" + } + } + + /// 文件描述。 + static func fileDescription(_ file: CloudFile) -> String { + if file.isFolder { + return "\(file.childNum) 个项目" + } + return "\(typeName(file.type)) \(fileSize(file.fileSize))" + } + + /// 文件大小展示。 + static func fileSize(_ bytes: Int64) -> String { + guard bytes > 0 else { return "0B" } + let units = ["B", "KB", "MB", "GB", "TB"] + var value = Double(bytes) + var index = 0 + while value >= 1024, index < units.count - 1 { + value /= 1024 + index += 1 + } + if index == 0 { + return "\(Int(value))\(units[index])" + } + return String(format: "%.1f%@", value, units[index]) + } + + /// URL 或文件名的后缀。 + static func fileExtension(_ value: String) -> String { + let ext = URL(string: value)?.pathExtension ?? URL(fileURLWithPath: value).pathExtension + return ext.isEmpty ? typeName(0) : ext.uppercased() + } +} + +/// 云盘 Android 同名图片资源访问工具。 +enum CloudDriveAsset { + /// 返回云盘图片资源,缺失时使用系统图标兜底。 + static func image(named name: String, fallbackSystemName: String) -> UIImage? { + UIImage(named: name)?.withRenderingMode(.alwaysOriginal) ?? UIImage(systemName: fallbackSystemName) + } +} diff --git a/suixinkan/UI/CloudDrive/CloudDriveListViewController.swift b/suixinkan/UI/CloudDrive/CloudDriveListViewController.swift new file mode 100644 index 0000000..251b33e --- /dev/null +++ b/suixinkan/UI/CloudDrive/CloudDriveListViewController.swift @@ -0,0 +1,529 @@ +// +// CloudDriveListViewController.swift +// suixinkan +// + +import Kingfisher +import PhotosUI +import SnapKit +import UIKit +import UniformTypeIdentifiers + +/// 相册云盘列表页,对齐 Android `CloudStorageListScreen`。 +final class CloudDriveListViewController: BaseViewController { + private let viewModel = CloudDriveListViewModel() + private let api: any CloudDriveServing + private let transferManager: CloudTransferManager + + private let pathScrollView = UIScrollView() + private let pathStackView = UIStackView() + private let searchContainer = UIView() + private let searchIconView = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_file_search", fallbackSystemName: "magnifyingglass")) + private let searchField = UITextField() + private let sortButton = UIButton(type: .system) + private let filterButton = UIButton(type: .system) + private let displayModeButton = UIButton(type: .system) + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private let uploadButton = AppButton(title: "上传") + private let bottomBar = UIView() + + /// 初始化相册云盘列表页。 + init( + api: (any CloudDriveServing)? = nil, + transferManager: CloudTransferManager = .shared + ) { + self.api = api ?? NetworkServices.shared.cloudDriveAPI + self.transferManager = transferManager + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "相册云盘" + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: CloudDriveAsset.image(named: "icon_cloud_storage_transit", fallbackSystemName: "arrow.up.arrow.down.circle"), + style: .plain, + target: self, + action: #selector(openTransferManager) + ) + } + + override func setupUI() { + view.backgroundColor = .white + + pathScrollView.showsHorizontalScrollIndicator = false + pathStackView.axis = .horizontal + pathStackView.alignment = .center + pathStackView.spacing = AppSpacing.xs + pathScrollView.addSubview(pathStackView) + + searchContainer.backgroundColor = AppColor.inputBackground + searchContainer.layer.cornerRadius = AppRadius.xs + searchIconView.tintColor = AppColor.textTertiary + searchField.placeholder = "搜索文件名称" + searchField.font = .app(.body) + searchField.textColor = AppColor.textPrimary + searchField.returnKeyType = .search + searchField.delegate = self + searchContainer.addSubview(searchIconView) + searchContainer.addSubview(searchField) + + configureFilterButton(sortButton) + configureFilterButton(filterButton) + displayModeButton.tintColor = AppColor.textPrimary + displayModeButton.backgroundColor = .white + displayModeButton.layer.cornerRadius = AppRadius.xs + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) + collectionView.backgroundColor = .white + collectionView.alwaysBounceVertical = true + collectionView.delegate = self + collectionView.register(CloudDriveFileCell.self, forCellWithReuseIdentifier: CloudDriveFileCell.reuseIdentifier) + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak self] collectionView, indexPath, file in + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: CloudDriveFileCell.reuseIdentifier, + for: indexPath + ) as! CloudDriveFileCell + cell.apply(file: file, mode: self?.viewModel.displayMode ?? .grid) + cell.onDetails = { [weak self] in self?.showControlSheet(for: file) } + return cell + } + + bottomBar.backgroundColor = .white + bottomBar.layer.borderColor = AppColor.border.cgColor + bottomBar.layer.borderWidth = 0.5 + + view.addSubview(pathScrollView) + view.addSubview(searchContainer) + view.addSubview(sortButton) + view.addSubview(filterButton) + view.addSubview(displayModeButton) + view.addSubview(collectionView) + view.addSubview(bottomBar) + bottomBar.addSubview(uploadButton) + } + + override func setupConstraints() { + pathScrollView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(52) + } + pathStackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalToSuperview() + } + searchContainer.snp.makeConstraints { make in + make.top.equalTo(pathScrollView.snp.bottom).offset(2) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(36) + } + searchIconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(AppSpacing.sm) + make.centerY.equalToSuperview() + make.width.height.equalTo(16) + } + searchField.snp.makeConstraints { make in + make.leading.equalTo(searchIconView.snp.trailing).offset(AppSpacing.sm) + make.top.bottom.trailing.equalToSuperview().inset(AppSpacing.xs) + } + sortButton.snp.makeConstraints { make in + make.top.equalTo(searchContainer.snp.bottom).offset(AppSpacing.md) + make.leading.equalToSuperview().offset(AppSpacing.md) + make.height.equalTo(36) + } + filterButton.snp.makeConstraints { make in + make.top.width.height.equalTo(sortButton) + make.leading.equalTo(sortButton.snp.trailing).offset(AppSpacing.sm) + } + displayModeButton.snp.makeConstraints { make in + make.centerY.equalTo(sortButton) + make.leading.equalTo(filterButton.snp.trailing).offset(AppSpacing.sm) + make.trailing.equalToSuperview().inset(6) + make.width.height.equalTo(36) + } + sortButton.snp.makeConstraints { make in + make.width.equalTo(filterButton) + } + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + uploadButton.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md) + } + collectionView.snp.makeConstraints { make in + make.top.equalTo(sortButton.snp.bottom).offset(AppSpacing.md) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + } + + override func bindActions() { + uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside) + displayModeButton.addTarget(self, action: #selector(displayModeTapped), for: .touchUpInside) + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + transferManager.onUploadCompleted = { [weak self] folderId in + guard let self else { return } + Task { await self.viewModel.handleUploadCompleted(parentFolderId: folderId, api: self.api) } + } + transferManager.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + configureMenus() + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.refresh(api: api, showLoading: true) } + } + + @MainActor + private func applyViewModel() { + rebuildBreadcrumb() + searchField.text = viewModel.searchText + sortButton.setTitle(viewModel.sortType.title, for: .normal) + filterButton.setTitle(viewModel.filterType.title, for: .normal) + let displayIconName = viewModel.displayMode == .grid ? "icon_cloud_storage_show_type_list" : "icon_cloud_storage_show_type_table" + let displayFallback = viewModel.displayMode == .grid ? "list.bullet" : "square.grid.2x2" + displayModeButton.setImage(CloudDriveAsset.image(named: displayIconName, fallbackSystemName: displayFallback), for: .normal) + sortButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_list_sort", fallbackSystemName: "arrow.up.arrow.down"), for: .normal) + filterButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_file_filter", fallbackSystemName: "line.3.horizontal.decrease.circle"), for: .normal) + collectionView.setCollectionViewLayout(makeLayout(), animated: false) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(viewModel.files) + dataSource.apply(snapshot, animatingDifferences: true) + + if viewModel.isLoading && viewModel.files.isEmpty { + showLoading() + } else { + hideLoading() + } + } + + private func rebuildBreadcrumb() { + pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + let homeButton = UIButton(type: .system) + homeButton.setImage(CloudDriveAsset.image(named: "icon_cloud_storage_go_home", fallbackSystemName: "house.fill"), for: .normal) + homeButton.tintColor = AppColor.textPrimary + homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside) + homeButton.snp.makeConstraints { make in + make.width.height.equalTo(24) + } + pathStackView.addArrangedSubview(homeButton) + + for (index, item) in viewModel.pathStack.enumerated() { + if index > 0 { + let arrow = UIImageView(image: CloudDriveAsset.image(named: "icon_cloud_storage_current_path_arrow", fallbackSystemName: "chevron.right")) + arrow.tintColor = AppColor.textTertiary + arrow.snp.makeConstraints { make in + make.width.equalTo(8) + } + pathStackView.addArrangedSubview(arrow) + } + let button = UIButton(type: .system) + button.setTitle(item.name, for: .normal) + button.titleLabel?.font = .app(index == viewModel.pathStack.count - 1 ? .body : .caption) + button.setTitleColor(index == viewModel.pathStack.count - 1 ? .black : AppColor.textSecondary, for: .normal) + button.tag = index + button.addTarget(self, action: #selector(pathTapped(_:)), for: .touchUpInside) + pathStackView.addArrangedSubview(button) + } + } + + private func configureMenus() { + sortButton.menu = UIMenu(children: CloudDriveSortType.allCases.map { type in + UIAction(title: type.title) { [weak self] _ in + guard let self else { return } + Task { await self.viewModel.chooseSortType(type, api: self.api) } + } + }) + sortButton.showsMenuAsPrimaryAction = true + + filterButton.menu = UIMenu(children: CloudDriveFilterType.allCases.map { type in + UIAction(title: type.title) { [weak self] _ in + guard let self else { return } + Task { await self.viewModel.chooseFilterType(type, api: self.api) } + } + }) + filterButton.showsMenuAsPrimaryAction = true + } + + private func configureFilterButton(_ button: UIButton) { + button.backgroundColor = AppColor.inputBackground + button.layer.cornerRadius = AppRadius.xs + button.titleLabel?.font = .app(.caption) + button.setTitleColor(AppColor.textPrimary, for: .normal) + button.contentHorizontalAlignment = .left + button.imageView?.contentMode = .scaleAspectFit + button.tintColor = AppColor.textPrimary + button.semanticContentAttribute = .forceLeftToRight + button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: AppSpacing.xs) + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: AppSpacing.sm, bottom: 0, right: AppSpacing.sm) + } + + private func makeLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { [weak self] _, environment in + let mode = self?.viewModel.displayMode ?? .grid + switch mode { + case .grid: + let columns = 2 + let spacing: CGFloat = 15 + let horizontalInset: CGFloat = 12 + let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2 + let itemWidth = (availableWidth - spacing) / CGFloat(columns) + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), heightDimension: .absolute(itemWidth * 0.82)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(itemWidth * 0.82)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: columns) + group.interItemSpacing = .fixed(spacing) + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: horizontalInset, bottom: 12, trailing: horizontalInset) + section.interGroupSpacing = spacing + return section + case .list: + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(72)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: AppSpacing.md, bottom: AppSpacing.md, trailing: AppSpacing.md) + return section + } + } + } + + private func showControlSheet(for file: CloudFile) { + viewModel.setControlFile(file) + let sheet = CloudDriveActionSheetViewController(title: "请选择", items: [ + CloudDriveActionSheetViewController.Item( + title: "删除", + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_delete", fallbackSystemName: "trash"), + isDestructive: true + ) { [weak self] in self?.confirmDelete() }, + CloudDriveActionSheetViewController.Item( + title: "下载", + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_download", fallbackSystemName: "arrow.down.circle"), + isDestructive: false + ) { [weak self] in self?.download(file) }, + CloudDriveActionSheetViewController.Item( + title: "移动", + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_move", fallbackSystemName: "folder"), + isDestructive: false + ) { [weak self] in self?.showMoveDialog() }, + CloudDriveActionSheetViewController.Item( + title: "详情", + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_item_details", fallbackSystemName: "info.circle"), + isDestructive: false + ) { [weak self] in self?.showDetailDialog(file) } + ]) + present(sheet, animated: true) + } + + private func confirmDelete() { + showAlert(title: "删除确认", message: "删除后无法恢复,确认删除吗?") { [weak self] in + guard let self else { return } + Task { await self.viewModel.deleteCurrentFile(api: self.api) } + } + } + + private func download(_ file: CloudFile) { + guard file.isSelectableMedia else { + showToast("当前类型不支持下载") + return + } + guard !file.fileUrl.isEmpty else { + showToast("文件地址不存在") + return + } + transferManager.addDownloadTask(fileURL: file.fileUrl, fileName: file.name, fileType: file.type, fileSize: file.fileSize) + } + + private func showMoveDialog() { + let items = viewModel.moveTargetFolders().map { folder in + CloudDriveActionSheetViewController.Item( + title: folder.name, + image: CloudDriveAsset.image(named: "icon_cloud_storage_file_type_folder", fallbackSystemName: "folder"), + isDestructive: false + ) { [weak self] in + guard let self else { return } + Task { await self.viewModel.moveCurrentFile(targetFolderId: folder.id, api: self.api) } + } + } + present(CloudDriveActionSheetViewController(title: "移动到", items: items), animated: true) + } + + private func showDetailDialog(_ file: CloudFile) { + let sheet = CloudDriveDetailSheetViewController(file: file) { [weak self] name in + guard let self else { return } + Task { await self.viewModel.saveCurrentFileName(name, api: self.api) } + } + present(sheet, animated: true) + } + + @objc private func uploadTapped() { + let sheet = CloudDriveActionSheetViewController(title: "选择上传类型", items: [ + CloudDriveActionSheetViewController.Item( + title: "上传图片", + image: CloudDriveAsset.image(named: "icon_cloud_storage_add_photo", fallbackSystemName: "photo"), + isDestructive: false + ) { [weak self] in self?.beginPick(type: 1) }, + CloudDriveActionSheetViewController.Item( + title: "上传视频", + image: CloudDriveAsset.image(named: "icon_cloud_storage_add_video", fallbackSystemName: "video"), + isDestructive: false + ) { [weak self] in self?.beginPick(type: 2) }, + CloudDriveActionSheetViewController.Item( + title: "新建文件夹", + image: CloudDriveAsset.image(named: "icon_cloud_storage_add_folder", fallbackSystemName: "folder.badge.plus"), + isDestructive: false + ) { [weak self] in self?.showCreateFolderDialog() } + ]) + present(sheet, animated: true) + } + + private func beginPick(type: Int) { + Task { + guard await viewModel.checkCanUpload(api: api) else { return } + await MainActor.run { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.selectionLimit = 1 + configuration.filter = type == 1 ? .images : .videos + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + picker.view.tag = type + present(picker, animated: true) + } + } + } + + private func showCreateFolderDialog() { + guard viewModel.pathStack.count < 5 else { + showToast("不能创建更深的文件夹") + return + } + let alert = UIAlertController(title: "新建文件夹", message: nil, preferredStyle: .alert) + alert.addTextField { textField in + textField.placeholder = "请输入文件夹名称" + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self, weak alert] _ in + guard let self else { return } + let name = alert?.textFields?.first?.text ?? "" + Task { await self.viewModel.addFolder(name: name, api: self.api) } + }) + present(alert, animated: true) + } + + @objc private func displayModeTapped() { + viewModel.toggleDisplayMode() + } + + @objc private func openTransferManager() { + navigationController?.pushViewController(CloudDriveTransferViewController(manager: transferManager), animated: true) + } + + @objc private func homeTapped() { + Task { await viewModel.goHome(api: api) } + } + + @objc private func pathTapped(_ sender: UIButton) { + Task { await viewModel.navigateToPathIndex(sender.tag, api: api) } + } + + private func openPreview(file: CloudFile) { + navigationController?.pushViewController(CloudDrivePreviewViewController(file: file), animated: true) + } + + private func stagePickedFile(result: PHPickerResult, fileType: Int) { + let provider = result.itemProvider + let typeIdentifier = fileType == 2 ? UTType.image.identifier : UTType.movie.identifier + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { [weak self] url, error in + guard let self else { return } + if let error { + Task { @MainActor in self.showToast(error.localizedDescription) } + return + } + guard let url else { return } + do { + let stagedURL = try Self.copyPickedFileToStage(url: url, suggestedName: provider.suggestedName, fileType: fileType) + let size = (try? FileManager.default.attributesOfItem(atPath: stagedURL.path)[.size] as? NSNumber)?.int64Value ?? 0 + Task { @MainActor in + self.transferManager.addUploadTask( + localFileURL: stagedURL, + fileName: stagedURL.lastPathComponent, + fileType: fileType, + fileSize: size, + parentFolderId: self.viewModel.currentFolderID + ) + } + } catch { + Task { @MainActor in self.showToast(error.localizedDescription) } + } + } + } + + private static func copyPickedFileToStage(url: URL, suggestedName: String?, fileType: Int) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cloud_drive_uploads", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let ext = url.pathExtension.isEmpty ? (fileType == 2 ? "jpg" : "mp4") : url.pathExtension + let baseName = CloudTransferFileName.sanitized(suggestedName ?? "cloud_upload") + let fileName = baseName.lowercased().hasSuffix(".\(ext.lowercased())") ? baseName : "\(baseName).\(ext)" + let destination = directory.appendingPathComponent("\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))_\(fileName)") + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.copyItem(at: url, to: destination) + return destination + } +} + +extension CloudDriveListViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + viewModel.updateSearchText(textField.text ?? "") + textField.resignFirstResponder() + Task { await viewModel.refresh(api: api, showLoading: true) } + return true + } +} + +extension CloudDriveListViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let file = dataSource.itemIdentifier(for: indexPath) else { return } + Task { + if let preview = await viewModel.handleFileTap(file, api: api) { + await MainActor.run { self.openPreview(file: preview) } + } + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let frameHeight = scrollView.frame.size.height + if offsetY > contentHeight - frameHeight - 120 { + let lastIndex = max(0, viewModel.files.count - 1) + Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: lastIndex, api: api) } + } + } +} + +extension CloudDriveListViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + let type = picker.view.tag + picker.dismiss(animated: true) + guard let result = results.first else { return } + stagePickedFile(result: result, fileType: type == 2 ? 1 : 2) + } +} diff --git a/suixinkan/UI/CloudDrive/CloudDrivePreviewViewController.swift b/suixinkan/UI/CloudDrive/CloudDrivePreviewViewController.swift new file mode 100644 index 0000000..552a871 --- /dev/null +++ b/suixinkan/UI/CloudDrive/CloudDrivePreviewViewController.swift @@ -0,0 +1,75 @@ +// +// CloudDrivePreviewViewController.swift +// suixinkan +// + +import AVKit +import Kingfisher +import SnapKit +import UIKit + +/// 云盘文件预览页,黑底展示图片或视频。 +final class CloudDrivePreviewViewController: BaseViewController { + private let file: CloudFile + private let imageView = UIImageView() + private var playerViewController: AVPlayerViewController? + + /// 初始化预览页。 + init(file: CloudFile) { + self.file = file + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "" + navigationController?.navigationBar.tintColor = .white + } + + override func setupUI() { + view.backgroundColor = .black + if file.isVideo { + setupVideo() + } else { + setupImage() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.navigationBar.tintColor = AppColor.primary + playerViewController?.player?.pause() + } + + private func setupImage() { + imageView.contentMode = .scaleAspectFit + imageView.backgroundColor = .black + view.addSubview(imageView) + imageView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + if let url = URL(string: file.fileUrl) { + imageView.kf.setImage(with: url) + } + } + + private func setupVideo() { + guard let url = URL(string: file.fileUrl) else { return } + let player = AVPlayer(url: url) + let controller = AVPlayerViewController() + controller.player = player + controller.view.backgroundColor = .black + addChild(controller) + view.addSubview(controller.view) + controller.view.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + controller.didMove(toParent: self) + playerViewController = controller + player.play() + } +} diff --git a/suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift b/suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift new file mode 100644 index 0000000..b105817 --- /dev/null +++ b/suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift @@ -0,0 +1,234 @@ +// +// CloudDriveTransferViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 云盘传输管理页,对齐 Android `CloudStorageTransitScreen`。 +final class CloudDriveTransferViewController: BaseViewController { + private enum Tab: Int { + case upload + case download + } + + private let manager: CloudTransferManager + private let segmentedControl = UISegmentedControl(items: ["上传", "下载"]) + private let tableView = UITableView(frame: .zero, style: .plain) + private var selectedTab: Tab = .upload + + /// 初始化传输管理页。 + init(manager: CloudTransferManager = .shared) { + self.manager = manager + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "传输管理" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + segmentedControl.selectedSegmentIndex = selectedTab.rawValue + tableView.backgroundColor = AppColor.pageBackground + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.register(CloudTransferCell.self, forCellReuseIdentifier: CloudTransferCell.reuseIdentifier) + + view.addSubview(segmentedControl) + view.addSubview(tableView) + } + + override func setupConstraints() { + segmentedControl.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.height.equalTo(36) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.bottom.equalToSuperview() + } + } + + override func bindActions() { + segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged) + manager.onTasksChange = { [weak self] in + Task { @MainActor in self?.tableView.reloadData() } + } + } + + private var visibleTasks: [CloudTransferTask] { + selectedTab == .upload ? manager.uploadTasks : manager.downloadTasks + } + + @objc private func tabChanged() { + selectedTab = Tab(rawValue: segmentedControl.selectedSegmentIndex) ?? .upload + tableView.reloadData() + } +} + +extension CloudDriveTransferViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + visibleTasks.count + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + 112 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: CloudTransferCell.reuseIdentifier, for: indexPath) as! CloudTransferCell + let task = visibleTasks[indexPath.row] + cell.apply(task: task) + cell.onPrimaryAction = { [weak self] in + guard let self else { return } + if task.kind == .upload { + task.status == .uploading ? self.manager.pauseUpload(id: task.id) : self.manager.resumeUpload(id: task.id) + } else { + task.status == .downloading ? self.manager.pauseDownload(id: task.id) : self.manager.resumeDownload(id: task.id) + } + } + cell.onCancel = { [weak self] in + guard let self else { return } + task.kind == .upload ? self.manager.cancelUpload(id: task.id) : self.manager.cancelDownload(id: task.id) + } + return cell + } +} + +/// 云盘传输任务 Cell。 +private final class CloudTransferCell: UITableViewCell { + static let reuseIdentifier = "CloudTransferCell" + + var onPrimaryAction: (() -> Void)? + var onCancel: (() -> Void)? + + private let cardView = UIView() + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let statusLabel = UILabel() + private let primaryButton = UIButton(type: .system) + private let cancelButton = UIButton(type: .system) + private let progressView = UIProgressView(progressViewStyle: .default) + private let percentLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + onPrimaryAction = nil + onCancel = nil + } + + func apply(task: CloudTransferTask) { + iconView.image = CloudDriveAsset.image( + named: task.fileType == 1 ? "icon_cloud_storage_file_type_video" : "icon_cloud_storage_file_type_photo", + fallbackSystemName: task.fileType == 1 ? "video.fill" : "photo.fill" + ) + titleLabel.text = task.fileName + statusLabel.text = "\(task.status.title) \(CloudDriveFormatter.fileSize(task.fileSize))" + progressView.progress = Float(task.progress) / 100 + percentLabel.text = "\(task.progress)%" + let isRunning = task.status == .uploading || task.status == .downloading + primaryButton.setImage(UIImage(systemName: isRunning ? "pause.fill" : "play.fill"), for: .normal) + primaryButton.isHidden = task.status == .completed + } + + private func setupUI() { + backgroundColor = .clear + selectionStyle = .none + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppRadius.sm + cardView.clipsToBounds = true + + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + titleLabel.font = .app(.body) + titleLabel.textColor = AppColor.textPrimary + titleLabel.lineBreakMode = .byTruncatingMiddle + statusLabel.font = .app(.caption) + statusLabel.textColor = AppColor.textTertiary + progressView.progressTintColor = AppColor.primary + percentLabel.font = .app(.caption) + percentLabel.textColor = AppColor.textSecondary + percentLabel.textAlignment = .right + + primaryButton.tintColor = AppColor.primary + cancelButton.tintColor = AppColor.textSecondary + cancelButton.setImage(UIImage(systemName: "xmark"), for: .normal) + primaryButton.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside) + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + + contentView.addSubview(cardView) + cardView.addSubview(iconView) + cardView.addSubview(titleLabel) + cardView.addSubview(statusLabel) + cardView.addSubview(primaryButton) + cardView.addSubview(cancelButton) + cardView.addSubview(progressView) + cardView.addSubview(percentLabel) + } + + private func setupConstraints() { + cardView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(AppSpacing.xs) + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + } + iconView.snp.makeConstraints { make in + make.leading.top.equalToSuperview().offset(AppSpacing.sm) + make.width.height.equalTo(48) + } + cancelButton.snp.makeConstraints { make in + make.trailing.top.equalToSuperview().inset(AppSpacing.xs) + make.width.height.equalTo(36) + } + primaryButton.snp.makeConstraints { make in + make.trailing.equalTo(cancelButton.snp.leading) + make.centerY.equalTo(cancelButton) + make.width.height.equalTo(36) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.sm) + make.top.equalTo(iconView) + make.trailing.equalTo(primaryButton.snp.leading).offset(-AppSpacing.xs) + } + statusLabel.snp.makeConstraints { make in + make.leading.trailing.equalTo(titleLabel) + make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xxs) + } + progressView.snp.makeConstraints { make in + make.leading.equalTo(iconView) + make.trailing.equalToSuperview().inset(AppSpacing.sm) + make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.sm) + } + percentLabel.snp.makeConstraints { make in + make.top.equalTo(progressView.snp.bottom).offset(AppSpacing.xxs) + make.trailing.equalTo(progressView) + } + } + + @objc private func primaryTapped() { + onPrimaryAction?() + } + + @objc private func cancelTapped() { + onCancel?() + } +} diff --git a/suixinkan/UI/MessageCenter/MessageCenterViewController.swift b/suixinkan/UI/MessageCenter/MessageCenterViewController.swift new file mode 100644 index 0000000..f394c8a --- /dev/null +++ b/suixinkan/UI/MessageCenter/MessageCenterViewController.swift @@ -0,0 +1,309 @@ +// +// MessageCenterViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 消息中心列表页,对齐 Android `MsgScreen` 的列表展示与未读处理。 +final class MessageCenterViewController: BaseViewController, UITableViewDelegate { + private enum Section { + case main + } + + private let viewModel: MessageCenterViewModel + private let api: any MessageCenterServing + private let tableView = UITableView(frame: .zero, style: .plain) + private let refreshControl = UIRefreshControl() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let footerSpinner = UIActivityIndicatorView(style: .medium) + private let emptyLabel = UILabel() + + private var dataSource: UITableViewDiffableDataSource! + + /// 初始化消息中心列表页。 + init( + viewModel: MessageCenterViewModel = MessageCenterViewModel(), + api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI + ) { + self.viewModel = viewModel + self.api = api + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { @MainActor in + await viewModel.loadInitial(api: api) + } + } + + override func setupNavigationBar() { + title = "消息中心" + } + + override func setupUI() { + view.backgroundColor = UIColor(hex: 0xF4F4F4) + + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.delegate = self + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 120 + tableView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 10, right: 0) + tableView.refreshControl = refreshControl + tableView.register(MessageCenterCell.self, forCellReuseIdentifier: MessageCenterCell.reuseIdentifier) + + loadingIndicator.hidesWhenStopped = true + loadingIndicator.color = AppColor.primary + + footerSpinner.hidesWhenStopped = true + footerSpinner.color = AppColor.primary + + emptyLabel.text = "暂无消息" + emptyLabel.font = .app(.body) + emptyLabel.textColor = UIColor(hex: 0x7B8EAA) + emptyLabel.textAlignment = .center + + view.addSubview(tableView) + view.addSubview(loadingIndicator) + + configureDataSource() + } + + override func setupConstraints() { + tableView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + override func bindActions() { + refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) + viewModel.onStateChange = { [weak self] in + Task { @MainActor in + self?.applyState() + } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in + self?.showToast(message) + } + } + viewModel.onOpenDetail = { [weak self] message in + Task { @MainActor in + self?.openDetail(message) + } + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + Task { @MainActor in + await viewModel.selectMessage(id: item.id, api: api) + } + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + Task { @MainActor in + await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row, api: api) + } + } + + @objc private func refreshTriggered() { + Task { @MainActor in + await viewModel.refresh(api: api) + } + } + + private func configureDataSource() { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item in + let cell = tableView.dequeueReusableCell( + withIdentifier: MessageCenterCell.reuseIdentifier, + for: indexPath + ) as! MessageCenterCell + cell.configure(with: item) + return cell + } + applySnapshot(animated: false) + } + + private func applyState() { + applySnapshot(animated: true) + if viewModel.isRefreshing { + if !refreshControl.isRefreshing { refreshControl.beginRefreshing() } + } else { + refreshControl.endRefreshing() + } + + if viewModel.isLoading { + loadingIndicator.startAnimating() + tableView.backgroundView = nil + } else { + loadingIndicator.stopAnimating() + tableView.backgroundView = viewModel.items.isEmpty ? emptyBackgroundView() : nil + } + + tableView.tableFooterView = footerView() + } + + private func applySnapshot(animated: Bool) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(viewModel.items) + dataSource.apply(snapshot, animatingDifferences: animated) + } + + private func emptyBackgroundView() -> UIView { + let container = UIView(frame: tableView.bounds) + emptyLabel.frame = container.bounds + emptyLabel.autoresizingMask = [.flexibleWidth, .flexibleHeight] + container.addSubview(emptyLabel) + return container + } + + private func footerView() -> UIView? { + guard !viewModel.items.isEmpty, viewModel.isLoadingMore else { return nil } + let container = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 44)) + footerSpinner.center = CGPoint(x: container.bounds.midX, y: container.bounds.midY) + footerSpinner.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin] + container.addSubview(footerSpinner) + footerSpinner.startAnimating() + return container + } + + private func openDetail(_ message: MessageItem) { + let controller = MessageDetailViewController(message: message, api: api) + controller.onDeleted = { [weak self] id in + self?.viewModel.removeMessage(id: id) + } + navigationController?.pushViewController(controller, animated: true) + } +} + +/// 消息列表卡片 Cell,对齐 Android `MsgItem`。 +final class MessageCenterCell: UITableViewCell { + static let reuseIdentifier = "MessageCenterCell" + + private let cardView = UIView() + private let typeImageView = UIImageView() + private let titleLabel = UILabel() + private let timeLabel = UILabel() + private let unreadDot = UIView() + private let contentLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置消息卡片内容。 + func configure(with item: MessageItem) { + typeImageView.image = UIImage(named: MessageTypeIcon.assetName(for: item.type)) + titleLabel.text = item.displayTitle + timeLabel.text = item.displayTime + contentLabel.text = item.content + unreadDot.isHidden = item.isRead + } + + private func setupUI() { + backgroundColor = .clear + contentView.backgroundColor = .clear + selectionStyle = .none + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 12 + cardView.clipsToBounds = true + + typeImageView.contentMode = .scaleAspectFit + + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = UIColor(hex: 0x333333) + titleLabel.numberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingTail + + timeLabel.font = .systemFont(ofSize: 12, weight: .thin) + timeLabel.textColor = UIColor(hex: 0x7B8EAA) + timeLabel.numberOfLines = 1 + + unreadDot.backgroundColor = UIColor(hex: 0xEF4444) + unreadDot.layer.cornerRadius = 4 + unreadDot.clipsToBounds = true + + contentLabel.font = .systemFont(ofSize: 14) + contentLabel.textColor = UIColor(hex: 0x2E3746) + contentLabel.numberOfLines = 2 + contentLabel.lineBreakMode = .byTruncatingTail + + contentView.addSubview(cardView) + [typeImageView, titleLabel, timeLabel, unreadDot, contentLabel].forEach(cardView.addSubview) + } + + private func setupConstraints() { + cardView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(6) + make.leading.trailing.equalToSuperview().inset(16) + } + typeImageView.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(16) + make.size.equalTo(50) + } + unreadDot.snp.makeConstraints { make in + make.top.equalTo(typeImageView.snp.top).offset(2) + make.trailing.equalToSuperview().inset(16) + make.size.equalTo(8) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(typeImageView.snp.top).offset(1) + make.leading.equalTo(typeImageView.snp.trailing).offset(8) + make.trailing.lessThanOrEqualTo(unreadDot.snp.leading).offset(-8) + } + timeLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(1) + make.leading.equalTo(titleLabel) + make.trailing.equalToSuperview().inset(16) + } + contentLabel.snp.makeConstraints { make in + make.top.equalTo(typeImageView.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalToSuperview().inset(16) + } + } +} + +/// 消息类型图标映射,对齐 Android `getMessageTypeIcon`。 +enum MessageTypeIcon { + static func assetName(for type: Int) -> String { + switch type { + case 1, 7: + return "ic_msg1" + case 2: + return "ic_msg2" + case 3: + return "ic_msg3" + case 4: + return "ic_msg4" + case 5: + return "ic_msg5" + case 8: + return "ic_msg8" + default: + return "ic_msg999" + } + } +} diff --git a/suixinkan/UI/MessageCenter/MessageDetailViewController.swift b/suixinkan/UI/MessageCenter/MessageDetailViewController.swift new file mode 100644 index 0000000..ddc3f73 --- /dev/null +++ b/suixinkan/UI/MessageCenter/MessageDetailViewController.swift @@ -0,0 +1,169 @@ +// +// MessageDetailViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 消息详情页,对齐 Android `MsgDetailScreen`。 +final class MessageDetailViewController: BaseViewController { + var onDeleted: ((Int) -> Void)? + + private let viewModel: MessageDetailViewModel + private let api: any MessageCenterServing + + private let scrollView = UIScrollView() + private let contentView = UIView() + private let typeImageView = UIImageView() + private let titleLabel = UILabel() + private let timeContainer = UIView() + private let timeLabel = UILabel() + private let bodyLabel = UILabel() + private let bottomBar = UIView() + private let deleteButton = UIButton(type: .system) + + /// 初始化消息详情页。 + init(message: MessageItem, api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI) { + viewModel = MessageDetailViewModel(message: message) + self.api = api + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "消息详情" + } + + override func setupUI() { + view.backgroundColor = .white + + typeImageView.contentMode = .scaleAspectFit + typeImageView.image = UIImage(named: MessageTypeIcon.assetName(for: viewModel.message.type)) + + titleLabel.text = viewModel.message.displayTitle + titleLabel.font = .systemFont(ofSize: 20, weight: .bold) + titleLabel.textColor = .black + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + + timeContainer.backgroundColor = UIColor(hex: 0xF4F4F4) + timeContainer.layer.cornerRadius = 4 + timeContainer.clipsToBounds = true + + timeLabel.text = viewModel.message.createdAt + timeLabel.font = .systemFont(ofSize: 12) + timeLabel.textColor = UIColor(hex: 0x4B5563) + timeLabel.textAlignment = .center + timeLabel.numberOfLines = 1 + + bodyLabel.text = viewModel.message.content + bodyLabel.font = .systemFont(ofSize: 16) + bodyLabel.textColor = .black + bodyLabel.numberOfLines = 0 + bodyLabel.textAlignment = .left + + bottomBar.backgroundColor = .white + deleteButton.setTitle("删除并返回", for: .normal) + deleteButton.setTitleColor(.white, for: .normal) + deleteButton.titleLabel?.font = .systemFont(ofSize: 16) + deleteButton.backgroundColor = UIColor(hex: 0xEF4444) + deleteButton.layer.cornerRadius = 8 + deleteButton.clipsToBounds = true + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + [typeImageView, titleLabel, timeContainer, bodyLabel].forEach(contentView.addSubview) + timeContainer.addSubview(timeLabel) + view.addSubview(bottomBar) + bottomBar.addSubview(deleteButton) + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + deleteButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(32) + make.height.equalTo(52) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + contentView.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + make.height.greaterThanOrEqualTo(scrollView.frameLayoutGuide) + } + typeImageView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(32) + make.centerX.equalToSuperview() + make.size.equalTo(64) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(typeImageView.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(32) + } + timeContainer.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.centerX.equalToSuperview() + } + timeLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4)) + } + bodyLabel.snp.makeConstraints { make in + make.top.equalTo(timeContainer.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(32) + make.bottom.lessThanOrEqualToSuperview().inset(24) + } + } + + override func bindActions() { + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + viewModel.onStateChange = { [weak self] in + Task { @MainActor in + self?.applyState() + } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in + self?.showToast(message) + } + } + viewModel.onDeleted = { [weak self] id in + Task { @MainActor in + self?.onDeleted?(id) + self?.navigationController?.popViewController(animated: true) + } + } + } + + @objc private func deleteTapped() { + let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确认", style: .destructive) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + await self.viewModel.delete(api: self.api) + } + }) + present(alert, animated: true) + } + + private func applyState() { + deleteButton.isEnabled = !viewModel.isDeleting + deleteButton.alpha = viewModel.isDeleting ? 0.65 : 1 + if viewModel.isDeleting { + showLoading() + } else { + hideLoading() + } + } +} diff --git a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift index 52372db..7a2d37f 100644 --- a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift +++ b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift @@ -25,6 +25,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController { private let nicknameField = UITextField() private let editProfileButton = UIButton(type: .system) private let introductionTextView = UITextView() + private let introductionPlaceholderLabel = UILabel() private let certificationWrap = ProfileTagWrapView() private let attrWrap = ProfileTagWrapView() @@ -139,13 +140,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController { hideLoading() } saveButton.isEnabled = viewModel.hasChanges - avatarImageView.loadRemoteImage(urlString: viewModel.avatarURL) + updateAvatar(urlString: viewModel.avatarURL) realNameLabel.text = "姓名: \(viewModel.realName)" nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : "昵称: \(viewModel.nickname)" nicknameField.isEnabled = viewModel.isEditingProfile introductionTextView.isEditable = viewModel.isEditingProfile introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x565656) + updateIntroductionPlaceholder() editProfileButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "square.and.pencil"), for: .normal) certificationWrap.setItems(viewModel.scenicCertification, style: .certification, removable: false) attrWrap.setItems(viewModel.attrLabels, style: .label, removable: true) { [weak self] label in @@ -204,6 +206,9 @@ final class ProfileSpaceSettingsViewController: BaseViewController { nicknameField.font = .systemFont(ofSize: 16) nicknameField.textColor = .black nicknameField.borderStyle = .none + nicknameField.placeholder = "请输入昵称" + nicknameField.returnKeyType = .done + nicknameField.delegate = self nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged) editProfileButton.tintColor = AppColor.primary editProfileButton.snp.makeConstraints { make in @@ -221,6 +226,13 @@ final class ProfileSpaceSettingsViewController: BaseViewController { introductionTextView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(24) } + introductionPlaceholderLabel.text = "请输入个人简介" + introductionPlaceholderLabel.font = introductionTextView.font + introductionPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA) + introductionTextView.addSubview(introductionPlaceholderLabel) + introductionPlaceholderLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } textStack.addArrangedSubview(realNameLabel) textStack.addArrangedSubview(nicknameRow) @@ -357,6 +369,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController { row.alignment = .fill field.placeholder = placeholder field.font = .systemFont(ofSize: 14) + field.returnKeyType = .done + field.delegate = self field.layer.cornerRadius = 8 field.layer.borderColor = AppColor.border.cgColor field.layer.borderWidth = 1 @@ -390,12 +404,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController { titleLabel.text = title titleLabel.font = .systemFont(ofSize: 14) titleLabel.textColor = UIColor(hex: 0x565656) - titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) let toLabel = UILabel() toLabel.text = "至" toLabel.font = .systemFont(ofSize: 14) toLabel.textColor = UIColor(hex: 0x565656) + let spacer = UIView() row.addArrangedSubview(titleLabel) + row.addArrangedSubview(spacer) row.addArrangedSubview(start) row.addArrangedSubview(toLabel) row.addArrangedSubview(end) @@ -408,6 +424,24 @@ final class ProfileSpaceSettingsViewController: BaseViewController { return row } + private func updateAvatar(urlString: String?) { + let trimmed = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { + avatarImageView.kf.cancelDownloadTask() + avatarImageView.backgroundColor = AppColor.inputBackground + avatarImageView.image = UIImage(systemName: "person.crop.circle.fill") + avatarImageView.tintColor = UIColor(hex: 0xB6BECA) + avatarImageView.contentMode = .scaleAspectFit + return + } + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.loadRemoteImage(urlString: trimmed) + } + + private func updateIntroductionPlaceholder() { + introductionPlaceholderLabel.isHidden = !viewModel.isEditingProfile || !introductionTextView.text.isEmpty + } + private func updateStatusButtons() { for button in statusButtons { let selected = button.tag == viewModel.acceptOrderStatus @@ -553,6 +587,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController { let alert = UIAlertController(title: "添加设备", message: nil, preferredStyle: .alert) alert.addTextField { field in field.placeholder = "请输入设备名称" + field.returnKeyType = .done + field.delegate = self } alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in @@ -590,9 +626,25 @@ final class ProfileSpaceSettingsViewController: BaseViewController { } } -extension ProfileSpaceSettingsViewController: UITextViewDelegate { +extension ProfileSpaceSettingsViewController: UITextFieldDelegate, UITextViewDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + func textViewDidChange(_ textView: UITextView) { viewModel.updateIntroduction(textView.text) + updateIntroductionPlaceholder() + } + + func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + guard text == "\n" else { return true } + textView.resignFirstResponder() + return false } } @@ -636,6 +688,7 @@ final class ProfileAddScheduleViewController: BaseViewController { private let endButton = ProfileTimeButton() private let orderButton = UIButton(type: .system) private let remarkTextView = UITextView() + private let remarkPlaceholderLabel = UILabel() private let countLabel = UILabel() init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) { @@ -719,6 +772,8 @@ final class ProfileAddScheduleViewController: BaseViewController { private func configureNameField() { nameField.placeholder = "请输入日程名称" nameField.font = .systemFont(ofSize: 14) + nameField.returnKeyType = .done + nameField.delegate = self nameField.layer.borderColor = AppColor.border.cgColor nameField.layer.borderWidth = 1 nameField.layer.cornerRadius = 8 @@ -771,6 +826,17 @@ final class ProfileAddScheduleViewController: BaseViewController { remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor remarkTextView.layer.borderWidth = 1 remarkTextView.layer.cornerRadius = 8 + remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12) + remarkTextView.textContainer.lineFragmentPadding = 0 + remarkPlaceholderLabel.text = "请输入备注信息" + remarkPlaceholderLabel.font = remarkTextView.font + remarkPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA) + remarkTextView.addSubview(remarkPlaceholderLabel) + remarkPlaceholderLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(10) + make.leading.equalToSuperview().offset(12) + make.trailing.equalToSuperview().inset(12) + } countLabel.text = "0/200" countLabel.textAlignment = .right countLabel.font = .systemFont(ofSize: 12) @@ -845,13 +911,29 @@ final class ProfileAddScheduleViewController: BaseViewController { } } -extension ProfileAddScheduleViewController: UITextViewDelegate { +extension ProfileAddScheduleViewController: UITextFieldDelegate, UITextViewDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + func textViewDidChange(_ textView: UITextView) { if textView.text.count > 200 { textView.text = String(textView.text.prefix(200)) } + remarkPlaceholderLabel.isHidden = !textView.text.isEmpty countLabel.text = "\(textView.text.count)/200" } + + func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + guard text == "\n" else { return true } + textView.resignFirstResponder() + return false + } } /// 个人空间配置时间按钮。 diff --git a/suixinkanTests/CloudDriveAPITests.swift b/suixinkanTests/CloudDriveAPITests.swift new file mode 100644 index 0000000..ae41188 --- /dev/null +++ b/suixinkanTests/CloudDriveAPITests.swift @@ -0,0 +1,71 @@ +// +// CloudDriveAPITests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 相册云盘 API 测试。 +final class CloudDriveAPITests: XCTestCase { + func testCloudDriveRequestsMatchAndroidPaths() async throws { + let session = MockURLSession(responses: [ + envelopeJSON(#"{"total":0,"list":[]}"#), + envelopeJSON("{}"), + envelopeJSON("{}"), + envelopeJSON("{}"), + envelopeJSON("{}"), + envelopeJSON(#"{"can_upload":false,"reason":"空间不足"}"#), + envelopeJSON("{}"), + envelopeJSON("{}"), + ]) + let api = CloudDriveAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.cloudFileList(parentFolderId: 4, name: "abc", type: 2, orderBy: 1, page: 3, pageSize: 20) + try await api.createCloudFolder(parentFolderId: 4, name: "新文件夹") + try await api.uploadCloudFile(parentFolderId: 4, fileUrl: "https://cdn/a.jpg", fileName: "a.jpg") + try await api.deleteCloudFiles(CloudFileDeleteRequest(list: [CloudFileOperationItem(id: 7, type: 2)])) + try await api.moveCloudFiles(CloudFileMoveRequest(targetFolderId: 9, list: [CloudFileOperationItem(id: 7, type: 2)])) + _ = try await api.checkUploadPermission() + try await api.modifyCloudFolderName(fileId: 8, name: "目录") + try await api.modifyCloudFileName(fileId: 7, fileName: "b.jpg") + + XCTAssertEqual(session.requests[0].httpMethod, "GET") + XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/cloud-driver/list") + let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "parent_folder_id" }?.value, "4") + XCTAssertEqual(query?.first { $0.name == "name" }?.value, "abc") + XCTAssertEqual(query?.first { $0.name == "type" }?.value, "2") + XCTAssertEqual(query?.first { $0.name == "order_by" }?.value, "1") + XCTAssertEqual(query?.first { $0.name == "page" }?.value, "3") + + XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/cloud-driver/folder-create") + let folderBody = try bodyObject(session.requests[1]) + XCTAssertEqual(folderBody["parent_folder_id"] as? Int, 4) + XCTAssertEqual(folderBody["name"] as? String, "新文件夹") + + XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/cloud-driver/file-upload") + let uploadBody = try bodyObject(session.requests[2]) + XCTAssertEqual(uploadBody["file_url"] as? String, "https://cdn/a.jpg") + XCTAssertEqual(uploadBody["file_name"] as? String, "a.jpg") + + XCTAssertEqual(session.requests[3].url?.path, "/api/yf-handset-app/photog/cloud-driver/delete") + XCTAssertEqual(session.requests[4].url?.path, "/api/yf-handset-app/photog/cloud-driver/move") + let moveBody = try bodyObject(session.requests[4]) + XCTAssertEqual(moveBody["target_folder_id"] as? Int, 9) + XCTAssertEqual(session.requests[5].url?.path, "/api/yf-handset-app/photog/cloud-driver/check-upload-permission") + XCTAssertEqual(session.requests[6].url?.path, "/api/yf-handset-app/photog/cloud-driver/folder-edit") + XCTAssertEqual(session.requests[7].url?.path, "/api/yf-handset-app/photog/cloud-driver/file-edit") + } + + private func bodyObject(_ request: URLRequest) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]) + } + + private func envelopeJSON(_ dataJSON: String) -> Data { + """ + {"code":100000,"msg":"success","data":\(dataJSON)} + """.data(using: .utf8)! + } +} diff --git a/suixinkanTests/CloudDriveViewModelTests.swift b/suixinkanTests/CloudDriveViewModelTests.swift new file mode 100644 index 0000000..d278346 --- /dev/null +++ b/suixinkanTests/CloudDriveViewModelTests.swift @@ -0,0 +1,139 @@ +// +// CloudDriveViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 相册云盘 ViewModel 测试。 +final class CloudDriveViewModelTests: XCTestCase { + func testLoadMoreSearchFilterSortAndFolderNavigation() async throws { + let api = MockCloudDriveAPI() + api.listResponses = [ + CloudFileListResponse(total: 3, list: [file(id: 1), folder(id: 2)]), + CloudFileListResponse(total: 3, list: [file(id: 3)]), + CloudFileListResponse(total: 1, list: [file(id: 4)]), + CloudFileListResponse(total: 0, list: []), + ] + let viewModel = CloudDriveListViewModel() + + await viewModel.refresh(api: api) + await viewModel.loadMoreIfNeeded(lastVisibleIndex: 1, api: api) + viewModel.updateSearchText("图") + await viewModel.chooseFilterType(.image, api: api) + _ = await viewModel.handleFileTap(folder(id: 9), api: api) + + XCTAssertEqual(viewModel.files.map(\.id), []) + XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 9]) + XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1, 1]) + XCTAssertEqual(api.listCalls[2].name, "图") + XCTAssertEqual(api.listCalls[2].type, 2) + } + + func testBackRestoresCachedRootAndGoHome() async { + let api = MockCloudDriveAPI() + api.listResponses = [ + CloudFileListResponse(total: 1, list: [folder(id: 2)]), + CloudFileListResponse(total: 1, list: [file(id: 5)]), + ] + let viewModel = CloudDriveListViewModel() + await viewModel.refresh(api: api) + _ = await viewModel.handleFileTap(folder(id: 2), api: api) + + let handled = await viewModel.navigateBack(api: api) + + XCTAssertTrue(handled) + XCTAssertEqual(viewModel.pathStack.map(\.id), [0]) + XCTAssertEqual(viewModel.files.map(\.id), [2]) + XCTAssertEqual(api.listCalls.count, 2) + } + + func testCreateDeleteMoveAndRenameRefresh() async { + let api = MockCloudDriveAPI() + api.listResponses = Array(repeating: CloudFileListResponse(), count: 5) + let viewModel = CloudDriveListViewModel() + + await viewModel.addFolder(name: " 新目录 ", api: api) + viewModel.setControlFile(file(id: 7, type: 2)) + await viewModel.deleteCurrentFile(api: api) + await viewModel.moveCurrentFile(targetFolderId: 9, api: api) + await viewModel.saveCurrentFileName(" 新名.jpg ", api: api) + viewModel.setControlFile(folder(id: 8)) + await viewModel.saveCurrentFileName(" 新目录名 ", api: api) + + XCTAssertEqual(api.createFolderCalls.first?.name, "新目录") + XCTAssertEqual(api.deleteCalls.first?.list.first?.id, 7) + XCTAssertEqual(api.moveCalls.first?.targetFolderId, 9) + XCTAssertEqual(api.renameFileCalls.first?.fileName, "新名.jpg") + XCTAssertEqual(api.renameFolderCalls.first?.name, "新目录名") + } + + func testUploadPermissionFailureShowsReason() async { + let api = MockCloudDriveAPI() + api.permission = CloudUploadPermissionResponse(canUpload: false, reason: "空间不足") + let viewModel = CloudDriveListViewModel() + var message: String? + viewModel.onShowMessage = { message = $0 } + + let canUpload = await viewModel.checkCanUpload(api: api) + + XCTAssertFalse(canUpload) + XCTAssertEqual(message, "空间不足") + } + + private func file(id: Int, type: Int = 2) -> CloudFile { + CloudFile(id: id, fileUrl: "https://cdn/\(id).jpg", name: "文件\(id)", type: type) + } + + private func folder(id: Int) -> CloudFile { + CloudFile(id: id, name: "目录\(id)", type: 99) + } +} + +@MainActor +private final class MockCloudDriveAPI: CloudDriveServing { + var listResponses: [CloudFileListResponse] = [] + var permission = CloudUploadPermissionResponse(canUpload: true, reason: "") + var listCalls: [(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int)] = [] + var createFolderCalls: [(parentFolderId: Int, name: String)] = [] + var uploadCalls: [(parentFolderId: Int, fileUrl: String, fileName: String)] = [] + var deleteCalls: [CloudFileDeleteRequest] = [] + var moveCalls: [CloudFileMoveRequest] = [] + var renameFolderCalls: [(fileId: Int, name: String)] = [] + var renameFileCalls: [(fileId: Int, fileName: String)] = [] + + func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> CloudFileListResponse { + listCalls.append((parentFolderId, name, type, orderBy, page, pageSize)) + return listResponses.isEmpty ? CloudFileListResponse() : listResponses.removeFirst() + } + + func createCloudFolder(parentFolderId: Int, name: String) async throws { + createFolderCalls.append((parentFolderId, name)) + } + + func uploadCloudFile(parentFolderId: Int, fileUrl: String, fileName: String) async throws { + uploadCalls.append((parentFolderId, fileUrl, fileName)) + } + + func deleteCloudFiles(_ request: CloudFileDeleteRequest) async throws { + deleteCalls.append(request) + } + + func moveCloudFiles(_ request: CloudFileMoveRequest) async throws { + moveCalls.append(request) + } + + func checkUploadPermission() async throws -> CloudUploadPermissionResponse { + permission + } + + func modifyCloudFolderName(fileId: Int, name: String) async throws { + renameFolderCalls.append((fileId, name)) + } + + func modifyCloudFileName(fileId: Int, fileName: String) async throws { + renameFileCalls.append((fileId, fileName)) + } +} diff --git a/suixinkanTests/CloudTransferManagerTests.swift b/suixinkanTests/CloudTransferManagerTests.swift new file mode 100644 index 0000000..8dec4cd --- /dev/null +++ b/suixinkanTests/CloudTransferManagerTests.swift @@ -0,0 +1,185 @@ +// +// CloudTransferManagerTests.swift +// suixinkanTests +// + +import Foundation +import XCTest +@testable import suixinkan + +@MainActor +/// 云盘传输管理测试。 +final class CloudTransferManagerTests: XCTestCase { + func testUploadRegistersFileAndRemovesCompletedTask() async throws { + let store = MemoryTransferStore() + let uploader = FakeCloudUploader(url: "https://cdn/a.jpg") + let api = TransferMockCloudDriveAPI() + let manager = CloudTransferManager( + store: store, + uploader: uploader, + api: api, + downloader: FakeDownloader(), + photoSaver: FakePhotoSaver(), + scenicIdProvider: { 10 } + ) + let fileURL = try makeTempFile(name: "a.jpg") + var completedFolder: Int? + manager.onUploadCompleted = { completedFolder = $0 } + + manager.addUploadTask(localFileURL: fileURL, fileName: "a.jpg", fileType: 2, fileSize: 3, parentFolderId: 6) + try await waitUntil { manager.uploadTasks.isEmpty } + + XCTAssertEqual(api.uploadCalls.first?.parentFolderId, 6) + XCTAssertEqual(api.uploadCalls.first?.fileUrl, "https://cdn/a.jpg") + XCTAssertEqual(completedFolder, 6) + XCTAssertTrue(store.saved.last?.allSatisfy { $0.kind != .upload } == true) + } + + func testUploadFailureKeepsFailedTask() async throws { + let uploader = FakeCloudUploader(error: TestError.failed) + let manager = CloudTransferManager( + store: MemoryTransferStore(), + uploader: uploader, + api: TransferMockCloudDriveAPI(), + downloader: FakeDownloader(), + photoSaver: FakePhotoSaver(), + scenicIdProvider: { 10 } + ) + let fileURL = try makeTempFile(name: "a.jpg") + + manager.addUploadTask(localFileURL: fileURL, fileName: "a.jpg", fileType: 2, fileSize: 3, parentFolderId: 0) + try await waitUntil { manager.uploadTasks.first?.status == .failed } + + XCTAssertEqual(manager.uploadTasks.first?.status, .failed) + } + + func testDownloadCompletesAndCanCancel() async throws { + let downloader = FakeDownloader() + let saver = FakePhotoSaver() + let manager = CloudTransferManager( + store: MemoryTransferStore(), + uploader: FakeCloudUploader(url: "unused"), + api: TransferMockCloudDriveAPI(), + downloader: downloader, + photoSaver: saver, + scenicIdProvider: { 10 } + ) + + manager.addDownloadTask(fileURL: "https://cdn/a.jpg", fileName: "a.jpg", fileType: 2) + try await waitUntil { manager.downloadTasks.first?.status == .completed } + + XCTAssertEqual(downloader.downloadCalls.first?.remoteURL, "https://cdn/a.jpg") + XCTAssertEqual(saver.saved.count, 1) + XCTAssertEqual(manager.downloadTasks.first?.progress, 100) + + let id = try XCTUnwrap(manager.downloadTasks.first?.id) + manager.cancelDownload(id: id) + XCTAssertTrue(manager.downloadTasks.isEmpty) + } + + private func makeTempFile(name: String) throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + name) + try Data([1, 2, 3]).write(to: url) + return url + } + + private func waitUntil( + timeout: TimeInterval = 2, + condition: @escaping () -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + if Date() > deadline { + XCTFail("Timed out waiting for condition") + return + } + try await Task.sleep(nanoseconds: 20_000_000) + } + } +} + +private final class MemoryTransferStore: CloudTransferTaskStoring { + var tasks: [CloudTransferTask] = [] + var saved: [[CloudTransferTask]] = [] + + func loadTasks() -> [CloudTransferTask] { + tasks + } + + func saveTasks(_ tasks: [CloudTransferTask]) { + self.tasks = tasks + saved.append(tasks) + } +} + +@MainActor +private final class FakeCloudUploader: CloudDriveUploading { + let url: String + let error: Error? + + init(url: String = "", error: Error? = nil) { + self.url = url + self.error = error + } + + func uploadCloudDriveFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let error { throw error } + onProgress(50) + onProgress(100) + return url + } +} + +private final class FakeDownloader: CloudFileDownloading { + var downloadCalls: [(remoteURL: String, fileName: String)] = [] + + func downloadFile(remoteURL: String, fileName: String, onProgress: @escaping @Sendable (Int) -> Void) async throws -> URL { + downloadCalls.append((remoteURL, fileName)) + onProgress(40) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + fileName) + try Data([1]).write(to: url) + onProgress(100) + return url + } +} + +private final class FakePhotoSaver: CloudPhotoSaving { + var saved: [(fileURL: URL, fileType: Int)] = [] + + func saveToPhotoLibrary(fileURL: URL, fileType: Int) async throws { + saved.append((fileURL, fileType)) + } +} + +@MainActor +private final class TransferMockCloudDriveAPI: CloudDriveServing { + var uploadCalls: [(parentFolderId: Int, fileUrl: String, fileName: String)] = [] + + func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> CloudFileListResponse { + CloudFileListResponse() + } + + func createCloudFolder(parentFolderId: Int, name: String) async throws {} + + func uploadCloudFile(parentFolderId: Int, fileUrl: String, fileName: String) async throws { + uploadCalls.append((parentFolderId, fileUrl, fileName)) + } + + func deleteCloudFiles(_ request: CloudFileDeleteRequest) async throws {} + + func moveCloudFiles(_ request: CloudFileMoveRequest) async throws {} + + func checkUploadPermission() async throws -> CloudUploadPermissionResponse { + CloudUploadPermissionResponse(canUpload: true, reason: "") + } + + func modifyCloudFolderName(fileId: Int, name: String) async throws {} + + func modifyCloudFileName(fileId: Int, fileName: String) async throws {} +} + +private enum TestError: LocalizedError { + case failed + + var errorDescription: String? { "failed" } +} diff --git a/suixinkanTests/MessageCenterAPITests.swift b/suixinkanTests/MessageCenterAPITests.swift new file mode 100644 index 0000000..6cf69e6 --- /dev/null +++ b/suixinkanTests/MessageCenterAPITests.swift @@ -0,0 +1,83 @@ +// +// MessageCenterAPITests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 消息中心 API 路径、参数与模型解码测试。 +@MainActor +final class MessageCenterAPITests: XCTestCase { + func testListBuildsAndroidQueryAndDecodesMessagePayload() async throws { + let payload = """ + { + "has_more": true, + "last_id": 18, + "is_refresh": false, + "items": [{ + "id": 21, + "receiver_id": 9, + "receiver_type": "staff", + "type": 2, + "type_name": "退款成功通知", + "title": "unused", + "content": "退款已完成", + "push_at": "2026-07-09 10:00:00", + "is_read": false, + "read_at": "", + "push_channel": 1, + "push_channel_name": "站内信", + "extra_data": {"order_no":"NO123","amount":12.5}, + "created_at": "2026-07-09 10:00:00", + "updated_at": "2026-07-09 10:01:00" + }] + } + """ + let session = MockURLSession(responses: [envelopeJSON(payload)]) + let api = MessageCenterAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.list(lastId: 7, limit: 20, unread: 0) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/msg/list") + let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "last_id" }?.value, "7") + XCTAssertEqual(query?.first { $0.name == "limit" }?.value, "20") + XCTAssertEqual(query?.first { $0.name == "unread" }?.value, "0") + XCTAssertTrue(response.hasMore) + XCTAssertEqual(response.lastId, 18) + XCTAssertEqual(response.items.first?.id, 21) + XCTAssertEqual(response.items.first?.isRead, false) + XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123")) + } + + func testReadAndDeleteUseAndroidPathsAndBody() async throws { + let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}")]) + let api = MessageCenterAPI(client: APIClient(environment: .testing, session: session)) + + try await api.markAsRead(messageId: 21) + try await api.delete(messageId: 21) + + XCTAssertEqual(session.requests[0].httpMethod, "POST") + XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/read") + let readQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(readQuery?.first { $0.name == "id" }?.value, "21") + + XCTAssertEqual(session.requests[1].httpMethod, "POST") + XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/delete") + let deleteBody = try bodyJSON(session.requests[1]) + XCTAssertEqual(deleteBody["id"] as? Int, 21) + } + + private func envelopeJSON(_ dataJSON: String) -> Data { + """ + {"code":100000,"msg":"success","data":\(dataJSON)} + """.data(using: .utf8)! + } + + private func bodyJSON(_ request: URLRequest) throws -> [String: Any] { + try XCTUnwrap(JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]) + } +} diff --git a/suixinkanTests/MessageCenterViewModelTests.swift b/suixinkanTests/MessageCenterViewModelTests.swift new file mode 100644 index 0000000..00825da --- /dev/null +++ b/suixinkanTests/MessageCenterViewModelTests.swift @@ -0,0 +1,171 @@ +// +// MessageCenterViewModelTests.swift +// suixinkanTests +// + +import Foundation +import XCTest +@testable import suixinkan + +/// 消息中心 ViewModel 测试。 +@MainActor +final class MessageCenterViewModelTests: XCTestCase { + func testInitialLoadAndLoadMoreUseLastIdUntilNoMore() async { + let api = MockMessageCenterAPI() + api.listResponses = [ + MessageListResponse(hasMore: true, lastId: 8, items: [makeMessage(id: 1), makeMessage(id: 8)]), + MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 9)]), + ] + let viewModel = MessageCenterViewModel() + + await viewModel.loadInitial(api: api) + await viewModel.loadMoreIfNeeded(lastVisibleIndex: 1, api: api) + await viewModel.loadMoreIfNeeded(lastVisibleIndex: 2, api: api) + + XCTAssertEqual(api.listCalls.map(\.lastId), [0, 8]) + XCTAssertEqual(api.listCalls.map(\.limit), [20, 20]) + XCTAssertEqual(api.listCalls.map(\.unread), [0, 0]) + XCTAssertEqual(viewModel.items.map(\.id), [1, 8, 9]) + XCTAssertFalse(viewModel.canLoadMore) + } + + func testUnreadSelectionMarksReadAndOpensDetail() async { + let api = MockMessageCenterAPI() + api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 3, isRead: false)])] + let viewModel = MessageCenterViewModel() + var opened: MessageItem? + viewModel.onOpenDetail = { opened = $0 } + + await viewModel.loadInitial(api: api) + await viewModel.selectMessage(id: 3, api: api) + + XCTAssertEqual(api.readCalls, [3]) + XCTAssertEqual(opened?.id, 3) + XCTAssertEqual(opened?.isRead, true) + XCTAssertEqual(viewModel.items.first?.isRead, true) + } + + func testReadSelectionOpensDetailWithoutCallingReadAPI() async { + let api = MockMessageCenterAPI() + api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 4, isRead: true)])] + let viewModel = MessageCenterViewModel() + var opened: MessageItem? + viewModel.onOpenDetail = { opened = $0 } + + await viewModel.loadInitial(api: api) + await viewModel.selectMessage(id: 4, api: api) + + XCTAssertTrue(api.readCalls.isEmpty) + XCTAssertEqual(opened?.id, 4) + } + + func testUnreadSelectionFailureShowsMessageAndDoesNotOpenDetail() async { + let api = MockMessageCenterAPI() + api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 5, isRead: false)])] + api.readError = TestError(message: "fail") + let viewModel = MessageCenterViewModel() + var message: String? + var didOpen = false + viewModel.onShowMessage = { message = $0 } + viewModel.onOpenDetail = { _ in didOpen = true } + + await viewModel.loadInitial(api: api) + await viewModel.selectMessage(id: 5, api: api) + + XCTAssertEqual(message, "标记已读失败") + XCTAssertFalse(didOpen) + XCTAssertEqual(viewModel.items.first?.isRead, false) + } + + func testDeleteSuccessRemovesMessageAndReportsDeletedId() async { + let api = MockMessageCenterAPI() + let listViewModel = MessageCenterViewModel() + api.listResponses = [MessageListResponse(items: [makeMessage(id: 7)])] + await listViewModel.loadInitial(api: api) + + let detailViewModel = MessageDetailViewModel(message: makeMessage(id: 7)) + var deletedId: Int? + var message: String? + detailViewModel.onDeleted = { deletedId = $0 } + detailViewModel.onShowMessage = { message = $0 } + + await detailViewModel.delete(api: api) + listViewModel.removeMessage(id: deletedId ?? 0) + + XCTAssertEqual(api.deleteCalls, [7]) + XCTAssertEqual(deletedId, 7) + XCTAssertEqual(message, "删除成功") + XCTAssertTrue(listViewModel.items.isEmpty) + } + + func testDeleteInvalidIdAndFailureMessages() async { + let api = MockMessageCenterAPI() + let invalidViewModel = MessageDetailViewModel(message: makeMessage(id: 0)) + var invalidMessage: String? + invalidViewModel.onShowMessage = { invalidMessage = $0 } + + await invalidViewModel.delete(api: api) + + XCTAssertEqual(invalidMessage, "消息ID无效") + XCTAssertTrue(api.deleteCalls.isEmpty) + + api.deleteError = TestError(message: "server down") + let failingViewModel = MessageDetailViewModel(message: makeMessage(id: 9)) + var failureMessage: String? + failingViewModel.onShowMessage = { failureMessage = $0 } + + await failingViewModel.delete(api: api) + + XCTAssertEqual(failureMessage, "删除失败: server down") + } +} + +@MainActor +private final class MockMessageCenterAPI: MessageCenterServing { + var listResponses: [MessageListResponse] = [] + var listCalls: [(lastId: Int, limit: Int, unread: Int)] = [] + var readCalls: [Int] = [] + var deleteCalls: [Int] = [] + var readError: Error? + var deleteError: Error? + + func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse { + listCalls.append((lastId, limit, unread)) + guard !listResponses.isEmpty else { return MessageListResponse() } + return listResponses.removeFirst() + } + + func markAsRead(messageId: Int) async throws { + readCalls.append(messageId) + if let readError { throw readError } + } + + func delete(messageId: Int) async throws { + deleteCalls.append(messageId) + if let deleteError { throw deleteError } + } +} + +private struct TestError: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem { + MessageItem( + id: id, + receiverId: 1, + receiverType: "staff", + type: 1, + typeName: "系统通知", + title: "系统通知", + content: "消息内容", + pushAt: "", + isRead: isRead, + readAt: "", + pushChannel: 1, + pushChannelName: "站内信", + createdAt: "2026-07-09 10:00:00", + updatedAt: "2026-07-09 10:00:00" + ) +} diff --git a/应用启动流程与接口数据存储梳理.md b/应用启动流程与接口数据存储梳理.md new file mode 100644 index 0000000..0384ad7 --- /dev/null +++ b/应用启动流程与接口数据存储梳理.md @@ -0,0 +1,682 @@ +# 应用启动流程与接口数据存储梳理 + +本文按当前 iOS 工程代码梳理应用从冷启动到各首屏展示期间发生的初始化、接口调用顺序,以及接口数据和本地状态的存储方式。范围仅覆盖当前 iOS 实现,不额外补充 Android 中存在但 iOS 尚未接入的能力。 + +## 1. 冷启动立即发生 + +冷启动入口在 `AppDelegate.application(_:didFinishLaunchingWithOptions:)`。 + +1. 调用 `UmengBootstrap.configureIfNeeded()`。 + - 友盟初始化受 `AppStore.shared.privacyAgreementAccepted` 控制。 + - 未同意隐私协议时直接返回,不初始化 SDK。 +2. 调用 `WeChatManager.shared.registerIfNeeded()`。 + - 校验 Universal Link。 + - 调用微信 SDK `WXApi.registerApp` 注册 AppID。 +3. 若 `AppStore.shared.privacyAgreementAccepted == true` 且 `AppStore.shared.token` 非空,则调用 `AMapBootstrap.configureIfNeeded()`。 + - 写入高德隐私合规状态。 + - 设置 `AMapServices.shared().apiKey` 和 HTTPS。 +4. 此阶段不直接调用任何业务后端接口。 + +随后系统进入 `SceneDelegate.scene(_:willConnectTo:options:)`。 + +1. 调用 `AppNavigationBarAppearance.applyGlobalAppearance()` 设置全局导航栏样式。 +2. 创建 `UIWindow`。 +3. 设置 `window.rootViewController = AppRouter.makeRootViewController()`。 +4. `AppRouter` 通过 `AppStore.shared.isLoggedIn` 判断根页面: + - token 非空:进入 `MainTabBarController`。 + - token 为空:进入 `UINavigationController(rootViewController: LoginViewController())`。 +5. 注册全局通知: + - `sessionDidExpire`:清除登录态并切回登录页。 + - `userDidLogout`:清除登录态并切回登录页。 + - `userDidLogin`:切到主 Tab。 + +## 2. 网络依赖初始化 + +`NetworkServices.shared` 是全局网络依赖容器,不在 `AppDelegate` 中主动创建,而是在页面或 Tab 首次访问业务 API 时懒加载。 + +初始化顺序如下: + +1. 创建 `APIClient(environment: .current)`。 +2. 用同一个 `APIClient` 创建各业务 API: + - `AuthAPI` + - `ProfileAPI` + - `StatisticsAPI` + - `OrderAPI` + - `HomeAPI` + - 以及其他业务模块 API。 +3. 创建 `OSSUploadService(configService: uploadAPI)`。 +4. 调用 `client.bindAuthTokenProvider`,从 `AppStore.shared.token` 读取 token。 + +`APIClient` 的公共请求规则: + +- `APIEnvironment.current` + - Debug:`https://api-test.zhifly.cn` + - Release:`https://api.zhifly.cn` +- 每个请求默认写入 Header: + - `Content-Type: application/json` + - `Accept: application/json` + - `X-APP-VERSION` + - `X-OS-TYPE: iOS` + - token 非空时写入 `token` +- 响应通过 `APIEnvelope` 解包。 + - 成功业务码:`100000` + - 非成功业务码抛出 `APIError.serverCode` + - 认证失效时广播 `sessionDidExpire`,由 `SceneDelegate` 清会话并回登录页。 + +## 3. 未登录启动与登录流程 + +未登录时根页面为登录页。 + +### 3.1 登录页展示前 + +`LoginViewController.setupUI` 会调用 `viewModel.applyStoredPreferences()`,从 `AppStore` 读取: + +- `lastLoginUsername` +- `privacyAgreementAccepted` + +这一步只恢复本地表单状态,不调用后端接口。 + +### 3.2 登录页出现后 + +`LoginViewController.viewDidAppear` 调用: + +```text +GET /api/app/config +``` + +调用链: + +```text +LoginViewController.viewDidAppear +-> LoginViewModel.loadAppConfig(authAPI:) +-> AuthAPI.getAppConfig() +``` + +用途: + +- 读取 App 配置。 +- 当前只使用 `enableRegister` 控制验证码登录/注册入口显隐。 +- 配置加载失败会静默忽略,不影响密码登录。 +- 该结果只保存在 `LoginViewModel.enableRegister` 内存态,不写入 `AppStore`。 + +### 3.3 点击登录后的接口顺序 + +用户点击登录按钮后,先做本地校验: + +- 手机号必须是 11 位且以 `1` 开头。 +- 密码不能为空。 +- 必须勾选隐私协议。 + +校验通过后发起登录: + +```text +POST /api/app/v9/login +``` + +调用链: + +```text +LoginViewController.performLogin() +-> LoginViewModel.login(authAPI:) +-> AuthAPI.login(username:password:) +``` + +返回数据包含临时 token 和可选账号列表。随后根据账号数量分流: + +- 只有一个账号:立即调用 `set-user` 换正式 token。 +- 多个账号:展示账号选择弹窗,用户确认后再调用 `set-user`。 + +正式账号选择接口: + +```text +POST /api/app/v9/set-user +``` + +调用链: + +```text +单账号: +LoginViewModel.resolveLoginResponse(...) +-> AuthAPI.setUser(..., tokenOverride: 临时 token) + +多账号: +LoginViewController.selectAccount(_:) +-> LoginViewModel.selectAccount(_:authAPI:) +-> AuthAPI.setUser(..., tokenOverride: 临时 token) +``` + +### 3.4 登录完成后的存储与跳转 + +登录完成调用: + +```text +AuthSessionHelper.completeLogin(...) +``` + +写入 `AppStore` 的内容: + +- `token` +- `lastLoginUsername` +- `privacyAgreementAccepted` +- `userId` +- `userName` +- `realName` +- `avatar` +- `phone` +- `accountType` +- `accountDisplayName` +- `roleCode` +- `roleName` +- `currentScenicId` +- `currentScenicName` +- `currentStoreId` + +其中账号上下文来自 `AccountSwitchAccount`、`V9ScenicUser` 或 `V9StoreUser`。 + +如果用户已同意隐私协议,还会再次触发: + +- `AMapBootstrap.configureIfNeeded()` +- `UmengBootstrap.configureIfNeeded()` + +最后发送: + +```text +NotificationName.userDidLogin +``` + +`SceneDelegate.handleUserDidLogin()` 收到通知后调用: + +```text +AppRouter.setRoot(.mainTab, on: window) +``` + +根页面切换到主 Tab。 + +## 4. 已登录启动与默认首页首次展示 + +已登录时,`AppRouter.makeRootViewController()` 创建 `MainTabBarController`。 + +### 4.1 主 Tab 创建 + +`MainTabBarController.viewDidLoad` 顺序: + +1. 设置 `delegate`。 +2. 配置 TabBar 外观。 +3. 调用 `configureTabs()` 创建五个 Tab slot: + - 首页:`HomeViewController` + - 订单:`OrdersViewController` + - 中间扫码占位页 + - 数据:`StatisticsViewController` + - 我的:`ProfileViewController` +4. 绑定订单角标 ViewModel。 +5. 绑定扫码处理器。 +6. 调用 `badgeViewModel.refreshPendingWriteOffCount()`。 + +注意:`MainTabBadgeViewModel.refreshPendingWriteOffCount()` 当前仅执行: + +```text +pendingWriteOffCount = nil +``` + +代码注释标明“接口未接入前保持为空”,因此这里不记录为真实后端接口调用。 + +### 4.2 默认首页首次出现 + +默认选中首页。`HomeViewController.viewDidAppear` 首次出现时调用: + +```text +initializeHome() +``` + +调用顺序: + +1. 展示全局 loading。 +2. `HomeViewModel.initialize(api:)` +3. 隐藏 loading。 +4. 刷新首页 UI。 +5. 延迟评估弹窗。 + +`HomeViewModel.initialize(api:)` 内部顺序: + +1. `locationStateStore.restoreStateIfNeeded()` + - 从 `AppStore` 恢复在线状态、上次位置上报时间、提醒分钟数。 + - 如果上次在线状态已过期,会清除在线状态和上次上报时间。 + - 不调用后端接口。 +2. `refreshLocalDisplayState()` + - 从 `AppStore` 读取当前景区、当前角色。 + - 无有效景区时清空 `currentScenicId/currentScenicName`。 +3. 强制调用 `loadPermissions(api:force: true)`。 + +权限接口: + +```text +GET /api/yf-handset-app/role-permission +``` + +调用链: + +```text +HomeViewModel.initialize(api:) +-> HomeViewModel.loadPermissions(api:force: true) +-> HomeAPI.rolePermissions() +``` + +权限返回后的存储: + +- 完整角色权限列表:`AppStore.saveRolePermissionList(_:)` +- 当前匹配角色的景区列表:`AppStore.saveRoleScenicList(_:)` +- 当前匹配角色的扁平权限:`AppStore.savePermissionItems(_:)` +- 匹配角色名:必要时写入 `AppStore.roleName` + +这些权限数据都按账号作用域 key 写入 `UserDefaults`,值为 JSON Data。 + +随后首页重建常用应用: + +- 从 `AppStore.permissionItems()` 读取权限。 +- `HomeMenuCatalog.visibleMenus(from:)` 生成可见菜单。 +- `HomeCommonMenuStore` 读取或写入常用应用 URI。 +- 常用应用 key 由 `accountScope + roleCode` 组成。 + +### 4.3 首页门店接口 + +权限加载完成后会调用: + +```text +HomeViewModel.loadStoreListIfNeeded(api:) +``` + +只有同时满足以下条件才会请求门店: + +- 当前角色为 `storeAdmin` +- `AppStore.currentScenicId > 0` + +接口: + +```text +GET /api/app/store/all +``` + +调用链: + +```text +HomeViewModel.loadStoreListIfNeeded(api:) +-> HomeAPI.storeList() +``` + +返回后的处理: + +- 根据 `currentScenicId` 和已保存的 `currentStoreId` 匹配当前门店。 +- 匹配结果保存在 `HomeViewModel.storeItem`。 +- 若匹配到门店,写入 `AppStore.currentStoreId`。 +- 门店列表本身不持久化。 + +### 4.4 首页弹窗评估与位置详情接口 + +首页 UI 刷新后调用: + +```text +evaluateDialogsWithDelay() +-> HomeViewModel.evaluateDialogs(api:) +``` + +弹窗优先级: + +1. 权限弹窗。 +2. 景区弹窗。 +3. 位置超时弹窗。 + +如果不需要权限弹窗、不需要景区弹窗、不是简化顶部角色,并且本地设置了位置提醒分钟数,则可能调用位置详情接口: + +```text +GET /api/yf-handset-app/photog/loacation/detail?staff_id=... +``` + +调用链: + +```text +HomeViewModel.evaluateDialogs(api:) +-> HomeViewModel.checkLocationTimeout(api:) +-> HomeAPI.locationDetail(staffId:) +``` + +返回后的处理: + +- 只用于判断是否展示位置超时提醒。 +- 结果不写入 `AppStore`。 +- 弹窗状态保存在 `HomeViewModel.showLocationTimeoutDialog` 等内存属性中。 + +## 5. 其他 Tab 首次展示时的接口 + +其他 Tab 的根控制器会在 `MainTabBarController.configureTabs()` 时创建,但各自的数据接口通常在首次展示时才调用。 + +### 5.1 订单 Tab + +`OrdersViewController.viewDidAppear` 首次出现时调用: + +```text +initializeList() +``` + +按当前角色选择接口。 + +摄影师: + +```text +GET /api/yf-handset-app/photog/order/listv2 +``` + +景区管理员: + +```text +GET /api/app/scenic-admin/order/list +``` + +门店管理员: + +```text +GET /api/app/store/order/list +``` + +调用链: + +```text +OrdersViewController.initializeList() +-> OrderListViewModel.refreshOrderList(api:) +-> OrderAPI.photographerOrderList(...) 或 OrderAPI.scenicAdminOrderList(...) + +或 + +OrdersViewController.initializeList() +-> DepositOrderListViewModel.refreshOrderList(api:) +-> OrderAPI.storeOrderList(...) +``` + +数据存储: + +- 摄影师/景区管理员订单列表保存在 `OrderListViewModel.orderList`。 +- 门店管理员订单列表保存在 `DepositOrderListViewModel.orderList`。 +- 分页状态、筛选状态、搜索状态都保存在对应 ViewModel 内存属性中。 +- 首屏订单列表不写入 `AppStore`。 + +### 5.2 数据 Tab + +`StatisticsViewController.viewDidAppear` 首次出现时调用: + +```text +initializeStatistics() +-> StatisticsViewModel.initStatistics(api:) +``` + +内部顺序: + +1. 加载汇总数据。 +2. 设置日明细日期范围为今日并刷新列表。 + +摄影师汇总: + +```text +GET /api/yf-handset-app/photog/analyse/user +``` + +摄影师日明细: + +```text +GET /api/yf-handset-app/photog/analyse/user/daily +``` + +景区管理员汇总: + +```text +GET /api/app/scenic-admin/analyse +``` + +景区管理员日明细: + +```text +GET /api/app/scenic-admin/analyse/daily +``` + +门店管理员汇总: + +```text +GET /api/app/store/analyse +``` + +门店管理员日明细: + +```text +GET /api/app/store/analyse/daily +``` + +数据存储: + +- 汇总数据保存在 `StatisticsViewModel.statistics`。 +- 日明细列表保存在 `StatisticsViewModel.statisticsList`。 +- 当前周期、日期范围、分页状态保存在 `StatisticsViewModel` 内存属性中。 +- 统计数据不写入 `AppStore`。 + +### 5.3 我的 Tab + +`ProfileViewController.viewWillAppear` 每次出现都会调用: + +```text +reloadProfile(showGlobalLoading:) +-> ProfileViewModel.reload(api:) +``` + +第一步固定调用用户资料接口: + +```text +GET /api/yf-handset-app/userinfo +``` + +调用链: + +```text +ProfileViewModel.reload(api:) +-> ProfileAPI.userInfo() +``` + +返回后的处理: + +- 保存在 `ProfileViewModel.userInfo`。 +- 调用 `AppStore.applyUserInfo(_:)` 回写展示字段: + - `userName` + - `realName` + - `avatar` + - `phone` + - `roleName`,仅在本地为空时由 `applyUserInfo` 写入;随后 `ProfileViewModel.reload` 也会在接口 `roleName` 非空时写入。 + +如果当前为摄影师角色,则并发调用: + +```text +GET /api/yf-handset-app/photog/real-name/info +GET /api/yf-handset-app/photog/wallet/bank-card/info +``` + +调用链: + +```text +async let realName = api.realNameInfo() +async let bankCard = api.bankCardInfo() +``` + +返回后的处理: + +- 实名信息保存在 `ProfileViewModel.realNameInfo`。 +- 银行卡信息保存在 `ProfileViewModel.bankCardInfo`。 +- 这两个结果不写入 `AppStore`。 + +## 6. 数据存储总览 + +### 6.1 AppStore / UserDefaults + +`AppStore` 是本地会话和业务快照的统一入口,底层使用 `UserDefaults`。 + +非账号作用域的主要 key: + +- `key_in_token` +- `key_last_login_username` +- `key_privacy_agreement_accepted` +- `key_in_user_id` +- `key_in_user_name` +- `key_in_real_name` +- `key_in_avatar` +- `key_in_phone` +- `key_in_account_type` +- `key_in_account_display_name` +- `key_in_role_code` +- `key_in_role_name` +- `key_in_current_scenic_id` +- `key_in_current_scenic_name` +- `key_in_current_store_id` + +账号作用域由 `accountCachePrefix` 生成: + +```text +userId_accountType +``` + +如果没有 userId,则使用 `guest`。账号作用域 key 用于存储: + +- 旧版角色 ID:`key_in_role_id` +- 完整 role-permission 列表:`key_role_permission_list` +- 当前角色扁平权限:`key_in_permission` +- 当前角色可选景区列表:`key_current_role_scenic_list` +- 在线状态:`key_online_status` +- 上次位置上报时间:`key_last_location_report_time` +- 位置提醒分钟数:`key_location_reminder_minutes` +- 收款到账语音播报开关:`key_is_open_receive_voice` +- 排队叫号相关本地设置。 + +权限和景区列表以 JSON Data 写入 `UserDefaults`: + +- `saveRolePermissionList(_:)` +- `savePermissionItems(_:)` +- `saveRoleScenicList(_:)` + +### 6.2 HomeCommonMenuStore + +首页常用应用由 `HomeCommonMenuStore` 单独管理,底层也是 `UserDefaults`。 + +存储 key: + +```text +{accountScope}_role_{roleCode}_common_uris +``` + +值为 URI 字符串数组。 + +首次有权限但没有常用应用记录时,会从权限列表生成最多 4 个默认常用入口并写入本地。 + +### 6.3 HomeLocationStateStore + +`HomeLocationStateStore` 负责首页在线状态和 2 小时位置上报倒计时。 + +持久化到 `AppStore` 的字段: + +- `onlineStatus` +- `lastLocationReportTime` +- `locationReminderMinutes` + +内存态字段: + +- `isOnline` +- `elapsedSeconds` +- `nextReportCountdownSeconds` +- `reminderMinutes` +- 倒计时 `Task` + +启动恢复逻辑: + +- 如果本地 `onlineStatus == false`,停止倒计时。 +- 如果没有 `lastLocationReportTime`,清除在线状态。 +- 如果距离上次上报已超过 2 小时,清除在线状态和上次上报时间。 +- 否则恢复倒计时。 + +### 6.4 ViewModel 内存态 + +多数页面接口结果只保存在 ViewModel 内存属性中,不持久化。 + +典型示例: + +- 登录配置:`LoginViewModel.enableRegister` +- 首页门店卡片:`HomeViewModel.storeItem` +- 首页弹窗状态:`HomeViewModel.showPermissionDialog`、`showScenicDialog`、`showLocationTimeoutDialog` +- 订单列表:`OrderListViewModel.orderList`、`DepositOrderListViewModel.orderList` +- 数据统计:`StatisticsViewModel.statistics`、`statisticsList` +- 我的页面实名/银行卡状态:`ProfileViewModel.realNameInfo`、`bankCardInfo` + +例外是明确调用 `AppStore` 的数据: + +- 登录 token 与账号上下文。 +- 用户资料展示字段。 +- 权限、景区、角色相关快照。 +- 门店当前 ID。 +- 位置在线状态与倒计时锚点。 + +## 7. 启动接口顺序速查 + +### 7.1 未登录冷启动 + +```text +AppDelegate.didFinishLaunching +-> 友盟/微信/高德 SDK 条件初始化 +-> SceneDelegate.willConnect +-> AppRouter.makeRootViewController() +-> LoginViewController.setupUI +-> LoginViewController.viewDidAppear +-> GET /api/app/config +``` + +点击登录后: + +```text +POST /api/app/v9/login +-> POST /api/app/v9/set-user +-> AppStore 写入登录态和账号上下文 +-> userDidLogin +-> AppRouter.setRoot(.mainTab) +``` + +### 7.2 已登录冷启动默认首页 + +```text +AppDelegate.didFinishLaunching +-> 友盟/微信/高德 SDK 条件初始化 +-> SceneDelegate.willConnect +-> AppRouter.makeRootViewController() +-> MainTabBarController.viewDidLoad +-> HomeViewController.viewDidAppear +-> HomeLocationStateStore.restoreStateIfNeeded() +-> GET /api/yf-handset-app/role-permission +-> 条件满足时 GET /api/app/store/all +-> 条件满足时 GET /api/yf-handset-app/photog/loacation/detail?staff_id=... +``` + +### 7.3 用户首次切到订单 Tab + +```text +OrdersViewController.viewDidAppear +-> 摄影师 GET /api/yf-handset-app/photog/order/listv2 + 或 景区管理员 GET /api/app/scenic-admin/order/list + 或 门店管理员 GET /api/app/store/order/list +``` + +### 7.4 用户首次切到数据 Tab + +```text +StatisticsViewController.viewDidAppear +-> 按角色 GET 汇总接口 +-> 按角色 GET 日明细接口 +``` + +### 7.5 用户切到我的 Tab + +```text +ProfileViewController.viewWillAppear +-> GET /api/yf-handset-app/userinfo +-> 摄影师角色并发: + GET /api/yf-handset-app/photog/real-name/info + GET /api/yf-handset-app/photog/wallet/bank-card/info +```