将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
543 lines
19 KiB
Swift
543 lines
19 KiB
Swift
//
|
||
// 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 }
|
||
}
|