From ccd63380cf811e51b5472bf88888868a77dd397a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 8 Jul 2026 13:06:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E6=A0=B7=E7=89=87?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suixinkan/App/NetworkServices.swift | 3 + .../SampleManagement/Contents.json | 6 + .../Contents.json | 15 + .../sample_ic_collect_count.svg | 3 + .../Contents.json | 15 + .../sample_ic_home_menu_route.svg | 4 + .../Contents.json | 15 + .../sample_ic_like_count.svg | 3 + .../Contents.json | 15 + .../sample_ic_share_count.svg | 3 + suixinkan/Core/Upload/OSSUploadService.swift | 24 +- .../Home/Services/HomeMenuCatalog.swift | 2 +- .../Home/Services/HomeRouteHandler.swift | 2 + .../API/SampleManagementAPI.swift | 122 +++ .../Models/SampleManagementModels.swift | 573 ++++++++++++ .../SampleManagementViewModels.swift | 562 +++++++++++ .../UI/Home/Views/HomeCollectionCells.swift | 2 +- .../SampleDetailViewController.swift | 682 ++++++++++++++ .../SampleListViewController.swift | 547 +++++++++++ .../UploadSampleViewController.swift | 883 ++++++++++++++++++ suixinkanTests/SampleManagementAPITests.swift | 116 +++ .../SampleManagementViewModelTests.swift | 197 ++++ 22 files changed, 3791 insertions(+), 3 deletions(-) create mode 100644 suixinkan/Assets.xcassets/SampleManagement/Contents.json create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/sample_ic_collect_count.svg create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/sample_ic_home_menu_route.svg create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/sample_ic_like_count.svg create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/sample_ic_share_count.svg create mode 100644 suixinkan/Features/SampleManagement/API/SampleManagementAPI.swift create mode 100644 suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift create mode 100644 suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift create mode 100644 suixinkan/UI/SampleManagement/SampleDetailViewController.swift create mode 100644 suixinkan/UI/SampleManagement/SampleListViewController.swift create mode 100644 suixinkan/UI/SampleManagement/UploadSampleViewController.swift create mode 100644 suixinkanTests/SampleManagementAPITests.swift create mode 100644 suixinkanTests/SampleManagementViewModelTests.swift diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 2287862..1c8a754 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -22,6 +22,7 @@ final class NetworkServices { let walletAPI: WalletAPI let scenicQueueAPI: ScenicQueueAPI let travelAlbumAPI: TravelAlbumAPI + let sampleManagementAPI: SampleManagementAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -39,6 +40,7 @@ final class NetworkServices { walletAPI = WalletAPI(client: client) scenicQueueAPI = ScenicQueueAPI(client: client) travelAlbumAPI = TravelAlbumAPI(client: client) + sampleManagementAPI = SampleManagementAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { @@ -61,6 +63,7 @@ final class NetworkServices { walletAPI = WalletAPI(client: apiClient) scenicQueueAPI = ScenicQueueAPI(client: apiClient) travelAlbumAPI = TravelAlbumAPI(client: apiClient) + sampleManagementAPI = SampleManagementAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) } diff --git a/suixinkan/Assets.xcassets/SampleManagement/Contents.json b/suixinkan/Assets.xcassets/SampleManagement/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/Contents.json b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/Contents.json new file mode 100644 index 0000000..45ccec2 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "sample_ic_collect_count.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/sample_ic_collect_count.svg b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/sample_ic_collect_count.svg new file mode 100644 index 0000000..5eae476 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/sample_ic_collect_count.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/Contents.json b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/Contents.json new file mode 100644 index 0000000..736f285 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "sample_ic_home_menu_route.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/sample_ic_home_menu_route.svg b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/sample_ic_home_menu_route.svg new file mode 100644 index 0000000..1e6f5cf --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/sample_ic_home_menu_route.svg @@ -0,0 +1,4 @@ + + + + diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/Contents.json b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/Contents.json new file mode 100644 index 0000000..b928b79 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "sample_ic_like_count.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/sample_ic_like_count.svg b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/sample_ic_like_count.svg new file mode 100644 index 0000000..60583d5 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/sample_ic_like_count.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/Contents.json b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/Contents.json new file mode 100644 index 0000000..60fc7c1 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "sample_ic_share_count.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/sample_ic_share_count.svg b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/sample_ic_share_count.svg new file mode 100644 index 0000000..eedb459 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/sample_ic_share_count.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index ef11643..571a9fc 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -18,8 +18,8 @@ protocol OSSUploadServing { func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String } -@MainActor /// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。 +@MainActor final class OSSUploadService { private let configService: any OSSConfigServing @@ -91,6 +91,24 @@ final class OSSUploadService { ) } + /// 上传样片素材,对齐 Android `moduleType = sample`。 + func uploadSampleFile( + 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: "sample", + onProgress: onProgress + ) + } + private func uploadFile( data: Data, fileName: String, @@ -144,6 +162,8 @@ final class OSSUploadService { extension OSSUploadService: OSSUploadServing {} +extension OSSUploadService: SampleOSSUploading {} + enum OSSUploadPolicy { static let maxFileSize = 2_048 * 1_024 * 1_024 private static let allowedExtensions: Set = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"] @@ -181,6 +201,8 @@ enum OSSUploadPolicy { return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "album": return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "sample": + return "sample/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "scenic_apply": return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift index 556f83c..bf66744 100644 --- a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -20,7 +20,7 @@ enum HomeMenuCatalog { HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "slider.horizontal.3"), HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"), HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"), - HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"), + HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"), HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"), HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"), HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"), diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index 7744b5f..ab739ab 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -64,6 +64,8 @@ enum HomeRouteHandler { viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true) case "travel_album": viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true) + case "sample_management": + viewController.navigationController?.pushViewController(SampleListViewController(), animated: true) case "wallet": showDeveloping(from: viewController) default: diff --git a/suixinkan/Features/SampleManagement/API/SampleManagementAPI.swift b/suixinkan/Features/SampleManagement/API/SampleManagementAPI.swift new file mode 100644 index 0000000..affc234 --- /dev/null +++ b/suixinkan/Features/SampleManagement/API/SampleManagementAPI.swift @@ -0,0 +1,122 @@ +// +// SampleManagementAPI.swift +// suixinkan +// + +import Foundation + +/// 样片管理服务协议,抽象列表、详情、上下架、上传与关联数据接口。 +@MainActor +protocol SampleManagementServing { + /// 拉取样片列表。 + func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> SampleMaterialListResponse + + /// 拉取样片详情。 + func detail(id: Int) async throws -> SampleDetail + + /// 设置样片上下架状态。 + func setListingStatus(id: Int, listingStatus: Int) async throws + + /// 上传样片相册。 + func upload(_ request: SampleUploadMaterialRequest) async throws + + /// 拉取景区全部打卡点。 + func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse + + /// 拉取当前景区项目列表。 + func projectList(scenicId: String, name: String?, page: Int, pageSize: Int) async throws -> SampleProjectListResponse +} + +/// 样片管理 API,封装 Android `NetworkApi` 中 media-album 与项目接口。 +@MainActor +final class SampleManagementAPI: SampleManagementServing { + private let client: APIClient + + /// 初始化样片管理 API。 + init(client: APIClient) { + self.client = client + } + + /// 拉取样片列表,固定 `type=2`。 + func list(status: Int, keyword: String?, page: Int = 1, pageSize: Int = 10) async throws -> SampleMaterialListResponse { + var items = [ + URLQueryItem(name: "status", value: String(status)), + URLQueryItem(name: "type", value: "2"), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))), + ] + if let keyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines), !keyword.isEmpty { + items.append(URLQueryItem(name: "keyword", value: keyword)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: items) + ) + } + + /// 拉取样片详情,固定 `type=2`。 + func detail(id: Int) async throws -> SampleDetail { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/media-album/detail", + queryItems: [ + URLQueryItem(name: "id", value: String(id)), + URLQueryItem(name: "type", value: "2"), + ] + ) + ) + } + + /// 设置样片上下架状态,固定 `type=2`。 + func setListingStatus(id: Int, listingStatus: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/media-album/operation", + queryItems: [ + URLQueryItem(name: "id", value: String(id)), + URLQueryItem(name: "listing_status", value: String(listingStatus)), + URLQueryItem(name: "type", value: "2"), + ] + ) + ) + } + + /// 上传样片相册。 + func upload(_ request: SampleUploadMaterialRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/media-album/upload", body: request) + ) + } + + /// 拉取景区全部打卡点。 + func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic-spot/list-all", + queryItems: [URLQueryItem(name: "scenic_id", value: scenicId)] + ) + ) + } + + /// 拉取当前景区项目列表。 + func projectList( + scenicId: String, + name: String? = nil, + page: Int = 1, + pageSize: Int = 10 + ) async throws -> SampleProjectListResponse { + var items = [ + URLQueryItem(name: "scenic_id", value: scenicId), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))), + ] + if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + items.append(URLQueryItem(name: "name", value: name)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: items) + ) + } +} diff --git a/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift b/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift new file mode 100644 index 0000000..52c574e --- /dev/null +++ b/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift @@ -0,0 +1,573 @@ +// +// SampleManagementModels.swift +// suixinkan +// + +import Foundation + +/// 样片列表筛选状态,对齐 Android `SampleListViewModel` 的四档筛选。 +enum SampleFilterStatus: Int, CaseIterable, Sendable, Equatable { + case all = 0 + case approved = 1 + case pending = 2 + case rejected = 3 + + /// 展示文案。 + var title: String { + switch self { + case .all: "全部" + case .approved: "已通过" + case .pending: "待审核" + case .rejected: "未通过" + } + } + + /// 服务端 `status` 参数,Android 固定映射为:全部 4、已审核 1、待审核 0、未通过 2。 + var apiStatus: Int { + switch self { + case .all: 4 + case .approved: 1 + case .pending: 0 + case .rejected: 2 + } + } +} + +/// 样片素材类型,对齐 Android `mediaType`:1 图片,2 视频。 +enum SampleMediaType: Int, CaseIterable, Sendable, Equatable { + case image = 1 + case video = 2 + + /// 展示文案。 + var title: String { + switch self { + case .image: "图片" + case .video: "视频" + } + } + + /// 最大可选数量。 + var maxCount: Int { + switch self { + case .image: 9 + case .video: 1 + } + } + + /// OSS 文件类型,Android `UploadTaskEntity.fileType` 中视频为 1、图片为 2。 + var uploadFileType: Int { + switch self { + case .image: 2 + case .video: 1 + } + } +} + +/// 样片列表项,对齐 Android `MaterialItemEntity`。 +struct SampleMaterialItem: Decodable, Sendable, Equatable, Hashable, Identifiable { + let id: Int + let name: String + let coverUrl: String + let createdAt: String + let status: Int + let downloadCount: Int + let likesCount: Int + let collectCount: Int + let shareCount: Int + let scenicSpotName: String + let projectName: String + let listingStatus: Int + + enum CodingKeys: String, CodingKey { + case id + case name + case coverUrl = "cover_url" + case createdAt = "created_at" + case status + case downloadCount = "download_count" + case likesCount = "likes_count" + case collectCount = "collect_count" + case shareCount = "share_count" + case scenicSpotName = "scenic_spot_name" + case projectName = "project_name" + case listingStatus = "listing_status" + } + + init( + id: Int = 0, + name: String = "", + coverUrl: String = "", + createdAt: String = "", + status: Int = 0, + downloadCount: Int = 0, + likesCount: Int = 0, + collectCount: Int = 0, + shareCount: Int = 0, + scenicSpotName: String = "", + projectName: String = "", + listingStatus: Int = 0 + ) { + self.id = id + self.name = name + self.coverUrl = coverUrl + self.createdAt = createdAt + self.status = status + self.downloadCount = downloadCount + self.likesCount = likesCount + self.collectCount = collectCount + self.shareCount = shareCount + self.scenicSpotName = scenicSpotName + self.projectName = projectName + self.listingStatus = listingStatus + } + + /// 是否已上架。 + var isListed: Bool { listingStatus == 1 } +} + +/// 样片列表响应,对齐 Android `MaterialListResponse`。 +struct SampleMaterialListResponse: Decodable, Sendable, Equatable { + let total: Int + let list: [SampleMaterialItem] + let order: SampleMaterialOrderInfo? + + init(total: Int = 0, list: [SampleMaterialItem] = [], order: SampleMaterialOrderInfo? = nil) { + self.total = total + self.list = list + self.order = order + } +} + +/// 样片列表统计信息,对齐 Android `MaterialOrderInfo`。 +struct SampleMaterialOrderInfo: Decodable, Sendable, Equatable { + let totalNum: Int + let avgOrderAmount: String + let refundTotal: String + let avgChange: Int + + enum CodingKeys: String, CodingKey { + case totalNum = "total_num" + case avgOrderAmount = "avg_order_amount" + case refundTotal = "refund_total" + case avgChange = "avg_change" + } + + init(totalNum: Int = 0, avgOrderAmount: String = "0", refundTotal: String = "0", avgChange: Int = 0) { + self.totalNum = totalNum + self.avgOrderAmount = avgOrderAmount + self.refundTotal = refundTotal + self.avgChange = avgChange + } +} + +/// 样片详情响应,对齐 Android `SampleDetailResponse`。 +struct SampleDetail: Decodable, Sendable, Equatable { + let id: Int + let name: String? + let type: Int? + let coverUrl: String? + let coverSize: Int? + let description: String? + let scenicSpotId: Int? + let scenicAreaId: Int? + let uploaderId: Int? + let status: Int + let likesCount: Int + let downloadCount: Int + let collectCount: Int + let createdAt: String? + let updatedAt: String? + let deletedAt: String? + let listingStatus: Int + let reviewNotes: String? + let reviewTime: String? + let reviewerId: Int? + let provinceId: Int? + let cityId: Int? + let shareCount: Int + let uploaderName: String? + let uploaderAvatar: String? + let uploaderDescription: String? + let platformCertification: Bool + let scenicCertification: Bool + let mediaList: [SampleDetailMediaItem]? + let scenicName: String? + let scenicCoverImg: String? + let scenicPlanesNum: Int + let scenicPhotographerNum: Int + let projectCover: String? + let projectName: String? + let projectPrice: Double? + let projectPriceDeposit: String? + let reviewer: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case type + case coverUrl = "cover_url" + case coverSize = "cover_size" + case description + case scenicSpotId = "scenic_spot_id" + case scenicAreaId = "scenic_area_id" + case uploaderId = "uploader_id" + case status + case likesCount = "likes_count" + case downloadCount = "download_count" + case collectCount = "collect_count" + case createdAt = "created_at" + case updatedAt = "updated_at" + case deletedAt = "deleted_at" + case listingStatus = "listing_status" + case reviewNotes = "review_notes" + case reviewTime = "review_time" + case reviewerId = "reviewer_id" + case provinceId = "province_id" + case cityId = "city_id" + case shareCount = "share_count" + case uploaderName = "uploader_name" + case uploaderAvatar = "uploader_avatar" + case uploaderDescription = "uploader_description" + case platformCertification = "platform_certification" + case scenicCertification = "scenic_certification" + case mediaList = "media_list" + case scenicName = "scenic_name" + case scenicCoverImg = "scenic_cover_img" + case scenicPlanesNum = "scenic_planes_num" + case scenicPhotographerNum = "scenic_photographer_num" + case projectCover = "project_cover" + case projectName = "project_name" + case projectPrice = "project_price" + case projectPriceDeposit = "project_price_deposit" + case reviewer + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + name = try container.decodeIfPresent(String.self, forKey: .name) + type = try container.decodeIfPresent(Int.self, forKey: .type) + coverUrl = try container.decodeIfPresent(String.self, forKey: .coverUrl) + coverSize = try container.decodeIfPresent(Int.self, forKey: .coverSize) + description = try container.decodeIfPresent(String.self, forKey: .description) + scenicSpotId = try container.decodeIfPresent(Int.self, forKey: .scenicSpotId) + scenicAreaId = try container.decodeIfPresent(Int.self, forKey: .scenicAreaId) + uploaderId = try container.decodeIfPresent(Int.self, forKey: .uploaderId) + status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0 + likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0 + downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0 + collectCount = try container.decodeIfPresent(Int.self, forKey: .collectCount) ?? 0 + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt) + listingStatus = try container.decodeIfPresent(Int.self, forKey: .listingStatus) ?? 0 + reviewNotes = try container.decodeIfPresent(String.self, forKey: .reviewNotes) + reviewTime = try container.decodeIfPresent(String.self, forKey: .reviewTime) + reviewerId = try container.decodeIfPresent(Int.self, forKey: .reviewerId) + provinceId = try container.decodeIfPresent(Int.self, forKey: .provinceId) + cityId = try container.decodeIfPresent(Int.self, forKey: .cityId) + shareCount = try container.decodeIfPresent(Int.self, forKey: .shareCount) ?? 0 + uploaderName = try container.decodeIfPresent(String.self, forKey: .uploaderName) + uploaderAvatar = try container.decodeIfPresent(String.self, forKey: .uploaderAvatar) + uploaderDescription = try container.decodeIfPresent(String.self, forKey: .uploaderDescription) + platformCertification = try container.decodeIfPresent(Bool.self, forKey: .platformCertification) ?? false + scenicCertification = try container.decodeIfPresent(Bool.self, forKey: .scenicCertification) ?? false + mediaList = try container.decodeIfPresent([SampleDetailMediaItem].self, forKey: .mediaList) + scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName) + scenicCoverImg = try container.decodeIfPresent(String.self, forKey: .scenicCoverImg) + scenicPlanesNum = try container.decodeIfPresent(Int.self, forKey: .scenicPlanesNum) ?? 0 + scenicPhotographerNum = try container.decodeIfPresent(Int.self, forKey: .scenicPhotographerNum) ?? 0 + projectCover = try container.decodeIfPresent(String.self, forKey: .projectCover) + projectName = try container.decodeIfPresent(String.self, forKey: .projectName) + projectPrice = try container.decodeFlexibleDouble(forKey: .projectPrice) + projectPriceDeposit = try container.decodeFlexibleString(forKey: .projectPriceDeposit) + reviewer = try container.decodeIfPresent(String.self, forKey: .reviewer) + } +} + +/// 样片详情媒体项,对齐 Android `MaterialDetailMediaItem`。 +struct SampleDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifiable { + let id: Int + let originalName: String? + let paidMaterialId: Int? + let ossUrl: String? + let thumbnailUrl: String? + let type: Int + let size: Int64? + let createdAt: String? + let updatedAt: String? + let deletedAt: String? + let likesCount: Int + let downloadCount: Int + let showUrl: String? + let width: Int? + let height: Int? + + enum CodingKeys: String, CodingKey { + case id + case originalName = "original_name" + case paidMaterialId = "paid_material_id" + case ossUrl = "oss_url" + case thumbnailUrl = "thumbnail_url" + case type + case size + case createdAt = "created_at" + case updatedAt = "updated_at" + case deletedAt = "deleted_at" + case likesCount = "likes_count" + case downloadCount = "download_count" + case showUrl = "show_url" + case width + case height + } + + /// 详情轮播优先展示地址。 + var displayURL: String { + (ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) + ?? (showUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) + ?? "" + } +} + +/// 样片关联项目项,对齐 Android `ProjectItem`。 +struct SampleProjectItem: Decodable, Sendable, Equatable, Hashable, Identifiable { + let id: Int + let type: Int + let typeName: String + let status: Int + let statusName: String + let name: String + let coverProject: String + let coverVideo: String + let price: String + let otPrice: String + let priceDeposit: String + let label: String + let attrLabel: [String]? + let creatorId: Int + + enum CodingKeys: String, CodingKey { + case id + case type + case typeName = "type_name" + case status + case statusName = "status_name" + case name + case coverProject = "cover_project" + case coverVideo = "cover_video" + case price + case otPrice = "ot_price" + case priceDeposit = "price_deposit" + case label + case attrLabel = "attr_label" + case creatorId = "creator_id" + } + + init( + id: Int = 0, + type: Int = 0, + typeName: String = "", + status: Int = 0, + statusName: String = "", + name: String = "", + coverProject: String = "", + coverVideo: String = "", + price: String = "", + otPrice: String = "", + priceDeposit: String = "", + label: String = "", + attrLabel: [String]? = nil, + creatorId: Int = 0 + ) { + self.id = id + self.type = type + self.typeName = typeName + self.status = status + self.statusName = statusName + self.name = name + self.coverProject = coverProject + self.coverVideo = coverVideo + self.price = price + self.otPrice = otPrice + self.priceDeposit = priceDeposit + self.label = label + self.attrLabel = attrLabel + self.creatorId = creatorId + } +} + +/// 样片关联项目分页响应,对齐 Android `ProjectListData`。 +struct SampleProjectListResponse: Decodable, Sendable, Equatable { + let list: [SampleProjectItem] + let total: Int + + init(list: [SampleProjectItem] = [], total: Int = 0) { + self.list = list + self.total = total + } +} + +/// 样片上传请求,对齐 Android `UploadMaterialRequest`。 +struct SampleUploadMaterialRequest: Encodable, Sendable, Equatable { + let name: String + let type: String + let mediaType: String + let mediaList: [SampleUploadMediaItem] + let coverUrl: String + let coverSize: String + let scenicSpotId: Int64 + let description: String + let materialTag: String + let projectId: Int? + + enum CodingKeys: String, CodingKey { + case name + case type + case mediaType = "media_type" + case mediaList = "media_list" + case coverUrl = "cover_url" + case coverSize = "cover_size" + case scenicSpotId = "scenic_spot_id" + case description + case materialTag = "material_tag" + case projectId = "project_id" + } + + init( + name: String, + mediaType: SampleMediaType, + mediaList: [SampleUploadMediaItem], + coverUrl: String, + coverSize: String, + scenicSpotId: Int64, + projectId: Int? + ) { + self.name = name + self.type = "2" + self.mediaType = String(mediaType.rawValue) + self.mediaList = mediaList + self.coverUrl = coverUrl + self.coverSize = coverSize + self.scenicSpotId = scenicSpotId + self.description = "" + self.materialTag = "" + self.projectId = projectId + } +} + +/// 样片上传素材项,对齐 Android `MediaItem`。 +struct SampleUploadMediaItem: Encodable, Sendable, Equatable { + let originalName: String + let ossUrl: String + let size: Int64 + let fileWidthSize: SampleUploadFileSize + + enum CodingKeys: String, CodingKey { + case originalName = "original_name" + case ossUrl = "oss_url" + case size + case fileWidthSize = "file_width_size" + } +} + +/// 样片上传文件宽高,对齐 Android `FileSize`。 +struct SampleUploadFileSize: Encodable, Sendable, Equatable { + let width: Int + let height: Int +} + +/// 上传页本地媒体项,不依赖 UIKit,供 ViewModel 管理上传状态。 +struct SampleUploadedMediaItem: Sendable, Equatable, Hashable, Identifiable { + let id: String + let data: Data + let thumbnailData: Data? + let fileName: String + let size: Int64 + let width: Int + let height: Int + var ossUrl: String + var isUploading: Bool + var uploadProgress: Int + + init( + id: String = UUID().uuidString, + data: Data, + thumbnailData: Data? = nil, + fileName: String, + size: Int64? = nil, + width: Int = 0, + height: Int = 0, + ossUrl: String = "", + isUploading: Bool = false, + uploadProgress: Int = 0 + ) { + self.id = id + self.data = data + self.thumbnailData = thumbnailData + self.fileName = fileName + self.size = size ?? Int64(data.count) + self.width = width + self.height = height + self.ossUrl = ossUrl + self.isUploading = isUploading + self.uploadProgress = uploadProgress + } +} + +/// 样片上传进度弹窗状态。 +struct SampleUploadDialogState: Sendable, Equatable { + let title: String + let progress: Int +} + +/// 样片 UI 展示格式化工具。 +enum SampleDisplayFormatter { + /// 格式化计数,超过 1000 按 Android 规则显示 k。 + static func formatCount(_ count: Int) -> String { + guard count >= 1000 else { return String(count) } + let value = Double(count) / 1000.0 + if value.truncatingRemainder(dividingBy: 1) == 0 { + return "\(Int(value))k" + } + return String(format: "%.1fk", value) + } + + /// 审核状态文案。 + static func reviewStatusText(_ status: Int) -> String { + switch status { + case 1: "已通过" + case 0: "待审核" + case 2: "未通过" + default: "未知" + } + } +} + +private extension KeyedDecodingContainer { + func decodeFlexibleDouble(forKey key: Key) throws -> Double? { + if let value = try decodeIfPresent(Double.self, forKey: key) { + return value + } + if let string = try decodeIfPresent(String.self, forKey: key) { + return Double(string) + } + return nil + } + + func decodeFlexibleString(forKey key: Key) throws -> String? { + if let value = try decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + return nil + } +} + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift b/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift new file mode 100644 index 0000000..035045f --- /dev/null +++ b/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift @@ -0,0 +1,562 @@ +// +// SampleManagementViewModels.swift +// suixinkan +// + +import Foundation + +/// 样片 OSS 上传协议,便于上传 ViewModel 在测试中注入替身。 +@MainActor +protocol SampleOSSUploading { + /// 上传样片文件并返回 OSS URL。 + func uploadSampleFile( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String +} + +/// 样片列表页 ViewModel,负责搜索、筛选、分页和上下架操作。 +final class SampleListViewModel { + private(set) var items: [SampleMaterialItem] = [] + private(set) var isLoading = false + private(set) var isRefreshing = false + private(set) var canLoadMore = false + private(set) var filterStatus: SampleFilterStatus = .all + private(set) var searchText = "" + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + private var currentPage = 1 + private var totalCount = 0 + private let pageSize = 10 + + /// 首次加载样片列表。 + func loadInitial(api: any SampleManagementServing) async { + await load(reset: true, showLoading: true, api: api) + } + + /// 下拉刷新样片列表。 + func refresh(api: any SampleManagementServing) async { + isRefreshing = true + notifyStateChange() + await load(reset: true, showLoading: false, api: api) + } + + /// 滚动到底部附近时加载更多。 + func loadMoreIfNeeded(lastVisibleIndex: Int, api: any SampleManagementServing) async { + guard lastVisibleIndex >= items.count - 3 else { return } + await load(reset: false, showLoading: false, api: api) + } + + /// 更新搜索文本并重新加载第一页。 + func updateSearchText(_ text: String, api: any SampleManagementServing) async { + searchText = text + await load(reset: true, showLoading: false, api: api) + } + + /// 选择筛选状态并重新加载第一页。 + func selectFilter(_ status: SampleFilterStatus, api: any SampleManagementServing) async { + guard filterStatus != status else { return } + filterStatus = status + notifyStateChange() + await load(reset: true, showLoading: false, api: api) + } + + /// 切换样片上下架状态。 + func toggleListing(sampleId: Int, checked: Bool, api: any SampleManagementServing) async { + guard let currentItem = items.first(where: { $0.id == sampleId }) else { return } + guard currentItem.status == 1 else { + onShowMessage?("审核通过才可以操作上下架") + return + } + + let targetStatus = checked ? 1 : 0 + guard currentItem.listingStatus != targetStatus else { return } + + do { + try await api.setListingStatus(id: sampleId, listingStatus: targetStatus) + items = items.map { item in + guard item.id == sampleId else { return item } + return SampleMaterialItem( + id: item.id, + name: item.name, + coverUrl: item.coverUrl, + createdAt: item.createdAt, + status: item.status, + downloadCount: item.downloadCount, + likesCount: item.likesCount, + collectCount: item.collectCount, + shareCount: item.shareCount, + scenicSpotName: item.scenicSpotName, + projectName: item.projectName, + listingStatus: targetStatus + ) + } + notifyStateChange() + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription) + } + } + + private func load(reset: Bool, showLoading: Bool, api: any SampleManagementServing) async { + if reset { + currentPage = 1 + canLoadMore = false + } else { + guard canLoadMore, !isLoading else { return } + currentPage += 1 + } + + isLoading = showLoading + notifyStateChange() + defer { + isLoading = false + isRefreshing = false + notifyStateChange() + } + + do { + let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + let response = try await api.list( + status: filterStatus.apiStatus, + keyword: keyword, + page: currentPage, + pageSize: pageSize + ) + totalCount = response.total + items = reset ? response.list : items + response.list + canLoadMore = items.count < totalCount + } catch is CancellationError { + return + } catch { + if !reset, currentPage > 1 { + currentPage -= 1 + } + if reset { + items = [] + totalCount = 0 + canLoadMore = false + } + onShowMessage?(error.localizedDescription) + } + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 样片详情页 ViewModel,负责详情加载状态。 +final class SampleDetailViewModel { + private(set) var detail: SampleDetail? + private(set) var isLoading = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + let sampleId: Int + + /// 初始化样片详情 ViewModel。 + init(sampleId: Int) { + self.sampleId = sampleId + } + + /// 加载样片详情。 + func load(api: any SampleManagementServing) async { + guard sampleId > 0 else { + onShowMessage?("样片ID无效") + return + } + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + detail = try await api.detail(id: sampleId) + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "获取样片详情失败" : error.localizedDescription) + } + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 样片上传页 ViewModel,负责表单、选择数据、上传进度与提交。 +final class UploadSampleViewModel { + private(set) var albumName = "" + private(set) var selectedScenic: ScenicInfo? + private(set) var scenicList: [ScenicInfo] = [] + private(set) var selectedScenicSpot: ScenicSpotItem? + private(set) var scenicSpotList: [ScenicSpotItem] = [] + private(set) var selectedProject: SampleProjectItem? + private(set) var projectList: [SampleProjectItem] = [] + private(set) var projectCanLoadMore = false + private(set) var mediaType: SampleMediaType = .image + private(set) var uploadedMediaList: [SampleUploadedMediaItem] = [] + private(set) var coverImage: SampleUploadedMediaItem? + private(set) var uploadDialogState: SampleUploadDialogState? + private(set) var isSubmitting = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onUploadSuccess: (() -> Void)? + + private let scenicListProvider: () -> [ScenicInfo] + private let currentScenicIdProvider: () -> Int + private var projectCurrentPage = 1 + private var projectTotal = 0 + private var projectIsLoading = false + private let projectPageSize = 10 + + /// 初始化样片上传 ViewModel。 + init( + scenicListProvider: @escaping () -> [ScenicInfo] = { AppStore.shared.roleScenicList() }, + currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId } + ) { + self.scenicListProvider = scenicListProvider + self.currentScenicIdProvider = currentScenicIdProvider + scenicList = scenicListProvider() + } + + /// 更新相册名称,最多保留 30 个字符。 + func updateAlbumName(_ name: String) { + albumName = String(name.prefix(30)) + notifyStateChange() + } + + /// 选择关联景区,并清空打卡点和项目选择。 + func selectScenic(_ scenic: ScenicInfo, api: any SampleManagementServing) async { + selectedScenic = scenic + selectedScenicSpot = nil + scenicSpotList = [] + selectedProject = nil + projectList = [] + projectCurrentPage = 1 + projectTotal = 0 + projectCanLoadMore = false + notifyStateChange() + await loadScenicSpots(api: api) + } + + /// 选择关联打卡点。 + func selectScenicSpot(_ spot: ScenicSpotItem) { + selectedScenicSpot = spot + notifyStateChange() + } + + /// 选择关联项目。 + func selectProject(_ project: SampleProjectItem) { + selectedProject = project + notifyStateChange() + } + + /// 切换素材类型,类型变化时清空已选素材。 + func selectMediaType(_ type: SampleMediaType) { + guard mediaType != type else { return } + mediaType = type + uploadedMediaList = [] + notifyStateChange() + } + + /// 加载项目列表。 + func loadProjects(refresh: Bool, api: any SampleManagementServing) async { + guard let scenicId = selectedScenic?.id, scenicId > 0 else { + onShowMessage?("请先选择景区") + return + } + if projectIsLoading && !refresh { return } + if refresh { + projectCurrentPage = 1 + projectList = [] + projectCanLoadMore = false + notifyStateChange() + } else { + guard projectCanLoadMore else { return } + } + + projectIsLoading = true + defer { + projectIsLoading = false + notifyStateChange() + } + + do { + let response = try await api.projectList( + scenicId: String(scenicId), + name: nil, + page: projectCurrentPage, + pageSize: projectPageSize + ) + projectTotal = response.total + projectList = refresh ? response.list : projectList + response.list + projectCurrentPage += 1 + projectCanLoadMore = projectList.count < projectTotal + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "获取项目列表失败" : error.localizedDescription) + } + } + + /// 增加并立即顺序上传样片素材。 + func addLocalMedia(_ mediaItems: [SampleUploadedMediaItem], uploader: any SampleOSSUploading) async { + guard !mediaItems.isEmpty else { return } + let remaining = mediaType.maxCount - uploadedMediaList.count + guard remaining > 0 else { + onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张") + return + } + + let acceptedItems = Array(mediaItems.prefix(remaining)) + if acceptedItems.count < mediaItems.count { + onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张") + } + let startIndex = uploadedMediaList.count + uploadedMediaList.append(contentsOf: acceptedItems) + notifyStateChange() + + for offset in acceptedItems.indices { + let index = startIndex + offset + await uploadMedia(at: index, batchPosition: offset + 1, batchTotal: acceptedItems.count, uploader: uploader) + } + uploadDialogState = nil + notifyStateChange() + } + + /// 设置封面并立即上传。 + func setCoverImage(_ image: SampleUploadedMediaItem, uploader: any SampleOSSUploading) async { + coverImage = image + notifyStateChange() + await uploadCover(uploader: uploader) + } + + /// 删除素材。 + func deleteMaterial(at index: Int) { + guard uploadedMediaList.indices.contains(index) else { return } + uploadedMediaList.remove(at: index) + notifyStateChange() + } + + /// 删除封面。 + func deleteCoverImage() { + coverImage = nil + notifyStateChange() + } + + /// 校验并提交样片。 + func uploadSample(api: any SampleManagementServing) async { + let trimmedName = albumName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + onShowMessage?("请输入相册名称") + return + } + guard trimmedName.count <= 30 else { + onShowMessage?("相册名称最多30个字符") + return + } + guard !uploadedMediaList.isEmpty else { + onShowMessage?("请至少上传一个素材") + return + } + guard let coverImage else { + onShowMessage?("请上传封面图片") + return + } + guard selectedScenic != nil else { + onShowMessage?("请选择关联景区") + return + } + guard let selectedScenicSpot else { + onShowMessage?("请选择关联打卡点") + return + } + guard let selectedProject else { + onShowMessage?("请选择关联项目") + return + } + guard uploadedMediaList.allSatisfy({ !$0.ossUrl.isEmpty }) else { + onShowMessage?("请等待素材上传完成") + return + } + guard !coverImage.ossUrl.isEmpty else { + onShowMessage?("请等待封面图片上传完成") + return + } + + let mediaList = uploadedMediaList.map { item in + SampleUploadMediaItem( + originalName: item.fileName, + ossUrl: item.ossUrl, + size: item.size, + fileWidthSize: SampleUploadFileSize(width: item.width, height: item.height) + ) + } + let request = SampleUploadMaterialRequest( + name: trimmedName, + mediaType: mediaType, + mediaList: mediaList, + coverUrl: coverImage.ossUrl, + coverSize: String(coverImage.size), + scenicSpotId: selectedScenicSpot.id, + projectId: selectedProject.id + ) + + isSubmitting = true + notifyStateChange() + defer { + isSubmitting = false + notifyStateChange() + } + + do { + try await api.upload(request) + onShowMessage?("上传成功") + onUploadSuccess?() + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription) + } + } + + private func loadScenicSpots(api: any SampleManagementServing) async { + guard let scenicId = selectedScenic?.id, scenicId > 0 else { return } + do { + let response = try await api.scenicSpotListAll(scenicId: String(scenicId)) + scenicSpotList = response.list + notifyStateChange() + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点失败" : error.localizedDescription) + } + } + + private func uploadMedia( + at index: Int, + batchPosition: Int, + batchTotal: Int, + uploader: any SampleOSSUploading + ) async { + guard uploadedMediaList.indices.contains(index) else { return } + let scenicId = currentScenicIdProvider() + guard scenicId > 0 else { + onShowMessage?("请先选择景区") + return + } + + uploadedMediaList[index].isUploading = true + uploadedMediaList[index].uploadProgress = 0 + uploadDialogState = SampleUploadDialogState(title: "正在上传素材(\(batchPosition)/\(batchTotal))", progress: 0) + notifyStateChange() + + let item = uploadedMediaList[index] + do { + let url = try await uploader.uploadSampleFile( + data: item.data, + fileName: item.fileName, + fileType: mediaType.uploadFileType, + scenicId: scenicId + ) { [weak self] progress in + guard let self else { return } + Task { @MainActor in + guard self.uploadedMediaList.indices.contains(index) else { return } + self.uploadedMediaList[index].uploadProgress = progress + self.uploadedMediaList[index].isUploading = progress < 100 + self.uploadDialogState = SampleUploadDialogState( + title: "正在上传素材(\(batchPosition)/\(batchTotal))", + progress: max(0, min(100, progress)) + ) + self.notifyStateChange() + } + } + guard uploadedMediaList.indices.contains(index) else { return } + uploadedMediaList[index].ossUrl = url + uploadedMediaList[index].isUploading = false + uploadedMediaList[index].uploadProgress = 100 + notifyStateChange() + } catch is CancellationError { + return + } catch { + guard uploadedMediaList.indices.contains(index) else { return } + uploadedMediaList[index].isUploading = false + uploadedMediaList[index].uploadProgress = 0 + onShowMessage?("上传失败: \(error.localizedDescription)") + notifyStateChange() + } + } + + private func uploadCover(uploader: any SampleOSSUploading) async { + guard var image = coverImage else { return } + let scenicId = currentScenicIdProvider() + guard scenicId > 0 else { + onShowMessage?("请先选择景区") + return + } + + image.isUploading = true + image.uploadProgress = 0 + coverImage = image + uploadDialogState = SampleUploadDialogState(title: "正在上传封面", progress: 0) + notifyStateChange() + + do { + let url = try await uploader.uploadSampleFile( + data: image.data, + fileName: image.fileName, + fileType: SampleMediaType.image.uploadFileType, + scenicId: scenicId + ) { [weak self] progress in + guard let self else { return } + Task { @MainActor in + guard var current = self.coverImage else { return } + current.uploadProgress = progress + current.isUploading = progress < 100 + self.coverImage = current + self.uploadDialogState = SampleUploadDialogState( + title: "正在上传封面", + progress: max(0, min(100, progress)) + ) + self.notifyStateChange() + } + } + guard var current = coverImage else { return } + current.ossUrl = url + current.isUploading = false + current.uploadProgress = 100 + coverImage = current + uploadDialogState = nil + notifyStateChange() + } catch is CancellationError { + return + } catch { + guard var current = coverImage else { return } + current.isUploading = false + current.uploadProgress = 0 + coverImage = current + uploadDialogState = nil + onShowMessage?("封面上传失败: \(error.localizedDescription)") + notifyStateChange() + } + } + + private func notifyStateChange() { + onStateChange?() + } +} + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/suixinkan/UI/Home/Views/HomeCollectionCells.swift b/suixinkan/UI/Home/Views/HomeCollectionCells.swift index 191536a..e2e47a6 100644 --- a/suixinkan/UI/Home/Views/HomeCollectionCells.swift +++ b/suixinkan/UI/Home/Views/HomeCollectionCells.swift @@ -126,7 +126,7 @@ final class HomeMenuCell: UICollectionViewCell { } func apply(menu: HomeMenuItem) { - iconView.image = UIImage(systemName: menu.iconName) + iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName) titleLabel.text = menu.title } diff --git a/suixinkan/UI/SampleManagement/SampleDetailViewController.swift b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift new file mode 100644 index 0000000..8357ac2 --- /dev/null +++ b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift @@ -0,0 +1,682 @@ +// +// SampleDetailViewController.swift +// suixinkan +// + +import AVFoundation +import Kingfisher +import SnapKit +import UIKit + +/// 样片详情页,对齐 Android `SampleDetailScreen`。 +final class SampleDetailViewController: BaseViewController { + private let viewModel: SampleDetailViewModel + private let api: any SampleManagementServing + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let carouselView = SampleMediaCarouselView() + private let sampleInfoSection = SampleDetailSampleInfoSection() + private let uploaderSection = SampleDetailUploaderSection() + private let scenicSection = SampleDetailImageInfoSection(title: "打卡地点") + private let projectSection = SampleDetailImageInfoSection(title: "样片绑定套餐") + private let reviewSection = SampleDetailReviewSection() + + /// 初始化样片详情页。 + init( + sampleId: Int, + api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI + ) { + viewModel = SampleDetailViewModel(sampleId: sampleId) + 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 = UIColor(hex: 0xF4F4F4) + contentStack.axis = .vertical + contentStack.spacing = 12 + scrollView.backgroundColor = UIColor(hex: 0xF4F4F4) + + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + contentStack.addArrangedSubview(carouselView) + contentStack.addArrangedSubview(sampleInfoSection) + contentStack.addArrangedSubview(uploaderSection) + contentStack.addArrangedSubview(scenicSection) + contentStack.addArrangedSubview(projectSection) + contentStack.addArrangedSubview(reviewSection) + } + + override func setupConstraints() { + scrollView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(scrollView) + } + carouselView.snp.makeConstraints { make in + make.height.equalTo(carouselView.snp.width).multipliedBy(9.0 / 16.0) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.load(api: api) } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + carouselView.pause() + } + + private func applyViewModel() { + if viewModel.isLoading && viewModel.detail == nil { + showLoading() + } else { + hideLoading() + } + guard let detail = viewModel.detail else { return } + carouselView.apply(media: detail.mediaList ?? []) + sampleInfoSection.apply(detail: detail) + uploaderSection.apply(detail: detail) + scenicSection.applyScenic(detail: detail) + projectSection.applyProject(detail: detail) + reviewSection.apply(detail: detail) + } +} + +/// 样片详情媒体轮播,支持图片自动切换与视频播完切换。 +private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { + private let scrollView = UIScrollView() + private let pageControl = UIPageControl() + private let placeholderLabel = UILabel() + private var media: [SampleDetailMediaItem] = [] + private var players: [Int: AVPlayer] = [:] + private var timer: Timer? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor(hex: 0xF5F5F5) + scrollView.isPagingEnabled = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.delegate = self + scrollView.bounces = false + pageControl.currentPageIndicatorTintColor = .white + pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5) + placeholderLabel.text = "暂无媒体" + placeholderLabel.font = .systemFont(ofSize: 14) + placeholderLabel.textColor = AppColor.textSecondary + placeholderLabel.textAlignment = .center + + addSubview(scrollView) + addSubview(pageControl) + addSubview(placeholderLabel) + scrollView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + pageControl.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalToSuperview().inset(8) + } + placeholderLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + layoutPages() + } + + /// 应用媒体列表。 + func apply(media: [SampleDetailMediaItem]) { + stopTimer() + NotificationCenter.default.removeObserver(self) + players.values.forEach { $0.pause() } + players.removeAll() + self.media = media + scrollView.subviews.forEach { $0.removeFromSuperview() } + pageControl.numberOfPages = media.count + pageControl.currentPage = 0 + pageControl.isHidden = media.count <= 1 + placeholderLabel.isHidden = !media.isEmpty + scrollView.isHidden = media.isEmpty + guard !media.isEmpty else { return } + + for (index, item) in media.enumerated() { + let page = UIView() + page.backgroundColor = UIColor(hex: 0xF5F5F5) + page.tag = index + scrollView.addSubview(page) + if item.type == 2 { + addVideo(urlText: item.displayURL, to: page, index: index) + } else { + addImage(urlText: item.displayURL, to: page) + } + } + setNeedsLayout() + layoutIfNeeded() + playCurrentPageIfNeeded() + } + + /// 暂停当前轮播。 + func pause() { + stopTimer() + players.values.forEach { $0.pause() } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateCurrentPage() + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + updateCurrentPage() + } + + private func addImage(urlText: String, to page: UIView) { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + page.addSubview(imageView) + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + if let url = URL(string: urlText), !urlText.isEmpty { + imageView.kf.setImage(with: url, placeholder: UIImage(named: "img_square_loading_big")) + } + } + + private func addVideo(urlText: String, to page: UIView, index: Int) { + guard let url = URL(string: urlText), !urlText.isEmpty else { + addImage(urlText: "", to: page) + return + } + let player = AVPlayer(url: url) + players[index] = player + let playerView = SamplePlayerLayerView() + playerView.playerLayer.player = player + page.addSubview(playerView) + playerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + NotificationCenter.default.addObserver( + self, + selector: #selector(videoDidEnd(_:)), + name: .AVPlayerItemDidPlayToEndTime, + object: player.currentItem + ) + } + + private func layoutPages() { + let size = bounds.size + guard size.width > 0, size.height > 0 else { return } + for (index, page) in scrollView.subviews.enumerated() { + page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height) + } + scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height) + scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0) + } + + private func updateCurrentPage() { + guard bounds.width > 0 else { return } + let page = Int(round(scrollView.contentOffset.x / bounds.width)) + pageControl.currentPage = max(0, min(page, media.count - 1)) + playCurrentPageIfNeeded() + } + + private func playCurrentPageIfNeeded() { + stopTimer() + players.forEach { index, player in + if index == pageControl.currentPage { + player.seek(to: .zero) + player.play() + } else { + player.pause() + } + } + guard !media.isEmpty else { return } + if media[pageControl.currentPage].type == 1 { + startImageTimer() + } + } + + private func startImageTimer() { + guard media.count > 1 else { return } + timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in + self?.advancePage() + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + @objc private func videoDidEnd(_ notification: Notification) { + advancePage() + } + + private func advancePage() { + guard media.count > 1, bounds.width > 0 else { return } + let nextPage = (pageControl.currentPage + 1) % media.count + pageControl.currentPage = nextPage + scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true) + if !scrollView.isDragging && !scrollView.isDecelerating { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.playCurrentPageIfNeeded() + } + } + } +} + +/// AVPlayerLayer 容器。 +private final class SamplePlayerLayerView: UIView { + override class var layerClass: AnyClass { AVPlayerLayer.self } + var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } +} + +/// 样片详情名称与上架状态区。 +private final class SampleDetailSampleInfoSection: UIView { + private let titleLabel = UILabel() + private let listingBadge = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + titleLabel.font = .systemFont(ofSize: 20, weight: .bold) + titleLabel.textColor = UIColor(hex: 0x2E3746) + titleLabel.numberOfLines = 0 + listingBadge.font = .systemFont(ofSize: 12) + listingBadge.layer.cornerRadius = 4 + listingBadge.clipsToBounds = true + listingBadge.textAlignment = .center + addSubview(titleLabel) + addSubview(listingBadge) + titleLabel.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(16) + make.leading.equalToSuperview().offset(16) + make.trailing.lessThanOrEqualTo(listingBadge.snp.leading).offset(-12) + } + listingBadge.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.centerY.equalTo(titleLabel) + make.height.equalTo(24) + make.width.greaterThanOrEqualTo(54) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(detail: SampleDetail) { + titleLabel.text = detail.name ?? "" + let listed = detail.listingStatus == 1 + listingBadge.text = listed ? "已上架" : "未上架" + let color = listed ? UIColor(hex: 0x22C55E) : AppColor.textSecondary + listingBadge.textColor = color + listingBadge.backgroundColor = color.withAlphaComponent(0.1) + } +} + +/// 样片上传人信息区。 +private final class SampleDetailUploaderSection: UIView { + private let titleLabel = UILabel() + private let avatarView = UIImageView() + private let nameLabel = UILabel() + private let tagStack = UIStackView() + private let descriptionLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + titleLabel.text = "上传人" + titleLabel.font = .systemFont(ofSize: 14, weight: .bold) + titleLabel.textColor = .black + avatarView.contentMode = .scaleAspectFill + avatarView.clipsToBounds = true + avatarView.layer.cornerRadius = 24 + avatarView.backgroundColor = UIColor(hex: 0xF5F5F5) + nameLabel.font = .systemFont(ofSize: 14, weight: .medium) + nameLabel.textColor = UIColor(hex: 0x002255) + descriptionLabel.font = .systemFont(ofSize: 14) + descriptionLabel.textColor = UIColor(hex: 0x7B8EAA) + descriptionLabel.numberOfLines = 0 + tagStack.axis = .horizontal + tagStack.spacing = 8 + + addSubview(titleLabel) + addSubview(avatarView) + addSubview(nameLabel) + addSubview(tagStack) + addSubview(descriptionLabel) + + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + } + avatarView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.leading.equalToSuperview().offset(16) + make.size.equalTo(48) + make.bottom.lessThanOrEqualToSuperview().inset(16) + } + nameLabel.snp.makeConstraints { make in + make.top.equalTo(avatarView) + make.leading.equalTo(avatarView.snp.trailing).offset(12) + } + tagStack.snp.makeConstraints { make in + make.leading.equalTo(nameLabel.snp.trailing).offset(8) + make.centerY.equalTo(nameLabel) + make.trailing.lessThanOrEqualToSuperview().inset(16) + } + descriptionLabel.snp.makeConstraints { make in + make.top.equalTo(nameLabel.snp.bottom).offset(4) + make.leading.equalTo(nameLabel) + make.trailing.equalToSuperview().inset(16) + make.bottom.equalToSuperview().inset(16) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(detail: SampleDetail) { + nameLabel.text = detail.uploaderName ?? "" + descriptionLabel.text = detail.uploaderDescription ?? "" + tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + if detail.platformCertification { + tagStack.addArrangedSubview(makeTag("平台认证")) + } + if detail.scenicCertification { + tagStack.addArrangedSubview(makeTag("景区认证")) + } + if let urlText = detail.uploaderAvatar, let url = URL(string: urlText), !urlText.isEmpty { + avatarView.kf.setImage(with: url, placeholder: UIImage(systemName: "person.crop.circle.fill")) + } else { + avatarView.image = UIImage(systemName: "person.crop.circle.fill") + avatarView.tintColor = AppColor.textTertiary + } + } + + private func makeTag(_ title: String) -> UILabel { + let label = UILabel() + label.text = title + label.textColor = .white + label.font = .systemFont(ofSize: 8) + label.textAlignment = .center + label.backgroundColor = AppColor.primary + label.layer.cornerRadius = 4 + label.clipsToBounds = true + label.snp.makeConstraints { make in + make.height.equalTo(16) + make.width.greaterThanOrEqualTo(44) + } + return label + } +} + +/// 样片详情图片信息区,复用打卡地点与绑定套餐布局。 +private final class SampleDetailImageInfoSection: UIView { + private let sectionTitleLabel = UILabel() + private let imageView = UIImageView() + private let titleLabel = UILabel() + private let firstLineLabel = UILabel() + private let secondLineLabel = UILabel() + + init(title: String) { + super.init(frame: .zero) + backgroundColor = .white + sectionTitleLabel.text = title + sectionTitleLabel.font = .systemFont(ofSize: 14, weight: .bold) + sectionTitleLabel.textColor = .black + imageView.backgroundColor = UIColor(hex: 0xF5F5F5) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 12 + titleLabel.font = .systemFont(ofSize: title == "打卡地点" ? 14 : 16, weight: .bold) + titleLabel.textColor = .black + [firstLineLabel, secondLineLabel].forEach { label in + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(hex: 0x4B5563) + label.numberOfLines = 1 + } + + addSubview(sectionTitleLabel) + addSubview(imageView) + addSubview(titleLabel) + addSubview(firstLineLabel) + addSubview(secondLineLabel) + sectionTitleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + } + imageView.snp.makeConstraints { make in + make.top.equalTo(sectionTitleLabel.snp.bottom).offset(12) + make.leading.equalToSuperview().offset(16) + make.height.equalTo(100) + make.width.equalTo(179) + make.bottom.equalToSuperview().inset(16) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(imageView).offset(2) + make.leading.equalTo(imageView.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(16) + } + firstLineLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.leading.trailing.equalTo(titleLabel) + } + secondLineLabel.snp.makeConstraints { make in + make.top.equalTo(firstLineLabel.snp.bottom).offset(7) + make.leading.trailing.equalTo(titleLabel) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func applyScenic(detail: SampleDetail) { + titleLabel.text = detail.scenicName ?? "" + firstLineLabel.attributedText = coloredValue(prefix: "无人机: ", value: "\(detail.scenicPlanesNum)架") + secondLineLabel.attributedText = coloredValue(prefix: "摄影师: ", value: "\(detail.scenicPhotographerNum)人") + setImage(detail.scenicCoverImg) + } + + func applyProject(detail: SampleDetail) { + titleLabel.text = detail.projectName ?? "" + if let price = detail.projectPrice { + firstLineLabel.attributedText = coloredValue(prefix: "价格: ", value: "\(price)元") + } else { + firstLineLabel.text = "" + } + if let deposit = detail.projectPriceDeposit { + secondLineLabel.attributedText = coloredValue(prefix: "定金: ", value: "\(deposit)元") + } else { + secondLineLabel.text = "" + } + setImage(detail.projectCover) + } + + private func setImage(_ urlText: String?) { + if let urlText, let url = URL(string: urlText), !urlText.isEmpty { + imageView.kf.setImage(with: url) + } else { + imageView.image = nil + } + } + + private func coloredValue(prefix: String, value: String) -> NSAttributedString { + let text = NSMutableAttributedString( + string: prefix, + attributes: [.foregroundColor: UIColor(hex: 0x4B5563), .font: UIFont.systemFont(ofSize: 12)] + ) + text.append( + NSAttributedString( + string: value, + attributes: [.foregroundColor: AppColor.primary, .font: UIFont.systemFont(ofSize: 12)] + ) + ) + return text + } +} + +/// 样片审核信息区。 +private final class SampleDetailReviewSection: UIView { + private let stack = UIStackView() + private let titleLabel = UILabel() + private let reviewerRow = SampleDetailInfoRow() + private let reviewTimeRow = SampleDetailInfoRow() + private let statusRow = SampleDetailStatusRow() + private let noteRow = SampleDetailInfoRow() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + stack.axis = .vertical + stack.spacing = 0 + titleLabel.text = "审核信息" + titleLabel.font = .systemFont(ofSize: 14, weight: .bold) + titleLabel.textColor = .black + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + stack.addArrangedSubview(titleLabel) + [reviewerRow, reviewTimeRow, statusRow, noteRow].forEach { row in + stack.addArrangedSubview(row) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(detail: SampleDetail) { + reviewerRow.apply(label: "审核人", value: detail.reviewer?.nonEmpty ?? "--") + reviewTimeRow.apply(label: "审核时间", value: detail.reviewTime?.nonEmpty ?? "--") + statusRow.apply(status: detail.status) + noteRow.apply(label: "审核备注", value: detail.reviewNotes?.nonEmpty ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12)) + } +} + +/// 样片详情普通信息行。 +private final class SampleDetailInfoRow: UIView { + private let label = UILabel() + private let valueLabel = UILabel() + private let separator = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + label.font = .systemFont(ofSize: 14) + label.textColor = UIColor(hex: 0x2E3746) + valueLabel.font = .systemFont(ofSize: 14, weight: .medium) + valueLabel.textColor = .black + valueLabel.textAlignment = .right + valueLabel.numberOfLines = 1 + separator.backgroundColor = UIColor(hex: 0xEEEEEE) + addSubview(label) + addSubview(valueLabel) + addSubview(separator) + snp.makeConstraints { make in + make.height.equalTo(44) + } + label.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + } + valueLabel.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12) + } + separator.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(1) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(label: String, value: String, valueColor: UIColor = .black, valueFont: UIFont = .systemFont(ofSize: 14, weight: .medium)) { + self.label.text = label + valueLabel.text = value + valueLabel.textColor = valueColor + valueLabel.font = valueFont + } +} + +/// 样片详情审核状态行。 +private final class SampleDetailStatusRow: UIView { + private let label = UILabel() + private let badge = SampleStatusBadgeView() + private let separator = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + label.text = "审核状态" + label.font = .systemFont(ofSize: 14) + label.textColor = UIColor(hex: 0x2E3746) + separator.backgroundColor = UIColor(hex: 0xEEEEEE) + addSubview(label) + addSubview(badge) + addSubview(separator) + snp.makeConstraints { make in + make.height.equalTo(44) + } + label.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + } + badge.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.height.equalTo(24) + } + separator.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(1) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(status: Int) { + badge.apply(status: status) + } +} + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/suixinkan/UI/SampleManagement/SampleListViewController.swift b/suixinkan/UI/SampleManagement/SampleListViewController.swift new file mode 100644 index 0000000..6f85ac0 --- /dev/null +++ b/suixinkan/UI/SampleManagement/SampleListViewController.swift @@ -0,0 +1,547 @@ +// +// SampleListViewController.swift +// suixinkan +// + +import Kingfisher +import SnapKit +import UIKit + +/// 样片相册列表页,对齐 Android `SampleListScreen`。 +final class SampleListViewController: BaseViewController { + private let viewModel: SampleListViewModel + private let api: any SampleManagementServing + + private let searchContainer = UIView() + private let searchBox = UIView() + private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass")) + private let searchTextField = UITextField() + private let filterContainer = UIView() + private let filterButton = UIButton(type: .system) + private let filterTitleLabel = UILabel() + private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down")) + private let filterDropdownView = UIView() + private let filterDropdownStack = UIStackView() + private let tableView = UITableView(frame: .zero, style: .plain) + private let emptyLabel = UILabel() + private let bottomBar = UIView() + private let addButton = UIButton(type: .system) + private var dataSource: UITableViewDiffableDataSource! + private var filterOptionButtons: [(SampleFilterStatus, UIButton)] = [] + + /// 初始化样片列表页。 + init( + viewModel: SampleListViewModel = SampleListViewModel(), + api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI + ) { + 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 setupNavigationBar() { + title = "样片相册列表" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + + searchContainer.backgroundColor = .white + searchBox.backgroundColor = UIColor(hex: 0xF4F4F4) + searchBox.layer.cornerRadius = 4 + searchIconView.tintColor = AppColor.textSecondary + searchIconView.contentMode = .scaleAspectFit + searchTextField.placeholder = "搜索相册标题" + searchTextField.font = .systemFont(ofSize: 13) + searchTextField.textColor = AppColor.textPrimary + searchTextField.clearButtonMode = .whileEditing + searchTextField.returnKeyType = .search + + filterContainer.backgroundColor = .white + filterButton.backgroundColor = UIColor(hex: 0xF4F4F4) + filterButton.layer.cornerRadius = 4 + filterTitleLabel.text = "筛选" + filterTitleLabel.font = .systemFont(ofSize: 14) + filterTitleLabel.textColor = UIColor(hex: 0x4B5563) + filterChevronView.tintColor = UIColor(hex: 0x4B5563) + filterChevronView.contentMode = .scaleAspectFit + filterDropdownView.backgroundColor = .white + filterDropdownView.layer.cornerRadius = 4 + filterDropdownView.layer.shadowColor = UIColor.black.cgColor + filterDropdownView.layer.shadowOpacity = 0.12 + filterDropdownView.layer.shadowRadius = 10 + filterDropdownView.layer.shadowOffset = CGSize(width: 0, height: 4) + filterDropdownView.isHidden = true + filterDropdownStack.axis = .vertical + filterDropdownStack.spacing = 0 + + tableView.backgroundColor = AppColor.pageBackground + tableView.separatorStyle = .none + tableView.estimatedRowHeight = 152 + tableView.rowHeight = UITableView.automaticDimension + tableView.delegate = self + tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0) + tableView.register(SampleListCell.self, forCellReuseIdentifier: SampleListCell.reuseIdentifier) + tableView.refreshControl = UIRefreshControl() + + dataSource = UITableViewDiffableDataSource(tableView: tableView) { + [weak self] tableView, indexPath, item in + let cell = tableView.dequeueReusableCell( + withIdentifier: SampleListCell.reuseIdentifier, + for: indexPath + ) as! SampleListCell + cell.apply(item: item) + cell.onToggle = { [weak self] checked in + guard let self else { return } + Task { await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api) } + } + return cell + } + + emptyLabel.text = "暂无样片" + emptyLabel.font = .systemFont(ofSize: 16) + emptyLabel.textColor = AppColor.textSecondary + emptyLabel.textAlignment = .center + emptyLabel.isHidden = true + + bottomBar.backgroundColor = .white + addButton.setTitle("添加", for: .normal) + addButton.setTitleColor(.white, for: .normal) + addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + addButton.backgroundColor = AppColor.primary + addButton.layer.cornerRadius = 8 + + view.addSubview(searchContainer) + searchContainer.addSubview(searchBox) + searchBox.addSubview(searchIconView) + searchBox.addSubview(searchTextField) + view.addSubview(filterContainer) + filterContainer.addSubview(filterButton) + filterButton.addSubview(filterTitleLabel) + filterButton.addSubview(filterChevronView) + view.addSubview(tableView) + view.addSubview(emptyLabel) + view.addSubview(bottomBar) + bottomBar.addSubview(addButton) + view.addSubview(filterDropdownView) + filterDropdownView.addSubview(filterDropdownStack) + configureFilterDropdown() + } + + override func setupConstraints() { + searchContainer.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(68) + } + searchBox.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.centerY.equalToSuperview() + make.height.equalTo(36) + } + searchIconView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.size.equalTo(15) + } + searchTextField.snp.makeConstraints { make in + make.leading.equalTo(searchIconView.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(12) + make.top.bottom.equalToSuperview() + } + filterContainer.snp.makeConstraints { make in + make.top.equalTo(searchContainer.snp.bottom).offset(1) + make.leading.trailing.equalToSuperview() + make.height.equalTo(68) + } + filterButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.centerY.equalToSuperview() + make.height.equalTo(36) + } + filterTitleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + } + filterChevronView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(14) + make.centerY.equalToSuperview() + make.size.equalTo(18) + } + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + addButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(15) + make.height.equalTo(48) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(filterContainer.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + filterDropdownView.snp.makeConstraints { make in + make.top.equalTo(filterButton.snp.bottom) + make.leading.trailing.equalTo(filterButton) + } + filterDropdownStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + emptyLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalTo(tableView) + make.leading.trailing.equalToSuperview().inset(24) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + searchTextField.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged) + searchTextField.delegate = self + filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside) + addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside) + tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.loadInitial(api: api) } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if needsRefreshOnAppear { + needsRefreshOnAppear = false + Task { await viewModel.refresh(api: api) } + } + } + + private var needsRefreshOnAppear = false + + @MainActor + private func applyViewModel() { + filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title + filterOptionButtons.forEach { status, button in + let selected = status == viewModel.filterStatus + button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .medium : .regular) + } + if searchTextField.text != viewModel.searchText { + searchTextField.text = viewModel.searchText + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(viewModel.items) + dataSource.apply(snapshot, animatingDifferences: true) + emptyLabel.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing + viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading() + if !viewModel.isRefreshing { + tableView.refreshControl?.endRefreshing() + } + } + + @objc private func searchTextChanged() { + let text = searchTextField.text ?? "" + Task { await viewModel.updateSearchText(text, api: api) } + } + + @objc private func filterTapped() { + filterDropdownView.isHidden.toggle() + view.bringSubviewToFront(filterDropdownView) + UIView.animate(withDuration: 0.2) { + self.filterChevronView.transform = self.filterDropdownView.isHidden + ? .identity + : CGAffineTransform(rotationAngle: .pi) + } + } + + @objc private func addTapped() { + let controller = UploadSampleViewController() + controller.onUploadSuccess = { [weak self] in + self?.needsRefreshOnAppear = true + } + navigationController?.pushViewController(controller, animated: true) + } + + @objc private func refreshPulled() { + hideFilterDropdown() + Task { await viewModel.refresh(api: api) } + } + + private func configureFilterDropdown() { + SampleFilterStatus.allCases.forEach { status in + let button = UIButton(type: .system) + button.contentHorizontalAlignment = .leading + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + button.setTitle(status.title, for: .normal) + button.backgroundColor = .white + button.snp.makeConstraints { make in + make.height.equalTo(48) + } + button.addAction(UIAction { [weak self] _ in + guard let self else { return } + self.hideFilterDropdown() + Task { await self.viewModel.selectFilter(status, api: self.api) } + }, for: .touchUpInside) + filterDropdownStack.addArrangedSubview(button) + filterOptionButtons.append((status, button)) + } + } + + private func hideFilterDropdown() { + guard !filterDropdownView.isHidden else { return } + filterDropdownView.isHidden = true + UIView.animate(withDuration: 0.2) { + self.filterChevronView.transform = .identity + } + } +} + +extension SampleListViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + hideFilterDropdown() + tableView.deselectRow(at: indexPath, animated: true) + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + navigationController?.pushViewController(SampleDetailViewController(sampleId: item.id), animated: true) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row, api: api) } + } +} + +extension SampleListViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} + +/// 样片列表卡片 cell,对齐 Android `SampleItemView`。 +private final class SampleListCell: UITableViewCell { + static let reuseIdentifier = "SampleListCell" + + private let cardView = UIView() + private let coverImageView = UIImageView() + private let placeholderLabel = UILabel() + private let titleLabel = UILabel() + private let projectLabel = UILabel() + private let scenicSpotLabel = UILabel() + private let statusBadge = SampleStatusBadgeView() + private let listingSwitch = UISwitch() + private let statsStack = UIStackView() + private let shareStatView = SampleStatView(iconName: "sample_ic_share_count") + private let likeStatView = SampleStatView(iconName: "sample_ic_like_count") + private let collectStatView = SampleStatView(iconName: "sample_ic_collect_count") + private var isApplying = false + + var onToggle: ((Bool) -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(item: SampleMaterialItem) { + isApplying = true + titleLabel.text = item.name + projectLabel.text = "项目: \(item.projectName)" + scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)" + statusBadge.apply(status: item.status) + listingSwitch.setOn(item.listingStatus == 1, animated: false) + shareStatView.setCount(item.shareCount) + likeStatView.setCount(item.likesCount) + collectStatView.setCount(item.collectCount) + + if let url = URL(string: item.coverUrl), !item.coverUrl.isEmpty { + placeholderLabel.isHidden = true + coverImageView.kf.setImage(with: url) + } else { + coverImageView.image = nil + placeholderLabel.isHidden = false + } + isApplying = false + } + + private func setupUI() { + selectionStyle = .none + backgroundColor = AppColor.pageBackground + contentView.backgroundColor = AppColor.pageBackground + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 12 + cardView.clipsToBounds = true + + coverImageView.backgroundColor = UIColor(hex: 0xF5F5F5) + coverImageView.contentMode = .scaleAspectFill + coverImageView.clipsToBounds = true + coverImageView.layer.cornerRadius = 8 + + placeholderLabel.text = "暂无图片" + placeholderLabel.font = .systemFont(ofSize: 12) + placeholderLabel.textColor = AppColor.textSecondary + placeholderLabel.textAlignment = .center + + titleLabel.font = .systemFont(ofSize: 16, weight: .bold) + titleLabel.textColor = .black + titleLabel.numberOfLines = 1 + + [projectLabel, scenicSpotLabel].forEach { label in + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(hex: 0x4B5563) + label.numberOfLines = 1 + } + + listingSwitch.onTintColor = AppColor.primary + listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged) + + statsStack.axis = .horizontal + statsStack.spacing = 16 + statsStack.alignment = .center + + contentView.addSubview(cardView) + cardView.addSubview(coverImageView) + coverImageView.addSubview(placeholderLabel) + cardView.addSubview(titleLabel) + cardView.addSubview(projectLabel) + cardView.addSubview(scenicSpotLabel) + cardView.addSubview(statusBadge) + cardView.addSubview(listingSwitch) + cardView.addSubview(statsStack) + [shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview) + + cardView.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(8) + make.leading.trailing.equalToSuperview().inset(16) + } + coverImageView.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(12) + make.size.equalTo(128) + } + placeholderLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(8) + } + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.equalTo(coverImageView.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(12) + } + projectLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.leading.trailing.equalTo(titleLabel) + } + scenicSpotLabel.snp.makeConstraints { make in + make.top.equalTo(projectLabel.snp.bottom).offset(6) + make.leading.trailing.equalTo(titleLabel) + } + statusBadge.snp.makeConstraints { make in + make.top.equalTo(scenicSpotLabel.snp.bottom).offset(10) + make.leading.equalTo(titleLabel) + make.height.equalTo(24) + } + listingSwitch.snp.makeConstraints { make in + make.centerY.equalTo(statusBadge) + make.trailing.equalToSuperview().inset(12) + make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8) + } + statsStack.snp.makeConstraints { make in + make.top.equalTo(statusBadge.snp.bottom).offset(10) + make.leading.equalTo(titleLabel) + make.trailing.lessThanOrEqualToSuperview().inset(12) + make.bottom.lessThanOrEqualToSuperview().inset(12) + } + } + + @objc private func switchChanged() { + guard !isApplying else { return } + onToggle?(listingSwitch.isOn) + } +} + +/// 样片状态 badge。 +final class SampleStatusBadgeView: UIView { + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 4 + addSubview(label) + label.font = .systemFont(ofSize: 12, weight: .medium) + label.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(6) + make.centerY.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 设置审核状态。 + func apply(status: Int) { + let color: UIColor + switch status { + case 1: color = UIColor(hex: 0x22C55E) + case 0: color = UIColor(hex: 0xFF7B00) + case 2: color = UIColor(hex: 0xEF4444) + default: color = AppColor.textSecondary + } + label.text = SampleDisplayFormatter.reviewStatusText(status) + label.textColor = color + backgroundColor = color.withAlphaComponent(0.1) + } +} + +/// 样片统计图标与数字。 +private final class SampleStatView: UIView { + private let iconView = UIImageView() + private let countLabel = UILabel() + + init(iconName: String) { + super.init(frame: .zero) + iconView.image = UIImage(named: iconName) + iconView.tintColor = UIColor(hex: 0x6B7280) + iconView.contentMode = .scaleAspectFit + countLabel.font = .systemFont(ofSize: 14) + countLabel.textColor = UIColor(hex: 0x6B7280) + addSubview(iconView) + addSubview(countLabel) + iconView.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.size.equalTo(16) + } + countLabel.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(4) + make.trailing.centerY.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setCount(_ count: Int) { + countLabel.text = SampleDisplayFormatter.formatCount(count) + } +} diff --git a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift new file mode 100644 index 0000000..2a03053 --- /dev/null +++ b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift @@ -0,0 +1,883 @@ +// +// UploadSampleViewController.swift +// suixinkan +// + +import AVFoundation +import PhotosUI +import SnapKit +import UniformTypeIdentifiers +import UIKit + +/// 样片相册上传页,对齐 Android `UploadSampleScreen`。 +final class UploadSampleViewController: BaseViewController { + private enum PickTarget { + case material + case cover + } + + private let viewModel: UploadSampleViewModel + private let api: any SampleManagementServing + private let uploader: any SampleOSSUploading + + private let scrollView = UIScrollView() + private let formCard = UIView() + private let formStack = UIStackView() + private let nameField = UITextField() + private let nameSection = SampleFormFieldView(title: "相册名称") + private let mediaTypeButton = SampleSelectButton() + private let mediaTypeSection = SampleFormFieldView(title: "素材类型") + private let mediaGridView = SampleUploadGridView() + private let materialSection = SampleFormFieldView(title: "上传素材") + private let materialHintLabel = UILabel() + private let coverView = SampleCoverUploadView() + private let coverSection = SampleFormFieldView(title: "上传封面") + private let scenicButton = SampleSelectButton() + private let scenicSection = SampleFormFieldView(title: "关联景区") + private let spotButton = SampleSelectButton() + private let spotSection = SampleFormFieldView(title: "关联打卡点") + private let projectButton = SampleSelectButton() + private let projectSection = SampleFormFieldView(title: "关联项目") + private let bottomBar = UIView() + private let cancelButton = UIButton(type: .system) + private let confirmButton = UIButton(type: .system) + private let progressOverlay = SampleUploadProgressOverlay() + + private var pickTarget: PickTarget = .material + + var onUploadSuccess: (() -> Void)? + + /// 初始化样片上传页。 + init( + viewModel: UploadSampleViewModel = UploadSampleViewModel(), + api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI, + uploader: any SampleOSSUploading = NetworkServices.shared.ossUploadService + ) { + self.viewModel = viewModel + self.api = api + self.uploader = uploader + 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 = UIColor(hex: 0xF4F4F4) + formCard.backgroundColor = .white + formCard.layer.cornerRadius = 12 + formCard.clipsToBounds = true + formStack.axis = .vertical + formStack.spacing = 16 + + nameField.placeholder = "请输入相册名称" + nameField.font = .systemFont(ofSize: 14) + nameField.textColor = .black + nameField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor + nameField.layer.borderWidth = 1 + nameField.layer.cornerRadius = 8 + nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) + nameField.leftViewMode = .always + nameField.clearButtonMode = .whileEditing + nameSection.setContent(nameField) + + mediaTypeSection.setContent(mediaTypeButton) + materialSection.setContent(makeMaterialContent()) + coverSection.setContent(coverView) + scenicSection.setContent(scenicButton) + spotSection.setContent(spotButton) + projectSection.setContent(projectButton) + + [nameSection, mediaTypeSection, materialSection, coverSection, scenicSection, spotSection, projectSection] + .forEach(formStack.addArrangedSubview) + + bottomBar.backgroundColor = .white + configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary) + configureBottomButton(confirmButton, title: "确认", backgroundColor: AppColor.primary, titleColor: .white) + + view.addSubview(scrollView) + scrollView.addSubview(formCard) + formCard.addSubview(formStack) + view.addSubview(bottomBar) + bottomBar.addSubview(cancelButton) + bottomBar.addSubview(confirmButton) + view.addSubview(progressOverlay) + progressOverlay.isHidden = true + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + cancelButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.equalToSuperview().offset(15) + make.width.equalTo(100) + make.height.equalTo(48) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16) + } + confirmButton.snp.makeConstraints { make in + make.top.height.equalTo(cancelButton) + make.leading.equalTo(cancelButton.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(15) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + formCard.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + make.width.equalTo(scrollView).offset(-32) + } + formStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + nameField.snp.makeConstraints { make in + make.height.equalTo(44) + } + [mediaTypeButton, scenicButton, spotButton, projectButton].forEach { button in + button.snp.makeConstraints { make in + make.height.equalTo(44) + } + } + progressOverlay.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onUploadSuccess = { [weak self] in + Task { @MainActor in + self?.onUploadSuccess?() + self?.navigationController?.popViewController(animated: true) + } + } + + nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged) + mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside) + mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) } + mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) } + coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) } + coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() } + scenicButton.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside) + spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside) + projectButton.addTarget(self, action: #selector(projectTapped), for: .touchUpInside) + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + applyViewModel() + } + + private func makeMaterialContent() -> UIView { + let container = UIView() + materialHintLabel.text = "建议尺寸: 1920x1080px, 大小不超过20MB" + materialHintLabel.font = .systemFont(ofSize: 12) + materialHintLabel.textColor = UIColor(hex: 0xB6BECA) + container.addSubview(mediaGridView) + container.addSubview(materialHintLabel) + mediaGridView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + materialHintLabel.snp.makeConstraints { make in + make.top.equalTo(mediaGridView.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview() + } + return container + } + + private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) { + button.setTitle(title, for: .normal) + button.setTitleColor(titleColor, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + button.backgroundColor = backgroundColor + button.layer.cornerRadius = 8 + } + + private func applyViewModel() { + if nameField.text != viewModel.albumName { + nameField.text = viewModel.albumName + } + mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "") + scenicButton.setTitle(viewModel.selectedScenic?.name, placeholder: "请选择景区") + spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择关联打卡点") + projectButton.setTitle(viewModel.selectedProject?.name, placeholder: "请选择关联项目") + mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType) + coverView.apply(item: viewModel.coverImage) + confirmButton.alpha = viewModel.isSubmitting ? 0.6 : 1 + confirmButton.isEnabled = !viewModel.isSubmitting + if let dialog = viewModel.uploadDialogState { + progressOverlay.apply(state: dialog) + progressOverlay.isHidden = false + } else { + progressOverlay.isHidden = true + } + } + + @objc private func nameChanged() { + viewModel.updateAlbumName(nameField.text ?? "") + } + + @objc private func mediaTypeTapped() { + let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet) + SampleMediaType.allCases.forEach { type in + sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in + self?.viewModel.selectMediaType(type) + }) + } + sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(sheet, animated: true) + } + + @objc private func scenicTapped() { + let sheet = UIAlertController(title: "关联景区", message: nil, preferredStyle: .actionSheet) + viewModel.scenicList.forEach { scenic in + sheet.addAction(UIAlertAction(title: scenic.name, style: .default) { [weak self] _ in + guard let self else { return } + Task { await self.viewModel.selectScenic(scenic, api: self.api) } + }) + } + sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(sheet, animated: true) + } + + @objc private func spotTapped() { + guard viewModel.selectedScenic != nil else { + showToast("请先选择关联景区") + return + } + let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet) + viewModel.scenicSpotList.forEach { spot in + sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in + self?.viewModel.selectScenicSpot(spot) + }) + } + sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(sheet, animated: true) + } + + @objc private func projectTapped() { + guard viewModel.selectedScenic != nil else { + showToast("请先选择景区") + return + } + Task { + if viewModel.projectList.isEmpty { + showLoading() + await viewModel.loadProjects(refresh: true, api: api) + hideLoading() + } + presentProjectSheet() + } + } + + private func presentProjectSheet() { + let sheet = UIAlertController(title: "关联项目", message: nil, preferredStyle: .actionSheet) + viewModel.projectList.forEach { project in + sheet.addAction(UIAlertAction(title: project.name, style: .default) { [weak self] _ in + self?.viewModel.selectProject(project) + }) + } + if viewModel.projectCanLoadMore { + sheet.addAction(UIAlertAction(title: "加载更多...", style: .default) { [weak self] _ in + guard let self else { return } + Task { + await self.viewModel.loadProjects(refresh: false, api: self.api) + self.presentProjectSheet() + } + }) + } + sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(sheet, animated: true) + } + + @objc private func cancelTapped() { + navigationController?.popViewController(animated: true) + } + + @objc private func confirmTapped() { + Task { + showLoading() + defer { hideLoading() } + await viewModel.uploadSample(api: api) + } + } + + private func presentPickerSource(target: PickTarget) { + pickTarget = target + let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraTitle = viewModel.mediaType == .video && target == .material ? "拍摄视频" : "拍照" + sheet.addAction(UIAlertAction(title: cameraTitle, style: .default) { [weak self] _ in + self?.presentCamera() + }) + } + sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in + self?.presentPhotoPicker() + }) + sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(sheet, animated: true) + } + + private func presentPhotoPicker() { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + if pickTarget == .cover { + configuration.filter = .images + configuration.selectionLimit = 1 + } else { + configuration.filter = viewModel.mediaType == .image ? .images : .videos + configuration.selectionLimit = viewModel.mediaType == .video + ? 1 + : max(1, viewModel.mediaType.maxCount - viewModel.uploadedMediaList.count) + } + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + + private func presentCamera() { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.delegate = self + if pickTarget == .material, viewModel.mediaType == .video { + picker.mediaTypes = [UTType.movie.identifier] + picker.cameraCaptureMode = .video + } else { + picker.mediaTypes = [UTType.image.identifier] + picker.cameraCaptureMode = .photo + } + present(picker, animated: true) + } + + private func uploadPickedItems(_ items: [SampleUploadedMediaItem]) { + guard !items.isEmpty else { return } + Task { + switch pickTarget { + case .material: + await viewModel.addLocalMedia(items, uploader: uploader) + case .cover: + await viewModel.setCoverImage(items[0], uploader: uploader) + } + } + } + + private func makeImageItem(image: UIImage, fileName: String) -> SampleUploadedMediaItem? { + guard let data = image.jpegData(compressionQuality: 0.9) else { return nil } + return SampleUploadedMediaItem( + data: data, + fileName: fileName, + width: Int(image.size.width * image.scale), + height: Int(image.size.height * image.scale) + ) + } + + private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> SampleUploadedMediaItem { + let size = videoDisplaySize(url: sourceURL) + return SampleUploadedMediaItem( + data: data, + thumbnailData: videoThumbnailData(url: sourceURL), + fileName: fileName, + width: Int(size.width), + height: Int(size.height) + ) + } + + private func videoDisplaySize(url: URL) -> CGSize { + let asset = AVAsset(url: url) + guard let track = asset.tracks(withMediaType: .video).first else { return .zero } + let transformed = track.naturalSize.applying(track.preferredTransform) + return CGSize(width: abs(transformed.width), height: abs(transformed.height)) + } + + private func videoThumbnailData(url: URL) -> Data? { + let generator = AVAssetImageGenerator(asset: AVAsset(url: url)) + generator.appliesPreferredTrackTransform = true + guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil } + return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75) + } +} + +extension UploadSampleViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard !results.isEmpty else { return } + + var loadedItems = [SampleUploadedMediaItem?](repeating: nil, count: results.count) + let group = DispatchGroup() + + for (index, result) in results.enumerated() { + let provider = result.itemProvider + if pickTarget == .material, viewModel.mediaType == .video { + group.enter() + provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in + defer { group.leave() } + guard let self, let url else { return } + let data = (try? Data(contentsOf: url)) ?? Data() + guard !data.isEmpty else { return } + let fileName = provider.suggestedName.map { "\($0).mp4" } ?? "sample_video_\(Int(Date().timeIntervalSince1970)).mp4" + loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url) + } + } else { + group.enter() + provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in + defer { group.leave() } + guard let self, let image = object as? UIImage else { return } + let fileName = "sample_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg" + loadedItems[index] = self.makeImageItem(image: image, fileName: fileName) + } + } + } + + group.notify(queue: .main) { [weak self] in + self?.uploadPickedItems(loadedItems.compactMap { $0 }) + } + } +} + +extension UploadSampleViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.dismiss(animated: true) + if pickTarget == .material, viewModel.mediaType == .video, + let url = info[.mediaURL] as? URL, + let data = try? Data(contentsOf: url) { + uploadPickedItems([makeVideoItem(data: data, fileName: url.lastPathComponent, sourceURL: url)]) + return + } + if let image = info[.originalImage] as? UIImage, + let item = makeImageItem(image: image, fileName: "sample_image_\(Int(Date().timeIntervalSince1970)).jpg") { + uploadPickedItems([item]) + } + } +} + +/// 样片上传表单字段容器。 +private final class SampleFormFieldView: UIView { + private let titleLabel = UILabel() + private let contentHolder = UIView() + + init(title: String) { + super.init(frame: .zero) + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 14, weight: .medium) + titleLabel.textColor = .black + addSubview(titleLabel) + addSubview(contentHolder) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + contentHolder.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setContent(_ view: UIView) { + contentHolder.subviews.forEach { $0.removeFromSuperview() } + contentHolder.addSubview(view) + view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} + +/// 样片上传下拉选择按钮。 +private final class SampleSelectButton: UIControl { + private let titleLabel = UILabel() + private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down")) + + override init(frame: CGRect) { + super.init(frame: frame) + layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor + layer.borderWidth = 1 + layer.cornerRadius = 8 + titleLabel.font = .systemFont(ofSize: 14) + chevronView.tintColor = .black + addSubview(titleLabel) + addSubview(chevronView) + titleLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8) + } + chevronView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(12) + make.centerY.equalToSuperview() + make.size.equalTo(20) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setTitle(_ title: String?, placeholder: String) { + let text = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + titleLabel.text = text.isEmpty ? placeholder : text + titleLabel.textColor = text.isEmpty ? UIColor(hex: 0x4B5563) : .black + } +} + +/// 样片素材上传网格。 +private final class SampleUploadGridView: UIView { + private let stack = UIStackView() + private var items: [SampleUploadedMediaItem] = [] + private var mediaType: SampleMediaType = .image + var onAdd: (() -> Void)? + var onDelete: ((Int) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + stack.axis = .vertical + stack.spacing = 8 + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(items: [SampleUploadedMediaItem], mediaType: SampleMediaType) { + self.items = items + self.mediaType = mediaType + stack.arrangedSubviews.forEach { $0.removeFromSuperview() } + let showAdd = items.count < mediaType.maxCount + let cells = items.map(Optional.some) + (showAdd ? [nil] : []) + let rows = Int(ceil(Double(cells.count) / 3.0)) + guard rows > 0 else { return } + for row in 0 ..< rows { + let rowStack = UIStackView() + rowStack.axis = .horizontal + rowStack.spacing = 8 + rowStack.distribution = .fillEqually + stack.addArrangedSubview(rowStack) + for column in 0 ..< 3 { + let index = row * 3 + column + if index < cells.count { + rowStack.addArrangedSubview(makeCell(item: cells[index], index: index)) + } else { + rowStack.addArrangedSubview(UIView()) + } + } + } + } + + private func makeCell(item: SampleUploadedMediaItem?, index: Int) -> UIView { + let cell = SampleUploadThumbView(mediaType: mediaType) + cell.snp.makeConstraints { make in + make.height.equalTo(cell.snp.width).dividedBy(1.3) + } + if let item { + cell.apply(item: item) + cell.onDelete = { [weak self] in self?.onDelete?(index) } + } else { + cell.applyAdd() + cell.onAdd = { [weak self] in self?.onAdd?() } + } + return cell + } +} + +/// 样片上传缩略图格子。 +private final class SampleUploadThumbView: UIControl { + private let imageView = UIImageView() + private let addCircle = UIView() + private let addIcon = UIImageView(image: UIImage(systemName: "plus")) + private let textLabel = UILabel() + private let deleteButton = UIButton(type: .system) + private let playIcon = UIImageView(image: UIImage(systemName: "play.fill")) + private let mediaType: SampleMediaType + + var onAdd: (() -> Void)? + var onDelete: (() -> Void)? + + init(mediaType: SampleMediaType) { + self.mediaType = mediaType + super.init(frame: .zero) + layer.cornerRadius = 8 + clipsToBounds = true + backgroundColor = .white + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + addCircle.backgroundColor = AppColor.primary + addCircle.layer.cornerRadius = 14 + addIcon.tintColor = .white + textLabel.font = .systemFont(ofSize: 12) + textLabel.textColor = UIColor(hex: 0x4B5563) + textLabel.textAlignment = .center + deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal) + deleteButton.tintColor = .white + deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) + deleteButton.layer.cornerRadius = 10 + playIcon.tintColor = .white + playIcon.isHidden = true + + addSubview(imageView) + addSubview(addCircle) + addCircle.addSubview(addIcon) + addSubview(textLabel) + addSubview(deleteButton) + addSubview(playIcon) + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + addCircle.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-10) + make.size.equalTo(28) + } + addIcon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(20) + } + textLabel.snp.makeConstraints { make in + make.top.equalTo(addCircle.snp.bottom).offset(4) + make.leading.trailing.equalToSuperview().inset(4) + } + deleteButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(4) + make.size.equalTo(20) + } + playIcon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(32) + } + addTarget(self, action: #selector(addTapped), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func applyAdd() { + imageView.image = nil + backgroundColor = .white + layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor + layer.borderWidth = 1 + addCircle.isHidden = false + textLabel.isHidden = false + textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频" + deleteButton.isHidden = true + playIcon.isHidden = true + isUserInteractionEnabled = true + } + + func apply(item: SampleUploadedMediaItem) { + layer.borderWidth = 0 + backgroundColor = UIColor(hex: 0xF5F5F5) + addCircle.isHidden = true + deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading + playIcon.isHidden = mediaType != .video || item.ossUrl.isEmpty || item.isUploading + if item.isUploading { + imageView.image = nil + textLabel.isHidden = false + textLabel.text = "上传中...\n\(item.uploadProgress)%" + textLabel.numberOfLines = 2 + } else { + textLabel.isHidden = true + imageView.image = UIImage(data: item.thumbnailData ?? item.data) + } + } + + @objc private func addTapped() { + guard !addCircle.isHidden else { return } + onAdd?() + } + + @objc private func deleteTapped() { + onDelete?() + } +} + +/// 样片封面上传视图。 +private final class SampleCoverUploadView: UIControl { + private let imageView = UIImageView() + private let addCircle = UIView() + private let addIcon = UIImageView(image: UIImage(systemName: "plus")) + private let titleLabel = UILabel() + private let hintLabel = UILabel() + private let deleteButton = UIButton(type: .system) + var onAdd: (() -> Void)? + var onDelete: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 8 + layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor + layer.borderWidth = 1 + clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + addCircle.backgroundColor = AppColor.primary + addCircle.layer.cornerRadius = 16 + addIcon.tintColor = .white + titleLabel.text = "点击上传图片" + titleLabel.font = .systemFont(ofSize: 14) + titleLabel.textColor = UIColor(hex: 0x4B5563) + titleLabel.textAlignment = .center + hintLabel.text = "建议尺寸: 1920x1080px, 大小不超过5MB" + hintLabel.font = .systemFont(ofSize: 12) + hintLabel.textColor = UIColor(hex: 0xB6BECA) + hintLabel.textAlignment = .center + deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal) + deleteButton.tintColor = .white + deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) + deleteButton.layer.cornerRadius = 12 + + addSubview(imageView) + addSubview(addCircle) + addCircle.addSubview(addIcon) + addSubview(titleLabel) + addSubview(hintLabel) + addSubview(deleteButton) + snp.makeConstraints { make in + make.height.equalTo(snp.width).dividedBy(2.59) + } + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + addCircle.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-22) + make.size.equalTo(32) + } + addIcon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(24) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(addCircle.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(12) + } + hintLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(12) + } + deleteButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(8) + make.size.equalTo(24) + } + addTarget(self, action: #selector(addTapped), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(item: SampleUploadedMediaItem?) { + guard let item else { + imageView.image = nil + addCircle.isHidden = false + titleLabel.isHidden = false + hintLabel.isHidden = false + deleteButton.isHidden = true + return + } + if item.isUploading { + imageView.image = nil + addCircle.isHidden = true + titleLabel.isHidden = false + titleLabel.text = "上传中...\n\(item.uploadProgress)%" + titleLabel.numberOfLines = 2 + hintLabel.isHidden = true + deleteButton.isHidden = true + } else { + imageView.image = UIImage(data: item.data) + addCircle.isHidden = true + titleLabel.isHidden = true + hintLabel.isHidden = true + deleteButton.isHidden = item.ossUrl.isEmpty + } + } + + @objc private func addTapped() { + guard addCircle.isHidden == false else { return } + onAdd?() + } + + @objc private func deleteTapped() { + onDelete?() + } +} + +/// 样片上传进度蒙层。 +private final class SampleUploadProgressOverlay: UIView { + private let card = UIView() + private let titleLabel = UILabel() + private let progressView = UIProgressView(progressViewStyle: .default) + private let percentLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor.black.withAlphaComponent(0.2) + card.backgroundColor = .white + card.layer.cornerRadius = 12 + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + progressView.progressTintColor = AppColor.primary + progressView.trackTintColor = UIColor(hex: 0xF3F4F6) + percentLabel.font = .systemFont(ofSize: 14) + percentLabel.textColor = AppColor.textSecondary + percentLabel.textAlignment = .center + addSubview(card) + card.addSubview(titleLabel) + card.addSubview(progressView) + card.addSubview(percentLabel) + card.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(44) + make.height.equalTo(card.snp.width).dividedBy(1.7) + } + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(24) + } + progressView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(32) + make.leading.trailing.equalToSuperview().inset(24) + make.height.equalTo(6) + } + percentLabel.snp.makeConstraints { make in + make.top.equalTo(progressView.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(24) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(state: SampleUploadDialogState) { + titleLabel.text = state.title + let progress = Float(max(0, min(100, state.progress))) / 100 + progressView.setProgress(progress, animated: true) + percentLabel.text = "\(max(0, min(100, state.progress)))%" + } +} diff --git a/suixinkanTests/SampleManagementAPITests.swift b/suixinkanTests/SampleManagementAPITests.swift new file mode 100644 index 0000000..df7770a --- /dev/null +++ b/suixinkanTests/SampleManagementAPITests.swift @@ -0,0 +1,116 @@ +// +// SampleManagementAPITests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 样片管理 API 测试。 +final class SampleManagementAPITests: XCTestCase { + func testListBuildsAndroidQuery() async throws { + let session = MockURLSession(responses: [envelopeJSON(#"{"total":0,"list":[],"order":null}"#)]) + let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.list(status: 4, keyword: "雪山", page: 2, pageSize: 10) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/media-album/list") + let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "status" }?.value, "4") + XCTAssertEqual(query?.first { $0.name == "keyword" }?.value, "雪山") + XCTAssertEqual(query?.first { $0.name == "type" }?.value, "2") + XCTAssertEqual(query?.first { $0.name == "page" }?.value, "2") + XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "10") + } + + func testDetailAndOperationUseSampleType() async throws { + let detail = envelopeJSON(#"{"id":8,"status":1,"listing_status":1}"#) + let empty = envelopeJSON("{}") + let session = MockURLSession(responses: [detail, empty]) + let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.detail(id: 8) + try await api.setListingStatus(id: 8, listingStatus: 0) + + XCTAssertEqual(session.requests[0].url?.path, "/api/app/media-album/detail") + let detailQuery = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(detailQuery?.first { $0.name == "id" }?.value, "8") + XCTAssertEqual(detailQuery?.first { $0.name == "type" }?.value, "2") + + XCTAssertEqual(session.requests[1].httpMethod, "POST") + XCTAssertEqual(session.requests[1].url?.path, "/api/app/media-album/operation") + let operationQuery = URLComponents(url: session.requests[1].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(operationQuery?.first { $0.name == "id" }?.value, "8") + XCTAssertEqual(operationQuery?.first { $0.name == "listing_status" }?.value, "0") + XCTAssertEqual(operationQuery?.first { $0.name == "type" }?.value, "2") + } + + func testUploadEncodesAndroidBodyKeys() async throws { + let session = MockURLSession(responses: [envelopeJSON("{}")]) + let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) + let request = SampleUploadMaterialRequest( + name: "样片A", + mediaType: .image, + mediaList: [ + SampleUploadMediaItem( + originalName: "a.jpg", + ossUrl: "https://cdn/a.jpg", + size: 1024, + fileWidthSize: SampleUploadFileSize(width: 1920, height: 1080) + ), + ], + coverUrl: "https://cdn/c.jpg", + coverSize: "512", + scenicSpotId: 9, + projectId: 7 + ) + + try await api.upload(request) + + let urlRequest = try XCTUnwrap(session.requests.first) + XCTAssertEqual(urlRequest.httpMethod, "POST") + XCTAssertEqual(urlRequest.url?.path, "/api/app/media-album/upload") + let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(urlRequest.httpBody)) as? [String: Any] + XCTAssertEqual(body?["name"] as? String, "样片A") + XCTAssertEqual(body?["type"] as? String, "2") + XCTAssertEqual(body?["media_type"] as? String, "1") + XCTAssertEqual(body?["cover_url"] as? String, "https://cdn/c.jpg") + XCTAssertEqual(body?["cover_size"] as? String, "512") + XCTAssertEqual(body?["scenic_spot_id"] as? Int, 9) + XCTAssertEqual(body?["material_tag"] as? String, "") + XCTAssertEqual(body?["project_id"] as? Int, 7) + let media = (body?["media_list"] as? [[String: Any]])?.first + XCTAssertEqual(media?["original_name"] as? String, "a.jpg") + XCTAssertEqual(media?["oss_url"] as? String, "https://cdn/a.jpg") + let fileSize = media?["file_width_size"] as? [String: Any] + XCTAssertEqual(fileSize?["width"] as? Int, 1920) + XCTAssertEqual(fileSize?["height"] as? Int, 1080) + } + + func testProjectAndScenicSpotPaths() async throws { + let spots = envelopeJSON(#"{"total":1,"list":[]}"#) + let projects = envelopeJSON(#"{"total":1,"list":[]}"#) + let session = MockURLSession(responses: [spots, projects]) + let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.scenicSpotListAll(scenicId: "12") + _ = try await api.projectList(scenicId: "12", name: nil, page: 3, pageSize: 10) + + XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/scenic-spot/list-all") + XCTAssertEqual(URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "scenic_id" }?.value, "12") + XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/project/list") + let query = URLComponents(url: session.requests[1].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "12") + XCTAssertEqual(query?.first { $0.name == "page" }?.value, "3") + XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "10") + } + + private func envelopeJSON(_ dataJSON: String) -> Data { + """ + {"code":100000,"msg":"success","data":\(dataJSON)} + """.data(using: .utf8)! + } +} diff --git a/suixinkanTests/SampleManagementViewModelTests.swift b/suixinkanTests/SampleManagementViewModelTests.swift new file mode 100644 index 0000000..f5621bf --- /dev/null +++ b/suixinkanTests/SampleManagementViewModelTests.swift @@ -0,0 +1,197 @@ +// +// SampleManagementViewModelTests.swift +// suixinkanTests +// + +import Foundation +import XCTest +@testable import suixinkan + +@MainActor +/// 样片管理 ViewModel 测试。 +final class SampleManagementViewModelTests: XCTestCase { + func testListLoadsRefreshesAndMapsFilterStatus() async { + let api = MockSampleAPI() + api.listResponses = [ + SampleMaterialListResponse(total: 12, list: (0 ..< 10).map { makeItem(id: $0) }), + SampleMaterialListResponse(total: 12, list: [makeItem(id: 10), makeItem(id: 11)]), + SampleMaterialListResponse(total: 1, list: [makeItem(id: 99)]), + ] + let viewModel = SampleListViewModel() + + await viewModel.loadInitial(api: api) + await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api) + await viewModel.selectFilter(.pending, api: api) + + XCTAssertEqual(api.listCalls.map(\.status), [4, 4, 0]) + XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1]) + XCTAssertEqual(viewModel.items.map(\.id), [99]) + } + + func testRejectedSampleCannotToggleListing() async { + let api = MockSampleAPI() + api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 1, status: 0, listingStatus: 0)])] + let viewModel = SampleListViewModel() + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.loadInitial(api: api) + await viewModel.toggleListing(sampleId: 1, checked: true, api: api) + + XCTAssertEqual(messages.last, "审核通过才可以操作上下架") + XCTAssertTrue(api.operationCalls.isEmpty) + } + + func testApprovedSampleToggleUpdatesLocalItem() async { + let api = MockSampleAPI() + api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])] + let viewModel = SampleListViewModel() + + await viewModel.loadInitial(api: api) + await viewModel.toggleListing(sampleId: 2, checked: true, api: api) + + XCTAssertEqual(api.operationCalls.first?.id, 2) + XCTAssertEqual(api.operationCalls.first?.listingStatus, 1) + XCTAssertEqual(viewModel.items.first?.listingStatus, 1) + } + + func testUploadValidationMessages() async { + let api = MockSampleAPI() + let viewModel = UploadSampleViewModel( + scenicListProvider: { [ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)] }, + currentScenicIdProvider: { 3 } + ) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.uploadSample(api: api) + viewModel.updateAlbumName("样片") + await viewModel.uploadSample(api: api) + await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg", ossUrl: "")], uploader: MockSampleUploader(urls: ["https://cdn/a.jpg"])) + await viewModel.uploadSample(api: api) + await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg", ossUrl: ""), uploader: MockSampleUploader(urls: ["https://cdn/c.jpg"])) + await viewModel.uploadSample(api: api) + + XCTAssertEqual(messages.prefix(4), [ + "请输入相册名称", + "请至少上传一个素材", + "请上传封面图片", + "请选择关联景区", + ]) + XCTAssertNil(api.lastUploadRequest) + } + + func testUploadSuccessBuildsSampleRequest() async { + let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil) + let api = MockSampleAPI() + api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1) + let viewModel = UploadSampleViewModel( + scenicListProvider: { [scenic] }, + currentScenicIdProvider: { 3 } + ) + let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg", "https://cdn/c.jpg"]) + var didSucceed = false + viewModel.onUploadSuccess = { didSucceed = true } + + viewModel.updateAlbumName("样片") + await viewModel.selectScenic(scenic, api: api) + viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点")) + viewModel.selectProject(SampleProjectItem(id: 7, name: "项目")) + await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader) + await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg"), uploader: uploader) + await viewModel.uploadSample(api: api) + + XCTAssertTrue(didSucceed) + XCTAssertEqual(api.lastUploadRequest?.name, "样片") + XCTAssertEqual(api.lastUploadRequest?.type, "2") + XCTAssertEqual(api.lastUploadRequest?.mediaType, "1") + XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.ossUrl, "https://cdn/a.jpg") + XCTAssertEqual(api.lastUploadRequest?.coverUrl, "https://cdn/c.jpg") + XCTAssertEqual(api.lastUploadRequest?.scenicSpotId, 9) + XCTAssertEqual(api.lastUploadRequest?.projectId, 7) + } +} + +@MainActor +private final class MockSampleAPI: SampleManagementServing { + var listResponses: [SampleMaterialListResponse] = [] + var listCalls: [(status: Int, keyword: String?, page: Int, pageSize: Int)] = [] + var operationCalls: [(id: Int, listingStatus: Int)] = [] + var lastUploadRequest: SampleUploadMaterialRequest? + var spotResponse = ScenicSpotListResponse(list: [], total: 0) + var projectResponse = SampleProjectListResponse(list: [], total: 0) + + func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> SampleMaterialListResponse { + listCalls.append((status, keyword, page, pageSize)) + guard !listResponses.isEmpty else { return SampleMaterialListResponse() } + return listResponses.removeFirst() + } + + func detail(id: Int) async throws -> SampleDetail { + throw APIError.emptyData + } + + func setListingStatus(id: Int, listingStatus: Int) async throws { + operationCalls.append((id, listingStatus)) + } + + func upload(_ request: SampleUploadMaterialRequest) async throws { + lastUploadRequest = request + } + + func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse { + spotResponse + } + + func projectList(scenicId: String, name: String?, page: Int, pageSize: Int) async throws -> SampleProjectListResponse { + projectResponse + } +} + +@MainActor +private final class MockSampleUploader: SampleOSSUploading { + private var urls: [String] + + init(urls: [String]) { + self.urls = urls + } + + func uploadSampleFile( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + onProgress(100) + return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst() + } +} + +private func makeItem(id: Int, status: Int = 1, listingStatus: Int = 0) -> SampleMaterialItem { + SampleMaterialItem( + id: id, + name: "样片\(id)", + coverUrl: "", + createdAt: "", + status: status, + downloadCount: 0, + likesCount: 0, + collectCount: 0, + shareCount: 0, + scenicSpotName: "打卡点", + projectName: "项目", + listingStatus: listingStatus + ) +} + +private func makeUploadItem(fileName: String, ossUrl: String = "") -> SampleUploadedMediaItem { + SampleUploadedMediaItem( + data: Data(repeating: 1, count: 10), + fileName: fileName, + size: 10, + width: 1920, + height: 1080, + ossUrl: ossUrl + ) +}