Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md. Co-authored-by: Cursor <cursoragent@cursor.com>
475 lines
16 KiB
Swift
475 lines
16 KiB
Swift
//
|
||
// 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))"
|
||
}
|
||
}
|