新增直播模块,包含推流管理与相册流程
将直播推流管理与直播相册从首页占位页迁移为完整实现,包含 alive album OSS 上传、首页路由与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
467
suixinkan/Features/Live/ViewModels/LiveViewModels.swift
Normal file
467
suixinkan/Features/Live/ViewModels/LiveViewModels.swift
Normal file
@ -0,0 +1,467 @@
|
||||
//
|
||||
// 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))"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user