Files
suixinkan_uikit/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift

613 lines
21 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.

//
// MaterialManagementViewModels.swift
// suixinkan
//
import Foundation
/// OSS 便 ViewModel
@MainActor
protocol MaterialOSSUploading {
/// OSS URL
func uploadMaterialFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String
}
/// ViewModel
final class MaterialListViewModel {
private(set) var items: [MaterialItem] = []
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var canLoadMore = false
private(set) var filterStatus: MaterialFilterStatus = .all
private(set) var searchText = ""
private(set) var orderInfo = MaterialOrderInfo()
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private var currentPage = 1
private var totalCount = 0
private let pageSize = 10
///
func loadInitial(api: any MaterialManagementServing) async {
await load(reset: true, showLoading: true, api: api)
}
///
func refresh(api: any MaterialManagementServing) async {
isRefreshing = true
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any MaterialManagementServing) async {
guard lastVisibleIndex >= items.count - 3 else { return }
await load(reset: false, showLoading: false, api: api)
}
///
func updateSearchText(_ text: String, api: any MaterialManagementServing) async {
searchText = text
await load(reset: true, showLoading: false, api: api)
}
///
func selectFilter(_ status: MaterialFilterStatus, api: any MaterialManagementServing) async {
guard filterStatus != status else { return }
filterStatus = status
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func toggleListing(materialId: Int, checked: Bool, api: any MaterialManagementServing) async {
guard let currentItem = items.first(where: { $0.id == materialId }) else { return }
guard currentItem.status == 1 else {
onShowMessage?("审核通过才可以操作上下架")
notifyStateChange()
return
}
let targetStatus = checked ? 1 : 0
guard currentItem.listingStatus != targetStatus else { return }
do {
try await api.setListingStatus(id: materialId, listingStatus: targetStatus)
items = items.map { item in
guard item.id == materialId else { return item }
return MaterialItem(
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)
}
}
///
func removeMaterial(id: Int) {
items.removeAll { $0.id == id }
totalCount = max(0, totalCount - 1)
canLoadMore = items.count < totalCount
notifyStateChange()
}
private func load(reset: Bool, showLoading: Bool, api: any MaterialManagementServing) 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).sxkNonEmpty
let response = try await api.list(
status: filterStatus.apiStatus,
keyword: keyword,
page: currentPage,
pageSize: pageSize
)
totalCount = response.total
orderInfo = response.order ?? MaterialOrderInfo()
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 MaterialDetailViewModel {
private(set) var detail: MaterialDetail?
private(set) var isLoading = false
private(set) var isDeleting = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onDeleted: ((Int) -> Void)?
let materialId: Int
/// ViewModel
init(materialId: Int) {
self.materialId = materialId
}
///
func load(api: any MaterialManagementServing) async {
guard materialId > 0 else {
onShowMessage?("素材ID无效")
return
}
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
detail = try await api.detail(id: materialId)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "获取素材详情失败" : error.localizedDescription)
}
}
///
func delete(api: any MaterialManagementServing) async {
guard materialId > 0 else {
onShowMessage?("素材ID无效")
return
}
isDeleting = true
notifyStateChange()
defer {
isDeleting = false
notifyStateChange()
}
do {
try await api.delete(id: materialId)
onShowMessage?("删除成功")
onDeleted?(materialId)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class MaterialFormViewModel {
enum Mode: Equatable {
case create
case edit(id: Int)
}
private(set) var materialName = ""
private(set) var selectedScenicSpot: ScenicSpotItem?
private(set) var scenicSpotList: [ScenicSpotItem] = []
private(set) var mediaType: MaterialMediaType = .image
private(set) var uploadedMediaList: [MaterialUploadedMediaItem] = []
private(set) var coverImage: MaterialUploadedMediaItem?
private(set) var tagList: [MaterialTagItem] = []
private(set) var tagInput = ""
private(set) var uploadDialogState: MaterialUploadDialogState?
private(set) var isSubmitting = false
private(set) var isLoadingDetail = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitSuccess: (() -> Void)?
let mode: Mode
private let currentScenicIdProvider: () -> Int
private var pendingScenicSpotId: Int64?
/// ViewModel
init(
mode: Mode = .create,
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }
) {
self.mode = mode
self.currentScenicIdProvider = currentScenicIdProvider
}
///
func loadInitial(api: any MaterialManagementServing) async {
await loadScenicSpots(api: api)
if case .edit(let id) = mode {
await loadDetail(id: id, api: api)
}
}
/// 30
func updateMaterialName(_ name: String) {
materialName = String(name.prefix(30))
notifyStateChange()
}
///
func selectScenicSpot(_ spot: ScenicSpotItem) {
selectedScenicSpot = spot
notifyStateChange()
}
///
func selectMediaType(_ type: MaterialMediaType) {
guard mediaType != type else { return }
mediaType = type
uploadedMediaList = []
notifyStateChange()
}
///
func updateTagInput(_ text: String) {
tagInput = text
notifyStateChange()
}
///
func addTag(api: any MaterialManagementServing) async {
let name = tagInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard name.count >= 5, name.count <= 15 else {
onShowMessage?("标签长度必须在5-15个字符之间")
return
}
guard tagList.count < 30 else {
onShowMessage?("最多只能添加30个标签")
return
}
guard !tagList.contains(where: { $0.name == name }) else {
onShowMessage?("标签已存在")
return
}
do {
let id = try await api.addTag(name: name)
tagList.append(MaterialTagItem(id: id, name: name))
tagInput = ""
onShowMessage?("添加标签成功")
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "添加标签失败" : error.localizedDescription)
}
}
///
func deleteTag(_ tag: MaterialTagItem) {
tagList.removeAll { $0.id == tag.id }
notifyStateChange()
}
///
func addLocalMedia(_ mediaItems: [MaterialUploadedMediaItem], uploader: any MaterialOSSUploading) async {
guard !mediaItems.isEmpty else { return }
let remaining = mediaType.maxCount - uploadedMediaList.count
guard remaining > 0 else {
onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张")
return
}
let accepted = Array(mediaItems.prefix(remaining))
uploadedMediaList.append(contentsOf: accepted)
notifyStateChange()
for offset in accepted.indices {
let index = uploadedMediaList.count - accepted.count + offset
await uploadMedia(at: index, title: "正在上传素材(\(offset + 1)/\(accepted.count))", uploader: uploader)
}
uploadDialogState = nil
notifyStateChange()
}
///
func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async {
coverImage = item
notifyStateChange()
await uploadCover(uploader: uploader)
uploadDialogState = nil
notifyStateChange()
}
///
func deleteMaterial(at index: Int) {
guard uploadedMediaList.indices.contains(index) else { return }
uploadedMediaList.remove(at: index)
notifyStateChange()
}
///
func deleteCoverImage() {
coverImage = nil
notifyStateChange()
}
///
func submit(api: any MaterialManagementServing) async {
guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
onShowMessage?("请输入素材名称")
return
}
guard materialName.count <= 30 else {
onShowMessage?("素材名称最多30个字符")
return
}
guard !uploadedMediaList.isEmpty else {
onShowMessage?("请至少上传一个素材")
return
}
guard let coverImage else {
onShowMessage?("请上传封面图片")
return
}
guard let selectedScenicSpot else {
onShowMessage?("请选择关联打卡点")
return
}
guard !uploadedMediaList.contains(where: { $0.ossUrl.isEmpty }) else {
onShowMessage?("请等待素材上传完成")
return
}
guard !coverImage.ossUrl.isEmpty else {
onShowMessage?("请等待封面图片上传完成")
return
}
let request = MaterialUploadRequest(
id: editID,
name: materialName,
mediaType: mediaType,
mediaList: uploadedMediaList.map {
MaterialUploadMediaItem(
originalName: $0.fileName,
ossUrl: $0.ossUrl,
size: $0.size,
fileWidthSize: MaterialUploadFileSize(width: $0.width, height: $0.height)
)
},
coverUrl: coverImage.ossUrl,
coverSize: String(coverImage.size),
scenicSpotId: selectedScenicSpot.id,
materialTag: tagList.filter { $0.id > 0 }.map { String($0.id) }.joined(separator: ",")
)
isSubmitting = true
notifyStateChange()
defer {
isSubmitting = false
notifyStateChange()
}
do {
switch mode {
case .create:
try await api.upload(request)
onShowMessage?("上传成功")
case .edit:
try await api.edit(request)
onShowMessage?("编辑成功")
}
onSubmitSuccess?()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "提交失败" : error.localizedDescription)
}
}
private var editID: Int? {
if case .edit(let id) = mode { return id }
return nil
}
private func loadScenicSpots(api: any MaterialManagementServing) async {
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
do {
let response = try await api.scenicSpotListAll(scenicId: String(scenicId))
scenicSpotList = response.list
if let pendingScenicSpotId {
selectedScenicSpot = scenicSpotList.first { $0.id == pendingScenicSpotId }
}
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点失败" : error.localizedDescription)
}
}
private func loadDetail(id: Int, api: any MaterialManagementServing) async {
isLoadingDetail = true
notifyStateChange()
defer {
isLoadingDetail = false
notifyStateChange()
}
do {
let detail = try await api.detail(id: id)
materialName = detail.name ?? ""
mediaType = detail.mediaList?.first?.type == 2 ? .video : .image
pendingScenicSpotId = detail.scenicSpotId
selectedScenicSpot = scenicSpotList.first { $0.id == detail.scenicSpotId }
tagList = detail.materialTag ?? []
uploadedMediaList = detail.mediaList?.map { media in
MaterialUploadedMediaItem(
data: Data(),
thumbnailData: nil,
previewURL: media.displayURL,
fileName: media.originalName ?? "material",
size: media.size ?? 0,
width: media.width ?? 0,
height: media.height ?? 0,
ossUrl: media.ossUrl ?? media.showUrl ?? "",
isUploading: false,
uploadProgress: 100
)
} ?? []
if let coverUrl = detail.coverUrl, !coverUrl.isEmpty {
coverImage = MaterialUploadedMediaItem(
data: Data(),
previewURL: coverUrl,
fileName: "cover",
size: Int64(detail.coverSize ?? 0),
ossUrl: coverUrl,
isUploading: false,
uploadProgress: 100
)
}
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "加载素材详情失败" : error.localizedDescription)
}
}
private func uploadMedia(at index: Int, title: String, uploader: any MaterialOSSUploading) async {
guard uploadedMediaList.indices.contains(index) else { return }
guard currentScenicIdProvider() > 0 else {
onShowMessage?("请先选择景区")
return
}
uploadedMediaList[index].isUploading = true
uploadedMediaList[index].uploadProgress = 0
uploadDialogState = MaterialUploadDialogState(title: title, progress: 0)
notifyStateChange()
do {
let item = uploadedMediaList[index]
let url = try await uploader.uploadMaterialFile(
data: item.data,
fileName: item.fileName,
fileType: mediaType.uploadFileType,
scenicId: currentScenicIdProvider(),
onProgress: { [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 = MaterialUploadDialogState(title: title, progress: progress)
self.notifyStateChange()
}
}
)
guard uploadedMediaList.indices.contains(index) else { return }
uploadedMediaList[index].ossUrl = url
uploadedMediaList[index].isUploading = false
uploadedMediaList[index].uploadProgress = 100
} catch is CancellationError {
return
} catch {
guard uploadedMediaList.indices.contains(index) else { return }
uploadedMediaList[index].isUploading = false
uploadedMediaList[index].uploadProgress = 0
onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription)
}
notifyStateChange()
}
private func uploadCover(uploader: any MaterialOSSUploading) async {
guard let coverImage else { return }
guard currentScenicIdProvider() > 0 else {
onShowMessage?("请先选择景区")
return
}
self.coverImage?.isUploading = true
self.coverImage?.uploadProgress = 0
uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: 0)
notifyStateChange()
do {
let url = try await uploader.uploadMaterialFile(
data: coverImage.data,
fileName: coverImage.fileName,
fileType: 2,
scenicId: currentScenicIdProvider(),
onProgress: { [weak self] progress in
guard let self else { return }
Task { @MainActor in
self.coverImage?.uploadProgress = progress
self.coverImage?.isUploading = progress < 100
self.uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: progress)
self.notifyStateChange()
}
}
)
self.coverImage?.ossUrl = url
self.coverImage?.isUploading = false
self.coverImage?.uploadProgress = 100
} catch is CancellationError {
return
} catch {
self.coverImage?.isUploading = false
self.coverImage?.uploadProgress = 0
onShowMessage?(error.localizedDescription.isEmpty ? "封面上传失败" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}
private extension String {
var sxkNonEmpty: String? { isEmpty ? nil : self }
}