diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 6b2211f..f8c617a 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -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) diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 202d895..64eb7b1 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -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": diff --git a/suixinkan/Core/Upload/Upload.md b/suixinkan/Core/Upload/Upload.md index 0a7f987..6f91c45 100644 --- a/suixinkan/Core/Upload/Upload.md +++ b/suixinkan/Core/Upload/Upload.md @@ -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` diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index d45be7e..b156267 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -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 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index 61827a7..ac17b84 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -60,6 +60,8 @@ enum HomeRoute: Hashable { case scenicSettlementReview case messageCenter case queueManagement + case liveManagement + case liveAlbum case modulePlaceholder(uri: String, title: String) } diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 36cc697..6a06bb5 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -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)) diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 5b3cff8..049c58d 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -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) } diff --git a/suixinkan/Features/Live/API/LiveAPI.swift b/suixinkan/Features/Live/API/LiveAPI.swift new file mode 100644 index 0000000..ca78d5a --- /dev/null +++ b/suixinkan/Features/Live/API/LiveAPI.swift @@ -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 {} diff --git a/suixinkan/Features/Live/Live.md b/suixinkan/Features/Live/Live.md new file mode 100644 index 0000000..cd7b94a --- /dev/null +++ b/suixinkan/Features/Live/Live.md @@ -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 提交给创建相册接口。 diff --git a/suixinkan/Features/Live/Models/LiveModels.swift b/suixinkan/Features/Live/Models/LiveModels.swift new file mode 100644 index 0000000..7baaa95 --- /dev/null +++ b/suixinkan/Features/Live/Models/LiveModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Live/ViewModels/LiveViewModels.swift b/suixinkan/Features/Live/ViewModels/LiveViewModels.swift new file mode 100644 index 0000000..ca77e69 --- /dev/null +++ b/suixinkan/Features/Live/ViewModels/LiveViewModels.swift @@ -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))" + } +} diff --git a/suixinkan/Features/Live/Views/LiveAlbumViews.swift b/suixinkan/Features/Live/Views/LiveAlbumViews.swift new file mode 100644 index 0000000..b9250bb --- /dev/null +++ b/suixinkan/Features/Live/Views/LiveAlbumViews.swift @@ -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 { + 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: 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" + } +} diff --git a/suixinkan/Features/Live/Views/LiveManagementViews.swift b/suixinkan/Features/Live/Views/LiveManagementViews.swift new file mode 100644 index 0000000..8a17adc --- /dev/null +++ b/suixinkan/Features/Live/Views/LiveManagementViews.swift @@ -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: 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(_ 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://") + } +} diff --git a/suixinkanTests/Fixtures/alive_album_folder_detail_success.json b/suixinkanTests/Fixtures/alive_album_folder_detail_success.json new file mode 100644 index 0000000..dfa108d --- /dev/null +++ b/suixinkanTests/Fixtures/alive_album_folder_detail_success.json @@ -0,0 +1,26 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "id": "6101", + "album_id": "5101", + "name": "五一直播相册", + "creator": "摄影师小苏", + "items": [ + { + "id": "7101", + "url": "https://cdn.example.com/alive/photo.jpg", + "type": "1", + "size": "204800", + "cover_img": "" + }, + { + "id": 7102, + "url": "https://cdn.example.com/alive/video.mp4", + "type": 2, + "size": 10485760, + "cover_img": "https://cdn.example.com/alive/video-cover.jpg" + } + ] + } +} diff --git a/suixinkanTests/Fixtures/alive_album_list_success.json b/suixinkanTests/Fixtures/alive_album_list_success.json new file mode 100644 index 0000000..afa42d9 --- /dev/null +++ b/suixinkanTests/Fixtures/alive_album_list_success.json @@ -0,0 +1,34 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "items": [ + { + "id": "6101", + "album_id": "5101", + "name": "五一直播相册", + "creator": "摄影师小苏", + "items": [ + { + "id": "7101", + "url": "https://cdn.example.com/alive/photo.jpg", + "type": "1", + "size": "204800", + "cover_img": "" + }, + { + "id": 7102, + "url": "https://cdn.example.com/alive/video.mp4", + "type": 2, + "size": 10485760, + "cover_img": "https://cdn.example.com/alive/video-cover.jpg" + } + ] + } + ], + "page": "1", + "page_size": "1", + "total": "2", + "total_pages": "2" + } +} diff --git a/suixinkanTests/Fixtures/alive_detail_success.json b/suixinkanTests/Fixtures/alive_detail_success.json new file mode 100644 index 0000000..3dcddc0 --- /dev/null +++ b/suixinkanTests/Fixtures/alive_detail_success.json @@ -0,0 +1,18 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "id": "8101", + "title": "景区晨间直播", + "cover_img": "https://cdn.example.com/alive/cover.jpg", + "push_url": "rtmp://push.example.com/live/8101", + "start_time": "1779505200", + "end_time": "1779508800", + "duration": "3600", + "status": "2", + "status_label": "直播中", + "manaul_push_mode": "2", + "manaul_push_state": "1", + "views_count": "1234" + } +} diff --git a/suixinkanTests/Fixtures/alive_list_success.json b/suixinkanTests/Fixtures/alive_list_success.json new file mode 100644 index 0000000..634427c --- /dev/null +++ b/suixinkanTests/Fixtures/alive_list_success.json @@ -0,0 +1,25 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "items": [ + { + "id": "8101", + "title": "景区晨间直播", + "cover_img": "https://cdn.example.com/alive/cover.jpg", + "push_url": "rtmp://push.example.com/live/8101", + "start_time": "1779505200", + "end_time": "1779508800", + "duration": "3600", + "status": "2", + "status_label": "直播中", + "manaul_push_mode": "2", + "manaul_push_state": "1", + "views_count": "1234" + } + ], + "total": "2", + "page": "1", + "page_size": "1" + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 043bfb8..b7d6703 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -62,13 +62,15 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum)) } /// 测试已知但未迁移的首页路由会进入安全占位。 func testKnownUnmigratedRoutesResolveToPlaceholders() { XCTAssertEqual( - HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), - .destination(.modulePlaceholder(uri: "live_stream_management", title: "直播管理")) + HomeMenuRouter.resolve(uri: "operating-area", title: ""), + .destination(.modulePlaceholder(uri: "operating-area", title: "运营区域")) ) } @@ -195,6 +197,8 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertTrue(uris.contains("message_center")) XCTAssertTrue(uris.contains("/scenic-queue")) XCTAssertTrue(uris.contains("queue_management")) + XCTAssertTrue(uris.contains("live_stream_management")) + XCTAssertTrue(uris.contains("live_album")) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) @@ -210,6 +214,8 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum)) } #endif diff --git a/suixinkanTests/Live/LiveAPITests.swift b/suixinkanTests/Live/LiveAPITests.swift new file mode 100644 index 0000000..aa988db --- /dev/null +++ b/suixinkanTests/Live/LiveAPITests.swift @@ -0,0 +1,168 @@ +// +// LiveAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 直播 API 测试,覆盖直播管理和直播相册接口编码。 +final class LiveAPITests: XCTestCase { + func testLiveListAndDetailDecodeFixturesAndUseExpectedQueries() async throws { + let listSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_list_success")) + let listAPI = LiveAPI(client: APIClient(session: listSession)) + + let list = try await listAPI.liveList(scenicId: 88, page: 0, pageSize: 0) + + XCTAssertEqual(list.total, 2) + XCTAssertEqual(list.page, 1) + XCTAssertEqual(list.pageSize, 1) + XCTAssertEqual(list.items.first?.id, 8101) + XCTAssertEqual(list.items.first?.manualPushMode, 2) + let listRequest = try XCTUnwrap(listSession.requests.first) + XCTAssertEqual(listRequest.httpMethod, "GET") + XCTAssertEqual(listRequest.url?.path, "/api/app/manual-live/list") + XCTAssertEqual(liveQueryItems(from: listRequest)["scenic_id"], "88") + XCTAssertEqual(liveQueryItems(from: listRequest)["page"], "1") + XCTAssertEqual(liveQueryItems(from: listRequest)["page_size"], "1") + + let detailSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_detail_success")) + let detailAPI = LiveAPI(client: APIClient(session: detailSession)) + + let detail = try await detailAPI.liveDetail(liveId: 8101) + + XCTAssertEqual(detail.id, 8101) + XCTAssertEqual(detail.pushUrl, "rtmp://push.example.com/live/8101") + XCTAssertEqual(detail.status, 2) + XCTAssertEqual(detail.duration, 3600) + let detailRequest = try XCTUnwrap(detailSession.requests.first) + XCTAssertEqual(detailRequest.url?.path, "/api/app/manual-live/detail") + XCTAssertEqual(liveQueryItems(from: detailRequest)["live_id"], "8101") + } + + func testLiveCreateAndControlsUseExpectedBodies() async throws { + let createSession = LiveRecordingURLSession(responses: Array(repeating: try TestFixture.data(named: "empty_success"), count: 5)) + let api = LiveAPI(client: APIClient(session: createSession)) + + try await api.liveCreate(LiveCreateRequest(scenicId: "88", title: "景区晨间直播", coverImg: "https://cdn.example.com/cover.jpg")) + + var request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/create") + var json = try liveJSONBody(from: request) + XCTAssertEqual(json["scenic_id"] as? String, "88") + XCTAssertEqual(json["title"] as? String, "景区晨间直播") + XCTAssertEqual(json["cover_img"] as? String, "https://cdn.example.com/cover.jpg") + + try await api.liveStart(liveId: 8101) + request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/start") + XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101) + + try await api.liveStop(liveId: 8101) + request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/stop") + XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101) + + try await api.liveFinish(liveId: 8101) + request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/finish") + XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101) + + try await api.liveSetPushMode(liveId: 8101, mode: 2) + request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/set-push-mode") + json = try liveJSONBody(from: request) + XCTAssertEqual(json["live_id"] as? Int, 8101) + XCTAssertEqual(json["manual_push_mode"] as? Int, 2) + } + + func testLiveAlbumAPIsUseExpectedRequests() async throws { + let listSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_album_list_success")) + let api = LiveAPI(client: APIClient(session: listSession)) + + let response = try await api.liveAlbumList(scenicId: 88, startTime: "2026-05-01", endTime: "2026-05-23", page: 0, pageSize: 0) + + XCTAssertEqual(response.total, 2) + XCTAssertEqual(response.items.first?.id, 6101) + XCTAssertEqual(response.items.first?.items.last?.previewURL, "https://cdn.example.com/alive/video-cover.jpg") + var request = try XCTUnwrap(listSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/folders") + var query = liveQueryItems(from: request) + XCTAssertEqual(query["scenic_id"], "88") + XCTAssertEqual(query["start_time"], "2026-05-01") + XCTAssertEqual(query["end_time"], "2026-05-23") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + + let mutationSession = LiveRecordingURLSession(data: try TestFixture.data(named: "empty_success")) + let mutationAPI = LiveAPI(client: APIClient(session: mutationSession)) + try await mutationAPI.liveAlbumCreateFolder( + LiveAlbumCreateFolderRequest( + scenicId: "88", + name: "五一直播相册", + items: [LiveAlbumCreateFileItem(url: "https://cdn.example.com/a.jpg", type: 1, size: 12, coverImg: nil)] + ) + ) + request = try XCTUnwrap(mutationSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/create-folder") + var json = try liveJSONBody(from: request) + XCTAssertEqual(json["scenic_id"] as? String, "88") + XCTAssertEqual((json["items"] as? [[String: Any]])?.first?["url"] as? String, "https://cdn.example.com/a.jpg") + + try await mutationAPI.liveAlbumDeleteFolder(folderId: 6101) + request = try XCTUnwrap(mutationSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/delete-folder") + XCTAssertEqual(try liveJSONBody(from: request)["folder_id"] as? Int, 6101) + + let detailSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_album_folder_detail_success")) + let detailAPI = LiveAPI(client: APIClient(session: detailSession)) + let folder = try await detailAPI.liveAlbumFolderDetail(folderId: 6101) + XCTAssertEqual(folder.items.count, 2) + request = try XCTUnwrap(detailSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/folder-detail") + XCTAssertEqual(liveQueryItems(from: request)["folder_id"], "6101") + + try await mutationAPI.liveAlbumDeleteFiles(folderId: 6101, fileIds: [7101, 7102]) + request = try XCTUnwrap(mutationSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/delete-files") + json = try liveJSONBody(from: request) + XCTAssertEqual(json["folder_id"] as? Int, 6101) + XCTAssertEqual(json["file_ids"] as? [Int], [7101, 7102]) + } +} + +private final class LiveRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + init(data: Data) { + responses = [data] + } + + init(responses: [Data]) { + self.responses = responses + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? try TestFixture.data(named: "empty_success") : responses.removeFirst() + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +private func liveQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return [:] } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +private func liveJSONBody(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} diff --git a/suixinkanTests/Live/LiveViewModelTests.swift b/suixinkanTests/Live/LiveViewModelTests.swift new file mode 100644 index 0000000..f03135a --- /dev/null +++ b/suixinkanTests/Live/LiveViewModelTests.swift @@ -0,0 +1,340 @@ +// +// LiveViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 直播 ViewModel 测试,覆盖列表、详情、相册和上传流程。 +final class LiveViewModelTests: XCTestCase { + func testLiveManagementReloadLoadMoreAndMissingScenicReset() async { + let api = LiveMock() + api.liveListResponses = [ + LiveListResponse(items: [live(id: 1, status: 2), live(id: 2, status: 3)], total: 3, page: 1, pageSize: 2), + LiveListResponse(items: [live(id: 3, status: 1)], total: 3, page: 2, pageSize: 2) + ] + let viewModel = LiveManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88) + await viewModel.loadMore(api: api, scenicId: 88) + await viewModel.loadMore(api: api, scenicId: 88) + + XCTAssertEqual(api.liveListRequests.map(\.page), [1, 2]) + XCTAssertEqual(viewModel.items.map(\.id), [1, 2, 3]) + XCTAssertEqual(viewModel.liveRunningCount, 1) + XCTAssertEqual(viewModel.liveFinishedCount, 1) + XCTAssertFalse(viewModel.hasMore) + + await viewModel.reload(api: api, scenicId: nil) + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertNil(viewModel.detail) + XCTAssertFalse(viewModel.hasMore) + } + + func testLiveManagementReloadFailureAndDetailFailureClearStaleState() async { + let api = LiveMock() + api.liveListResponses = [LiveListResponse(items: [live(id: 1)], total: 1)] + api.liveDetailResponses = [live(id: 1, title: "旧详情")] + let viewModel = LiveManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88) + await viewModel.loadDetail(api: api, liveId: 1) + + XCTAssertEqual(viewModel.items.map(\.id), [1]) + XCTAssertEqual(viewModel.detail?.title, "旧详情") + + api.liveListError = LiveTestError.sample + await viewModel.reload(api: api, scenicId: 88) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertNil(viewModel.detail) + XCTAssertFalse(viewModel.hasMore) + XCTAssertEqual(viewModel.errorMessage, "测试错误") + + api.liveListError = nil + api.liveDetailError = LiveTestError.sample + await viewModel.loadDetail(api: api, liveId: 1) + XCTAssertNil(viewModel.detail) + } + + func testLiveManagementActionsRefreshAfterSuccess() async throws { + let api = LiveMock() + api.liveListResponses = [ + LiveListResponse(items: [live(id: 1, status: 1)], total: 1), + LiveListResponse(items: [live(id: 1, status: 2)], total: 1), + LiveListResponse(items: [live(id: 1, status: 3)], total: 1), + LiveListResponse(items: [live(id: 4, status: 1)], total: 1) + ] + let viewModel = LiveManagementViewModel() + await viewModel.reload(api: api, scenicId: 88) + + try await viewModel.control(api: api, item: live(id: 1, status: 1), scenicId: 88) + XCTAssertEqual(api.startIds, [1]) + XCTAssertEqual(viewModel.items.first?.status, 2) + + try await viewModel.finish(api: api, item: live(id: 1, status: 2), scenicId: 88) + XCTAssertEqual(api.finishIds, [1]) + XCTAssertEqual(viewModel.items.first?.status, 3) + + try await viewModel.create(api: api, scenicId: 88, title: " 新直播 ", coverURL: " https://cdn.example.com/c.jpg ") + XCTAssertEqual(api.createRequests.first?.title, "新直播") + XCTAssertEqual(api.createRequests.first?.coverImg, "https://cdn.example.com/c.jpg") + XCTAssertEqual(viewModel.items.first?.id, 4) + } + + func testLiveDetailActionsRefreshDetail() async throws { + let api = LiveMock() + api.liveDetailResponses = [ + live(id: 1, status: 2, manualPushMode: 1), + live(id: 1, status: 3, manualPushMode: 1), + live(id: 1, status: 3, manualPushMode: 2) + ] + let viewModel = LiveDetailViewModel(detail: live(id: 1, status: 1, manualPushMode: 1)) + + try await viewModel.control(api: api) + XCTAssertEqual(api.startIds, [1]) + XCTAssertEqual(viewModel.detail.status, 2) + + try await viewModel.finish(api: api) + XCTAssertEqual(api.finishIds, [1]) + XCTAssertEqual(viewModel.detail.status, 3) + + try await viewModel.setPushMode(api: api, mode: 2) + XCTAssertEqual(api.pushModeRequests.map(\.mode), [2]) + XCTAssertEqual(viewModel.detail.manualPushMode, 2) + } + + func testLiveAlbumDateValidationReloadLoadMoreAndFailureBehavior() async { + let api = LiveMock() + api.albumListResponses = [ + LiveAlbumFolderListResponse(items: [folder(id: 1)], page: 1, pageSize: 1, total: 2), + LiveAlbumFolderListResponse(items: [folder(id: 2)], page: 2, pageSize: 1, total: 2) + ] + let viewModel = LiveAlbumViewModel() + + XCTAssertNoThrow(try viewModel.setStartDate(date("2026-05-01"))) + XCTAssertNoThrow(try viewModel.setEndDate(date("2026-05-23"))) + await viewModel.reload(api: api, scenicId: 88) + await viewModel.loadMore(api: api, scenicId: 88) + + XCTAssertEqual(api.albumListRequests.map(\.page), [1, 2]) + XCTAssertEqual(api.albumListRequests.first?.startTime, "2026-05-01") + XCTAssertEqual(api.albumListRequests.first?.endTime, "2026-05-23") + XCTAssertEqual(viewModel.folders.map(\.id), [1, 2]) + XCTAssertFalse(viewModel.hasMore) + XCTAssertThrowsError(try viewModel.setStartDate(date("2026-06-01"))) + + api.albumListError = LiveTestError.sample + await viewModel.reload(api: api, scenicId: 88) + XCTAssertTrue(viewModel.folders.isEmpty) + XCTAssertFalse(viewModel.hasMore) + + await viewModel.reload(api: api, scenicId: nil) + XCTAssertTrue(viewModel.folders.isEmpty) + } + + func testLiveAlbumCreateUploadsFilesAndClearsForm() async throws { + let api = LiveMock() + let uploader = LiveUploadMock() + let uploadedURLs = ["https://cdn.example.com/live_albums/a.jpg", "https://cdn.example.com/live_albums/b.mp4"] + uploader.urls = uploadedURLs + let viewModel = LiveAlbumCreateViewModel() + viewModel.name = " 五一相册 " + viewModel.addLocalFiles([ + LiveAlbumLocalUploadFile(data: Data([1, 2, 3]), fileName: "a.jpg", fileType: 1), + LiveAlbumLocalUploadFile(data: Data([4, 5, 6]), fileName: "b.mp4", fileType: 2) + ]) + + try await viewModel.submit(scenicId: 88, api: api, uploader: uploader) + + XCTAssertEqual(uploader.uploads.map(\.fileName), ["a.jpg", "b.mp4"]) + let request = try XCTUnwrap(api.albumCreateRequests.first) + XCTAssertEqual(request.scenicId, "88") + XCTAssertEqual(request.name, "五一相册") + XCTAssertEqual(request.items.map(\.url), uploadedURLs) + XCTAssertEqual(request.items.map(\.type), [1, 2]) + XCTAssertTrue(viewModel.name.isEmpty) + XCTAssertTrue(viewModel.localFiles.isEmpty) + XCTAssertEqual(viewModel.uploadProgress, 100) + } + + func testLiveAlbumCreateFailureKeepsForm() async { + let api = LiveMock() + let uploader = LiveUploadMock() + uploader.error = LiveTestError.sample + let viewModel = LiveAlbumCreateViewModel() + viewModel.name = "相册" + viewModel.addLocalFiles([LiveAlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 1)]) + + await XCTAssertThrowsErrorAsync(try await viewModel.submit(scenicId: 88, api: api, uploader: uploader)) + + XCTAssertEqual(viewModel.name, "相册") + XCTAssertEqual(viewModel.localFiles.count, 1) + XCTAssertTrue(api.albumCreateRequests.isEmpty) + } + + func testLiveAlbumPreviewLoadAndDeleteRefresh() async throws { + let api = LiveMock() + api.albumDetailResponses = [ + folder(id: 1, items: [albumFile(id: 11), albumFile(id: 12)]), + folder(id: 1, items: [albumFile(id: 12)]) + ] + let viewModel = LiveAlbumPreviewViewModel(folderId: 1, startIndex: 0, summary: nil) + + await viewModel.load(api: api) + XCTAssertEqual(viewModel.files.map(\.id), [11, 12]) + + try await viewModel.deleteCurrentFile(api: api) + XCTAssertEqual(api.albumDeleteFileRequests.first?.fileIds, [11]) + XCTAssertEqual(viewModel.files.map(\.id), [12]) + } +} + +@MainActor +private final class LiveMock: LiveServing { + var liveListResponses: [LiveListResponse] = [] + var liveDetailResponses: [LiveEntity] = [] + var albumListResponses: [LiveAlbumFolderListResponse] = [] + var albumDetailResponses: [LiveAlbumFolderItem] = [] + var liveListError: Error? + var liveDetailError: Error? + var albumListError: Error? + var albumDetailError: Error? + var createRequests: [LiveCreateRequest] = [] + var startIds: [Int] = [] + var stopIds: [Int] = [] + var finishIds: [Int] = [] + var pushModeRequests: [(liveId: Int, mode: Int)] = [] + var albumCreateRequests: [LiveAlbumCreateFolderRequest] = [] + var albumDeleteFolderIds: [Int] = [] + var albumDeleteFileRequests: [(folderId: Int, fileIds: [Int])] = [] + private(set) var liveListRequests: [(scenicId: Int, page: Int, pageSize: Int)] = [] + private(set) var albumListRequests: [(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int)] = [] + + func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse { + liveListRequests.append((scenicId, page, pageSize)) + if let liveListError { throw liveListError } + return liveListResponses.isEmpty ? LiveListResponse() : liveListResponses.removeFirst() + } + + func liveDetail(liveId: Int) async throws -> LiveEntity { + if let liveDetailError { throw liveDetailError } + return liveDetailResponses.isEmpty ? live(id: liveId) : liveDetailResponses.removeFirst() + } + + func liveCreate(_ request: LiveCreateRequest) async throws { + createRequests.append(request) + } + + func liveStart(liveId: Int) async throws { + startIds.append(liveId) + } + + func liveStop(liveId: Int) async throws { + stopIds.append(liveId) + } + + func liveFinish(liveId: Int) async throws { + finishIds.append(liveId) + } + + func liveSetPushMode(liveId: Int, mode: Int) async throws { + pushModeRequests.append((liveId, mode)) + } + + func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse { + albumListRequests.append((scenicId, startTime, endTime, page, pageSize)) + if let albumListError { throw albumListError } + return albumListResponses.isEmpty ? LiveAlbumFolderListResponse() : albumListResponses.removeFirst() + } + + func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws { + albumCreateRequests.append(request) + } + + func liveAlbumDeleteFolder(folderId: Int) async throws { + albumDeleteFolderIds.append(folderId) + } + + func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem { + if let albumDetailError { throw albumDetailError } + return albumDetailResponses.isEmpty ? folder(id: folderId) : albumDetailResponses.removeFirst() + } + + func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws { + albumDeleteFileRequests.append((folderId, fileIds)) + } +} + +@MainActor +private final class LiveUploadMock: OSSUploadServing { + var urls: [String] = [] + var error: Error? + private(set) var uploads: [(data: Data, fileName: String, fileType: Int, scenicId: Int)] = [] + + func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let error { throw error } + uploads.append((data, fileName, fileType, scenicId)) + onProgress(100) + return urls.isEmpty ? "https://cdn.example.com/\(fileName)" : urls.removeFirst() + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +private func live(id: Int, title: String = "直播", status: Int = 1, manualPushMode: Int = 1) -> LiveEntity { + LiveEntity( + id: id, + title: title, + coverImg: "https://cdn.example.com/\(id).jpg", + pushUrl: "rtmp://push.example.com/live/\(id)", + duration: 3600, + status: status, + statusLabel: status == 2 ? "直播中" : (status == 3 ? "已结束" : "待开始"), + manualPushMode: manualPushMode, + viewsCount: 10 + ) +} + +private func folder(id: Int, items: [LiveAlbumFileItem] = [albumFile(id: 1)]) -> LiveAlbumFolderItem { + LiveAlbumFolderItem(id: id, albumId: id + 100, name: "相册\(id)", creator: "摄影师", items: items) +} + +private func albumFile(id: Int) -> LiveAlbumFileItem { + LiveAlbumFileItem(id: id, url: "https://cdn.example.com/\(id).jpg", type: 1, size: 100) +} + +private func date(_ value: String) -> Date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.date(from: value)! +} + +private enum LiveTestError: LocalizedError { + case sample + var errorDescription: String? { "测试错误" } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected error", file: file, line: line) + } catch {} +} diff --git a/suixinkanTests/TestFixture.swift b/suixinkanTests/TestFixture.swift index 3778e5e..64264d9 100644 --- a/suixinkanTests/TestFixture.swift +++ b/suixinkanTests/TestFixture.swift @@ -12,11 +12,21 @@ import Foundation enum TestFixture { /// 读取指定名称的 JSON fixture。 static func data(named name: String, filePath: String = #filePath) throws -> Data { - let url = URL(fileURLWithPath: filePath) + var directory = URL(fileURLWithPath: filePath).deletingLastPathComponent() + while directory.path != "/" { + let url = directory + .appendingPathComponent("Fixtures") + .appendingPathComponent("\(name).json") + if FileManager.default.fileExists(atPath: url.path) { + return try Data(contentsOf: url) + } + directory.deleteLastPathComponent() + } + let fallback = URL(fileURLWithPath: filePath) .deletingLastPathComponent() .appendingPathComponent("Fixtures") .appendingPathComponent("\(name).json") - return try Data(contentsOf: url) + return try Data(contentsOf: fallback) } /// 读取并解包后端统一 Envelope。 @@ -33,3 +43,16 @@ enum TestFixture { enum FixtureError: Error { case emptyPayload } + +extension OSSUploadServing { + /// 旧上传替身默认不关心直播相册入口;Live 专用测试会覆盖此方法记录调用。 + func uploadAliveAlbumFile( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + "" + } +} diff --git a/suixinkanTests/UploadTests.swift b/suixinkanTests/UploadTests.swift index 2350077..8a25a98 100644 --- a/suixinkanTests/UploadTests.swift +++ b/suixinkanTests/UploadTests.swift @@ -51,6 +51,10 @@ final class UploadTests: XCTestCase { OSSUploadPolicy.objectKey(fileName: "cover.jpg", scenicId: 9, moduleType: "project", date: date, uuid: uuid, timeZone: timeZone), "project/20260101/9/123456781234123412341234567890AB_cover.jpg" ) + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "live.mp4", scenicId: 9, moduleType: "alive_album", date: date, uuid: uuid, timeZone: timeZone), + "live_albums/20260101/9/123456781234123412341234567890AB_live.mp4" + ) } /// 测试 MIME 类型推断和 URL 拼接规则。 diff --git a/功能同步Checklist.md b/功能同步Checklist.md index c72e7ad..a99a78e 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -102,7 +102,7 @@ | 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | | 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 | | 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 | -| 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 | +| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制推流地址、开始/暂停/结束、推流模式切换,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 不接入播放器/推流 SDK,音视频专项后续人工验证。 | | 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | | 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | | 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 | @@ -134,6 +134,6 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination ## 下一批建议迁移顺序 -1. 直播管理/直播相册:入口已识别但业务独立,建议单独拆模块迁移。 -2. 运营区域/飞手认证:仍为首页占位入口,可按旧工程业务优先级拆分。 +1. 运营区域/飞手认证:仍为首页占位入口,可按旧工程业务优先级拆分。 +2. 直播播放器/推流专项:需要明确 iOS 播放器/推流 SDK、真机摄像头/麦克风和网络切换策略。 3. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。