feat: 同步样片管理功能
This commit is contained in:
@ -22,6 +22,7 @@ final class NetworkServices {
|
||||
let walletAPI: WalletAPI
|
||||
let scenicQueueAPI: ScenicQueueAPI
|
||||
let travelAlbumAPI: TravelAlbumAPI
|
||||
let sampleManagementAPI: SampleManagementAPI
|
||||
let uploadAPI: UploadAPI
|
||||
let ossUploadService: OSSUploadService
|
||||
|
||||
@ -39,6 +40,7 @@ final class NetworkServices {
|
||||
walletAPI = WalletAPI(client: client)
|
||||
scenicQueueAPI = ScenicQueueAPI(client: client)
|
||||
travelAlbumAPI = TravelAlbumAPI(client: client)
|
||||
sampleManagementAPI = SampleManagementAPI(client: client)
|
||||
uploadAPI = UploadAPI(client: client)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
client.bindAuthTokenProvider {
|
||||
@ -61,6 +63,7 @@ final class NetworkServices {
|
||||
walletAPI = WalletAPI(client: apiClient)
|
||||
scenicQueueAPI = ScenicQueueAPI(client: apiClient)
|
||||
travelAlbumAPI = TravelAlbumAPI(client: apiClient)
|
||||
sampleManagementAPI = SampleManagementAPI(client: apiClient)
|
||||
uploadAPI = UploadAPI(client: apiClient)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
}
|
||||
|
||||
6
suixinkan/Assets.xcassets/SampleManagement/Contents.json
Normal file
6
suixinkan/Assets.xcassets/SampleManagement/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
15
suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/Contents.json
vendored
Normal file
15
suixinkan/Assets.xcassets/SampleManagement/sample_ic_collect_count.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "sample_ic_collect_count.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
@ -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 |
15
suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/Contents.json
vendored
Normal file
15
suixinkan/Assets.xcassets/SampleManagement/sample_ic_home_menu_route.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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 |
15
suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/Contents.json
vendored
Normal file
15
suixinkan/Assets.xcassets/SampleManagement/sample_ic_like_count.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "sample_ic_like_count.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
@ -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 |
15
suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/Contents.json
vendored
Normal file
15
suixinkan/Assets.xcassets/SampleManagement/sample_ic_share_count.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "sample_ic_share_count.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
@ -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 |
@ -18,8 +18,8 @@ protocol OSSUploadServing {
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。
|
||||
@MainActor
|
||||
final class OSSUploadService {
|
||||
private let configService: any OSSConfigServing
|
||||
|
||||
@ -91,6 +91,24 @@ final class OSSUploadService {
|
||||
)
|
||||
}
|
||||
|
||||
/// 上传样片素材,对齐 Android `moduleType = sample`。
|
||||
func uploadSampleFile(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
try await uploadFile(
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
fileType: fileType,
|
||||
scenicId: scenicId,
|
||||
moduleType: "sample",
|
||||
onProgress: onProgress
|
||||
)
|
||||
}
|
||||
|
||||
private func uploadFile(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
@ -144,6 +162,8 @@ final class OSSUploadService {
|
||||
|
||||
extension OSSUploadService: OSSUploadServing {}
|
||||
|
||||
extension OSSUploadService: SampleOSSUploading {}
|
||||
|
||||
enum OSSUploadPolicy {
|
||||
static let maxFileSize = 2_048 * 1_024 * 1_024
|
||||
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
|
||||
@ -181,6 +201,8 @@ enum OSSUploadPolicy {
|
||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "album":
|
||||
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "sample":
|
||||
return "sample/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "scenic_apply":
|
||||
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
default:
|
||||
|
||||
@ -20,7 +20,7 @@ enum HomeMenuCatalog {
|
||||
HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "slider.horizontal.3"),
|
||||
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"),
|
||||
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"),
|
||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"),
|
||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"),
|
||||
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"),
|
||||
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"),
|
||||
HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"),
|
||||
|
||||
@ -64,6 +64,8 @@ enum HomeRouteHandler {
|
||||
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||
case "travel_album":
|
||||
viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true)
|
||||
case "sample_management":
|
||||
viewController.navigationController?.pushViewController(SampleListViewController(), animated: true)
|
||||
case "wallet":
|
||||
showDeveloping(from: viewController)
|
||||
default:
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -126,7 +126,7 @@ final class HomeMenuCell: UICollectionViewCell {
|
||||
}
|
||||
|
||||
func apply(menu: HomeMenuItem) {
|
||||
iconView.image = UIImage(systemName: menu.iconName)
|
||||
iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName)
|
||||
titleLabel.text = menu.title
|
||||
}
|
||||
|
||||
|
||||
682
suixinkan/UI/SampleManagement/SampleDetailViewController.swift
Normal file
682
suixinkan/UI/SampleManagement/SampleDetailViewController.swift
Normal 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 }
|
||||
}
|
||||
547
suixinkan/UI/SampleManagement/SampleListViewController.swift
Normal file
547
suixinkan/UI/SampleManagement/SampleListViewController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
883
suixinkan/UI/SampleManagement/UploadSampleViewController.swift
Normal file
883
suixinkan/UI/SampleManagement/UploadSampleViewController.swift
Normal 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)))%"
|
||||
}
|
||||
}
|
||||
116
suixinkanTests/SampleManagementAPITests.swift
Normal file
116
suixinkanTests/SampleManagementAPITests.swift
Normal 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)!
|
||||
}
|
||||
}
|
||||
197
suixinkanTests/SampleManagementViewModelTests.swift
Normal file
197
suixinkanTests/SampleManagementViewModelTests.swift
Normal 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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user