feat: 同步样片管理功能

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

View File

@ -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"),

View File

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

View File

@ -0,0 +1,122 @@
//
// SampleManagementAPI.swift
// suixinkan
//
import Foundation
///
@MainActor
protocol SampleManagementServing {
///
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> SampleMaterialListResponse
///
func detail(id: Int) async throws -> SampleDetail
///
func setListingStatus(id: Int, listingStatus: Int) async throws
///
func upload(_ request: SampleUploadMaterialRequest) async throws
///
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse
///
func projectList(scenicId: String, name: String?, page: Int, pageSize: Int) async throws -> SampleProjectListResponse
}
/// API Android `NetworkApi` media-album
@MainActor
final class SampleManagementAPI: SampleManagementServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// `type=2`
func list(status: Int, keyword: String?, page: Int = 1, pageSize: Int = 10) async throws -> SampleMaterialListResponse {
var items = [
URLQueryItem(name: "status", value: String(status)),
URLQueryItem(name: "type", value: "2"),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
if let keyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines), !keyword.isEmpty {
items.append(URLQueryItem(name: "keyword", value: keyword))
}
return try await client.send(
APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: items)
)
}
/// `type=2`
func detail(id: Int) async throws -> SampleDetail {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/media-album/detail",
queryItems: [
URLQueryItem(name: "id", value: String(id)),
URLQueryItem(name: "type", value: "2"),
]
)
)
}
/// `type=2`
func setListingStatus(id: Int, listingStatus: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/media-album/operation",
queryItems: [
URLQueryItem(name: "id", value: String(id)),
URLQueryItem(name: "listing_status", value: String(listingStatus)),
URLQueryItem(name: "type", value: "2"),
]
)
)
}
///
func upload(_ request: SampleUploadMaterialRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
)
}
///
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
queryItems: [URLQueryItem(name: "scenic_id", value: scenicId)]
)
)
}
///
func projectList(
scenicId: String,
name: String? = nil,
page: Int = 1,
pageSize: Int = 10
) async throws -> SampleProjectListResponse {
var items = [
URLQueryItem(name: "scenic_id", value: scenicId),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
items.append(URLQueryItem(name: "name", value: name))
}
return try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: items)
)
}
}

View File

