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,194 @@
//
// LiveAPI.swift
// suixinkan
//
import Foundation
/// manual-live view-album
@MainActor
protocol LiveServing {
///
func liveList(scenicId: String, page: Int, pageSize: Int) async throws -> LiveListResponse
///
func liveDetail(liveId: Int) async throws -> LiveItem
///
func createLive(scenicId: String, title: String, coverImg: String) async throws
///
func startLive(liveId: Int) async throws
///
func stopLive(liveId: Int) async throws
///
func finishLive(liveId: Int) async throws
///
func setPushMode(liveId: Int, mode: Int) async throws
///
func albumList(scenicId: String, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
///
func createAlbumFolder(scenicId: String, name: String, items: [LiveAlbumFile]) async throws
///
func deleteAlbumFolder(folderId: Int) async throws
///
func albumFolderDetail(folderId: Int) async throws -> LiveAlbumFolder
///
func deleteAlbumFile(folderId: Int, fileId: Int) async throws
}
/// API Android `NetworkApi` alive/manual-live view-album
@MainActor
final class LiveAPI: LiveServing {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
///
func liveList(scenicId: String, page: Int = 1, pageSize: Int = 10) async throws -> LiveListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/manual-live/list",
queryItems: [
URLQueryItem(name: "scenic_id", value: scenicId),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
)
)
}
///
func liveDetail(liveId: Int) async throws -> LiveItem {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/manual-live/detail",
queryItems: [URLQueryItem(name: "live_id", value: String(liveId))]
)
)
}
///
func createLive(scenicId: String, title: String, coverImg: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/manual-live/create",
body: LiveCreateRequest(scenicId: scenicId, title: title, coverImg: coverImg)
)
)
}
///
func startLive(liveId: Int) async throws {
try await controlLive(path: "/api/app/manual-live/start", liveId: liveId)
}
///
func stopLive(liveId: Int) async throws {
try await controlLive(path: "/api/app/manual-live/stop", liveId: liveId)
}
///
func finishLive(liveId: Int) async throws {
try await controlLive(path: "/api/app/manual-live/finish", liveId: liveId)
}
///
func setPushMode(liveId: Int, mode: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/manual-live/set-push-mode",
body: LivePushModeRequest(liveId: liveId, manualPushMode: mode)
)
)
}
///
func albumList(
scenicId: String,
startTime: String? = nil,
endTime: String? = nil,
page: Int = 1,
pageSize: Int = 10
) async throws -> LiveAlbumFolderListResponse {
var items = [
URLQueryItem(name: "scenic_id", value: scenicId),
URLQueryItem(name: "page", value: String(max(page, 1))),
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
]
if let startTime, !startTime.isEmpty {
items.append(URLQueryItem(name: "start_time", value: startTime))
}
if let endTime, !endTime.isEmpty {
items.append(URLQueryItem(name: "end_time", value: endTime))
}
return try await client.send(
APIRequest(method: .get, path: "/api/app/view-album/folders", queryItems: items)
)
}
///
func createAlbumFolder(scenicId: String, name: String, items: [LiveAlbumFile]) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/view-album/create-folder",
body: LiveAlbumFolderCreateRequest(scenicId: scenicId, name: name, items: items)
)
)
}
///
func deleteAlbumFolder(folderId: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/view-album/delete-folder",
body: LiveAlbumFolderDeleteRequest(folderId: folderId)
)
)
}
///
func albumFolderDetail(folderId: Int) async throws -> LiveAlbumFolder {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/view-album/folder-detail",
queryItems: [URLQueryItem(name: "folder_id", value: String(folderId))]
)
)
}
///
func deleteAlbumFile(folderId: Int, fileId: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/view-album/delete-files",
body: LiveAlbumFileDeleteRequest(folderId: folderId, fileIds: [fileId])
)
)
}
private func controlLive(path: String, liveId: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: path, body: LiveControlRequest(liveId: liveId))
)
}
}

