将直播推流管理与直播相册从首页占位页迁移为完整实现,包含 alive album OSS 上传、首页路由与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
468 lines
15 KiB
Swift
468 lines
15 KiB
Swift
//
|
||
// LiveViewModels.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import Foundation
|
||
import Observation
|
||
|
||
@MainActor
|
||
@Observable
|
||
/// 直播管理 ViewModel,负责直播列表、详情、创建和控制动作。
|
||
final class LiveManagementViewModel {
|
||
var items: [LiveEntity] = []
|
||
var detail: LiveEntity?
|
||
var loading = false
|
||
var loadingMore = false
|
||
var hasMore = false
|
||
var page = 1
|
||
var errorMessage: String?
|
||
|
||
@ObservationIgnored private let pageSize = 10
|
||
@ObservationIgnored private var total = 0
|
||
|
||
/// 进行中的直播数量。
|
||
var liveRunningCount: Int {
|
||
items.filter { $0.status == 2 }.count
|
||
}
|
||
|
||
/// 已结束的直播数量。
|
||
var liveFinishedCount: Int {
|
||
items.filter { $0.status == 3 }.count
|
||
}
|
||
|
||
/// 重新加载直播列表。
|
||
func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async {
|
||
guard let scenicId else {
|
||
reset()
|
||
return
|
||
}
|
||
if showLoading { loading = true }
|
||
errorMessage = nil
|
||
defer { loading = false }
|
||
do {
|
||
try await loadPage(api: api, scenicId: scenicId, page: 1)
|
||
} catch {
|
||
clearListAndDetail()
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 加载下一页直播列表。
|
||
func loadMore(api: any LiveServing, scenicId: Int?) async {
|
||
guard let scenicId, hasMore, !loadingMore else { return }
|
||
loadingMore = true
|
||
defer { loadingMore = false }
|
||
do {
|
||
try await loadPage(api: api, scenicId: scenicId, page: page + 1)
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 创建直播并刷新列表。
|
||
func create(api: any LiveServing, scenicId: Int?, title: String, coverURL: String) async throws {
|
||
guard let scenicId else {
|
||
throw LiveValidationError.missingScenic
|
||
}
|
||
let normalizedTitle = title.liveTrimmed
|
||
let normalizedCover = coverURL.liveTrimmed
|
||
guard !normalizedTitle.isEmpty else {
|
||
throw LiveValidationError.emptyTitle
|
||
}
|
||
guard normalizedCover.liveIsHTTPURL else {
|
||
throw LiveValidationError.invalidCoverURL
|
||
}
|
||
try await api.liveCreate(LiveCreateRequest(scenicId: String(scenicId), title: normalizedTitle, coverImg: normalizedCover))
|
||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||
}
|
||
|
||
/// 加载直播详情,失败时清空旧详情。
|
||
func loadDetail(api: any LiveServing, liveId: Int) async {
|
||
do {
|
||
detail = try await api.liveDetail(liveId: liveId)
|
||
} catch {
|
||
detail = nil
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 开始或暂停直播,并刷新列表。
|
||
func control(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws {
|
||
if item.status == 2 {
|
||
try await api.liveStop(liveId: item.id)
|
||
} else if item.status != 3 {
|
||
try await api.liveStart(liveId: item.id)
|
||
}
|
||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||
}
|
||
|
||
/// 结束直播并刷新列表。
|
||
func finish(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws {
|
||
try await api.liveFinish(liveId: item.id)
|
||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||
}
|
||
|
||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||
let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize)
|
||
if page == 1 {
|
||
items = response.items
|
||
} else {
|
||
let incomingById = Dictionary(uniqueKeysWithValues: response.items.map { ($0.id, $0) })
|
||
let kept = items.filter { incomingById[$0.id] == nil }
|
||
items = kept + response.items
|
||
}
|
||
self.page = page
|
||
total = response.total
|
||
hasMore = items.count < total
|
||
}
|
||
|
||
private func reset() {
|
||
clearListAndDetail()
|
||
loading = false
|
||
loadingMore = false
|
||
errorMessage = nil
|
||
}
|
||
|
||
private func clearListAndDetail() {
|
||
items = []
|
||
detail = nil
|
||
page = 1
|
||
total = 0
|
||
hasMore = false
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@Observable
|
||
/// 直播详情 ViewModel,负责直播详情页动作后的详情刷新。
|
||
final class LiveDetailViewModel {
|
||
var detail: LiveEntity
|
||
var loading = false
|
||
var actionInFlight = false
|
||
var errorMessage: String?
|
||
|
||
init(detail: LiveEntity) {
|
||
self.detail = detail
|
||
}
|
||
|
||
/// 刷新直播详情。
|
||
func refresh(api: any LiveServing, showLoading: Bool = true) async {
|
||
if showLoading { loading = true }
|
||
defer { loading = false }
|
||
do {
|
||
detail = try await api.liveDetail(liveId: detail.id)
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 开始或暂停当前直播。
|
||
func control(api: any LiveServing) async throws {
|
||
guard detail.status != 3 else { return }
|
||
actionInFlight = true
|
||
defer { actionInFlight = false }
|
||
if detail.status == 2 {
|
||
try await api.liveStop(liveId: detail.id)
|
||
} else {
|
||
try await api.liveStart(liveId: detail.id)
|
||
}
|
||
await refresh(api: api, showLoading: false)
|
||
}
|
||
|
||
/// 结束当前直播。
|
||
func finish(api: any LiveServing) async throws {
|
||
guard detail.status != 3 else { return }
|
||
actionInFlight = true
|
||
defer { actionInFlight = false }
|
||
try await api.liveFinish(liveId: detail.id)
|
||
await refresh(api: api, showLoading: false)
|
||
}
|
||
|
||
/// 切换推流模式。
|
||
func setPushMode(api: any LiveServing, mode: Int) async throws {
|
||
guard detail.manualPushMode != mode else { return }
|
||
actionInFlight = true
|
||
defer { actionInFlight = false }
|
||
try await api.liveSetPushMode(liveId: detail.id, mode: mode)
|
||
await refresh(api: api, showLoading: false)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@Observable
|
||
/// 直播相册 ViewModel,负责相册列表、筛选、新建和删除。
|
||
final class LiveAlbumViewModel {
|
||
var folders: [LiveAlbumFolderItem] = []
|
||
var startDate: Date?
|
||
var endDate: Date?
|
||
var loading = false
|
||
var loadingMore = false
|
||
var hasMore = false
|
||
var page = 1
|
||
var errorMessage: String?
|
||
|
||
@ObservationIgnored private let pageSize = 10
|
||
@ObservationIgnored private var total = 0
|
||
|
||
/// 设置开始时间并校验时间顺序。
|
||
func setStartDate(_ date: Date) throws {
|
||
if let endDate, Calendar.current.startOfDay(for: date) > Calendar.current.startOfDay(for: endDate) {
|
||
throw LiveValidationError.invalidDateRange
|
||
}
|
||
startDate = date
|
||
}
|
||
|
||
/// 设置结束时间并校验时间顺序。
|
||
func setEndDate(_ date: Date) throws {
|
||
if let startDate, Calendar.current.startOfDay(for: date) < Calendar.current.startOfDay(for: startDate) {
|
||
throw LiveValidationError.invalidDateRange
|
||
}
|
||
endDate = date
|
||
}
|
||
|
||
/// 清除日期筛选。
|
||
func clearDateFilters() {
|
||
startDate = nil
|
||
endDate = nil
|
||
}
|
||
|
||
/// 重新加载直播相册列表。
|
||
func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async {
|
||
guard let scenicId else {
|
||
reset()
|
||
return
|
||
}
|
||
if showLoading { loading = true }
|
||
errorMessage = nil
|
||
defer { loading = false }
|
||
do {
|
||
try await loadPage(api: api, scenicId: scenicId, page: 1)
|
||
} catch {
|
||
clearFolders()
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 加载下一页直播相册。
|
||
func loadMore(api: any LiveServing, scenicId: Int?) async {
|
||
guard let scenicId, hasMore, !loadingMore else { return }
|
||
loadingMore = true
|
||
let nextPage = page + 1
|
||
defer { loadingMore = false }
|
||
do {
|
||
try await loadPage(api: api, scenicId: scenicId, page: nextPage)
|
||
} catch {
|
||
page = max(1, nextPage - 1)
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 删除直播相册并刷新。
|
||
func deleteFolder(api: any LiveServing, folderId: Int, scenicId: Int?) async throws {
|
||
try await api.liveAlbumDeleteFolder(folderId: folderId)
|
||
await reload(api: api, scenicId: scenicId, showLoading: false)
|
||
}
|
||
|
||
private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws {
|
||
let response = try await api.liveAlbumList(
|
||
scenicId: scenicId,
|
||
startTime: startDate?.liveDayText,
|
||
endTime: endDate?.liveDayText,
|
||
page: page,
|
||
pageSize: pageSize
|
||
)
|
||
if page == 1 {
|
||
folders = response.items
|
||
} else {
|
||
let incomingById = Set(response.items.map(\.id))
|
||
folders = folders.filter { !incomingById.contains($0.id) } + response.items
|
||
}
|
||
self.page = page
|
||
total = response.total
|
||
hasMore = folders.count < total
|
||
}
|
||
|
||
private func reset() {
|
||
clearFolders()
|
||
loading = false
|
||
loadingMore = false
|
||
errorMessage = nil
|
||
}
|
||
|
||
private func clearFolders() {
|
||
folders = []
|
||
page = 1
|
||
total = 0
|
||
hasMore = false
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@Observable
|
||
/// 新建直播相册 ViewModel,负责本地素材上传和创建相册。
|
||
final class LiveAlbumCreateViewModel {
|
||
var name = ""
|
||
var localFiles: [LiveAlbumLocalUploadFile] = []
|
||
var submitting = false
|
||
var uploadProgress = 0
|
||
var errorMessage: String?
|
||
|
||
/// 添加本地待上传素材。
|
||
func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) {
|
||
localFiles.append(contentsOf: files)
|
||
}
|
||
|
||
/// 删除本地待上传素材。
|
||
func removeLocalFile(id: UUID) {
|
||
localFiles.removeAll { $0.id == id }
|
||
}
|
||
|
||
/// 上传本地素材并创建直播相册。
|
||
func submit(scenicId: Int?, api: any LiveServing, uploader: any OSSUploadServing) async throws {
|
||
guard let scenicId else { throw LiveValidationError.missingScenic }
|
||
let normalizedName = name.liveTrimmed
|
||
guard !normalizedName.isEmpty else { throw LiveValidationError.emptyAlbumName }
|
||
guard !localFiles.isEmpty else { throw LiveValidationError.emptyAlbumFiles }
|
||
guard !submitting else { return }
|
||
|
||
submitting = true
|
||
uploadProgress = 0
|
||
defer { submitting = false }
|
||
|
||
var uploadedItems: [LiveAlbumCreateFileItem] = []
|
||
let count = max(localFiles.count, 1)
|
||
do {
|
||
for index in localFiles.indices {
|
||
let file = localFiles[index]
|
||
let url = try await uploader.uploadAliveAlbumFile(
|
||
data: file.data,
|
||
fileName: file.fileName,
|
||
fileType: file.fileType,
|
||
scenicId: scenicId
|
||
) { progress in
|
||
Task { @MainActor in
|
||
let base = Double(index) / Double(count)
|
||
let step = Double(progress) / Double(count)
|
||
self.uploadProgress = min(99, Int((base + step / 100) * 100))
|
||
}
|
||
}
|
||
localFiles[index].uploadedURL = url
|
||
uploadedItems.append(
|
||
LiveAlbumCreateFileItem(url: url, type: file.fileType, size: file.size, coverImg: nil)
|
||
)
|
||
}
|
||
try await api.liveAlbumCreateFolder(
|
||
LiveAlbumCreateFolderRequest(scenicId: String(scenicId), name: normalizedName, items: uploadedItems)
|
||
)
|
||
name = ""
|
||
localFiles = []
|
||
uploadProgress = 100
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
throw error
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@Observable
|
||
/// 直播相册预览 ViewModel,负责加载相册详情和删除当前素材。
|
||
final class LiveAlbumPreviewViewModel {
|
||
let folderId: Int
|
||
var folder: LiveAlbumFolderItem?
|
||
var files: [LiveAlbumFileItem] = []
|
||
var currentIndex: Int
|
||
var loading = false
|
||
var errorMessage: String?
|
||
|
||
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
|
||
self.folderId = folderId
|
||
currentIndex = max(startIndex, 0)
|
||
folder = summary
|
||
files = summary?.items ?? []
|
||
}
|
||
|
||
/// 加载相册详情。
|
||
func load(api: any LiveServing) async {
|
||
loading = true
|
||
defer { loading = false }
|
||
do {
|
||
let detail = try await api.liveAlbumFolderDetail(folderId: folderId)
|
||
folder = detail
|
||
files = detail.items
|
||
if currentIndex >= files.count {
|
||
currentIndex = max(files.count - 1, 0)
|
||
}
|
||
} catch {
|
||
files = []
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
/// 删除当前预览素材并刷新详情。
|
||
func deleteCurrentFile(api: any LiveServing) async throws {
|
||
guard files.indices.contains(currentIndex) else { return }
|
||
let file = files[currentIndex]
|
||
try await api.liveAlbumDeleteFiles(folderId: folderId, fileIds: [file.id])
|
||
await load(api: api)
|
||
}
|
||
}
|
||
|
||
/// 直播模块校验错误。
|
||
enum LiveValidationError: LocalizedError, Equatable {
|
||
case missingScenic
|
||
case emptyTitle
|
||
case invalidCoverURL
|
||
case invalidDateRange
|
||
case emptyAlbumName
|
||
case emptyAlbumFiles
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .missingScenic:
|
||
"当前账号缺少景区信息"
|
||
case .emptyTitle:
|
||
"请输入直播标题"
|
||
case .invalidCoverURL:
|
||
"封面图地址需以 http:// 或 https:// 开头"
|
||
case .invalidDateRange:
|
||
"开始时间不能大于结束时间"
|
||
case .emptyAlbumName:
|
||
"请输入相册名称"
|
||
case .emptyAlbumFiles:
|
||
"请上传素材"
|
||
}
|
||
}
|
||
}
|
||
|
||
extension Date {
|
||
/// 直播接口使用的日期格式。
|
||
var liveDayText: String {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "zh_CN")
|
||
formatter.dateFormat = "yyyy-MM-dd"
|
||
return formatter.string(from: self)
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var liveIsHTTPURL: Bool {
|
||
let lower = liveTrimmed.lowercased()
|
||
return lower.hasPrefix("http://") || lower.hasPrefix("https://")
|
||
}
|
||
}
|
||
|
||
extension Int64 {
|
||
/// 直播时长展示文案。
|
||
var liveDurationText: String {
|
||
let totalSeconds = Swift.max(Int(self), 0)
|
||
let hours = totalSeconds / 3600
|
||
let minutes = (totalSeconds % 3600) / 60
|
||
let seconds = totalSeconds % 60
|
||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||
}
|
||
}
|