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
+ )
+}