feat: 同步样片管理功能
This commit is contained in:
@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user