feat: add live module and optimize report list

This commit is contained in:
2026-07-08 14:25:14 +08:00
parent 8ca8bcf948
commit 4a78a0c21a
14 changed files with 3712 additions and 102 deletions

View File

@ -0,0 +1,582 @@
//
// 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)
}
}