新增直播模块,包含推流管理与相册流程

将直播推流管理与直播相册从首页占位页迁移为完整实现,包含 alive album OSS 上传、首页路由与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 14:38:52 +08:00
parent c39c3d3c75
commit fcb692b56a
23 changed files with 2811 additions and 11 deletions

View 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))"
}
}