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