@ -0,0 +1,573 @@
//
// SampleManagementModels.swift
// suixinkan
//
import Foundation
/// Android `SampleListViewModel`
enum SampleFilterStatus: Int, CaseIterable, Sendable, Equatable {
case all = 0
case approved = 1
case pending = 2
case rejected = 3
///
var title: String {
switch self {
case .all: "全部"
case .approved: "已通过"
case .pending: "待审核"
case .rejected: "未通过"
}
}
/// `status` Android 4 1 0 2
var apiStatus: Int {
switch self {
case .all: 4
case .approved: 1
case .pending: 0
case .rejected: 2
}
}
}
/// Android `mediaType`1 2
enum SampleMediaType: Int, CaseIterable, Sendable, Equatable {
case image = 1
case video = 2
///
var title: String {
switch self {
case .image: "图片"
case .video: "视频"
}
}
///
var maxCount: Int {
switch self {
case .image: 9
case .video: 1
}
}
/// OSS Android `UploadTaskEntity.fileType` 1 2
var uploadFileType: Int {
switch self {
case .image: 2
case .video: 1
}
}
}
/// Android `MaterialItemEntity`
struct SampleMaterialItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let name: String
let coverUrl: String
let createdAt: String
let status: Int
let downloadCount: Int
let likesCount: Int
let collectCount: Int
let shareCount: Int
let scenicSpotName: String
let projectName: String
let listingStatus: Int
enum CodingKeys: String, CodingKey {
case id
case name
case coverUrl = "cover_url"
case createdAt = "created_at"
case status
case downloadCount = "download_count"
case likesCount = "likes_count"
case collectCount = "collect_count"
case shareCount = "share_count"
case scenicSpotName = "scenic_spot_name"
case projectName = "project_name"
case listingStatus = "listing_status"
}
init(
id: Int = 0,
name: String = "",
coverUrl: String = "",
createdAt: String = "",
status: Int = 0,
downloadCount: Int = 0,
likesCount: Int = 0,
collectCount: Int = 0,
shareCount: Int = 0,
scenicSpotName: String = "",
projectName: String = "",
listingStatus: Int = 0
) {
self.id = id
self.name = name
self.coverUrl = coverUrl
self.createdAt = createdAt
self.status = status
self.downloadCount = downloadCount
self.likesCount = likesCount
self.collectCount = collectCount
self.shareCount = shareCount
self.scenicSpotName = scenicSpotName
self.projectName = projectName
self.listingStatus = listingStatus
}
///
var isListed: Bool { listingStatus == 1 }
}
/// Android `MaterialListResponse`
struct SampleMaterialListResponse: Decodable, Sendable, Equatable {
let total: Int
let list: [SampleMaterialItem]
let order: SampleMaterialOrderInfo?
init(total: Int = 0, list: [SampleMaterialItem] = [], order: SampleMaterialOrderInfo? = nil) {
self.total = total
self.list = list
self.order = order
}
}
/// Android `MaterialOrderInfo`
struct SampleMaterialOrderInfo: Decodable, Sendable, Equatable {
let totalNum: Int
let avgOrderAmount: String
let refundTotal: String
let avgChange: Int
enum CodingKeys: String, CodingKey {
case totalNum = "total_num"
case avgOrderAmount = "avg_order_amount"
case refundTotal = "refund_total"
case avgChange = "avg_change"
}
init(totalNum: Int = 0, avgOrderAmount: String = "0", refundTotal: String = "0", avgChange: Int = 0) {
self.totalNum = totalNum
self.avgOrderAmount = avgOrderAmount
self.refundTotal = refundTotal
self.avgChange = avgChange
}
}
/// Android `SampleDetailResponse`
struct SampleDetail: Decodable, Sendable, Equatable {
let id: Int
let name: String?
let type: Int?
let coverUrl: String?
let coverSize: Int?
let description: String?
let scenicSpotId: Int?
let scenicAreaId: Int?
let uploaderId: Int?
let status: Int
let likesCount: Int
let downloadCount: Int
let collectCount: Int
let createdAt: String?
let updatedAt: String?
let deletedAt: String?
let listingStatus: Int
let reviewNotes: String?
let reviewTime: String?
let reviewerId: Int?
let provinceId: Int?
let cityId: Int?
let shareCount: Int
let uploaderName: String?
let uploaderAvatar: String?
let uploaderDescription: String?
let platformCertification: Bool
let scenicCertification: Bool
let mediaList: [SampleDetailMediaItem]?
let scenicName: String?
let scenicCoverImg: String?
let scenicPlanesNum: Int
let scenicPhotographerNum: Int
let projectCover: String?
let projectName: String?
let projectPrice: Double?
let projectPriceDeposit: String?
let reviewer: String?
enum CodingKeys: String, CodingKey {
case id
case name
case type
case coverUrl = "cover_url"
case coverSize = "cover_size"
case description
case scenicSpotId = "scenic_spot_id"
case scenicAreaId = "scenic_area_id"
case uploaderId = "uploader_id"
case status
case likesCount = "likes_count"
case downloadCount = "download_count"
case collectCount = "collect_count"
case createdAt = "created_at"
case updatedAt = "updated_at"
case deletedAt = "deleted_at"
case listingStatus = "listing_status"
case reviewNotes = "review_notes"
case reviewTime = "review_time"
case reviewerId = "reviewer_id"
case provinceId = "province_id"
case cityId = "city_id"
case shareCount = "share_count"
case uploaderName = "uploader_name"
case uploaderAvatar = "uploader_avatar"
case uploaderDescription = "uploader_description"
case platformCertification = "platform_certification"
case scenicCertification = "scenic_certification"
case mediaList = "media_list"
case scenicName = "scenic_name"
case scenicCoverImg = "scenic_cover_img"
case scenicPlanesNum = "scenic_planes_num"
case scenicPhotographerNum = "scenic_photographer_num"
case projectCover = "project_cover"
case projectName = "project_name"
case projectPrice = "project_price"
case projectPriceDeposit = "project_price_deposit"
case reviewer
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
name = try container.decodeIfPresent(String.self, forKey: .name)
type = try container.decodeIfPresent(Int.self, forKey: .type)
coverUrl = try container.decodeIfPresent(String.self, forKey: .coverUrl)
coverSize = try container.decodeIfPresent(Int.self, forKey: .coverSize)
description = try container.decodeIfPresent(String.self, forKey: .description)
scenicSpotId = try container.decodeIfPresent(Int.self, forKey: .scenicSpotId)
scenicAreaId = try container.decodeIfPresent(Int.self, forKey: .scenicAreaId)
uploaderId = try container.decodeIfPresent(Int.self, forKey: .uploaderId)
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0
downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0
collectCount = try container.decodeIfPresent(Int.self, forKey: .collectCount) ?? 0
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt)
updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
listingStatus = try container.decodeIfPresent(Int.self, forKey: .listingStatus) ?? 0
reviewNotes = try container.decodeIfPresent(String.self, forKey: .reviewNotes)
reviewTime = try container.decodeIfPresent(String.self, forKey: .reviewTime)
reviewerId = try container.decodeIfPresent(Int.self, forKey: .reviewerId)
provinceId = try container.decodeIfPresent(Int.self, forKey: .provinceId)
cityId = try container.decodeIfPresent(Int.self, forKey: .cityId)
shareCount = try container.decodeIfPresent(Int.self, forKey: .shareCount) ?? 0
uploaderName = try container.decodeIfPresent(String.self, forKey: .uploaderName)
uploaderAvatar = try container.decodeIfPresent(String.self, forKey: .uploaderAvatar)
uploaderDescription = try container.decodeIfPresent(String.self, forKey: .uploaderDescription)
platformCertification = try container.decodeIfPresent(Bool.self, forKey: .platformCertification) ?? false
scenicCertification = try container.decodeIfPresent(Bool.self, forKey: .scenicCertification) ?? false
mediaList = try container.decodeIfPresent([SampleDetailMediaItem].self, forKey: .mediaList)
scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName)
scenicCoverImg = try container.decodeIfPresent(String.self, forKey: .scenicCoverImg)
scenicPlanesNum = try container.decodeIfPresent(Int.self, forKey: .scenicPlanesNum) ?? 0
scenicPhotographerNum = try container.decodeIfPresent(Int.self, forKey: .scenicPhotographerNum) ?? 0
projectCover = try container.decodeIfPresent(String.self, forKey: .projectCover)
projectName = try container.decodeIfPresent(String.self, forKey: .projectName)
projectPrice = try container.decodeFlexibleDouble(forKey: .projectPrice)
projectPriceDeposit = try container.decodeFlexibleString(forKey: .projectPriceDeposit)
reviewer = try container.decodeIfPresent(String.self, forKey: .reviewer)
}
}
/// Android `MaterialDetailMediaItem`
struct SampleDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let originalName: String?
let paidMaterialId: Int?
let ossUrl: String?
let thumbnailUrl: String?
let type: Int
let size: Int64?
let createdAt: String?
let updatedAt: String?
let deletedAt: String?
let likesCount: Int
let downloadCount: Int
let showUrl: String?
let width: Int?
let height: Int?
enum CodingKeys: String, CodingKey {
case id
case originalName = "original_name"
case paidMaterialId = "paid_material_id"
case ossUrl = "oss_url"
case thumbnailUrl = "thumbnail_url"
case type
case size
case createdAt = "created_at"
case updatedAt = "updated_at"
case deletedAt = "deleted_at"
case likesCount = "likes_count"
case downloadCount = "download_count"
case showUrl = "show_url"
case width
case height
}
///
var displayURL: String {
(ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
?? (showUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
?? ""
}
}
/// Android `ProjectItem`
struct SampleProjectItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let type: Int
let typeName: String
let status: Int
let statusName: String
let name: String
let coverProject: String
let coverVideo: String
let price: String
let otPrice: String
let priceDeposit: String
let label: String
let attrLabel: [String]?
let creatorId: Int
enum CodingKeys: String, CodingKey {
case id
case type
case typeName = "type_name"
case status
case statusName = "status_name"
case name
case coverProject = "cover_project"
case coverVideo = "cover_video"
case price
case otPrice = "ot_price"
case priceDeposit = "price_deposit"
case label
case attrLabel = "attr_label"
case creatorId = "creator_id"
}
init(
id: Int = 0,
type: Int = 0,
typeName: String = "",
status: Int = 0,
statusName: String = "",
name: String = "",
coverProject: String = "",
coverVideo: String = "",
price: String = "",
otPrice: String = "",
priceDeposit: String = "",
label: String = "",
attrLabel: [String]? = nil,
creatorId: Int = 0
) {
self.id = id
self.type = type
self.typeName = typeName
self.status = status
self.statusName = statusName
self.name = name
self.coverProject = coverProject
self.coverVideo = coverVideo
self.price = price
self.otPrice = otPrice
self.priceDeposit = priceDeposit
self.label = label
self.attrLabel = attrLabel
self.creatorId = creatorId
}
}
/// Android `ProjectListData`
struct SampleProjectListResponse: Decodable, Sendable, Equatable {
let list: [SampleProjectItem]
let total: Int
init(list: [SampleProjectItem] = [], total: Int = 0) {
self.list = list
self.total = total
}
}
/// Android `UploadMaterialRequest`
struct SampleUploadMaterialRequest: Encodable, Sendable, Equatable {
let name: String
let type: String
let mediaType: String
let mediaList: [SampleUploadMediaItem]
let coverUrl: String
let coverSize: String
let scenicSpotId: Int64
let description: String
let materialTag: String
let projectId: Int?
enum CodingKeys: String, CodingKey {
case name
case type
case mediaType = "media_type"
case mediaList = "media_list"
case coverUrl = "cover_url"
case coverSize = "cover_size"
case scenicSpotId = "scenic_spot_id"
case description
case materialTag = "material_tag"
case projectId = "project_id"
}
init(
name: String,
mediaType: SampleMediaType,
mediaList: [SampleUploadMediaItem],
coverUrl: String,
coverSize: String,
scenicSpotId: Int64,
projectId: Int?
) {
self.name = name
self.type = "2"
self.mediaType = String(mediaType.rawValue)
self.mediaList = mediaList
self.coverUrl = coverUrl
self.coverSize = coverSize
self.scenicSpotId = scenicSpotId
self.description = ""
self.materialTag = ""
self.projectId = projectId
}
}
/// Android `MediaItem`
struct SampleUploadMediaItem: Encodable, Sendable, Equatable {
let originalName: String
let ossUrl: String
let size: Int64
let fileWidthSize: SampleUploadFileSize
enum CodingKeys: String, CodingKey {
case originalName = "original_name"
case ossUrl = "oss_url"
case size
case fileWidthSize = "file_width_size"
}
}
/// Android `FileSize`
struct SampleUploadFileSize: Encodable, Sendable, Equatable {
let width: Int
let height: Int
}
/// UIKit ViewModel
struct SampleUploadedMediaItem: Sendable, Equatable, Hashable, Identifiable {
let id: String
let data: Data
let thumbnailData: Data?
let fileName: String
let size: Int64
let width: Int
let height: Int
var ossUrl: String
var isUploading: Bool
var uploadProgress: Int
init(
id: String = UUID().uuidString,
data: Data,
thumbnailData: Data? = nil,
fileName: String,
size: Int64? = nil,
width: Int = 0,
height: Int = 0,
ossUrl: String = "",
isUploading: Bool = false,
uploadProgress: Int = 0
) {
self.id = id
self.data = data
self.thumbnailData = thumbnailData
self.fileName = fileName
self.size = size ?? Int64(data.count)
self.width = width
self.height = height
self.ossUrl = ossUrl
self.isUploading = isUploading
self.uploadProgress = uploadProgress
}
}
///
struct SampleUploadDialogState: Sendable, Equatable {
let title: String
let progress: Int
}
/// UI
enum SampleDisplayFormatter {
/// 1000 Android k
static func formatCount(_ count: Int) -> String {
guard count >= 1000 else { return String(count) }
let value = Double(count) / 1000.0
if value.truncatingRemainder(dividingBy: 1) == 0 {
return "\(Int(value))k"
}
return String(format: "%.1fk", value)
}
///
static func reviewStatusText(_ status: Int) -> String {
switch status {
case 1: "已通过"
case 0: "待审核"
case 2: "未通过"
default: "未知"
}
}
}
private extension KeyedDecodingContainer {
func decodeFlexibleDouble(forKey key: Key) throws -> Double? {
if let value = try decodeIfPresent(Double.self, forKey: key) {
return value
}
if let string = try decodeIfPresent(String.self, forKey: key) {
return Double(string)
}
return nil
}
func decodeFlexibleString(forKey key: Key) throws -> String? {
if let value = try decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
return nil
}
}
private extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}

