feat: 更新素材管理和举报风险地图

This commit is contained in:
2026-07-09 12:20:02 +08:00
parent 9b92d81902
commit 42aca73588
42 changed files with 6164 additions and 1369 deletions

View File

@ -0,0 +1,612 @@
//
// 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 }
}