将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
639 lines
22 KiB
Swift
639 lines
22 KiB
Swift
//
|
|
// 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()
|
|
if reset {
|
|
items = response.list
|
|
} else {
|
|
let existingIDs = Set(items.map(\.id))
|
|
items.append(contentsOf: response.list.filter { !existingIDs.contains($0.id) })
|
|
}
|
|
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 pendingDeleteTag: MaterialTagItem?
|
|
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.session.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 = String(text.prefix(15))
|
|
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 requestDeleteTag(_ tag: MaterialTagItem) {
|
|
pendingDeleteTag = tag
|
|
notifyStateChange()
|
|
}
|
|
|
|
/// 取消删除标签。
|
|
func cancelDeleteTag() {
|
|
pendingDeleteTag = nil
|
|
notifyStateChange()
|
|
}
|
|
|
|
/// 确认删除当前待删除标签。
|
|
func confirmDeleteTag() {
|
|
guard let tag = pendingDeleteTag else { return }
|
|
tagList.removeAll { $0.id == tag.id }
|
|
pendingDeleteTag = nil
|
|
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 }
|
|
let clampedProgress = max(0, min(100, progress))
|
|
self.uploadedMediaList[index].uploadProgress = clampedProgress
|
|
self.uploadedMediaList[index].isUploading = clampedProgress < 100
|
|
if clampedProgress < 100 {
|
|
self.uploadDialogState = MaterialUploadDialogState(title: title, progress: clampedProgress)
|
|
}
|
|
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
|
|
let clampedProgress = max(0, min(100, progress))
|
|
self.coverImage?.uploadProgress = clampedProgress
|
|
self.coverImage?.isUploading = clampedProgress < 100
|
|
if clampedProgress < 100 {
|
|
self.uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: clampedProgress)
|
|
}
|
|
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 }
|
|
}
|