feat: add live module and optimize report list
This commit is contained in:
194
suixinkan/Features/Live/API/LiveAPI.swift
Normal file
194
suixinkan/Features/Live/API/LiveAPI.swift
Normal file
@ -0,0 +1,194 @@
|
||||
//
|
||||
// LiveAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 直播服务协议,抽象 manual-live 与 view-album 接口。
|
||||
@MainActor
|
||||
protocol LiveServing {
|
||||
/// 拉取直播列表。
|
||||
func liveList(scenicId: String, page: Int, pageSize: Int) async throws -> LiveListResponse
|
||||
|
||||
/// 拉取直播详情。
|
||||
func liveDetail(liveId: Int) async throws -> LiveItem
|
||||
|
||||
/// 新建直播。
|
||||
func createLive(scenicId: String, title: String, coverImg: String) async throws
|
||||
|
||||
/// 开始直播。
|
||||
func startLive(liveId: Int) async throws
|
||||
|
||||
/// 暂停直播。
|
||||
func stopLive(liveId: Int) async throws
|
||||
|
||||
/// 结束直播。
|
||||
func finishLive(liveId: Int) async throws
|
||||
|
||||
/// 设置直播推流模式。
|
||||
func setPushMode(liveId: Int, mode: Int) async throws
|
||||
|
||||
/// 拉取直播相册列表。
|
||||
func albumList(scenicId: String, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
|
||||
|
||||
/// 创建直播相册文件夹。
|
||||
func createAlbumFolder(scenicId: String, name: String, items: [LiveAlbumFile]) async throws
|
||||
|
||||
/// 删除直播相册文件夹。
|
||||
func deleteAlbumFolder(folderId: Int) async throws
|
||||
|
||||
/// 拉取直播相册详情。
|
||||
func albumFolderDetail(folderId: Int) async throws -> LiveAlbumFolder
|
||||
|
||||
/// 删除直播相册素材。
|
||||
func deleteAlbumFile(folderId: Int, fileId: Int) async throws
|
||||
}
|
||||
|
||||
/// 直播 API,封装 Android `NetworkApi` 中 alive/manual-live 与 view-album 接口。
|
||||
@MainActor
|
||||
final class LiveAPI: LiveServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化直播 API。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取直播列表。
|
||||
func liveList(scenicId: String, page: Int = 1, pageSize: Int = 10) async throws -> LiveListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/manual-live/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: scenicId),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取直播详情。
|
||||
func liveDetail(liveId: Int) async throws -> LiveItem {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/manual-live/detail",
|
||||
queryItems: [URLQueryItem(name: "live_id", value: String(liveId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 新建直播。
|
||||
func createLive(scenicId: String, title: String, coverImg: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/manual-live/create",
|
||||
body: LiveCreateRequest(scenicId: scenicId, title: title, coverImg: coverImg)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 开始直播。
|
||||
func startLive(liveId: Int) async throws {
|
||||
try await controlLive(path: "/api/app/manual-live/start", liveId: liveId)
|
||||
}
|
||||
|
||||
/// 暂停直播。
|
||||
func stopLive(liveId: Int) async throws {
|
||||
try await controlLive(path: "/api/app/manual-live/stop", liveId: liveId)
|
||||
}
|
||||
|
||||
/// 结束直播。
|
||||
func finishLive(liveId: Int) async throws {
|
||||
try await controlLive(path: "/api/app/manual-live/finish", liveId: liveId)
|
||||
}
|
||||
|
||||
/// 设置直播推流模式。
|
||||
func setPushMode(liveId: Int, mode: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/manual-live/set-push-mode",
|
||||
body: LivePushModeRequest(liveId: liveId, manualPushMode: mode)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取直播相册列表。
|
||||
func albumList(
|
||||
scenicId: String,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
) async throws -> LiveAlbumFolderListResponse {
|
||||
var items = [
|
||||
URLQueryItem(name: "scenic_id", value: scenicId),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
|
||||
]
|
||||
if let startTime, !startTime.isEmpty {
|
||||
items.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime, !endTime.isEmpty {
|
||||
items.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/view-album/folders", queryItems: items)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建直播相册文件夹。
|
||||
func createAlbumFolder(scenicId: String, name: String, items: [LiveAlbumFile]) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/view-album/create-folder",
|
||||
body: LiveAlbumFolderCreateRequest(scenicId: scenicId, name: name, items: items)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除直播相册文件夹。
|
||||
func deleteAlbumFolder(folderId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/view-album/delete-folder",
|
||||
body: LiveAlbumFolderDeleteRequest(folderId: folderId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取直播相册详情。
|
||||
func albumFolderDetail(folderId: Int) async throws -> LiveAlbumFolder {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/view-album/folder-detail",
|
||||
queryItems: [URLQueryItem(name: "folder_id", value: String(folderId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除直播相册素材。
|
||||
func deleteAlbumFile(folderId: Int, fileId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/view-album/delete-files",
|
||||
body: LiveAlbumFileDeleteRequest(folderId: folderId, fileIds: [fileId])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func controlLive(path: String, liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: path, body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
}
|
||||
370
suixinkan/Features/Live/Models/LiveModels.swift
Normal file
370
suixinkan/Features/Live/Models/LiveModels.swift
Normal file
@ -0,0 +1,370 @@
|
||||
//
|
||||
// LiveModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 直播状态,对齐 Android `AliveEntity.status`。
|
||||
enum LiveStatus: Int, Sendable, Equatable {
|
||||
case pending = 1
|
||||
case living = 2
|
||||
case paused = 3
|
||||
case finished = 4
|
||||
case unknown = 0
|
||||
|
||||
/// 是否允许开始、暂停或结束。
|
||||
var isOperable: Bool { self != .finished }
|
||||
}
|
||||
|
||||
/// 直播推流模式,对齐 Android `manualPushMode`。
|
||||
enum LivePushMode: Int, CaseIterable, Sendable, Equatable {
|
||||
case clarityFirst = 1
|
||||
case smoothFirst = 2
|
||||
|
||||
/// 展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .clarityFirst: "清晰度优先"
|
||||
case .smoothFirst: "流畅度优先"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播列表与详情实体,对齐 Android `AliveEntity`。
|
||||
struct LiveItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let coverImg: String
|
||||
let pushUrl: String
|
||||
let startTime: Int64
|
||||
let endTime: Int64
|
||||
let duration: Int64
|
||||
let status: Int
|
||||
let statusLabel: String
|
||||
let manualPushMode: Int
|
||||
let manualPushState: Int
|
||||
let viewsCount: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case coverImg = "cover_img"
|
||||
case pushUrl = "push_url"
|
||||
case startTime = "start_time"
|
||||
case endTime = "end_time"
|
||||
case duration
|
||||
case status
|
||||
case statusLabel = "status_label"
|
||||
case manualPushMode = "manual_push_mode"
|
||||
case manualPushState = "manual_push_state"
|
||||
case legacyManualPushMode = "manaul_push_mode"
|
||||
case legacyManualPushState = "manaul_push_state"
|
||||
case viewsCount = "views_count"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
title: String = "",
|
||||
coverImg: String = "",
|
||||
pushUrl: String = "",
|
||||
startTime: Int64 = 0,
|
||||
endTime: Int64 = 0,
|
||||
duration: Int64 = 0,
|
||||
status: Int = 0,
|
||||
statusLabel: String = "",
|
||||
manualPushMode: Int = 1,
|
||||
manualPushState: Int = 0,
|
||||
viewsCount: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.coverImg = coverImg
|
||||
self.pushUrl = pushUrl
|
||||
self.startTime = startTime
|
||||
self.endTime = endTime
|
||||
self.duration = duration
|
||||
self.status = status
|
||||
self.statusLabel = statusLabel
|
||||
self.manualPushMode = manualPushMode
|
||||
self.manualPushState = manualPushState
|
||||
self.viewsCount = viewsCount
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
title = try container.decodeLossyString(forKey: .title)
|
||||
coverImg = try container.decodeLossyString(forKey: .coverImg).unescapedSlashes
|
||||
pushUrl = try container.decodeLossyString(forKey: .pushUrl).unescapedSlashes
|
||||
startTime = try container.decodeLossyInt64(forKey: .startTime) ?? 0
|
||||
endTime = try container.decodeLossyInt64(forKey: .endTime) ?? 0
|
||||
duration = try container.decodeLossyInt64(forKey: .duration) ?? 0
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
|
||||
manualPushMode = try container.decodeLossyInt(forKey: .manualPushMode)
|
||||
?? container.decodeLossyInt(forKey: .legacyManualPushMode)
|
||||
?? 1
|
||||
manualPushState = try container.decodeLossyInt(forKey: .manualPushState)
|
||||
?? container.decodeLossyInt(forKey: .legacyManualPushState)
|
||||
?? 0
|
||||
viewsCount = try container.decodeLossyInt(forKey: .viewsCount) ?? 0
|
||||
}
|
||||
|
||||
/// 类型化直播状态。
|
||||
var liveStatus: LiveStatus { LiveStatus(rawValue: status) ?? .unknown }
|
||||
|
||||
/// 标题兜底。
|
||||
var displayTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "暂无标题" : title }
|
||||
|
||||
/// 当前操作按钮标题。
|
||||
var controlTitle: String { liveStatus == .living ? "暂停" : "开始" }
|
||||
}
|
||||
|
||||
/// 直播分页响应,对齐 Android `ItemListResponse<AliveEntity>`。
|
||||
struct LiveListResponse: Decodable, Sendable, Equatable {
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let total: Int
|
||||
let totalPages: Int
|
||||
let items: [LiveItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
case total
|
||||
case totalPages = "total_pages"
|
||||
case items
|
||||
}
|
||||
|
||||
init(page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0, items: [LiveItem] = []) {
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
self.total = total
|
||||
self.totalPages = totalPages
|
||||
self.items = items
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增直播请求。
|
||||
struct LiveCreateRequest: Encodable, Sendable, Equatable {
|
||||
let scenicId: String
|
||||
let title: String
|
||||
let coverImg: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case title
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播控制请求。
|
||||
struct LiveControlRequest: Encodable, Sendable, Equatable {
|
||||
let liveId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播推流模式请求。
|
||||
struct LivePushModeRequest: Encodable, Sendable, Equatable {
|
||||
let liveId: Int
|
||||
let manualPushMode: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
case manualPushMode = "manual_push_mode"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册文件实体,对齐 Android `AliveAlbumFileEntity`。
|
||||
struct LiveAlbumFile: Codable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let url: String
|
||||
let type: Int
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
let id: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url
|
||||
case type
|
||||
case size
|
||||
case coverImg = "cover_img"
|
||||
case id
|
||||
}
|
||||
|
||||
init(url: String, type: Int, size: Int64, coverImg: String? = nil, id: Int = 0) {
|
||||
self.url = url.unescapedSlashes
|
||||
self.type = type
|
||||
self.size = size
|
||||
self.coverImg = coverImg?.unescapedSlashes
|
||||
self.id = id
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
url = try container.decodeLossyString(forKey: .url).unescapedSlashes
|
||||
type = try container.decodeLossyInt(forKey: .type) ?? 2
|
||||
size = try container.decodeLossyInt64(forKey: .size) ?? 0
|
||||
let cover = try container.decodeIfPresent(String.self, forKey: .coverImg)
|
||||
coverImg = cover?.unescapedSlashes
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册文件夹实体,对齐 Android `AliveAlbumFolderEntity`。
|
||||
struct LiveAlbumFolder: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let albumId: Int
|
||||
let name: String
|
||||
let creator: String
|
||||
let items: [LiveAlbumFile]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case albumId = "album_id"
|
||||
case name
|
||||
case creator
|
||||
case items
|
||||
}
|
||||
|
||||
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFile] = []) {
|
||||
self.id = id
|
||||
self.albumId = albumId
|
||||
self.name = name
|
||||
self.creator = creator
|
||||
self.items = items
|
||||
}
|
||||
|
||||
/// 标题兜底。
|
||||
var displayName: String { name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "暂无标题" : name }
|
||||
}
|
||||
|
||||
/// 直播相册列表响应。
|
||||
struct LiveAlbumFolderListResponse: Decodable, Sendable, Equatable {
|
||||
let items: [LiveAlbumFolder]
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let total: Int
|
||||
let totalPages: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
case total
|
||||
case totalPages = "total_pages"
|
||||
}
|
||||
|
||||
init(items: [LiveAlbumFolder] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
|
||||
self.items = items
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
self.total = total
|
||||
self.totalPages = totalPages
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播相册文件夹请求。
|
||||
struct LiveAlbumFolderCreateRequest: Encodable, Sendable, Equatable {
|
||||
let scenicId: String
|
||||
let name: String
|
||||
let items: [LiveAlbumFile]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
case items
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册文件夹请求。
|
||||
struct LiveAlbumFolderDeleteRequest: Encodable, Sendable, Equatable {
|
||||
let folderId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册文件请求。
|
||||
struct LiveAlbumFileDeleteRequest: Encodable, Sendable, Equatable {
|
||||
let folderId: Int
|
||||
let fileIds: [Int]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
case fileIds = "file_ids"
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地待上传直播相册素材。
|
||||
struct LivePickedMedia: Sendable, Equatable {
|
||||
let data: Data
|
||||
let fileName: String
|
||||
let size: Int64
|
||||
let width: Int
|
||||
let height: Int
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeLossyString(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)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) { return intValue }
|
||||
if let doubleValue = Double(text) { return Int(doubleValue) }
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeLossyInt64(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) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int64(text) { return intValue }
|
||||
if let doubleValue = Double(text) { return Int64(doubleValue) }
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int64(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var unescapedSlashes: String {
|
||||
replacingOccurrences(of: "\\/", with: "/")
|
||||
}
|
||||
}
|
||||
582
suixinkan/Features/Live/ViewModels/LiveViewModels.swift
Normal file
582
suixinkan/Features/Live/ViewModels/LiveViewModels.swift
Normal file
@ -0,0 +1,582 @@
|
||||
//
|
||||
// LiveViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 直播 OSS 上传协议,便于直播 ViewModel 在测试中注入替身。
|
||||
@MainActor
|
||||
protocol LiveOSSUploading {
|
||||
/// 上传直播封面并返回 OSS URL。
|
||||
func uploadLiveCover(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||
|
||||
/// 上传直播相册素材并返回 OSS URL。
|
||||
func uploadLiveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||
}
|
||||
|
||||
extension OSSUploadService: LiveOSSUploading {}
|
||||
|
||||
/// 直播列表页 ViewModel,负责分页、新建和直播控制。
|
||||
final class LiveManageViewModel {
|
||||
private(set) var items: [LiveItem] = []
|
||||
private(set) var isLoading = false
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var canLoadMore = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onLiveCreated: (() -> Void)?
|
||||
|
||||
private let currentScenicIdProvider: () -> Int
|
||||
private var currentPage = 1
|
||||
private var totalCount = 0
|
||||
private let pageSize = 10
|
||||
|
||||
/// 初始化直播管理 ViewModel。
|
||||
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) {
|
||||
self.currentScenicIdProvider = currentScenicIdProvider
|
||||
}
|
||||
|
||||
/// 首次加载直播列表。
|
||||
func loadInitial(api: any LiveServing) async {
|
||||
await load(reset: true, showLoading: true, api: api)
|
||||
}
|
||||
|
||||
/// 下拉刷新直播列表。
|
||||
func refresh(api: any LiveServing) async {
|
||||
isRefreshing = true
|
||||
notifyStateChange()
|
||||
await load(reset: true, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 滚动到底部附近时加载更多。
|
||||
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any LiveServing) async {
|
||||
guard lastVisibleIndex >= items.count - 3 else { return }
|
||||
await load(reset: false, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 新建直播并刷新第一页。
|
||||
func createLive(title: String, coverUrl: String, api: any LiveServing) async {
|
||||
let name = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else {
|
||||
onShowMessage?("请输入直播标题")
|
||||
return
|
||||
}
|
||||
guard !coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
onShowMessage?("请上传封面图片")
|
||||
return
|
||||
}
|
||||
guard let scenicId = scenicIdText() else { return }
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
try await api.createLive(scenicId: scenicId, title: name, coverImg: coverUrl)
|
||||
onLiveCreated?()
|
||||
await load(reset: true, showLoading: true, api: api)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "新增直播失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制直播地址。
|
||||
func copyPushURL(_ item: LiveItem) -> String {
|
||||
item.pushUrl
|
||||
}
|
||||
|
||||
/// 根据当前直播状态执行开始或暂停。
|
||||
func controlLive(_ item: LiveItem, api: any LiveServing) async {
|
||||
guard item.liveStatus.isOperable else { return }
|
||||
await performAndReload(api: api) {
|
||||
if item.liveStatus == .living {
|
||||
try await api.stopLive(liveId: item.id)
|
||||
} else if item.liveStatus == .pending || item.liveStatus == .paused {
|
||||
try await api.startLive(liveId: item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 结束直播。
|
||||
func finishLive(_ item: LiveItem, api: any LiveServing) async {
|
||||
guard item.liveStatus.isOperable else { return }
|
||||
await performAndReload(api: api) {
|
||||
try await api.finishLive(liveId: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func load(reset: Bool, showLoading: Bool, api: any LiveServing) async {
|
||||
guard let scenicId = scenicIdText() else { return }
|
||||
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 response = try await api.liveList(scenicId: scenicId, page: currentPage, pageSize: pageSize)
|
||||
totalCount = response.total
|
||||
items = reset ? response.items : items + response.items
|
||||
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.isEmpty ? "获取直播列表失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func performAndReload(api: any LiveServing, operation: () async throws -> Void) async {
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
try await operation()
|
||||
await load(reset: true, showLoading: true, api: api)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func scenicIdText() -> String? {
|
||||
let scenicId = currentScenicIdProvider()
|
||||
guard scenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return nil
|
||||
}
|
||||
return String(scenicId)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播详情页 ViewModel,负责详情加载、控制和推流模式切换。
|
||||
final class LiveDetailViewModel {
|
||||
private(set) var item: LiveItem?
|
||||
private(set) var pushMode: LivePushMode = .clarityFirst
|
||||
private(set) var isLoading = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
let liveId: Int
|
||||
|
||||
/// 初始化直播详情 ViewModel。
|
||||
init(liveId: Int) {
|
||||
self.liveId = liveId
|
||||
}
|
||||
|
||||
/// 加载直播详情。
|
||||
func load(api: any LiveServing) async {
|
||||
guard liveId > 0 else {
|
||||
onShowMessage?("直播ID无效")
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let detail = try await api.liveDetail(liveId: liveId)
|
||||
item = detail
|
||||
pushMode = LivePushMode(rawValue: detail.manualPushMode) ?? .clarityFirst
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "获取直播信息失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置推流模式。
|
||||
func changeMode(_ mode: LivePushMode, api: any LiveServing) async {
|
||||
guard pushMode != mode, item?.liveStatus.isOperable == true else { return }
|
||||
await performAndReload(api: api) {
|
||||
try await api.setPushMode(liveId: liveId, mode: mode.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前直播状态执行开始或暂停。
|
||||
func controlLive(api: any LiveServing) async {
|
||||
guard let item, item.liveStatus.isOperable else { return }
|
||||
await performAndReload(api: api) {
|
||||
if item.liveStatus == .living {
|
||||
try await api.stopLive(liveId: item.id)
|
||||
} else if item.liveStatus == .pending || item.liveStatus == .paused {
|
||||
try await api.startLive(liveId: item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 结束直播。
|
||||
func finishLive(api: any LiveServing) async {
|
||||
guard let item, item.liveStatus.isOperable else { return }
|
||||
await performAndReload(api: api) {
|
||||
try await api.finishLive(liveId: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func performAndReload(api: any LiveServing, operation: () async throws -> Void) async {
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
try await operation()
|
||||
await load(api: api)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册列表页 ViewModel,负责日期筛选、分页和文件夹删除。
|
||||
final class LiveAlbumListViewModel {
|
||||
private(set) var folders: [LiveAlbumFolder] = []
|
||||
private(set) var startDate: Date?
|
||||
private(set) var endDate: Date?
|
||||
private(set) var isLoading = false
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var canLoadMore = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private let currentScenicIdProvider: () -> Int
|
||||
private var currentPage = 1
|
||||
private var totalCount = 0
|
||||
private let pageSize = 10
|
||||
|
||||
/// 初始化直播相册列表 ViewModel。
|
||||
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) {
|
||||
self.currentScenicIdProvider = currentScenicIdProvider
|
||||
}
|
||||
|
||||
/// 首次加载相册列表。
|
||||
func loadInitial(api: any LiveServing) async {
|
||||
await load(reset: true, showLoading: true, api: api)
|
||||
}
|
||||
|
||||
/// 下拉刷新相册列表。
|
||||
func refresh(api: any LiveServing) async {
|
||||
isRefreshing = true
|
||||
notifyStateChange()
|
||||
await load(reset: true, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 滚动到底部附近时加载更多。
|
||||
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any LiveServing) async {
|
||||
guard lastVisibleIndex >= folders.count - 3 else { return }
|
||||
await load(reset: false, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 更新开始时间。
|
||||
func setStartDate(_ date: Date, api: any LiveServing) async {
|
||||
if let endDate, date.startOfDay > endDate.startOfDay {
|
||||
onShowMessage?("开始时间不能大于结束时间")
|
||||
return
|
||||
}
|
||||
startDate = date.startOfDay
|
||||
notifyStateChange()
|
||||
await load(reset: true, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 更新时间结束。
|
||||
func setEndDate(_ date: Date, api: any LiveServing) async {
|
||||
if let startDate, date.startOfDay < startDate.startOfDay {
|
||||
onShowMessage?("结束时间不能小于开始时间")
|
||||
return
|
||||
}
|
||||
endDate = date.startOfDay
|
||||
notifyStateChange()
|
||||
await load(reset: true, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 删除相册文件夹。
|
||||
func deleteFolder(_ folder: LiveAlbumFolder, api: any LiveServing) async {
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
try await api.deleteAlbumFolder(folderId: folder.id)
|
||||
await load(reset: true, showLoading: true, api: api)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "删除相册失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func load(reset: Bool, showLoading: Bool, api: any LiveServing) async {
|
||||
guard let scenicId = scenicIdText() else { return }
|
||||
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 response = try await api.albumList(
|
||||
scenicId: scenicId,
|
||||
startTime: startDate.map(Self.dateString),
|
||||
endTime: endDate.map(Self.dateString),
|
||||
page: currentPage,
|
||||
pageSize: pageSize
|
||||
)
|
||||
totalCount = response.total
|
||||
folders = reset ? response.items : folders + response.items
|
||||
canLoadMore = folders.count < totalCount
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
if !reset, currentPage > 1 { currentPage -= 1 }
|
||||
if reset {
|
||||
folders = []
|
||||
totalCount = 0
|
||||
canLoadMore = false
|
||||
}
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "获取直播相册失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func scenicIdText() -> String? {
|
||||
let scenicId = currentScenicIdProvider()
|
||||
guard scenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return nil
|
||||
}
|
||||
return String(scenicId)
|
||||
}
|
||||
|
||||
static func dateString(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册上传页 ViewModel,负责本地素材上传和创建相册。
|
||||
final class LiveAlbumAddViewModel {
|
||||
private(set) var title = ""
|
||||
private(set) var files: [LiveAlbumFile] = []
|
||||
private(set) var isLoading = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onCreateSuccess: (() -> Void)?
|
||||
|
||||
private let currentScenicIdProvider: () -> Int
|
||||
|
||||
/// 初始化直播相册上传 ViewModel。
|
||||
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) {
|
||||
self.currentScenicIdProvider = currentScenicIdProvider
|
||||
}
|
||||
|
||||
/// 更新作品名称,最多保留 20 个字符。
|
||||
func updateTitle(_ title: String) {
|
||||
self.title = String(title.prefix(20))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 上传本地素材并追加到列表。
|
||||
func addLocalMedia(_ media: LivePickedMedia, uploader: any LiveOSSUploading) async {
|
||||
guard let scenicId = scenicId() else { return }
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let url = try await uploader.uploadLiveAlbumFile(
|
||||
data: media.data,
|
||||
fileName: media.fileName,
|
||||
fileType: 2,
|
||||
scenicId: scenicId,
|
||||
onProgress: { _ in }
|
||||
)
|
||||
files.append(LiveAlbumFile(url: url, type: 2, size: media.size, coverImg: ""))
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除本地已上传素材。
|
||||
func deleteLocalFile(at index: Int) {
|
||||
guard files.indices.contains(index) else { return }
|
||||
files.remove(at: index)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 创建直播相册。
|
||||
func create(api: any LiveServing) async {
|
||||
let name = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else {
|
||||
onShowMessage?("请输入作品名称")
|
||||
return
|
||||
}
|
||||
guard !files.isEmpty else {
|
||||
onShowMessage?("请上传素材")
|
||||
return
|
||||
}
|
||||
guard let scenicId = scenicId().map(String.init) else { return }
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
try await api.createAlbumFolder(scenicId: scenicId, name: name, items: files)
|
||||
onCreateSuccess?()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "创建相册失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func scenicId() -> Int? {
|
||||
let scenicId = currentScenicIdProvider()
|
||||
guard scenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return nil
|
||||
}
|
||||
return scenicId
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册预览页 ViewModel,负责详情加载、翻页和删除当前素材。
|
||||
final class LiveAlbumPreviewViewModel {
|
||||
private(set) var files: [LiveAlbumFile] = []
|
||||
private(set) var currentIndex: Int
|
||||
private(set) var isLoading = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
let folderId: Int
|
||||
|
||||
/// 初始化直播相册预览 ViewModel。
|
||||
init(folderId: Int, startIndex: Int) {
|
||||
self.folderId = folderId
|
||||
self.currentIndex = max(startIndex, 0)
|
||||
}
|
||||
|
||||
/// 加载相册详情。
|
||||
func load(api: any LiveServing) async {
|
||||
guard folderId > 0 else {
|
||||
onShowMessage?("相册ID无效")
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let detail = try await api.albumFolderDetail(folderId: folderId)
|
||||
files = detail.items
|
||||
currentIndex = files.isEmpty ? 0 : min(currentIndex, files.count - 1)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "获取相册详情失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新当前预览位置。
|
||||
func updateCurrentIndex(_ index: Int) {
|
||||
currentIndex = max(0, min(index, max(files.count - 1, 0)))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 删除当前素材并重新加载详情。
|
||||
func deleteCurrentFile(api: any LiveServing) async {
|
||||
guard files.indices.contains(currentIndex) else { return }
|
||||
let file = files[currentIndex]
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
try await api.deleteAlbumFile(folderId: folderId, fileId: file.id)
|
||||
currentIndex = 0
|
||||
await load(api: api)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "删除素材失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
var startOfDay: Date {
|
||||
Calendar.current.startOfDay(for: self)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user