// // LiveViewModels.swift // suixinkan // // Created by Codex on 2026/6/25. // import Foundation @MainActor /// 直播管理 ViewModel,负责直播列表、详情、创建和控制动作。 final class LiveManagementViewModel { var onChange: (() -> Void)? var items: [LiveEntity] = [] { didSet { onChange?() } } var detail: LiveEntity? { didSet { onChange?() } } var loading = false { didSet { onChange?() } } var loadingMore = false { didSet { onChange?() } } var hasMore = false { didSet { onChange?() } } var page = 1 { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } private let pageSize = 10 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) } /// 加载Page数据。 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 /// 直播详情 ViewModel,负责直播详情页动作后的详情刷新。 final class LiveDetailViewModel { var onChange: (() -> Void)? var detail: LiveEntity { didSet { onChange?() } } var loading = false { didSet { onChange?() } } var actionInFlight = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } /// 初始化实例。 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 /// 直播相册 ViewModel,负责相册列表、筛选、新建和删除。 final class LiveAlbumViewModel { var onChange: (() -> Void)? var folders: [LiveAlbumFolderItem] = [] { didSet { onChange?() } } var startDate: Date? { didSet { onChange?() } } var endDate: Date? { didSet { onChange?() } } var loading = false { didSet { onChange?() } } var loadingMore = false { didSet { onChange?() } } var hasMore = false { didSet { onChange?() } } var page = 1 { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } private let pageSize = 10 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) } /// 加载Page数据。 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 } /// 清空Folders。 private func clearFolders() { folders = [] page = 1 total = 0 hasMore = false } } @MainActor /// 新建直播相册 ViewModel,负责本地素材上传和创建相册。 final class LiveAlbumCreateViewModel { var onChange: (() -> Void)? var name = "" { didSet { onChange?() } } var localFiles: [LiveAlbumLocalUploadFile] = [] { didSet { onChange?() } } var submitting = false { didSet { onChange?() } } var uploadProgress = 0 { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } /// 添加本地待上传素材。 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 /// 直播相册预览 ViewModel,负责加载相册详情和删除当前素材。 final class LiveAlbumPreviewViewModel { var onChange: (() -> Void)? let folderId: Int var folder: LiveAlbumFolderItem? { didSet { onChange?() } } var files: [LiveAlbumFileItem] = [] { didSet { onChange?() } } var currentIndex: Int { didSet { onChange?() } } var loading = false { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } /// 初始化实例。 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))" } }