兼容 Android 媒体项 size 字段格式,增强灵活解码容错,并统一样片列表开关确认逻辑与安全区布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
595 lines
20 KiB
Swift
595 lines
20 KiB
Swift
//
|
|
// 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 = ""
|
|
private(set) var operatingSampleIDs: Set<Int> = []
|
|
|
|
var onStateChange: (() -> Void)?
|
|
var onShowMessage: ((String) -> Void)?
|
|
|
|
private var currentPage = 1
|
|
private var totalCount = 0
|
|
private let pageSize = 10
|
|
private var isRequestingList = false
|
|
private var isLoadingMore = false
|
|
|
|
/// 是否正在执行样片上下架操作。
|
|
var isOperating: Bool { !operatingSampleIDs.isEmpty }
|
|
|
|
/// 首次加载样片列表。
|
|
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)
|
|
}
|
|
|
|
/// 切换样片上下架状态。
|
|
@discardableResult
|
|
func toggleListing(sampleId: Int, checked: Bool, api: any SampleManagementServing) async -> Bool {
|
|
guard let currentItem = items.first(where: { $0.id == sampleId }) else { return !checked }
|
|
let previousState = currentItem.isListed
|
|
guard currentItem.status == 1 else {
|
|
onShowMessage?("审核通过才可以操作上下架")
|
|
notifyStateChange()
|
|
return previousState
|
|
}
|
|
|
|
let targetStatus = checked ? 1 : 0
|
|
guard currentItem.listingStatus != targetStatus else { return previousState }
|
|
|
|
guard !operatingSampleIDs.contains(sampleId) else { return previousState }
|
|
operatingSampleIDs.insert(sampleId)
|
|
notifyStateChange()
|
|
defer {
|
|
operatingSampleIDs.remove(sampleId)
|
|
notifyStateChange()
|
|
}
|
|
|
|
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()
|
|
return checked
|
|
} catch is CancellationError {
|
|
return previousState
|
|
} catch {
|
|
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
|
|
return previousState
|
|
}
|
|
}
|
|
|
|
private func load(reset: Bool, showLoading: Bool, api: any SampleManagementServing) async {
|
|
guard !isRequestingList else {
|
|
if isRefreshing {
|
|
isRefreshing = false
|
|
notifyStateChange()
|
|
}
|
|
return
|
|
}
|
|
if reset {
|
|
currentPage = 1
|
|
canLoadMore = false
|
|
} else {
|
|
guard canLoadMore, !isLoading, !isLoadingMore else { return }
|
|
isLoadingMore = true
|
|
currentPage += 1
|
|
}
|
|
|
|
isRequestingList = true
|
|
isLoading = showLoading
|
|
notifyStateChange()
|
|
defer {
|
|
isRequestingList = false
|
|
if !reset { isLoadingMore = false }
|
|
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
|
|
}
|
|
guard !projectIsLoading else { 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 }
|
|
guard self.uploadedMediaList[index].isUploading 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 }
|
|
guard current.isUploading 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 }
|
|
}
|