feat: 同步样片管理功能

This commit is contained in:
2026-07-08 13:06:11 +08:00
parent e88cd4a9a4
commit ccd63380cf
22 changed files with 3791 additions and 3 deletions

View File

@ -22,6 +22,7 @@ final class NetworkServices {
let walletAPI: WalletAPI let walletAPI: WalletAPI
let scenicQueueAPI: ScenicQueueAPI let scenicQueueAPI: ScenicQueueAPI
let travelAlbumAPI: TravelAlbumAPI let travelAlbumAPI: TravelAlbumAPI
let sampleManagementAPI: SampleManagementAPI
let uploadAPI: UploadAPI let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService let ossUploadService: OSSUploadService
@ -39,6 +40,7 @@ final class NetworkServices {
walletAPI = WalletAPI(client: client) walletAPI = WalletAPI(client: client)
scenicQueueAPI = ScenicQueueAPI(client: client) scenicQueueAPI = ScenicQueueAPI(client: client)
travelAlbumAPI = TravelAlbumAPI(client: client) travelAlbumAPI = TravelAlbumAPI(client: client)
sampleManagementAPI = SampleManagementAPI(client: client)
uploadAPI = UploadAPI(client: client) uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
client.bindAuthTokenProvider { client.bindAuthTokenProvider {
@ -61,6 +63,7 @@ final class NetworkServices {
walletAPI = WalletAPI(client: apiClient) walletAPI = WalletAPI(client: apiClient)
scenicQueueAPI = ScenicQueueAPI(client: apiClient) scenicQueueAPI = ScenicQueueAPI(client: apiClient)
travelAlbumAPI = TravelAlbumAPI(client: apiClient) travelAlbumAPI = TravelAlbumAPI(client: apiClient)
sampleManagementAPI = SampleManagementAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient)
ossUploadService = OSSUploadService(configService: uploadAPI) ossUploadService = OSSUploadService(configService: uploadAPI)
} }

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "sample_ic_collect_count.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M8.761 1.492Q8.522 1.027 7.992 1Q7.489 1.027 7.224 1.492L5.527 5.101L1.71 5.703Q1.207 5.785 1.048 6.277Q0.889 6.796 1.26 7.179L4.016 9.995L3.354 13.987Q3.301 14.506 3.698 14.835Q4.122 15.135 4.599 14.889L7.992 13.03L11.411 14.889Q11.862 15.135 12.286 14.835Q12.71 14.506 12.631 13.987L11.995 9.995L14.751 7.179Q15.096 6.796 14.963 6.277Q14.778 5.785 14.274 5.703L10.457 5.101L8.761 1.492Z" fill="#6B7280"/>
</svg>

After

Width:  |  Height:  |  Size: 509 B

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "sample_ic_home_menu_route.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="#000000" fill-opacity="0"/>
<path d="M21 5L12.414 5L10.414 3L3 3C2.448 3 2 3.448 2 4L2 20C2 20.552 2.448 21 3 21L21 21C21.552 21 22 20.552 22 20L22 6C22 5.448 21.552 5 21 5ZM15 12.667C15.238 12.825 15.238 13.175 15 13.333L10.122 16.585C9.999 16.667 9.841 16.675 9.711 16.605C9.581 16.535 9.5 16.4 9.5 16.252L9.5 9.747C9.5 9.599 9.581 9.464 9.711 9.394C9.841 9.324 9.999 9.332 10.122 9.414L15 12.667Z" fill="#0073FF"/>
</svg>

After

Width:  |  Height:  |  Size: 543 B

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "sample_ic_like_count.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M4.693 6.107C4.642 6.096 4.59 6.091 4.539 6.091L1.769 6.091C1.345 6.091 1 6.447 1 6.886L1 14.205C1 14.644 1.345 15 1.769 15L4.539 15C4.59 15 4.642 14.995 4.693 14.984L4.693 6.107ZM3.154 13.091C2.984 13.091 2.846 12.948 2.846 12.773C2.846 12.597 2.984 12.455 3.154 12.455C3.324 12.455 3.462 12.597 3.462 12.773C3.462 12.948 3.324 13.091 3.154 13.091ZM14.986 7.566L14.041 13.921C13.948 14.542 13.432 15 12.824 15L6.847 15C6.275 15.002 5.727 14.767 5.323 14.348C5.318 14.342 5.313 14.337 5.308 14.331L5.308 6.524C5.67 5.931 6.132 5.419 6.521 4.988L6.608 4.892C7.597 3.795 7.675 2.895 7.675 1.832C7.675 1.448 7.849 1.245 7.994 1.143C8.163 1.025 8.351 1 8.48 1C8.783 1 9.244 1.141 9.629 1.538C9.925 1.843 10.279 2.409 10.279 3.374C10.279 3.815 10.175 4.309 10.088 4.645C9.976 5.081 9.821 5.529 9.651 5.906C9.629 5.956 9.632 6.015 9.661 6.061C9.689 6.107 9.737 6.134 9.789 6.133L9.791 6.133L13.759 6.1L13.769 6.1C14.523 6.1 15.1 6.795 14.985 7.566L14.986 7.566Z" fill="#6B7280"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "sample_ic_share_count.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path d="M14.845 6.617L9.648 1.142C9.454 0.953 9.105 0.953 8.911 1.142C8.795 1.255 8.756 1.406 8.756 1.557L8.756 4.502C4.452 4.502 1 7.863 1 12.017C1 13.036 1.233 14.056 1.659 15C2.28 11.866 5.343 9.487 8.756 9.487L8.756 12.432C8.717 12.583 8.795 12.734 8.911 12.848C8.989 12.961 9.144 12.999 9.299 12.999C9.454 12.999 9.609 12.923 9.687 12.81L14.845 7.41C14.961 7.297 15 7.183 15 7.032C15 6.881 14.922 6.73 14.845 6.617Z" fill="#6B7280"/>
</svg>

After

Width:  |  Height:  |  Size: 533 B

View File

@ -18,8 +18,8 @@ protocol OSSUploadServing {
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
} }
@MainActor
/// OSS STS SDK URL /// OSS STS SDK URL
@MainActor
final class OSSUploadService { final class OSSUploadService {
private let configService: any OSSConfigServing 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( private func uploadFile(
data: Data, data: Data,
fileName: String, fileName: String,
@ -144,6 +162,8 @@ final class OSSUploadService {
extension OSSUploadService: OSSUploadServing {} extension OSSUploadService: OSSUploadServing {}
extension OSSUploadService: SampleOSSUploading {}
enum OSSUploadPolicy { enum OSSUploadPolicy {
static let maxFileSize = 2_048 * 1_024 * 1_024 static let maxFileSize = 2_048 * 1_024 * 1_024
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"] private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
@ -181,6 +201,8 @@ enum OSSUploadPolicy {
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "album": case "album":
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "sample":
return "sample/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "scenic_apply": case "scenic_apply":
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
default: default:

View File

@ -20,7 +20,7 @@ enum HomeMenuCatalog {
HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "slider.horizontal.3"), HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "slider.horizontal.3"),
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"), HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"),
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"), 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: "live_stream_management", title: "直播管理", iconName: "video"),
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"), HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"),
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"), HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"),

View File

@ -64,6 +64,8 @@ enum HomeRouteHandler {
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true) viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
case "travel_album": case "travel_album":
viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true) viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true)
case "sample_management":
viewController.navigationController?.pushViewController(SampleListViewController(), animated: true)
case "wallet": case "wallet":
showDeveloping(from: viewController) showDeveloping(from: viewController)
default: default:

View File

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

View File

@ -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 }
}

View File

@ -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 }
}

View File

@ -126,7 +126,7 @@ final class HomeMenuCell: UICollectionViewCell {
} }
func apply(menu: HomeMenuItem) { func apply(menu: HomeMenuItem) {
iconView.image = UIImage(systemName: menu.iconName) iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName)
titleLabel.text = menu.title titleLabel.text = menu.title
} }

View File

@ -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 }
}

View File

@ -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<Int, SampleMaterialItem>!
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<Int, SampleMaterialItem>(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<Int, SampleMaterialItem>()
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)
}
}

View File

@ -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)))%"
}
}

View File

@ -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)!
}
}

View File

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