Add Live module with stream management and album flows.
Migrate live stream management and live album from home placeholders, including alive album OSS upload, home routing, and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -33,6 +33,7 @@ struct RootView: View {
|
||||
@State private var messageCenterAPI: MessageCenterAPI
|
||||
@State private var scenicQueueAPI: ScenicQueueAPI
|
||||
@State private var scenicQueueRuntime = ScenicQueueRuntime()
|
||||
@State private var liveAPI: LiveAPI
|
||||
@State private var taskAPI: TaskAPI
|
||||
@State private var projectAPI: ProjectAPI
|
||||
@State private var scheduleAPI: ScheduleAPI
|
||||
@ -64,6 +65,7 @@ struct RootView: View {
|
||||
_scenicSettlementAPI = State(initialValue: ScenicSettlementAPI(client: apiClient))
|
||||
_messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient))
|
||||
_scenicQueueAPI = State(initialValue: ScenicQueueAPI(client: apiClient))
|
||||
_liveAPI = State(initialValue: LiveAPI(client: apiClient))
|
||||
_taskAPI = State(initialValue: TaskAPI(client: apiClient))
|
||||
_projectAPI = State(initialValue: ProjectAPI(client: apiClient))
|
||||
_scheduleAPI = State(initialValue: ScheduleAPI(client: apiClient))
|
||||
@ -113,6 +115,7 @@ struct RootView: View {
|
||||
.environment(messageCenterAPI)
|
||||
.environment(scenicQueueAPI)
|
||||
.environment(scenicQueueRuntime)
|
||||
.environment(liveAPI)
|
||||
.environment(taskAPI)
|
||||
.environment(projectAPI)
|
||||
.environment(scheduleAPI)
|
||||
|
||||
@ -28,6 +28,9 @@ protocol OSSUploadServing {
|
||||
/// 上传相册文件,并返回可访问的 OSS 文件 URL。
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||
|
||||
/// 上传直播相册文件,并返回可访问的 OSS 文件 URL。
|
||||
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||
|
||||
/// 上传任务附件,并返回可访问的 OSS 文件 URL。
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||
|
||||
@ -75,6 +78,11 @@ final class OSSUploadService {
|
||||
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "album_upload", onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传直播相册文件,并返回可访问的 OSS 文件 URL。
|
||||
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "alive_album", onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传任务附件,并返回可访问的 OSS 文件 URL。
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "task_upload", onProgress: onProgress)
|
||||
@ -196,6 +204,8 @@ enum OSSUploadPolicy {
|
||||
return "cloud_driver/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "album_upload":
|
||||
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "alive_album":
|
||||
return "live_albums/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "project":
|
||||
return "project/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "punch_point":
|
||||
|
||||
@ -38,6 +38,7 @@ Upload 模块负责 App 内通用文件上传能力,当前主要服务个人
|
||||
- `task_upload`:`task_upload/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `cloud_driver`:`cloud_driver/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `album_upload`:`album/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `alive_album`:`live_albums/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `punch_point`:`punch_point/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `scenic_apply`:`scenic_apply/yyyyMMdd/scenicId/uuid_fileName`
|
||||
|
||||
|
||||
@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
||||
|
||||
## 后续迁移
|
||||
|
||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管。
|
||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管。
|
||||
|
||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||
|
||||
@ -60,6 +60,8 @@ enum HomeRoute: Hashable {
|
||||
case scenicSettlementReview
|
||||
case messageCenter
|
||||
case queueManagement
|
||||
case liveManagement
|
||||
case liveAlbum
|
||||
case modulePlaceholder(uri: String, title: String)
|
||||
}
|
||||
|
||||
|
||||
@ -139,6 +139,10 @@ enum HomeMenuRouter {
|
||||
return .destination(.messageCenter)
|
||||
case "/scenic-queue", "queue_management":
|
||||
return .destination(.queueManagement)
|
||||
case "live_stream_management":
|
||||
return .destination(.liveManagement)
|
||||
case "live_album":
|
||||
return .destination(.liveAlbum)
|
||||
case "store", "more_functions":
|
||||
return .destination(.moreFunctions)
|
||||
case "fly", "pilot_controller":
|
||||
@ -147,9 +151,7 @@ enum HomeMenuRouter {
|
||||
title: title.isEmpty ? self.title(for: uri) : title,
|
||||
reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。"
|
||||
)
|
||||
case "live_stream_management",
|
||||
"live_album",
|
||||
"operating-area",
|
||||
case "operating-area",
|
||||
"pilot_cert":
|
||||
let resolvedTitle = title.isEmpty ? self.title(for: uri) : title
|
||||
return .destination(.modulePlaceholder(uri: uri, title: resolvedTitle))
|
||||
|
||||
@ -96,6 +96,10 @@ extension HomeRoute {
|
||||
MessageCenterView()
|
||||
case .queueManagement:
|
||||
QueueManagementView()
|
||||
case .liveManagement:
|
||||
LiveManagementView()
|
||||
case .liveAlbum:
|
||||
LiveAlbumView()
|
||||
case let .modulePlaceholder(uri, title):
|
||||
HomeMigrationModuleView(title: title, uri: uri)
|
||||
}
|
||||
|
||||
161
suixinkan/Features/Live/API/LiveAPI.swift
Normal file
161
suixinkan/Features/Live/API/LiveAPI.swift
Normal file
@ -0,0 +1,161 @@
|
||||
//
|
||||
// LiveAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 直播服务协议,定义直播管理和直播相册接口能力。
|
||||
@MainActor
|
||||
protocol LiveServing {
|
||||
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse
|
||||
func liveDetail(liveId: Int) async throws -> LiveEntity
|
||||
func liveCreate(_ request: LiveCreateRequest) async throws
|
||||
func liveStart(liveId: Int) async throws
|
||||
func liveStop(liveId: Int) async throws
|
||||
func liveFinish(liveId: Int) async throws
|
||||
func liveSetPushMode(liveId: Int, mode: Int) async throws
|
||||
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse
|
||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws
|
||||
func liveAlbumDeleteFolder(folderId: Int) async throws
|
||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem
|
||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播 API,封装手动直播和直播相册网络请求。
|
||||
final class LiveAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
|
||||
/// 初始化直播 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取直播列表。
|
||||
func liveList(scenicId: Int, 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: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取直播详情。
|
||||
func liveDetail(liveId: Int) async throws -> LiveEntity {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/manual-live/detail",
|
||||
queryItems: [URLQueryItem(name: "live_id", value: String(liveId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建直播。
|
||||
func liveCreate(_ request: LiveCreateRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/create", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 开始直播。
|
||||
func liveStart(liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/start", body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 暂停直播。
|
||||
func liveStop(liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/stop", body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 结束直播。
|
||||
func liveFinish(liveId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/manual-live/finish", body: LiveControlRequest(liveId: liveId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 切换直播推流模式。
|
||||
func liveSetPushMode(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 liveAlbumList(
|
||||
scenicId: Int,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
) async throws -> LiveAlbumFolderListResponse {
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
if let startTime = startTime?.liveTrimmed, !startTime.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime = endTime?.liveTrimmed, !endTime.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/view-album/folders", queryItems: queryItems)
|
||||
)
|
||||
}
|
||||
|
||||
/// 创建直播相册。
|
||||
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/view-album/create-folder", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除直播相册。
|
||||
func liveAlbumDeleteFolder(folderId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/view-album/delete-folder", body: LiveAlbumDeleteFolderRequest(folderId: folderId))
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取直播相册详情。
|
||||
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/view-album/folder-detail",
|
||||
queryItems: [URLQueryItem(name: "folder_id", value: String(folderId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除直播相册内的素材。
|
||||
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/view-album/delete-files",
|
||||
body: LiveAlbumDeleteFilesRequest(folderId: folderId, fileIds: fileIds)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveAPI: LiveServing {}
|
||||
19
suixinkan/Features/Live/Live.md
Normal file
19
suixinkan/Features/Live/Live.md
Normal file
@ -0,0 +1,19 @@
|
||||
# 直播模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`Features/Live` 承接首页 `live_stream_management` 和 `live_album` 权限入口,负责手动直播管理和直播相册素材管理。
|
||||
|
||||
## 代码结构
|
||||
|
||||
- `LiveAPI`:封装 `/api/app/manual-live/...` 和 `/api/app/view-album/...` 接口。
|
||||
- `LiveManagementViewModel`:管理直播列表分页、创建、开始/暂停、结束和详情失败清理。
|
||||
- `LiveAlbumViewModel`:管理直播相册日期筛选、分页和删除相册。
|
||||
- `LiveAlbumCreateViewModel`:管理本地图片/视频上传到 OSS 后创建直播相册。
|
||||
- `LiveAlbumPreviewViewModel`:进入相册预览时加载相册详情,并支持删除单个素材。
|
||||
|
||||
## 业务边界
|
||||
|
||||
本模块不接入播放器或推流 SDK。直播详情展示推流地址、复制和控制能力;视频素材预览优先展示接口返回的封面,缺少封面时展示视频占位,并提供打开或复制原始 URL。
|
||||
|
||||
直播封面创建沿用旧 iOS 的 URL 输入。直播相册素材通过 `PhotosPicker` 选择后,使用 `OSSUploadService.uploadAliveAlbumFile` 上传到 `live_albums/yyyyMMdd/scenicId/...`,再把最终 URL 提交给创建相册接口。
|
||||
386
suixinkan/Features/Live/Models/LiveModels.swift
Normal file
386
suixinkan/Features/Live/Models/LiveModels.swift
Normal file
@ -0,0 +1,386 @@
|
||||
//
|
||||
// LiveModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 直播列表响应实体。
|
||||
struct LiveListResponse: Decodable {
|
||||
let items: [LiveEntity]
|
||||
let total: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
case total
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
|
||||
init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) {
|
||||
self.items = items
|
||||
self.total = total
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? []
|
||||
total = try container.liveDecodeLossyInt(forKey: .total) ?? 0
|
||||
page = try container.liveDecodeLossyInt(forKey: .page) ?? 1
|
||||
pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播实体,表示一场手动直播。
|
||||
struct LiveEntity: Decodable, Identifiable, Equatable {
|
||||
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 = "manaul_push_mode"
|
||||
case manualPushState = "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.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
title = try container.liveDecodeLossyString(forKey: .title)
|
||||
coverImg = try container.liveDecodeLossyString(forKey: .coverImg)
|
||||
pushUrl = try container.liveDecodeLossyString(forKey: .pushUrl)
|
||||
startTime = Int64(try container.liveDecodeLossyInt(forKey: .startTime) ?? 0)
|
||||
endTime = Int64(try container.liveDecodeLossyInt(forKey: .endTime) ?? 0)
|
||||
duration = Int64(try container.liveDecodeLossyInt(forKey: .duration) ?? 0)
|
||||
status = try container.liveDecodeLossyInt(forKey: .status) ?? 0
|
||||
statusLabel = try container.liveDecodeLossyString(forKey: .statusLabel)
|
||||
manualPushMode = try container.liveDecodeLossyInt(forKey: .manualPushMode) ?? 1
|
||||
manualPushState = try container.liveDecodeLossyInt(forKey: .manualPushState) ?? 0
|
||||
viewsCount = try container.liveDecodeLossyInt(forKey: .viewsCount) ?? 0
|
||||
}
|
||||
|
||||
var displayStatus: String {
|
||||
statusLabel.liveNonEmpty ?? "状态\(status)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播请求实体。
|
||||
struct LiveCreateRequest: Encodable, 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, Equatable {
|
||||
let liveId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播推流模式切换请求实体。
|
||||
struct LivePushModeRequest: Encodable, Equatable {
|
||||
let liveId: Int
|
||||
let manualPushMode: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case liveId = "live_id"
|
||||
case manualPushMode = "manual_push_mode"
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册列表响应实体。
|
||||
struct LiveAlbumFolderListResponse: Decodable {
|
||||
let items: [LiveAlbumFolderItem]
|
||||
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: [LiveAlbumFolderItem] = [], 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
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? []
|
||||
page = try container.liveDecodeLossyInt(forKey: .page) ?? 1
|
||||
pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10
|
||||
total = try container.liveDecodeLossyInt(forKey: .total) ?? 0
|
||||
totalPages = try container.liveDecodeLossyInt(forKey: .totalPages) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册文件夹实体。
|
||||
struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable {
|
||||
let id: Int
|
||||
let albumId: Int
|
||||
let name: String
|
||||
let creator: String
|
||||
let items: [LiveAlbumFileItem]
|
||||
|
||||
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: [LiveAlbumFileItem] = []) {
|
||||
self.id = id
|
||||
self.albumId = albumId
|
||||
self.name = name
|
||||
self.creator = creator
|
||||
self.items = items
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
albumId = try container.liveDecodeLossyInt(forKey: .albumId) ?? 0
|
||||
name = try container.liveDecodeLossyString(forKey: .name)
|
||||
creator = try container.liveDecodeLossyString(forKey: .creator)
|
||||
items = (try? container.decode([LiveAlbumFileItem].self, forKey: .items)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册素材实体。
|
||||
struct LiveAlbumFileItem: Decodable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let url: String
|
||||
let type: Int
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case url
|
||||
case type
|
||||
case size
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
|
||||
init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) {
|
||||
self.id = id
|
||||
self.url = url
|
||||
self.type = type
|
||||
self.size = size
|
||||
self.coverImg = coverImg
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.liveDecodeLossyInt(forKey: .id) ?? 0
|
||||
url = try container.liveDecodeLossyString(forKey: .url)
|
||||
type = try container.liveDecodeLossyInt(forKey: .type) ?? 1
|
||||
size = Int64(try container.liveDecodeLossyInt(forKey: .size) ?? 0)
|
||||
coverImg = try? container.decodeIfPresent(String.self, forKey: .coverImg)
|
||||
}
|
||||
|
||||
var isVideo: Bool { type == 2 }
|
||||
|
||||
var previewURL: String {
|
||||
if let cover = coverImg?.liveTrimmed, !cover.isEmpty {
|
||||
return cover
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播相册请求实体。
|
||||
struct LiveAlbumCreateFolderRequest: Encodable, Equatable {
|
||||
let scenicId: String
|
||||
let name: String
|
||||
let items: [LiveAlbumCreateFileItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case name
|
||||
case items
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建直播相册素材请求实体。
|
||||
struct LiveAlbumCreateFileItem: Encodable, Equatable {
|
||||
let url: String
|
||||
let type: Int
|
||||
let size: Int64
|
||||
let coverImg: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url
|
||||
case type
|
||||
case size
|
||||
case coverImg = "cover_img"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册请求实体。
|
||||
struct LiveAlbumDeleteFolderRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除直播相册素材请求实体。
|
||||
struct LiveAlbumDeleteFilesRequest: Encodable, Equatable {
|
||||
let folderId: Int
|
||||
let fileIds: [Int]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case folderId = "folder_id"
|
||||
case fileIds = "file_ids"
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地待上传直播相册素材实体。
|
||||
struct LiveAlbumLocalUploadFile: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let data: Data
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let size: Int64
|
||||
var uploadedURL: String?
|
||||
|
||||
init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) {
|
||||
self.id = id
|
||||
self.data = data
|
||||
self.fileName = fileName
|
||||
self.fileType = fileType
|
||||
self.size = size ?? Int64(data.count)
|
||||
self.uploadedURL = uploadedURL
|
||||
}
|
||||
|
||||
var isVideo: Bool { fileType == 2 }
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func liveDecodeLossyString(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)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "1" : "0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func liveDecodeLossyInt(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(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(trimmed) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(trimmed) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// 直播模块内部使用的去空白字符串。
|
||||
var liveTrimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// 直播模块内部使用的非空字符串兜底。
|
||||
var liveNonEmpty: String? {
|
||||
let value = liveTrimmed
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
467
suixinkan/Features/Live/ViewModels/LiveViewModels.swift
Normal file
467
suixinkan/Features/Live/ViewModels/LiveViewModels.swift
Normal file
@ -0,0 +1,467 @@
|
||||
//
|
||||
// LiveViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播管理 ViewModel,负责直播列表、详情、创建和控制动作。
|
||||
final class LiveManagementViewModel {
|
||||
var items: [LiveEntity] = []
|
||||
var detail: LiveEntity?
|
||||
var loading = false
|
||||
var loadingMore = false
|
||||
var hasMore = false
|
||||
var page = 1
|
||||
var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let pageSize = 10
|
||||
@ObservationIgnored 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)
|
||||
}
|
||||
|
||||
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
|
||||
@Observable
|
||||
/// 直播详情 ViewModel,负责直播详情页动作后的详情刷新。
|
||||
final class LiveDetailViewModel {
|
||||
var detail: LiveEntity
|
||||
var loading = false
|
||||
var actionInFlight = false
|
||||
var errorMessage: String?
|
||||
|
||||
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
|
||||
@Observable
|
||||
/// 直播相册 ViewModel,负责相册列表、筛选、新建和删除。
|
||||
final class LiveAlbumViewModel {
|
||||
var folders: [LiveAlbumFolderItem] = []
|
||||
var startDate: Date?
|
||||
var endDate: Date?
|
||||
var loading = false
|
||||
var loadingMore = false
|
||||
var hasMore = false
|
||||
var page = 1
|
||||
var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let pageSize = 10
|
||||
@ObservationIgnored 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func clearFolders() {
|
||||
folders = []
|
||||
page = 1
|
||||
total = 0
|
||||
hasMore = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 新建直播相册 ViewModel,负责本地素材上传和创建相册。
|
||||
final class LiveAlbumCreateViewModel {
|
||||
var name = ""
|
||||
var localFiles: [LiveAlbumLocalUploadFile] = []
|
||||
var submitting = false
|
||||
var uploadProgress = 0
|
||||
var errorMessage: String?
|
||||
|
||||
/// 添加本地待上传素材。
|
||||
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
|
||||
@Observable
|
||||
/// 直播相册预览 ViewModel,负责加载相册详情和删除当前素材。
|
||||
final class LiveAlbumPreviewViewModel {
|
||||
let folderId: Int
|
||||
var folder: LiveAlbumFolderItem?
|
||||
var files: [LiveAlbumFileItem] = []
|
||||
var currentIndex: Int
|
||||
var loading = false
|
||||
var errorMessage: String?
|
||||
|
||||
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))"
|
||||
}
|
||||
}
|
||||
519
suixinkan/Features/Live/Views/LiveAlbumViews.swift
Normal file
519
suixinkan/Features/Live/Views/LiveAlbumViews.swift
Normal file
@ -0,0 +1,519 @@
|
||||
//
|
||||
// LiveAlbumViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 直播相册首页,展示相册列表、筛选和上传入口。
|
||||
struct LiveAlbumView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LiveAlbumViewModel()
|
||||
@State private var showStartPicker = false
|
||||
@State private var showEndPicker = false
|
||||
@State private var showCreatePage = false
|
||||
@State private var deleteTarget: LiveAlbumFolderItem?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
filterSection
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.folders.isEmpty {
|
||||
ContentUnavailableView("暂无素材", systemImage: "photo.stack")
|
||||
.frame(maxWidth: .infinity, minHeight: 300)
|
||||
} else {
|
||||
ForEach(viewModel.folders) { folder in
|
||||
LiveAlbumFolderCard(
|
||||
folder: folder,
|
||||
onDelete: { deleteTarget = folder },
|
||||
preview: { file in
|
||||
let startIndex = folder.items.firstIndex(of: file) ?? 0
|
||||
LiveAlbumPreviewView(folderId: folder.id, startIndex: startIndex, summary: folder)
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
guard folder.id == viewModel.folders.last?.id else { return }
|
||||
Task { await viewModel.loadMore(api: liveAPI, scenicId: accountContext.currentScenic?.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.loadingMore {
|
||||
ProgressView()
|
||||
}
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
Button {
|
||||
showCreatePage = true
|
||||
} label: {
|
||||
Label("上传素材", systemImage: "square.and.arrow.up")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
.background(.white)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("直播相册")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
.task(id: accountContext.currentScenic?.id) {
|
||||
await reload(showLoading: viewModel.folders.isEmpty)
|
||||
}
|
||||
.sheet(isPresented: $showStartPicker) {
|
||||
LiveDatePickerSheet(title: "开始时间", initialDate: viewModel.startDate ?? Date()) { date in
|
||||
applyStartDate(date)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEndPicker) {
|
||||
LiveDatePickerSheet(title: "结束时间", initialDate: viewModel.endDate ?? Date()) { date in
|
||||
applyEndDate(date)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $showCreatePage) {
|
||||
LiveAlbumCreateView {
|
||||
showCreatePage = false
|
||||
Task { await reload(showLoading: false) }
|
||||
}
|
||||
}
|
||||
.confirmationDialog("删除相册", isPresented: deleteDialogBinding, presenting: deleteTarget) { folder in
|
||||
Button("删除", role: .destructive) {
|
||||
Task { await deleteFolder(folder) }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
} message: { _ in
|
||||
Text("确认删除该相册?删除后不可恢复。")
|
||||
}
|
||||
}
|
||||
|
||||
private var filterSection: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
filterButton(title: viewModel.startDate?.liveDayText ?? "开始时间") {
|
||||
showStartPicker = true
|
||||
}
|
||||
filterButton(title: viewModel.endDate?.liveDayText ?? "结束时间") {
|
||||
showEndPicker = true
|
||||
}
|
||||
Button {
|
||||
viewModel.clearDateFilters()
|
||||
Task { await reload(showLoading: true) }
|
||||
} label: {
|
||||
Image(systemName: "arrow.counterclockwise")
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("重置筛选")
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
private var deleteDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { deleteTarget != nil },
|
||||
set: { if !$0 { deleteTarget = nil } }
|
||||
)
|
||||
}
|
||||
|
||||
private func filterButton(title: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Label(title, systemImage: "calendar")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private func applyStartDate(_ date: Date) {
|
||||
do {
|
||||
try viewModel.setStartDate(date)
|
||||
Task { await reload(showLoading: true) }
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyEndDate(_ date: Date) {
|
||||
do {
|
||||
try viewModel.setEndDate(date)
|
||||
Task { await reload(showLoading: true) }
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
|
||||
await viewModel.reload(api: liveAPI, scenicId: accountContext.currentScenic?.id, showLoading: false)
|
||||
}
|
||||
if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteFolder(_ folder: LiveAlbumFolderItem) async {
|
||||
do {
|
||||
try await viewModel.deleteFolder(api: liveAPI, folderId: folder.id, scenicId: accountContext.currentScenic?.id)
|
||||
deleteTarget = nil
|
||||
toastCenter.show("相册已删除")
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveAlbumFolderCard<Preview: View>: View {
|
||||
let folder: LiveAlbumFolderItem
|
||||
let onDelete: () -> Void
|
||||
@ViewBuilder let preview: (LiveAlbumFileItem) -> Preview
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(folder.name.liveNonEmpty ?? "暂无标题")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
if !folder.creator.liveTrimmed.isEmpty {
|
||||
Text(folder.creator)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
.frame(width: 34, height: 34)
|
||||
}
|
||||
}
|
||||
|
||||
if folder.items.isEmpty {
|
||||
Text("暂无素材")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 72)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(folder.items) { file in
|
||||
NavigationLink(destination: preview(file)) {
|
||||
LiveAlbumThumb(file: file)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveAlbumThumb: View {
|
||||
let file: LiveAlbumFileItem
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
if file.isVideo && file.coverImg?.liveTrimmed.isEmpty != false {
|
||||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
|
||||
.fill(Color(hex: 0x111827))
|
||||
.overlay {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
} else {
|
||||
RemoteImage(urlString: file.previewURL, contentMode: .fill) {
|
||||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
|
||||
.fill(Color(hex: 0xEEF2FF))
|
||||
}
|
||||
}
|
||||
if file.isVideo {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.white)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
.frame(height: 108)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveDatePickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let title: String
|
||||
@State var initialDate: Date
|
||||
let onConfirm: (Date) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
DatePicker("", selection: $initialDate, displayedComponents: .date)
|
||||
.datePickerStyle(.graphical)
|
||||
.padding()
|
||||
.navigationTitle(title)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("确定") {
|
||||
onConfirm(initialDate)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建直播相册页面,支持选择本地图片/视频并上传。
|
||||
struct LiveAlbumCreateView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = LiveAlbumCreateViewModel()
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
let onCreated: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
TextField("请输入相册名称", text: $viewModel.name)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
PhotosPicker(selection: $pickerItems, maxSelectionCount: 20, matching: .any(of: [.images, .videos])) {
|
||||
Label("选择图片或视频", systemImage: "photo.on.rectangle.angled")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
localFileGrid
|
||||
if viewModel.submitting {
|
||||
ProgressView(value: Double(viewModel.uploadProgress), total: 100)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
Text(viewModel.submitting ? "提交中..." : "保存相册")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.submitting)
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.background(.white)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("上传素材")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onChange(of: pickerItems) { _, newItems in
|
||||
Task { await importPickerItems(newItems) }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var localFileGrid: some View {
|
||||
if viewModel.localFiles.isEmpty {
|
||||
ContentUnavailableView("未选择素材", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(viewModel.localFiles) { file in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
|
||||
.fill(file.isVideo ? Color(hex: 0x111827) : Color(hex: 0xEEF2FF))
|
||||
.frame(height: 108)
|
||||
.overlay {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: file.isVideo ? "video.fill" : "photo.fill")
|
||||
Text(file.fileName)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(file.isVideo ? .white : AppDesign.textSecondary)
|
||||
.padding(8)
|
||||
}
|
||||
Button {
|
||||
viewModel.removeLocalFile(id: file.id)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(Color(hex: 0xEF4444))
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func importPickerItems(_ items: [PhotosPickerItem]) async {
|
||||
let files = await LiveAlbumPickerLoader.loadFiles(from: items)
|
||||
viewModel.addLocalFiles(files)
|
||||
pickerItems = []
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
do {
|
||||
try await viewModel.submit(scenicId: accountContext.currentScenic?.id, api: liveAPI, uploader: uploadService)
|
||||
toastCenter.show("相册已创建")
|
||||
onCreated()
|
||||
dismiss()
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播相册预览页面。
|
||||
struct LiveAlbumPreviewView: View {
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel: LiveAlbumPreviewViewModel
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
init(folderId: Int, startIndex: Int, summary: LiveAlbumFolderItem?) {
|
||||
_viewModel = State(initialValue: LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex, summary: summary))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.files.isEmpty {
|
||||
ContentUnavailableView("没有可预览的素材", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
} else {
|
||||
TabView(selection: $viewModel.currentIndex) {
|
||||
ForEach(Array(viewModel.files.enumerated()), id: \.offset) { index, file in
|
||||
LiveAlbumPreviewPage(file: file)
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .automatic))
|
||||
.background(Color.black)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
Text("删除素材")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.files.isEmpty)
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.background(Color.black)
|
||||
}
|
||||
.navigationTitle("预览")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbarBackground(Color.black, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.task { await viewModel.load(api: liveAPI) }
|
||||
.confirmationDialog("删除素材", isPresented: $showDeleteConfirm) {
|
||||
Button("删除", role: .destructive) {
|
||||
Task { await deleteCurrentFile() }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
} message: {
|
||||
Text("确认删除该素材?删除后不可恢复。")
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteCurrentFile() async {
|
||||
do {
|
||||
try await viewModel.deleteCurrentFile(api: liveAPI)
|
||||
toastCenter.show("素材已删除")
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveAlbumPreviewPage: View {
|
||||
let file: LiveAlbumFileItem
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if file.isVideo && file.coverImg?.liveTrimmed.isEmpty != false {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 72, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
RemoteImage(urlString: file.previewURL, contentMode: .fit) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
if file.isVideo {
|
||||
if let url = URL(string: file.url) {
|
||||
Link("打开视频地址", destination: url)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Button("复制视频地址") {
|
||||
UIPasteboard.general.string = file.url
|
||||
toastCenter.show("视频地址已复制")
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
|
||||
/// PhotosPicker 数据加载器,把系统选择转换为直播相册待上传文件。
|
||||
enum LiveAlbumPickerLoader {
|
||||
static func loadFiles(from items: [PhotosPickerItem]) async -> [LiveAlbumLocalUploadFile] {
|
||||
var files: [LiveAlbumLocalUploadFile] = []
|
||||
for item in items {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self) else { continue }
|
||||
let fileType = item.supportedContentTypes.contains(where: { $0.conforms(to: .movie) }) ? 2 : 1
|
||||
let ext = preferredExtension(for: item, fileType: fileType)
|
||||
files.append(
|
||||
LiveAlbumLocalUploadFile(
|
||||
data: data,
|
||||
fileName: "live_album_\(UUID().uuidString.replacingOccurrences(of: "-", with: "")).\(ext)",
|
||||
fileType: fileType
|
||||
)
|
||||
)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
private static func preferredExtension(for item: PhotosPickerItem, fileType: Int) -> String {
|
||||
if let type = item.supportedContentTypes.first(where: { fileType == 2 ? $0.conforms(to: .movie) : $0.conforms(to: .image) }),
|
||||
let ext = type.preferredFilenameExtension {
|
||||
return ext
|
||||
}
|
||||
return fileType == 2 ? "mp4" : "jpg"
|
||||
}
|
||||
}
|
||||
582
suixinkan/Features/Live/Views/LiveManagementViews.swift
Normal file
582
suixinkan/Features/Live/Views/LiveManagementViews.swift
Normal file
@ -0,0 +1,582 @@
|
||||
//
|
||||
// LiveManagementViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 直播管理首页,展示直播列表和控制入口。
|
||||
struct LiveManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LiveManagementViewModel()
|
||||
@State private var showAddPage = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
summarySection
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.items.isEmpty {
|
||||
ContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right")
|
||||
.frame(maxWidth: .infinity, minHeight: 280)
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
LiveManagementCard(
|
||||
item: item,
|
||||
onCopyURL: {
|
||||
UIPasteboard.general.string = item.pushUrl
|
||||
toastCenter.show("推流地址已复制")
|
||||
},
|
||||
onControl: { Task { await control(item) } },
|
||||
onFinish: { Task { await finish(item) } },
|
||||
detail: {
|
||||
LiveDetailView(initialDetail: item) {
|
||||
Task { await reload(showLoading: false) }
|
||||
}
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
guard item.id == viewModel.items.last?.id else { return }
|
||||
Task { await viewModel.loadMore(api: liveAPI, scenicId: accountContext.currentScenic?.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.loadingMore {
|
||||
ProgressView()
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
Button {
|
||||
showAddPage = true
|
||||
} label: {
|
||||
Label("添加直播", systemImage: "plus.circle.fill")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
.background(.white)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("直播管理")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
.task(id: accountContext.currentScenic?.id) {
|
||||
await reload(showLoading: viewModel.items.isEmpty)
|
||||
}
|
||||
.navigationDestination(isPresented: $showAddPage) {
|
||||
LiveAddView {
|
||||
showAddPage = false
|
||||
Task { await reload(showLoading: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
LiveSummaryChip(title: "总直播", value: "\(viewModel.items.count)", color: AppDesign.primary)
|
||||
LiveSummaryChip(title: "进行中", value: "\(viewModel.liveRunningCount)", color: AppDesign.success)
|
||||
LiveSummaryChip(title: "已结束", value: "\(viewModel.liveFinishedCount)", color: Color(hex: 0x64748B))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) {
|
||||
await viewModel.reload(api: liveAPI, scenicId: accountContext.currentScenic?.id, showLoading: false)
|
||||
}
|
||||
if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func control(_ item: LiveEntity) async {
|
||||
do {
|
||||
try await viewModel.control(api: liveAPI, item: item, scenicId: accountContext.currentScenic?.id)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ item: LiveEntity) async {
|
||||
do {
|
||||
try await viewModel.finish(api: liveAPI, item: item, scenicId: accountContext.currentScenic?.id)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveSummaryChip: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(value)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveManagementCard<Detail: View>: View {
|
||||
let item: LiveEntity
|
||||
let onCopyURL: () -> Void
|
||||
let onControl: () -> Void
|
||||
let onFinish: () -> Void
|
||||
@ViewBuilder let detail: () -> Detail
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
RemoteImage(urlString: item.coverImg, contentMode: .fill) {
|
||||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)
|
||||
.fill(Color(hex: 0xE8EEF8))
|
||||
.overlay {
|
||||
Image(systemName: "play.rectangle.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary.opacity(0.75))
|
||||
}
|
||||
}
|
||||
.frame(width: 118, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(item.title.liveNonEmpty ?? "暂无标题")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text("观看:\(item.viewsCount)")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text("时长:\(item.duration.liveDurationText)")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(item.displayStatus)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(statusColor)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
NavigationLink(destination: detail) {
|
||||
LiveSmallAction(title: "详情", color: AppDesign.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Button(action: onControl) {
|
||||
LiveSmallAction(title: item.status == 2 ? "暂停" : "开始", color: Color(hex: 0xFF7B00))
|
||||
}
|
||||
.disabled(item.status == 3)
|
||||
Button(action: onFinish) {
|
||||
LiveSmallAction(title: "结束", color: Color(hex: 0xEF4444))
|
||||
}
|
||||
.disabled(item.status == 3)
|
||||
Button(action: onCopyURL) {
|
||||
LiveSmallAction(title: "复制地址", color: AppDesign.success)
|
||||
}
|
||||
.disabled(item.pushUrl.liveTrimmed.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch item.status {
|
||||
case 2: AppDesign.success
|
||||
case 3: Color(hex: 0x64748B)
|
||||
default: AppDesign.primary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveSmallAction: View {
|
||||
let title: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(color)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 32)
|
||||
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加直播页面。
|
||||
struct LiveAddView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var title = ""
|
||||
@State private var coverURL = ""
|
||||
@State private var submitting = false
|
||||
let onCreated: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
previewSection
|
||||
formSection
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
HStack {
|
||||
if submitting { ProgressView().tint(.white) }
|
||||
Text(submitting ? "创建中..." : "确认添加")
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canSubmit)
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.background(.white)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("添加直播")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
!submitting && !title.liveTrimmed.isEmpty && coverURL.liveTrimmed.liveIsHTTPURL
|
||||
}
|
||||
|
||||
private var previewSection: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
if coverURL.liveTrimmed.liveIsHTTPURL {
|
||||
RemoteImage(urlString: coverURL.liveTrimmed, contentMode: .fill) {
|
||||
previewPlaceholder
|
||||
}
|
||||
} else {
|
||||
previewPlaceholder
|
||||
}
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text("直播封面")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
Text(title.liveTrimmed.isEmpty ? "输入标题后预览展示效果" : title.liveTrimmed)
|
||||
.font(.system(size: AppMetrics.FontSize.title2, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(LinearGradient(colors: [.clear, .black.opacity(0.72)], startPoint: .top, endPoint: .bottom))
|
||||
}
|
||||
.frame(height: 210)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private var previewPlaceholder: some View {
|
||||
LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
.overlay {
|
||||
Image(systemName: "play.rectangle.fill")
|
||||
.font(.system(size: 46, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
}
|
||||
}
|
||||
|
||||
private var formSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
Text("直播信息")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||||
TextField("请输入直播标题", text: $title)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
TextField("请输入 http/https 图片地址", text: $coverURL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.URL)
|
||||
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
|
||||
if !coverURL.liveTrimmed.isEmpty && !coverURL.liveTrimmed.liveIsHTTPURL {
|
||||
Text("封面图地址需以 http:// 或 https:// 开头")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
guard canSubmit else { return }
|
||||
guard let scenicId = accountContext.currentScenic?.id else {
|
||||
toastCenter.show("当前账号缺少景区信息")
|
||||
return
|
||||
}
|
||||
submitting = true
|
||||
defer { submitting = false }
|
||||
do {
|
||||
try await liveAPI.liveCreate(LiveCreateRequest(scenicId: String(scenicId), title: title.liveTrimmed, coverImg: coverURL.liveTrimmed))
|
||||
onCreated()
|
||||
dismiss()
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 直播详情页面,展示推流信息和控制动作。
|
||||
struct LiveDetailView: View {
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel: LiveDetailViewModel
|
||||
@State private var copied = false
|
||||
let onChanged: () -> Void
|
||||
|
||||
init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) {
|
||||
_viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail))
|
||||
self.onChanged = onChanged
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
coverSection
|
||||
infoSection
|
||||
pushURLSection
|
||||
pushModeSection
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
bottomBar
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("直播信息")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("刷新") {
|
||||
Task { await refresh(showLoading: true) }
|
||||
}
|
||||
.disabled(viewModel.loading || viewModel.actionInFlight)
|
||||
}
|
||||
}
|
||||
.task { await refresh(showLoading: false) }
|
||||
}
|
||||
|
||||
private var coverSection: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) {
|
||||
LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
.overlay {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text(viewModel.detail.displayStatus)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 26)
|
||||
.background(statusColor.opacity(0.92), in: Capsule())
|
||||
Text(viewModel.detail.title.liveNonEmpty ?? "暂无标题")
|
||||
.font(.system(size: AppMetrics.FontSize.title, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(LinearGradient(colors: [.clear, .black.opacity(0.72)], startPoint: .top, endPoint: .bottom))
|
||||
}
|
||||
.frame(height: 210)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private var infoSection: some View {
|
||||
liveDetailCard("直播信息") {
|
||||
LiveInfoRow(title: "标题", value: viewModel.detail.title.liveNonEmpty ?? "--")
|
||||
LiveInfoRow(title: "状态", value: viewModel.detail.displayStatus)
|
||||
LiveInfoRow(title: "直播时长", value: viewModel.detail.duration.liveDurationText)
|
||||
LiveInfoRow(title: "观看人次", value: "\(viewModel.detail.viewsCount)")
|
||||
}
|
||||
}
|
||||
|
||||
private var pushURLSection: some View {
|
||||
liveDetailCard("推流地址") {
|
||||
Text(viewModel.detail.pushUrl.liveNonEmpty ?? "--")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
Button {
|
||||
UIPasteboard.general.string = viewModel.detail.pushUrl
|
||||
copied = true
|
||||
toastCenter.show("推流地址已复制")
|
||||
} label: {
|
||||
Label(copied ? "已复制推流地址" : "复制推流地址", systemImage: "doc.on.doc")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.disabled(viewModel.detail.pushUrl.liveTrimmed.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private var pushModeSection: some View {
|
||||
liveDetailCard("推流模式") {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
pushModeButton("清晰度优先", mode: 1)
|
||||
pushModeButton("流畅度优先", mode: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
Button {
|
||||
Task { await control() }
|
||||
} label: {
|
||||
Text(viewModel.detail.status == 2 ? "暂停直播" : "开始直播")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.detail.status == 3 || viewModel.actionInFlight)
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await finish() }
|
||||
} label: {
|
||||
Text("结束直播")
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(viewModel.detail.status == 3 || viewModel.actionInFlight)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch viewModel.detail.status {
|
||||
case 2: AppDesign.success
|
||||
case 3: Color(hex: 0x64748B)
|
||||
default: AppDesign.primary
|
||||
}
|
||||
}
|
||||
|
||||
private func liveDetailCard<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
content()
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private func pushModeButton(_ title: String, mode: Int) -> some View {
|
||||
Button {
|
||||
Task { await setPushMode(mode) }
|
||||
} label: {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(viewModel.detail.manualPushMode == mode ? .white : AppDesign.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 46)
|
||||
.background(viewModel.detail.manualPushMode == mode ? AppDesign.primary : Color(hex: 0xEEF2F7), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
}
|
||||
.disabled(viewModel.actionInFlight || viewModel.detail.manualPushMode == mode)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func refresh(showLoading: Bool) async {
|
||||
await globalLoading.withOptionalLoading(showLoading) {
|
||||
await viewModel.refresh(api: liveAPI, showLoading: false)
|
||||
}
|
||||
if let message = viewModel.errorMessage {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func control() async {
|
||||
do {
|
||||
try await viewModel.control(api: liveAPI)
|
||||
onChanged()
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func finish() async {
|
||||
do {
|
||||
try await viewModel.finish(api: liveAPI)
|
||||
onChanged()
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func setPushMode(_ mode: Int) async {
|
||||
do {
|
||||
try await viewModel.setPushMode(api: liveAPI, mode: mode)
|
||||
onChanged()
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LiveInfoRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
|
||||
Text(title)
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.frame(width: 72, alignment: .leading)
|
||||
Text(value)
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var liveIsHTTPURL: Bool {
|
||||
let lower = liveTrimmed.lowercased()
|
||||
return lower.hasPrefix("http://") || lower.hasPrefix("https://")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user