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
@@ -0,0 +1,140 @@
//
// MaterialManagementAPI.swift
// suixinkan
//
import Foundation
/// 素材管理服务协议,抽象列表、详情、上下架、删除、标签、上传与编辑接口。
@MainActor
protocol MaterialManagementServing {
/// 拉取素材列表。
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> MaterialListResponse
/// 拉取素材详情。
func detail(id: Int) async throws -> MaterialDetail
/// 设置素材上下架状态。
func setListingStatus(id: Int, listingStatus: Int) async throws
/// 删除素材。
func delete(id: Int) async throws
/// 新增素材标签。
func addTag(name: String) async throws -> Int
/// 上传素材。
func upload(_ request: MaterialUploadRequest) async throws
/// 编辑素材。
func edit(_ request: MaterialUploadRequest) async throws
/// 拉取景区全部打卡点。
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse
}
/// 素材管理 API,封装 Android `NetworkApi` 中 `media-album type=1` 接口。
@MainActor
final class MaterialManagementAPI: MaterialManagementServing {
private let client: APIClient
/// 初始化素材管理 API。
init(client: APIClient) {
self.client = client
}
/// 拉取素材列表,固定 `type=1`。
func list(status: Int, keyword: String?, page: Int = 1, pageSize: Int = 10) async throws -> MaterialListResponse {
var items = [
URLQueryItem(name: "status", value: String(status)),
URLQueryItem(name: "type", value: "1"),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
if let keyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines), !keyword.isEmpty {
items.append(URLQueryItem(name: "keyword", value: keyword))
}
return try await client.send(
APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: items)
)
}
/// 拉取素材详情,固定 `type=1`。
func detail(id: Int) async throws -> MaterialDetail {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/media-album/detail",
queryItems: [
URLQueryItem(name: "id", value: String(id)),
URLQueryItem(name: "type", value: "1"),
]
)
)
}
/// 设置素材上下架状态,固定 `type=1`。
func setListingStatus(id: Int, listingStatus: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/media-album/operation",
queryItems: [
URLQueryItem(name: "id", value: String(id)),
URLQueryItem(name: "listing_status", value: String(listingStatus)),
URLQueryItem(name: "type", value: "1"),
]
)
)
}
/// 删除素材,固定 `type=1`。
func delete(id: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/media-album/delete",
queryItems: [
URLQueryItem(name: "id", value: String(id)),
URLQueryItem(name: "type", value: "1"),
]
)
)
}
/// 新增素材标签。
func addTag(name: String) async throws -> Int {
try await client.send(
APIRequest(
method: .post,
path: "/api/app/media-album/add-tag",
queryItems: [URLQueryItem(name: "name", value: name)]
)
)
}
/// 上传素材。
func upload(_ request: MaterialUploadRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
)
}
/// 编辑素材。
func edit(_ request: MaterialUploadRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/media-album/edit", body: request)
)
}
/// 拉取景区全部打卡点。
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
queryItems: [URLQueryItem(name: "scenic_id", value: scenicId)]
)
)
}
}
@@ -0,0 +1,541 @@
//
// MaterialManagementModels.swift
// suixinkan
//
import Foundation
/// 素材管理筛选状态,对齐 Android `MaterialListViewModel` 的四档筛选。
enum MaterialFilterStatus: Int, CaseIterable, Sendable, Equatable {
case all = 0
case approved = 1
case pending = 2
case rejected = 3
/// 展示文案。
var title: String {
switch self {
case .all: "全部"
case .approved: "已通过"
case .pending: "待审核"
case .rejected: "未通过"
}
}
/// 服务端 `status` 参数,Android 映射为:全部 4、已通过 1、待审核 0、未通过 2。
var apiStatus: Int {
switch self {
case .all: 4
case .approved: 1
case .pending: 0
case .rejected: 2
}
}
}
/// 素材媒体类型,对齐 Android `mediaType`:1 图片,2 视频。
enum MaterialMediaType: Int, CaseIterable, Sendable, Equatable {
case image = 1
case video = 2
/// 展示文案。
var title: String {
switch self {
case .image: "图片"
case .video: "视频"
}
}
/// 最大可选数量。
var maxCount: Int {
switch self {
case .image: 9
case .video: 1
}
}
/// OSS 文件类型,Android 上传任务中视频为 1、图片为 2。
var uploadFileType: Int {
switch self {
case .image: 2
case .video: 1
}
}
}
/// 素材列表项,对齐 Android `MaterialItemEntity`。
struct MaterialItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let name: String
let coverUrl: String
let createdAt: String
let status: Int
let downloadCount: Int
let likesCount: Int
let collectCount: Int
let shareCount: Int
let scenicSpotName: String
let projectName: String
let listingStatus: Int
enum CodingKeys: String, CodingKey {
case id
case name
case coverUrl = "cover_url"
case createdAt = "created_at"
case status
case downloadCount = "download_count"
case likesCount = "likes_count"
case collectCount = "collect_count"
case shareCount = "share_count"
case scenicSpotName = "scenic_spot_name"
case projectName = "project_name"
case listingStatus = "listing_status"
}
init(
id: Int = 0,
name: String = "",
coverUrl: String = "",
createdAt: String = "",
status: Int = 0,
downloadCount: Int = 0,
likesCount: Int = 0,
collectCount: Int = 0,
shareCount: Int = 0,
scenicSpotName: String = "",
projectName: String = "",
listingStatus: Int = 0
) {
self.id = id
self.name = name
self.coverUrl = coverUrl
self.createdAt = createdAt
self.status = status
self.downloadCount = downloadCount
self.likesCount = likesCount
self.collectCount = collectCount
self.shareCount = shareCount
self.scenicSpotName = scenicSpotName
self.projectName = projectName
self.listingStatus = listingStatus
}
}
/// 素材列表响应,对齐 Android `MaterialListResponse`。
struct MaterialListResponse: Decodable, Sendable, Equatable {
let total: Int
let list: [MaterialItem]
let order: MaterialOrderInfo?
init(total: Int = 0, list: [MaterialItem] = [], order: MaterialOrderInfo? = nil) {
self.total = total
self.list = list
self.order = order
}
}
/// 素材订单统计信息,对齐 Android `MaterialOrderInfo`。
struct MaterialOrderInfo: Decodable, Sendable, Equatable, Hashable {
let totalNum: Int
let avgOrderAmount: String
let refundTotal: String
let avgChange: Int
enum CodingKeys: String, CodingKey {
case totalNum = "total_num"
case avgOrderAmount = "avg_order_amount"
case refundTotal = "refund_total"
case avgChange = "avg_change"
}
init(totalNum: Int = 0, avgOrderAmount: String = "0", refundTotal: String = "0", avgChange: Int = 0) {
self.totalNum = totalNum
self.avgOrderAmount = avgOrderAmount
self.refundTotal = refundTotal
self.avgChange = avgChange
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
totalNum = try container.decodeIfPresent(Int.self, forKey: .totalNum) ?? 0
avgOrderAmount = try container.decodeIfPresent(String.self, forKey: .avgOrderAmount) ?? "0"
refundTotal = try container.decodeIfPresent(String.self, forKey: .refundTotal) ?? "0"
avgChange = try container.decodeIfPresent(Int.self, forKey: .avgChange) ?? 0
}
}
/// 素材详情响应,对齐 Android `MaterialDetailResponse`。
struct MaterialDetail: Decodable, Sendable, Equatable {
let id: Int
let name: String?
let type: String?
let ossUrl: String?
let coverUrl: String?
let coverSize: Int?
let description: String?
let scenicSpotId: Int64?
let scenicAreaId: Int?
let materialTag: [MaterialTagItem]?
let uploaderId: Int?
let status: Int
let likesCount: Int
let downloadCount: Int
let collectCount: Int
let isRecommended: Bool
let createdAt: String?
let updatedAt: String?
let deletedAt: String?
let listingStatus: Int
let reviewNotes: String?
let reviewTime: String?
let reviewerId: Int?
let reviewer: String?
let provinceId: Int?
let cityId: Int?
let shareCount: Int
let isWeb: Int
let isMini: Int
let uploaderName: String?
let uploaderAvatar: String?
let uploaderDescription: String?
let platformCertification: Bool
let scenicCertification: Bool
let mediaList: [MaterialDetailMediaItem]?
enum CodingKeys: String, CodingKey {
case id
case name
case type
case ossUrl = "oss_url"
case coverUrl = "cover_url"
case coverSize = "cover_size"
case description
case scenicSpotId = "scenic_spot_id"
case scenicAreaId = "scenic_area_id"
case materialTag = "material_tag"
case uploaderId = "uploader_id"
case status
case likesCount = "likes_count"
case downloadCount = "download_count"
case collectCount = "collect_count"
case isRecommended = "is_recommended"
case createdAt = "created_at"
case updatedAt = "updated_at"
case deletedAt = "deleted_at"
case listingStatus = "listing_status"
case reviewNotes = "review_notes"
case reviewTime = "review_time"
case reviewerId = "reviewer_id"
case reviewer
case provinceId = "province_id"
case cityId = "city_id"
case shareCount = "share_count"
case isWeb = "is_web"
case isMini = "is_mini"
case uploaderName = "uploader_name"
case uploaderAvatar = "uploader_avatar"
case uploaderDescription = "uploader_description"
case platformCertification = "platform_certification"
case scenicCertification = "scenic_certification"
case mediaList = "media_list"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
name = try container.decodeIfPresent(String.self, forKey: .name)
type = try container.decodeFlexibleString(forKey: .type)
ossUrl = try container.decodeIfPresent(String.self, forKey: .ossUrl)
coverUrl = try container.decodeIfPresent(String.self, forKey: .coverUrl)
coverSize = try container.decodeIfPresent(Int.self, forKey: .coverSize)
description = try container.decodeIfPresent(String.self, forKey: .description)
scenicSpotId = try container.decodeFlexibleInt64(forKey: .scenicSpotId)
scenicAreaId = try container.decodeIfPresent(Int.self, forKey: .scenicAreaId)
materialTag = try container.decodeIfPresent([MaterialTagItem].self, forKey: .materialTag)
uploaderId = try container.decodeIfPresent(Int.self, forKey: .uploaderId)
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0
downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0
collectCount = try container.decodeIfPresent(Int.self, forKey: .collectCount) ?? 0
isRecommended = try container.decodeIfPresent(Bool.self, forKey: .isRecommended) ?? false
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt)
updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
listingStatus = try container.decodeIfPresent(Int.self, forKey: .listingStatus) ?? 0
reviewNotes = try container.decodeIfPresent(String.self, forKey: .reviewNotes)
reviewTime = try container.decodeIfPresent(String.self, forKey: .reviewTime)
reviewerId = try container.decodeIfPresent(Int.self, forKey: .reviewerId)
reviewer = try container.decodeIfPresent(String.self, forKey: .reviewer)
provinceId = try container.decodeIfPresent(Int.self, forKey: .provinceId)
cityId = try container.decodeIfPresent(Int.self, forKey: .cityId)
shareCount = try container.decodeIfPresent(Int.self, forKey: .shareCount) ?? 0
isWeb = try container.decodeIfPresent(Int.self, forKey: .isWeb) ?? 0
isMini = try container.decodeIfPresent(Int.self, forKey: .isMini) ?? 0
uploaderName = try container.decodeIfPresent(String.self, forKey: .uploaderName)
uploaderAvatar = try container.decodeIfPresent(String.self, forKey: .uploaderAvatar)
uploaderDescription = try container.decodeIfPresent(String.self, forKey: .uploaderDescription)
platformCertification = try container.decodeIfPresent(Bool.self, forKey: .platformCertification) ?? false
scenicCertification = try container.decodeIfPresent(Bool.self, forKey: .scenicCertification) ?? false
mediaList = try container.decodeIfPresent([MaterialDetailMediaItem].self, forKey: .mediaList)
}
}
/// 素材标签项。
struct MaterialTagItem: Codable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let name: String
}
/// 素材详情媒体项,对齐 Android `MaterialDetailMediaItem`。
struct MaterialDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let originalName: String?
let paidMaterialId: Int?
let ossUrl: String?
let thumbnailUrl: String?
let type: Int
let size: Int64?
let createdAt: String?
let updatedAt: String?
let deletedAt: String?
let likesCount: Int
let downloadCount: Int
let showUrl: String?
let width: Int?
let height: Int?
enum CodingKeys: String, CodingKey {
case id
case originalName = "original_name"
case paidMaterialId = "paid_material_id"
case ossUrl = "oss_url"
case thumbnailUrl = "thumbnail_url"
case type
case size
case createdAt = "created_at"
case updatedAt = "updated_at"
case deletedAt = "deleted_at"
case likesCount = "likes_count"
case downloadCount = "download_count"
case showUrl = "show_url"
case width
case height
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
originalName = try container.decodeIfPresent(String.self, forKey: .originalName)
paidMaterialId = try container.decodeIfPresent(Int.self, forKey: .paidMaterialId)
ossUrl = try container.decodeIfPresent(String.self, forKey: .ossUrl)
thumbnailUrl = try container.decodeIfPresent(String.self, forKey: .thumbnailUrl)
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 1
size = try container.decodeIfPresent(Int64.self, forKey: .size)
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt)
updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0
downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0
showUrl = try container.decodeIfPresent(String.self, forKey: .showUrl)
width = try container.decodeIfPresent(Int.self, forKey: .width)
height = try container.decodeIfPresent(Int.self, forKey: .height)
}
/// 详情轮播优先展示地址。
var displayURL: String {
(ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).sxkNonEmpty)
?? (showUrl?.trimmingCharacters(in: .whitespacesAndNewlines).sxkNonEmpty)
?? ""
}
}
/// 素材上传与编辑请求,对齐 Android `UploadMaterialRequest` / `EditMaterialRequest`。
struct MaterialUploadRequest: Encodable, Sendable, Equatable {
let id: Int?
let name: String
let type: String
let mediaType: String
let mediaList: [MaterialUploadMediaItem]
let coverUrl: String
let coverSize: String
let scenicSpotId: Int64
let description: String
let materialTag: String
enum CodingKeys: String, CodingKey {
case id
case name
case type
case mediaType = "media_type"
case mediaList = "media_list"
case coverUrl = "cover_url"
case coverSize = "cover_size"
case scenicSpotId = "scenic_spot_id"
case description
case materialTag = "material_tag"
}
init(
id: Int? = nil,
name: String,
mediaType: MaterialMediaType,
mediaList: [MaterialUploadMediaItem],
coverUrl: String,
coverSize: String,
scenicSpotId: Int64,
materialTag: String
) {
self.id = id
self.name = name
self.type = "1"
self.mediaType = String(mediaType.rawValue)
self.mediaList = mediaList
self.coverUrl = coverUrl
self.coverSize = coverSize
self.scenicSpotId = scenicSpotId
self.description = ""
self.materialTag = materialTag
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(type, forKey: .type)
try container.encode(mediaType, forKey: .mediaType)
try container.encode(mediaList, forKey: .mediaList)
try container.encode(coverUrl, forKey: .coverUrl)
try container.encode(coverSize, forKey: .coverSize)
try container.encode(scenicSpotId, forKey: .scenicSpotId)
try container.encode(description, forKey: .description)
try container.encode(materialTag, forKey: .materialTag)
}
}
/// 素材上传媒体项,对齐 Android `MediaItem`。
struct MaterialUploadMediaItem: Encodable, Sendable, Equatable {
let originalName: String
let ossUrl: String
let size: Int64
let fileWidthSize: MaterialUploadFileSize
enum CodingKeys: String, CodingKey {
case originalName = "original_name"
case ossUrl = "oss_url"
case size
case fileWidthSize = "file_width_size"
}
}
/// 素材上传文件宽高。
struct MaterialUploadFileSize: Encodable, Sendable, Equatable {
let width: Int
let height: Int
}
/// 上传页本地媒体项,不依赖 UIKit,供 ViewModel 管理上传状态。
struct MaterialUploadedMediaItem: Sendable, Equatable, Hashable, Identifiable {
let id: String
let data: Data
let thumbnailData: Data?
let previewURL: String?
let fileName: String
let size: Int64
let width: Int
let height: Int
var ossUrl: String
var isUploading: Bool
var uploadProgress: Int
init(
id: String = UUID().uuidString,
data: Data,
thumbnailData: Data? = nil,
previewURL: String? = nil,
fileName: String,
size: Int64? = nil,
width: Int = 0,
height: Int = 0,
ossUrl: String = "",
isUploading: Bool = false,
uploadProgress: Int = 0
) {
self.id = id
self.data = data
self.thumbnailData = thumbnailData
self.previewURL = previewURL
self.fileName = fileName
self.size = size ?? Int64(data.count)
self.width = width
self.height = height
self.ossUrl = ossUrl
self.isUploading = isUploading
self.uploadProgress = uploadProgress
}
}
/// 素材上传进度弹窗状态。
struct MaterialUploadDialogState: Sendable, Equatable {
let title: String
let progress: Int
}
/// 素材 UI 展示格式化工具。
enum MaterialDisplayFormatter {
/// 格式化计数,超过 1000 按 Android 规则显示 k。
static func formatCount(_ count: Int) -> String {
guard count >= 1000 else { return String(count) }
let value = Double(count) / 1000.0
if value.truncatingRemainder(dividingBy: 1) == 0 {
return "\(Int(value))k"
}
return String(format: "%.1fk", value)
}
/// 格式化整数,增加千位分隔符。
static func formatNumber(_ number: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.string(from: NSNumber(value: number)) ?? String(number)
}
/// 审核状态文案。
static func reviewStatusText(_ status: Int) -> String {
switch status {
case 1: "已通过"
case 0: "待审核"
case 2: "未通过"
default: "未知"
}
}
}
private extension KeyedDecodingContainer {
func decodeFlexibleString(forKey key: Key) throws -> String? {
if let value = try decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
return nil
}
func decodeFlexibleInt64(forKey key: Key) throws -> Int64? {
if let value = try decodeIfPresent(Int64.self, forKey: key) {
return value
}
if let value = try decodeIfPresent(Int.self, forKey: key) {
return Int64(value)
}
if let value = try decodeIfPresent(String.self, forKey: key) {
return Int64(value)
}
return nil
}
}
private extension String {
var sxkNonEmpty: String? { isEmpty ? nil : self }
}
@@ -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 }
}