// // 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) } }