View File

@ -0,0 +1,562 @@
//
// SampleManagementViewModels.swift
// suixinkan
//
import Foundation
/// OSS 便 ViewModel
@MainActor
protocol SampleOSSUploading {
/// OSS URL
func uploadSampleFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String
}
/// ViewModel
final class SampleListViewModel {
private(set) var items: [SampleMaterialItem] = []
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var canLoadMore = false
private(set) var filterStatus: SampleFilterStatus = .all
private(set) var searchText = ""
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private var currentPage = 1
private var totalCount = 0
private let pageSize = 10
///
func loadInitial(api: any SampleManagementServing) async {
await load(reset: true, showLoading: true, api: api)
}
///
func refresh(api: any SampleManagementServing) async {
isRefreshing = true
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any SampleManagementServing) async {
guard lastVisibleIndex >= items.count - 3 else { return }
await load(reset: false, showLoading: false, api: api)
}
///
func updateSearchText(_ text: String, api: any SampleManagementServing) async {
searchText = text
await load(reset: true, showLoading: false, api: api)
}
///
func selectFilter(_ status: SampleFilterStatus, api: any SampleManagementServing) async {
guard filterStatus != status else { return }
filterStatus = status
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func toggleListing(sampleId: Int, checked: Bool, api: any SampleManagementServing) async {
guard let currentItem = items.first(where: { $0.id == sampleId }) else { return }
guard currentItem.status == 1 else {
onShowMessage?("审核通过才可以操作上下架")
return
}
let targetStatus = checked ? 1 : 0
guard currentItem.listingStatus != targetStatus else { return }
do {
try await api.setListingStatus(id: sampleId, listingStatus: targetStatus)
items = items.map { item in
guard item.id == sampleId else { return item }
return SampleMaterialItem(
id: item.id,
name: item.name,
coverUrl: item.coverUrl,
createdAt: item.createdAt,
status: item.status,
downloadCount: item.downloadCount,
likesCount: item.likesCount,
collectCount: item.collectCount,
shareCount: item.shareCount,
scenicSpotName: item.scenicSpotName,
projectName: item.projectName,
listingStatus: targetStatus
)
}
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
}
}
private func load(reset: Bool, showLoading: Bool, api: any SampleManagementServing) async {
if reset {
currentPage = 1
canLoadMore = false
} else {
guard canLoadMore, !isLoading else { return }
currentPage += 1
}
isLoading = showLoading
notifyStateChange()
defer {
isLoading = false
isRefreshing = false
notifyStateChange()
}
do {
let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let response = try await api.list(
status: filterStatus.apiStatus,
keyword: keyword,
page: currentPage,
pageSize: pageSize
)
totalCount = response.total
items = reset ? response.list : items + response.list
canLoadMore = items.count < totalCount
} catch is CancellationError {
return
} catch {
if !reset, currentPage > 1 {
currentPage -= 1
}
if reset {
items = []
totalCount = 0
canLoadMore = false
}
onShowMessage?(error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class SampleDetailViewModel {
private(set) var detail: SampleDetail?
private(set) var isLoading = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
let sampleId: Int
/// ViewModel
init(sampleId: Int) {
self.sampleId = sampleId
}
///
func load(api: any SampleManagementServing) async {
guard sampleId > 0 else {
onShowMessage?("样片ID无效")
return
}
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
detail = try await api.detail(id: sampleId)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "获取样片详情失败" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class UploadSampleViewModel {
private(set) var albumName = ""
private(set) var selectedScenic: ScenicInfo?
private(set) var scenicList: [ScenicInfo] = []
private(set) var selectedScenicSpot: ScenicSpotItem?
private(set) var scenicSpotList: [ScenicSpotItem] = []
private(set) var selectedProject: SampleProjectItem?
private(set) var projectList: [SampleProjectItem] = []
private(set) var projectCanLoadMore = false
private(set) var mediaType: SampleMediaType = .image
private(set) var uploadedMediaList: [SampleUploadedMediaItem] = []
private(set) var coverImage: SampleUploadedMediaItem?
private(set) var uploadDialogState: SampleUploadDialogState?
private(set) var isSubmitting = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onUploadSuccess: (() -> Void)?
private let scenicListProvider: () -> [ScenicInfo]
private let currentScenicIdProvider: () -> Int
private var projectCurrentPage = 1
private var projectTotal = 0
private var projectIsLoading = false
private let projectPageSize = 10
/// ViewModel
init(
scenicListProvider: @escaping () -> [ScenicInfo] = { AppStore.shared.roleScenicList() },
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }
) {
self.scenicListProvider = scenicListProvider
self.currentScenicIdProvider = currentScenicIdProvider
scenicList = scenicListProvider()
}
/// 30
func updateAlbumName(_ name: String) {
albumName = String(name.prefix(30))
notifyStateChange()
}
///
func selectScenic(_ scenic: ScenicInfo, api: any SampleManagementServing) async {
selectedScenic = scenic
selectedScenicSpot = nil
scenicSpotList = []
selectedProject = nil
projectList = []
projectCurrentPage = 1
projectTotal = 0
projectCanLoadMore = false
notifyStateChange()
await loadScenicSpots(api: api)
}
///
func selectScenicSpot(_ spot: ScenicSpotItem) {
selectedScenicSpot = spot
notifyStateChange()
}
///
func selectProject(_ project: SampleProjectItem) {
selectedProject = project
notifyStateChange()
}
///
func selectMediaType(_ type: SampleMediaType) {
guard mediaType != type else { return }
mediaType = type
uploadedMediaList = []
notifyStateChange()
}
///
func loadProjects(refresh: Bool, api: any SampleManagementServing) async {
guard let scenicId = selectedScenic?.id, scenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
if projectIsLoading && !refresh { return }
if refresh {
projectCurrentPage = 1
projectList = []
projectCanLoadMore = false
notifyStateChange()
} else {
guard projectCanLoadMore else { return }
}
projectIsLoading = true
defer {
projectIsLoading = false
notifyStateChange()
}
do {
let response = try await api.projectList(
scenicId: String(scenicId),
name: nil,
page: projectCurrentPage,
pageSize: projectPageSize
)
projectTotal = response.total
projectList = refresh ? response.list : projectList + response.list
projectCurrentPage += 1
projectCanLoadMore = projectList.count < projectTotal
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "获取项目列表失败" : error.localizedDescription)
}
}
///
func addLocalMedia(_ mediaItems: [SampleUploadedMediaItem], uploader: any SampleOSSUploading) async {
guard !mediaItems.isEmpty else { return }
let remaining = mediaType.maxCount - uploadedMediaList.count
guard remaining > 0 else {
onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张")
return
}
let acceptedItems = Array(mediaItems.prefix(remaining))
if acceptedItems.count < mediaItems.count {
onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张")
}
let startIndex = uploadedMediaList.count
uploadedMediaList.append(contentsOf: acceptedItems)
notifyStateChange()
for offset in acceptedItems.indices {
let index = startIndex + offset
await uploadMedia(at: index, batchPosition: offset + 1, batchTotal: acceptedItems.count, uploader: uploader)
}
uploadDialogState = nil
notifyStateChange()
}
///
func setCoverImage(_ image: SampleUploadedMediaItem, uploader: any SampleOSSUploading) async {
coverImage = image
notifyStateChange()
await uploadCover(uploader: uploader)
}
///
func deleteMaterial(at index: Int) {
guard uploadedMediaList.indices.contains(index) else { return }
uploadedMediaList.remove(at: index)
notifyStateChange()
}
///
func deleteCoverImage() {
coverImage = nil
notifyStateChange()
}
///
func uploadSample(api: any SampleManagementServing) async {
let trimmedName = albumName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else {
onShowMessage?("请输入相册名称")
return
}
guard trimmedName.count <= 30 else {
onShowMessage?("相册名称最多30个字符")
return
}
guard !uploadedMediaList.isEmpty else {
onShowMessage?("请至少上传一个素材")
return
}
guard let coverImage else {
onShowMessage?("请上传封面图片")
return
}
guard selectedScenic != nil else {
onShowMessage?("请选择关联景区")
return
}
guard let selectedScenicSpot else {
onShowMessage?("请选择关联打卡点")
return
}
guard let selectedProject else {
onShowMessage?("请选择关联项目")
return
}
guard uploadedMediaList.allSatisfy({ !$0.ossUrl.isEmpty }) else {
onShowMessage?("请等待素材上传完成")
return
}
guard !coverImage.ossUrl.isEmpty else {
onShowMessage?("请等待封面图片上传完成")
return
}
let mediaList = uploadedMediaList.map { item in
SampleUploadMediaItem(
originalName: item.fileName,
ossUrl: item.ossUrl,
size: item.size,
fileWidthSize: SampleUploadFileSize(width: item.width, height: item.height)
)
}
let request = SampleUploadMaterialRequest(
name: trimmedName,
mediaType: mediaType,
mediaList: mediaList,
coverUrl: coverImage.ossUrl,
coverSize: String(coverImage.size),
scenicSpotId: selectedScenicSpot.id,
projectId: selectedProject.id
)
isSubmitting = true
notifyStateChange()
defer {
isSubmitting = false
notifyStateChange()
}
do {
try await api.upload(request)
onShowMessage?("上传成功")
onUploadSuccess?()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription)
}
}
private func loadScenicSpots(api: any SampleManagementServing) async {
guard let scenicId = selectedScenic?.id, scenicId > 0 else { return }
do {
let response = try await api.scenicSpotListAll(scenicId: String(scenicId))
scenicSpotList = response.list
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点失败" : error.localizedDescription)
}
}
private func uploadMedia(
at index: Int,
batchPosition: Int,
batchTotal: Int,
uploader: any SampleOSSUploading
) async {
guard uploadedMediaList.indices.contains(index) else { return }
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
uploadedMediaList[index].isUploading = true
uploadedMediaList[index].uploadProgress = 0
uploadDialogState = SampleUploadDialogState(title: "正在上传素材(\(batchPosition)/\(batchTotal))", progress: 0)
notifyStateChange()
let item = uploadedMediaList[index]
do {
let url = try await uploader.uploadSampleFile(
data: item.data,
fileName: item.fileName,
fileType: mediaType.uploadFileType,
scenicId: scenicId
) { [weak self] progress in
guard let self else { return }
Task { @MainActor in
guard self.uploadedMediaList.indices.contains(index) else { return }
self.uploadedMediaList[index].uploadProgress = progress
self.uploadedMediaList[index].isUploading = progress < 100
self.uploadDialogState = SampleUploadDialogState(
title: "正在上传素材(\(batchPosition)/\(batchTotal))",
progress: max(0, min(100, progress))
)
self.notifyStateChange()
}
}
guard uploadedMediaList.indices.contains(index) else { return }
uploadedMediaList[index].ossUrl = url
uploadedMediaList[index].isUploading = false
uploadedMediaList[index].uploadProgress = 100
notifyStateChange()
} catch is CancellationError {
return
} catch {
guard uploadedMediaList.indices.contains(index) else { return }
uploadedMediaList[index].isUploading = false
uploadedMediaList[index].uploadProgress = 0
onShowMessage?("上传失败: \(error.localizedDescription)")
notifyStateChange()
}
}
private func uploadCover(uploader: any SampleOSSUploading) async {
guard var image = coverImage else { return }
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
image.isUploading = true
image.uploadProgress = 0
coverImage = image
uploadDialogState = SampleUploadDialogState(title: "正在上传封面", progress: 0)
notifyStateChange()
do {
let url = try await uploader.uploadSampleFile(
data: image.data,
fileName: image.fileName,
fileType: SampleMediaType.image.uploadFileType,
scenicId: scenicId
) { [weak self] progress in
guard let self else { return }
Task { @MainActor in
guard var current = self.coverImage else { return }
current.uploadProgress = progress
current.isUploading = progress < 100
self.coverImage = current
self.uploadDialogState = SampleUploadDialogState(
title: "正在上传封面",
progress: max(0, min(100, progress))
)
self.notifyStateChange()
}
}
guard var current = coverImage else { return }
current.ossUrl = url
current.isUploading = false
current.uploadProgress = 100
coverImage = current
uploadDialogState = nil
notifyStateChange()
} catch is CancellationError {
return
} catch {
guard var current = coverImage else { return }
current.isUploading = false
current.uploadProgress = 0
coverImage = current
uploadDialogState = nil
onShowMessage?("封面上传失败: \(error.localizedDescription)")
notifyStateChange()
}
}
private func notifyStateChange() {
onStateChange?()
}
}
private extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}