View File

@ -0,0 +1,370 @@
//
// LiveModels.swift
// suixinkan
//
import Foundation
/// Android `AliveEntity.status`
enum LiveStatus: Int, Sendable, Equatable {
case pending = 1
case living = 2
case paused = 3
case finished = 4
case unknown = 0
///
var isOperable: Bool { self != .finished }
}
/// Android `manualPushMode`
enum LivePushMode: Int, CaseIterable, Sendable, Equatable {
case clarityFirst = 1
case smoothFirst = 2
///
var title: String {
switch self {
case .clarityFirst: "清晰度优先"
case .smoothFirst: "流畅度优先"
}
}
}
/// Android `AliveEntity`
struct LiveItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let title: String
let coverImg: String
let pushUrl: String
let startTime: Int64
let endTime: Int64
let duration: Int64
let status: Int
let statusLabel: String
let manualPushMode: Int
let manualPushState: Int
let viewsCount: Int
enum CodingKeys: String, CodingKey {
case id
case title
case coverImg = "cover_img"
case pushUrl = "push_url"
case startTime = "start_time"
case endTime = "end_time"
case duration
case status
case statusLabel = "status_label"
case manualPushMode = "manual_push_mode"
case manualPushState = "manual_push_state"
case legacyManualPushMode = "manaul_push_mode"
case legacyManualPushState = "manaul_push_state"
case viewsCount = "views_count"
}
init(
id: Int = 0,
title: String = "",
coverImg: String = "",
pushUrl: String = "",
startTime: Int64 = 0,
endTime: Int64 = 0,
duration: Int64 = 0,
status: Int = 0,
statusLabel: String = "",
manualPushMode: Int = 1,
manualPushState: Int = 0,
viewsCount: Int = 0
) {
self.id = id
self.title = title
self.coverImg = coverImg
self.pushUrl = pushUrl
self.startTime = startTime
self.endTime = endTime
self.duration = duration
self.status = status
self.statusLabel = statusLabel
self.manualPushMode = manualPushMode
self.manualPushState = manualPushState
self.viewsCount = viewsCount
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
title = try container.decodeLossyString(forKey: .title)
coverImg = try container.decodeLossyString(forKey: .coverImg).unescapedSlashes
pushUrl = try container.decodeLossyString(forKey: .pushUrl).unescapedSlashes
startTime = try container.decodeLossyInt64(forKey: .startTime) ?? 0
endTime = try container.decodeLossyInt64(forKey: .endTime) ?? 0
duration = try container.decodeLossyInt64(forKey: .duration) ?? 0
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusLabel = try container.decodeLossyString(forKey: .statusLabel)
manualPushMode = try container.decodeLossyInt(forKey: .manualPushMode)
?? container.decodeLossyInt(forKey: .legacyManualPushMode)
?? 1
manualPushState = try container.decodeLossyInt(forKey: .manualPushState)
?? container.decodeLossyInt(forKey: .legacyManualPushState)
?? 0
viewsCount = try container.decodeLossyInt(forKey: .viewsCount) ?? 0
}
///
var liveStatus: LiveStatus { LiveStatus(rawValue: status) ?? .unknown }
///
var displayTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "暂无标题" : title }
///
var controlTitle: String { liveStatus == .living ? "暂停" : "开始" }
}
/// Android `ItemListResponse<AliveEntity>`
struct LiveListResponse: Decodable, Sendable, Equatable {
let page: Int
let pageSize: Int
let total: Int
let totalPages: Int
let items: [LiveItem]
enum CodingKeys: String, CodingKey {
case page
case pageSize = "page_size"
case total
case totalPages = "total_pages"
case items
}
init(page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0, items: [LiveItem] = []) {
self.page = page
self.pageSize = pageSize
self.total = total
self.totalPages = totalPages
self.items = items
}
}
///
struct LiveCreateRequest: Encodable, Sendable, Equatable {
let scenicId: String
let title: String
let coverImg: String
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case title
case coverImg = "cover_img"
}
}
///
struct LiveControlRequest: Encodable, Sendable, Equatable {
let liveId: Int
enum CodingKeys: String, CodingKey {
case liveId = "live_id"
}
}
///
struct LivePushModeRequest: Encodable, Sendable, Equatable {
let liveId: Int
let manualPushMode: Int
enum CodingKeys: String, CodingKey {
case liveId = "live_id"
case manualPushMode = "manual_push_mode"
}
}
/// Android `AliveAlbumFileEntity`
struct LiveAlbumFile: Codable, Sendable, Equatable, Hashable, Identifiable {
let url: String
let type: Int
let size: Int64
let coverImg: String?
let id: Int
enum CodingKeys: String, CodingKey {
case url
case type
case size
case coverImg = "cover_img"
case id
}
init(url: String, type: Int, size: Int64, coverImg: String? = nil, id: Int = 0) {
self.url = url.unescapedSlashes
self.type = type
self.size = size
self.coverImg = coverImg?.unescapedSlashes
self.id = id
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
url = try container.decodeLossyString(forKey: .url).unescapedSlashes
type = try container.decodeLossyInt(forKey: .type) ?? 2
size = try container.decodeLossyInt64(forKey: .size) ?? 0
let cover = try container.decodeIfPresent(String.self, forKey: .coverImg)
coverImg = cover?.unescapedSlashes
id = try container.decodeLossyInt(forKey: .id) ?? 0
}
}
/// Android `AliveAlbumFolderEntity`
struct LiveAlbumFolder: Decodable, Sendable, Equatable, Hashable, Identifiable {
let id: Int
let albumId: Int
let name: String
let creator: String
let items: [LiveAlbumFile]
enum CodingKeys: String, CodingKey {
case id
case albumId = "album_id"
case name
case creator
case items
}
init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFile] = []) {
self.id = id
self.albumId = albumId
self.name = name
self.creator = creator
self.items = items
}
///
var displayName: String { name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "暂无标题" : name }
}
///
struct LiveAlbumFolderListResponse: Decodable, Sendable, Equatable {
let items: [LiveAlbumFolder]
let page: Int
let pageSize: Int
let total: Int
let totalPages: Int
enum CodingKeys: String, CodingKey {
case items
case page
case pageSize = "page_size"
case total
case totalPages = "total_pages"
}
init(items: [LiveAlbumFolder] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) {
self.items = items
self.page = page
self.pageSize = pageSize
self.total = total
self.totalPages = totalPages
}
}
///
struct LiveAlbumFolderCreateRequest: Encodable, Sendable, Equatable {
let scenicId: String
let name: String
let items: [LiveAlbumFile]
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case name
case items
}
}
///
struct LiveAlbumFolderDeleteRequest: Encodable, Sendable, Equatable {
let folderId: Int
enum CodingKeys: String, CodingKey {
case folderId = "folder_id"
}
}
///
struct LiveAlbumFileDeleteRequest: Encodable, Sendable, Equatable {
let folderId: Int
let fileIds: [Int]
enum CodingKeys: String, CodingKey {
case folderId = "folder_id"
case fileIds = "file_ids"
}
}
///
struct LivePickedMedia: Sendable, Equatable {
let data: Data
let fileName: String
let size: Int64
let width: Int
let height: Int
}
private extension KeyedDecodingContainer {
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
return ""
}
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) { return intValue }
if let doubleValue = Double(text) { return Int(doubleValue) }
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
func decodeLossyInt64(forKey key: Key) throws -> Int64? {
if let value = try? decodeIfPresent(Int64.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return Int64(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int64(text) { return intValue }
if let doubleValue = Double(text) { return Int64(doubleValue) }
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int64(value)
}
return nil
}
}
private extension String {
var unescapedSlashes: String {
replacingOccurrences(of: "\\/", with: "/")
}
}

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