feat: 添加云盘与消息中心功能
@ -392,8 +392,8 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版";
|
INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输";
|
INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输";
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡";
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码和排队打卡点小程序码到相册";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码、排队打卡点小程序码和云盘图片视频到相册";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证和银行卡照片";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证、银行卡照片和云盘上传素材";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
@ -456,8 +456,8 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版";
|
INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输";
|
INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输";
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡";
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码和排队打卡点小程序码到相册";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码、排队打卡点小程序码和云盘图片视频到相册";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证和银行卡照片";
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证、银行卡照片和云盘上传素材";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
|
|||||||
@ -27,6 +27,8 @@ final class NetworkServices {
|
|||||||
let materialManagementAPI: MaterialManagementAPI
|
let materialManagementAPI: MaterialManagementAPI
|
||||||
let punchPointAPI: PunchPointAPI
|
let punchPointAPI: PunchPointAPI
|
||||||
let liveAPI: LiveAPI
|
let liveAPI: LiveAPI
|
||||||
|
let cloudDriveAPI: CloudDriveAPI
|
||||||
|
let messageCenterAPI: MessageCenterAPI
|
||||||
let wildPhotographerReportAPI: WildPhotographerReportAPI
|
let wildPhotographerReportAPI: WildPhotographerReportAPI
|
||||||
let uploadAPI: UploadAPI
|
let uploadAPI: UploadAPI
|
||||||
let ossUploadService: OSSUploadService
|
let ossUploadService: OSSUploadService
|
||||||
@ -50,6 +52,8 @@ final class NetworkServices {
|
|||||||
materialManagementAPI = MaterialManagementAPI(client: client)
|
materialManagementAPI = MaterialManagementAPI(client: client)
|
||||||
punchPointAPI = PunchPointAPI(client: client)
|
punchPointAPI = PunchPointAPI(client: client)
|
||||||
liveAPI = LiveAPI(client: client)
|
liveAPI = LiveAPI(client: client)
|
||||||
|
cloudDriveAPI = CloudDriveAPI(client: client)
|
||||||
|
messageCenterAPI = MessageCenterAPI(client: client)
|
||||||
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
|
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
|
||||||
uploadAPI = UploadAPI(client: client)
|
uploadAPI = UploadAPI(client: client)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
@ -78,6 +82,8 @@ final class NetworkServices {
|
|||||||
materialManagementAPI = MaterialManagementAPI(client: apiClient)
|
materialManagementAPI = MaterialManagementAPI(client: apiClient)
|
||||||
punchPointAPI = PunchPointAPI(client: apiClient)
|
punchPointAPI = PunchPointAPI(client: apiClient)
|
||||||
liveAPI = LiveAPI(client: apiClient)
|
liveAPI = LiveAPI(client: apiClient)
|
||||||
|
cloudDriveAPI = CloudDriveAPI(client: apiClient)
|
||||||
|
messageCenterAPI = MessageCenterAPI(client: apiClient)
|
||||||
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
|
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
|
||||||
uploadAPI = UploadAPI(client: apiClient)
|
uploadAPI = UploadAPI(client: apiClient)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
|
|||||||
6
suixinkan/Assets.xcassets/CloudDriveIcons/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_folder.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_add_folder.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_photo.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_add_photo.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_add_video.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_add_video.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_current_path_arrow.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_delete.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_delete.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_details.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_details.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_download.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_filter.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_filter.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_item_details.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 727 B |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_move.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_move.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_play.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_play.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_search.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_search.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_file_share.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_share.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_type_folder.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_type_photo.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_file_type_video.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_folder_type.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_folder_type.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_go_home.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_go_home.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_list_sort.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_list_sort.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_show_type_list.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_show_type_table.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
13
suixinkan/Assets.xcassets/CloudDriveIcons/icon_cloud_storage_transit.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "icon_cloud_storage_transit.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
6
suixinkan/Assets.xcassets/MessageCenter/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
12
suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_msg1.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/MessageCenter/ic_msg1.imageset/ic_msg1.png
vendored
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_msg2.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/MessageCenter/ic_msg2.imageset/ic_msg2.png
vendored
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
12
suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_msg3.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/MessageCenter/ic_msg3.imageset/ic_msg3.png
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
12
suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_msg4.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/MessageCenter/ic_msg4.imageset/ic_msg4.png
vendored
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
12
suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_msg5.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/MessageCenter/ic_msg5.imageset/ic_msg5.png
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
12
suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_msg8.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/MessageCenter/ic_msg8.imageset/ic_msg8.png
vendored
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
12
suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ic_msg999.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/MessageCenter/ic_msg999.imageset/ic_msg999.png
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
@ -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(
|
func uploadWildReportAttachment(
|
||||||
data: Data,
|
data: Data,
|
||||||
@ -280,7 +298,7 @@ enum OSSUploadPolicy {
|
|||||||
formatter.timeZone = timeZone
|
formatter.timeZone = timeZone
|
||||||
formatter.dateFormat = "yyyyMMdd"
|
formatter.dateFormat = "yyyyMMdd"
|
||||||
let currentDate = formatter.string(from: date)
|
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)
|
let safeName = sanitizedFileName(fileName)
|
||||||
switch moduleType {
|
switch moduleType {
|
||||||
case "user_avatar":
|
case "user_avatar":
|
||||||
@ -307,6 +325,16 @@ enum OSSUploadPolicy {
|
|||||||
return "live_covers/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "live_covers/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
case "alive_album":
|
case "alive_album":
|
||||||
return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
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":
|
case "photog_report":
|
||||||
return "photog_report/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "photog_report/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
default:
|
default:
|
||||||
|
|||||||
144
suixinkan/Features/CloudDrive/API/CloudDriveAPI.swift
Normal file
@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
165
suixinkan/Features/CloudDrive/Models/CloudDriveModels.swift
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Void, Never>] = [:]
|
||||||
|
|
||||||
|
/// 初始化传输管理器。
|
||||||
|
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>) -> Self {
|
||||||
|
min(max(self, range.lowerBound), range.upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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])
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Int> = []
|
||||||
|
|
||||||
|
/// 当前文件夹 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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,8 +37,12 @@ enum HomeRouteHandler {
|
|||||||
selectTab(.statistics, from: viewController)
|
selectTab(.statistics, from: viewController)
|
||||||
case "system_settings":
|
case "system_settings":
|
||||||
viewController.navigationController?.pushViewController(SettingViewController(), animated: true)
|
viewController.navigationController?.pushViewController(SettingViewController(), animated: true)
|
||||||
|
case "message_center":
|
||||||
|
viewController.navigationController?.pushViewController(MessageCenterViewController(), animated: true)
|
||||||
case "space_settings":
|
case "space_settings":
|
||||||
viewController.navigationController?.pushViewController(ProfileSpaceSettingsViewController(), animated: true)
|
viewController.navigationController?.pushViewController(ProfileSpaceSettingsViewController(), animated: true)
|
||||||
|
case "cloud_management":
|
||||||
|
viewController.navigationController?.pushViewController(CloudDriveListViewController(), animated: true)
|
||||||
case "pilot_cert":
|
case "pilot_cert":
|
||||||
let controller = RealNameAuthViewController()
|
let controller = RealNameAuthViewController()
|
||||||
viewController.navigationController?.pushViewController(controller, animated: true)
|
viewController.navigationController?.pushViewController(controller, animated: true)
|
||||||
|
|||||||
67
suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift
Normal file
@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
enum TaskFileSource: String, Sendable, Equatable {
|
||||||
case cloud
|
case cloud
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
189
suixinkan/UI/CloudDrive/CloudDriveFileCell.swift
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
62
suixinkan/UI/CloudDrive/CloudDriveFormatter.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
529
suixinkan/UI/CloudDrive/CloudDriveListViewController.swift
Normal file
@ -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<Int, CloudFile>!
|
||||||
|
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<Int, CloudFile>(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<Int, CloudFile>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
234
suixinkan/UI/CloudDrive/CloudDriveTransferViewController.swift
Normal file
@ -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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
309
suixinkan/UI/MessageCenter/MessageCenterViewController.swift
Normal file
@ -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<Section, MessageItem>!
|
||||||
|
|
||||||
|
/// 初始化消息中心列表页。
|
||||||
|
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<Section, MessageItem>(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<Section, MessageItem>()
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
suixinkan/UI/MessageCenter/MessageDetailViewController.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
|||||||
private let nicknameField = UITextField()
|
private let nicknameField = UITextField()
|
||||||
private let editProfileButton = UIButton(type: .system)
|
private let editProfileButton = UIButton(type: .system)
|
||||||
private let introductionTextView = UITextView()
|
private let introductionTextView = UITextView()
|
||||||
|
private let introductionPlaceholderLabel = UILabel()
|
||||||
private let certificationWrap = ProfileTagWrapView()
|
private let certificationWrap = ProfileTagWrapView()
|
||||||
|
|
||||||
private let attrWrap = ProfileTagWrapView()
|
private let attrWrap = ProfileTagWrapView()
|
||||||
@ -139,13 +140,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
|||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
saveButton.isEnabled = viewModel.hasChanges
|
saveButton.isEnabled = viewModel.hasChanges
|
||||||
avatarImageView.loadRemoteImage(urlString: viewModel.avatarURL)
|
updateAvatar(urlString: viewModel.avatarURL)
|
||||||
realNameLabel.text = "姓名: \(viewModel.realName)"
|
realNameLabel.text = "姓名: \(viewModel.realName)"
|
||||||
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : "昵称: \(viewModel.nickname)"
|
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : "昵称: \(viewModel.nickname)"
|
||||||
nicknameField.isEnabled = viewModel.isEditingProfile
|
nicknameField.isEnabled = viewModel.isEditingProfile
|
||||||
introductionTextView.isEditable = viewModel.isEditingProfile
|
introductionTextView.isEditable = viewModel.isEditingProfile
|
||||||
introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction
|
introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction
|
||||||
introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x565656)
|
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)
|
editProfileButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "square.and.pencil"), for: .normal)
|
||||||
certificationWrap.setItems(viewModel.scenicCertification, style: .certification, removable: false)
|
certificationWrap.setItems(viewModel.scenicCertification, style: .certification, removable: false)
|
||||||
attrWrap.setItems(viewModel.attrLabels, style: .label, removable: true) { [weak self] label in
|
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.font = .systemFont(ofSize: 16)
|
||||||
nicknameField.textColor = .black
|
nicknameField.textColor = .black
|
||||||
nicknameField.borderStyle = .none
|
nicknameField.borderStyle = .none
|
||||||
|
nicknameField.placeholder = "请输入昵称"
|
||||||
|
nicknameField.returnKeyType = .done
|
||||||
|
nicknameField.delegate = self
|
||||||
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
|
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
|
||||||
editProfileButton.tintColor = AppColor.primary
|
editProfileButton.tintColor = AppColor.primary
|
||||||
editProfileButton.snp.makeConstraints { make in
|
editProfileButton.snp.makeConstraints { make in
|
||||||
@ -221,6 +226,13 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
|||||||
introductionTextView.snp.makeConstraints { make in
|
introductionTextView.snp.makeConstraints { make in
|
||||||
make.height.greaterThanOrEqualTo(24)
|
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(realNameLabel)
|
||||||
textStack.addArrangedSubview(nicknameRow)
|
textStack.addArrangedSubview(nicknameRow)
|
||||||
@ -357,6 +369,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
|||||||
row.alignment = .fill
|
row.alignment = .fill
|
||||||
field.placeholder = placeholder
|
field.placeholder = placeholder
|
||||||
field.font = .systemFont(ofSize: 14)
|
field.font = .systemFont(ofSize: 14)
|
||||||
|
field.returnKeyType = .done
|
||||||
|
field.delegate = self
|
||||||
field.layer.cornerRadius = 8
|
field.layer.cornerRadius = 8
|
||||||
field.layer.borderColor = AppColor.border.cgColor
|
field.layer.borderColor = AppColor.border.cgColor
|
||||||
field.layer.borderWidth = 1
|
field.layer.borderWidth = 1
|
||||||
@ -390,12 +404,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
|||||||
titleLabel.text = title
|
titleLabel.text = title
|
||||||
titleLabel.font = .systemFont(ofSize: 14)
|
titleLabel.font = .systemFont(ofSize: 14)
|
||||||
titleLabel.textColor = UIColor(hex: 0x565656)
|
titleLabel.textColor = UIColor(hex: 0x565656)
|
||||||
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
let toLabel = UILabel()
|
let toLabel = UILabel()
|
||||||
toLabel.text = "至"
|
toLabel.text = "至"
|
||||||
toLabel.font = .systemFont(ofSize: 14)
|
toLabel.font = .systemFont(ofSize: 14)
|
||||||
toLabel.textColor = UIColor(hex: 0x565656)
|
toLabel.textColor = UIColor(hex: 0x565656)
|
||||||
|
let spacer = UIView()
|
||||||
row.addArrangedSubview(titleLabel)
|
row.addArrangedSubview(titleLabel)
|
||||||
|
row.addArrangedSubview(spacer)
|
||||||
row.addArrangedSubview(start)
|
row.addArrangedSubview(start)
|
||||||
row.addArrangedSubview(toLabel)
|
row.addArrangedSubview(toLabel)
|
||||||
row.addArrangedSubview(end)
|
row.addArrangedSubview(end)
|
||||||
@ -408,6 +424,24 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
|||||||
return row
|
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() {
|
private func updateStatusButtons() {
|
||||||
for button in statusButtons {
|
for button in statusButtons {
|
||||||
let selected = button.tag == viewModel.acceptOrderStatus
|
let selected = button.tag == viewModel.acceptOrderStatus
|
||||||
@ -553,6 +587,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
|
|||||||
let alert = UIAlertController(title: "添加设备", message: nil, preferredStyle: .alert)
|
let alert = UIAlertController(title: "添加设备", message: nil, preferredStyle: .alert)
|
||||||
alert.addTextField { field in
|
alert.addTextField { field in
|
||||||
field.placeholder = "请输入设备名称"
|
field.placeholder = "请输入设备名称"
|
||||||
|
field.returnKeyType = .done
|
||||||
|
field.delegate = self
|
||||||
}
|
}
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
|
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) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
viewModel.updateIntroduction(textView.text)
|
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 endButton = ProfileTimeButton()
|
||||||
private let orderButton = UIButton(type: .system)
|
private let orderButton = UIButton(type: .system)
|
||||||
private let remarkTextView = UITextView()
|
private let remarkTextView = UITextView()
|
||||||
|
private let remarkPlaceholderLabel = UILabel()
|
||||||
private let countLabel = UILabel()
|
private let countLabel = UILabel()
|
||||||
|
|
||||||
init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) {
|
init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) {
|
||||||
@ -719,6 +772,8 @@ final class ProfileAddScheduleViewController: BaseViewController {
|
|||||||
private func configureNameField() {
|
private func configureNameField() {
|
||||||
nameField.placeholder = "请输入日程名称"
|
nameField.placeholder = "请输入日程名称"
|
||||||
nameField.font = .systemFont(ofSize: 14)
|
nameField.font = .systemFont(ofSize: 14)
|
||||||
|
nameField.returnKeyType = .done
|
||||||
|
nameField.delegate = self
|
||||||
nameField.layer.borderColor = AppColor.border.cgColor
|
nameField.layer.borderColor = AppColor.border.cgColor
|
||||||
nameField.layer.borderWidth = 1
|
nameField.layer.borderWidth = 1
|
||||||
nameField.layer.cornerRadius = 8
|
nameField.layer.cornerRadius = 8
|
||||||
@ -771,6 +826,17 @@ final class ProfileAddScheduleViewController: BaseViewController {
|
|||||||
remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||||
remarkTextView.layer.borderWidth = 1
|
remarkTextView.layer.borderWidth = 1
|
||||||
remarkTextView.layer.cornerRadius = 8
|
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.text = "0/200"
|
||||||
countLabel.textAlignment = .right
|
countLabel.textAlignment = .right
|
||||||
countLabel.font = .systemFont(ofSize: 12)
|
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) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
if textView.text.count > 200 {
|
if textView.text.count > 200 {
|
||||||
textView.text = String(textView.text.prefix(200))
|
textView.text = String(textView.text.prefix(200))
|
||||||
}
|
}
|
||||||
|
remarkPlaceholderLabel.isHidden = !textView.text.isEmpty
|
||||||
countLabel.text = "\(textView.text.count)/200"
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 个人空间配置时间按钮。
|
/// 个人空间配置时间按钮。
|
||||||
|
|||||||
71
suixinkanTests/CloudDriveAPITests.swift
Normal file
@ -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)!
|
||||||
|
}
|
||||||
|
}
|
||||||
139
suixinkanTests/CloudDriveViewModelTests.swift
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
185
suixinkanTests/CloudTransferManagerTests.swift
Normal file
@ -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" }
|
||||||
|
}
|
||||||
83
suixinkanTests/MessageCenterAPITests.swift
Normal file
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
171
suixinkanTests/MessageCenterViewModelTests.swift
Normal file
@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
682
应用启动流程与接口数据存储梳理.md
Normal file
@ -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
|
||||||
|
```
|
||||||