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 0000000..e55b6f3 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/icon_cloud_storage_add_folder.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/Contents.json new file mode 100644 index 0000000..1d8a158 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_add_photo.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/icon_cloud_storage_add_photo.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/icon_cloud_storage_add_photo.png new file mode 100644 index 0000000..aaf9b94 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/icon_cloud_storage_add_photo.png differ 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 0000000..705f827 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/icon_cloud_storage_add_video.png differ 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 0000000..7beffde Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_current_path_arrow.imageset/icon_cloud_storage_current_path_arrow.png differ 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 0000000..f360a15 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/icon_cloud_storage_file_delete.png differ 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 0000000..a332db0 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/icon_cloud_storage_file_details.png differ 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 0000000..8ec83a1 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_download.imageset/icon_cloud_storage_file_download.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/Contents.json new file mode 100644 index 0000000..20f9690 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_filter.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/icon_cloud_storage_file_filter.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/icon_cloud_storage_file_filter.png new file mode 100644 index 0000000..de4891e Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/icon_cloud_storage_file_filter.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/Contents.json new file mode 100644 index 0000000..d2a3a80 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_item_details.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/icon_cloud_storage_file_item_details.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/icon_cloud_storage_file_item_details.png new file mode 100644 index 0000000..258173d Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_item_details.imageset/icon_cloud_storage_file_item_details.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/Contents.json new file mode 100644 index 0000000..66ee239 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_move.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/icon_cloud_storage_file_move.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/icon_cloud_storage_file_move.png new file mode 100644 index 0000000..bad7ace Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/icon_cloud_storage_file_move.png differ 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 0000000..b68df50 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/icon_cloud_storage_file_play.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/Contents.json new file mode 100644 index 0000000..62995df --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_search.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/icon_cloud_storage_file_search.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/icon_cloud_storage_file_search.png new file mode 100644 index 0000000..d3c8679 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/icon_cloud_storage_file_search.png differ 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 0000000..f38a604 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/icon_cloud_storage_file_share.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/Contents.json new file mode 100644 index 0000000..555634a --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_type_folder.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/icon_cloud_storage_file_type_folder.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/icon_cloud_storage_file_type_folder.png new file mode 100644 index 0000000..aef26b4 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_folder.imageset/icon_cloud_storage_file_type_folder.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/Contents.json new file mode 100644 index 0000000..3640b79 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_type_photo.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/icon_cloud_storage_file_type_photo.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/icon_cloud_storage_file_type_photo.png new file mode 100644 index 0000000..2d0d805 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_photo.imageset/icon_cloud_storage_file_type_photo.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/Contents.json new file mode 100644 index 0000000..e0db243 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_file_type_video.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/icon_cloud_storage_file_type_video.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/icon_cloud_storage_file_type_video.png new file mode 100644 index 0000000..7101184 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_type_video.imageset/icon_cloud_storage_file_type_video.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/Contents.json new file mode 100644 index 0000000..3bbcd7c --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_folder_type.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/icon_cloud_storage_folder_type.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/icon_cloud_storage_folder_type.png new file mode 100644 index 0000000..02f3c52 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/icon_cloud_storage_folder_type.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/Contents.json new file mode 100644 index 0000000..2e384c1 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_go_home.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/icon_cloud_storage_go_home.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/icon_cloud_storage_go_home.png new file mode 100644 index 0000000..071885f Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/icon_cloud_storage_go_home.png differ 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 0000000..a73b033 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/icon_cloud_storage_list_sort.png differ 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 0000000..f280ea1 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_list.imageset/icon_cloud_storage_show_type_list.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/Contents.json new file mode 100644 index 0000000..e6e3e60 --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_show_type_table.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/icon_cloud_storage_show_type_table.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/icon_cloud_storage_show_type_table.png new file mode 100644 index 0000000..0492599 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_show_type_table.imageset/icon_cloud_storage_show_type_table.png differ diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/Contents.json b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/Contents.json new file mode 100644 index 0000000..509e3bf --- /dev/null +++ b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "icon_cloud_storage_transit.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/icon_cloud_storage_transit.png b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/icon_cloud_storage_transit.png new file mode 100644 index 0000000..2c07cb5 Binary files /dev/null and b/suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/icon_cloud_storage_transit.png differ 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 0000000..6268f06 Binary files /dev/null and b/suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/ic_msg1.png differ diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/Contents.json b/suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/Contents.json new file mode 100644 index 0000000..92b826f --- /dev/null +++ b/suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_msg2.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/ic_msg2.png b/suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/ic_msg2.png new file mode 100644 index 0000000..1cf006a Binary files /dev/null and b/suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/ic_msg2.png differ 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 0000000..57abbf6 Binary files /dev/null and b/suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/ic_msg3.png differ diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/Contents.json b/suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/Contents.json new file mode 100644 index 0000000..01fbbb4 --- /dev/null +++ b/suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_msg4.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/ic_msg4.png b/suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/ic_msg4.png new file mode 100644 index 0000000..d2b3324 Binary files /dev/null and b/suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/ic_msg4.png differ diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/Contents.json b/suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/Contents.json new file mode 100644 index 0000000..b265612 --- /dev/null +++ b/suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "ic_msg5.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/ic_msg5.png b/suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/ic_msg5.png new file mode 100644 index 0000000..1a94706 Binary files /dev/null and b/suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/ic_msg5.png differ 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 0000000..1c2cf3e Binary files /dev/null and b/suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/ic_msg8.png differ 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 0000000..f80058d Binary files /dev/null and b/suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/ic_msg999.png differ 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 +```