Files
suixinkan_uikit/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift
汉秋 005349f8e6 模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:01:08 +08:00

563 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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.permissions.roleScenicList() },
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.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 }
}