diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 1c8a754..ccbb062 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -23,6 +23,7 @@ final class NetworkServices { let scenicQueueAPI: ScenicQueueAPI let travelAlbumAPI: TravelAlbumAPI let sampleManagementAPI: SampleManagementAPI + let liveAPI: LiveAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -41,6 +42,7 @@ final class NetworkServices { scenicQueueAPI = ScenicQueueAPI(client: client) travelAlbumAPI = TravelAlbumAPI(client: client) sampleManagementAPI = SampleManagementAPI(client: client) + liveAPI = LiveAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { @@ -64,6 +66,7 @@ final class NetworkServices { scenicQueueAPI = ScenicQueueAPI(client: apiClient) travelAlbumAPI = TravelAlbumAPI(client: apiClient) sampleManagementAPI = SampleManagementAPI(client: apiClient) + liveAPI = LiveAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) } diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 571a9fc..ee88d01 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -109,6 +109,41 @@ final class OSSUploadService { ) } + /// 上传直播封面,对齐 Android `moduleType = live_covers`。 + func uploadLiveCover( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + try await uploadFile( + data: data, + fileName: fileName, + fileType: 2, + scenicId: scenicId, + moduleType: "live_covers", + onProgress: onProgress + ) + } + + /// 上传直播相册素材,对齐 Android `moduleType = alive_album`。 + func uploadLiveAlbumFile( + 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 + ) + } + private func uploadFile( data: Data, fileName: String, @@ -205,6 +240,10 @@ enum OSSUploadPolicy { return "sample/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "scenic_apply": return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "live_covers": + return "live_covers/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "alive_album": + return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" } diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index ab739ab..7860009 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -66,6 +66,10 @@ enum HomeRouteHandler { viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true) case "sample_management": viewController.navigationController?.pushViewController(SampleListViewController(), animated: true) + case "live_stream_management": + viewController.navigationController?.pushViewController(LiveManageViewController(), animated: true) + case "live_album": + viewController.navigationController?.pushViewController(LiveAlbumListViewController(), animated: true) case "wallet": showDeveloping(from: viewController) default: diff --git a/suixinkan/Features/Live/API/LiveAPI.swift b/suixinkan/Features/Live/API/LiveAPI.swift new file mode 100644 index 0000000..a06286b --- /dev/null +++ b/suixinkan/Features/Live/API/LiveAPI.swift @@ -0,0 +1,194 @@ +// +// LiveAPI.swift +// suixinkan +// + +import Foundation + +/// 直播服务协议,抽象 manual-live 与 view-album 接口。 +@MainActor +protocol LiveServing { + /// 拉取直播列表。 + func liveList(scenicId: String, page: Int, pageSize: Int) async throws -> LiveListResponse + + /// 拉取直播详情。 + func liveDetail(liveId: Int) async throws -> LiveItem + + /// 新建直播。 + func createLive(scenicId: String, title: String, coverImg: String) async throws + + /// 开始直播。 + func startLive(liveId: Int) async throws + + /// 暂停直播。 + func stopLive(liveId: Int) async throws + + /// 结束直播。 + func finishLive(liveId: Int) async throws + + /// 设置直播推流模式。 + func setPushMode(liveId: Int, mode: Int) async throws + + /// 拉取直播相册列表。 + func albumList(scenicId: String, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse + + /// 创建直播相册文件夹。 + func createAlbumFolder(scenicId: String, name: String, items: [LiveAlbumFile]) async throws + + /// 删除直播相册文件夹。 + func deleteAlbumFolder(folderId: Int) async throws + + /// 拉取直播相册详情。 + func albumFolderDetail(folderId: Int) async throws -> LiveAlbumFolder + + /// 删除直播相册素材。 + func deleteAlbumFile(folderId: Int, fileId: Int) async throws +} + +/// 直播 API,封装 Android `NetworkApi` 中 alive/manual-live 与 view-album 接口。 +@MainActor +final class LiveAPI: LiveServing { + private let client: APIClient + + /// 初始化直播 API。 + init(client: APIClient) { + self.client = client + } + + /// 拉取直播列表。 + func liveList(scenicId: String, page: Int = 1, pageSize: Int = 10) async throws -> LiveListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/manual-live/list", + queryItems: [ + URLQueryItem(name: "scenic_id", value: scenicId), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))), + ] + ) + ) + } + + /// 拉取直播详情。 + func liveDetail(liveId: Int) async throws -> LiveItem { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/manual-live/detail", + queryItems: [URLQueryItem(name: "live_id", value: String(liveId))] + ) + ) + } + + /// 新建直播。 + func createLive(scenicId: String, title: String, coverImg: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/manual-live/create", + body: LiveCreateRequest(scenicId: scenicId, title: title, coverImg: coverImg) + ) + ) + } + + /// 开始直播。 + func startLive(liveId: Int) async throws { + try await controlLive(path: "/api/app/manual-live/start", liveId: liveId) + } + + /// 暂停直播。 + func stopLive(liveId: Int) async throws { + try await controlLive(path: "/api/app/manual-live/stop", liveId: liveId) + } + + /// 结束直播。 + func finishLive(liveId: Int) async throws { + try await controlLive(path: "/api/app/manual-live/finish", liveId: liveId) + } + + /// 设置直播推流模式。 + func setPushMode(liveId: Int, mode: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/manual-live/set-push-mode", + body: LivePushModeRequest(liveId: liveId, manualPushMode: mode) + ) + ) + } + + /// 拉取直播相册列表。 + func albumList( + scenicId: String, + startTime: String? = nil, + endTime: String? = nil, + page: Int = 1, + pageSize: Int = 10 + ) async throws -> LiveAlbumFolderListResponse { + var items = [ + URLQueryItem(name: "scenic_id", value: scenicId), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))), + ] + if let startTime, !startTime.isEmpty { + items.append(URLQueryItem(name: "start_time", value: startTime)) + } + if let endTime, !endTime.isEmpty { + items.append(URLQueryItem(name: "end_time", value: endTime)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/app/view-album/folders", queryItems: items) + ) + } + + /// 创建直播相册文件夹。 + func createAlbumFolder(scenicId: String, name: String, items: [LiveAlbumFile]) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/view-album/create-folder", + body: LiveAlbumFolderCreateRequest(scenicId: scenicId, name: name, items: items) + ) + ) + } + + /// 删除直播相册文件夹。 + func deleteAlbumFolder(folderId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/view-album/delete-folder", + body: LiveAlbumFolderDeleteRequest(folderId: folderId) + ) + ) + } + + /// 拉取直播相册详情。 + func albumFolderDetail(folderId: Int) async throws -> LiveAlbumFolder { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/view-album/folder-detail", + queryItems: [URLQueryItem(name: "folder_id", value: String(folderId))] + ) + ) + } + + /// 删除直播相册素材。 + func deleteAlbumFile(folderId: Int, fileId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/view-album/delete-files", + body: LiveAlbumFileDeleteRequest(folderId: folderId, fileIds: [fileId]) + ) + ) + } + + private func controlLive(path: String, liveId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: path, body: LiveControlRequest(liveId: liveId)) + ) + } +} diff --git a/suixinkan/Features/Live/Models/LiveModels.swift b/suixinkan/Features/Live/Models/LiveModels.swift new file mode 100644 index 0000000..1653fb6 --- /dev/null +++ b/suixinkan/Features/Live/Models/LiveModels.swift @@ -0,0 +1,370 @@ +// +// LiveModels.swift +// suixinkan +// + +import Foundation + +/// 直播状态,对齐 Android `AliveEntity.status`。 +enum LiveStatus: Int, Sendable, Equatable { + case pending = 1 + case living = 2 + case paused = 3 + case finished = 4 + case unknown = 0 + + /// 是否允许开始、暂停或结束。 + var isOperable: Bool { self != .finished } +} + +/// 直播推流模式,对齐 Android `manualPushMode`。 +enum LivePushMode: Int, CaseIterable, Sendable, Equatable { + case clarityFirst = 1 + case smoothFirst = 2 + + /// 展示文案。 + var title: String { + switch self { + case .clarityFirst: "清晰度优先" + case .smoothFirst: "流畅度优先" + } + } +} + +/// 直播列表与详情实体,对齐 Android `AliveEntity`。 +struct LiveItem: Decodable, Sendable, Equatable, Hashable, Identifiable { + let id: Int + let title: String + let coverImg: String + let pushUrl: String + let startTime: Int64 + let endTime: Int64 + let duration: Int64 + let status: Int + let statusLabel: String + let manualPushMode: Int + let manualPushState: Int + let viewsCount: Int + + enum CodingKeys: String, CodingKey { + case id + case title + case coverImg = "cover_img" + case pushUrl = "push_url" + case startTime = "start_time" + case endTime = "end_time" + case duration + case status + case statusLabel = "status_label" + case manualPushMode = "manual_push_mode" + case manualPushState = "manual_push_state" + case legacyManualPushMode = "manaul_push_mode" + case legacyManualPushState = "manaul_push_state" + case viewsCount = "views_count" + } + + init( + id: Int = 0, + title: String = "", + coverImg: String = "", + pushUrl: String = "", + startTime: Int64 = 0, + endTime: Int64 = 0, + duration: Int64 = 0, + status: Int = 0, + statusLabel: String = "", + manualPushMode: Int = 1, + manualPushState: Int = 0, + viewsCount: Int = 0 + ) { + self.id = id + self.title = title + self.coverImg = coverImg + self.pushUrl = pushUrl + self.startTime = startTime + self.endTime = endTime + self.duration = duration + self.status = status + self.statusLabel = statusLabel + self.manualPushMode = manualPushMode + self.manualPushState = manualPushState + self.viewsCount = viewsCount + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + title = try container.decodeLossyString(forKey: .title) + coverImg = try container.decodeLossyString(forKey: .coverImg).unescapedSlashes + pushUrl = try container.decodeLossyString(forKey: .pushUrl).unescapedSlashes + startTime = try container.decodeLossyInt64(forKey: .startTime) ?? 0 + endTime = try container.decodeLossyInt64(forKey: .endTime) ?? 0 + duration = try container.decodeLossyInt64(forKey: .duration) ?? 0 + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusLabel = try container.decodeLossyString(forKey: .statusLabel) + manualPushMode = try container.decodeLossyInt(forKey: .manualPushMode) + ?? container.decodeLossyInt(forKey: .legacyManualPushMode) + ?? 1 + manualPushState = try container.decodeLossyInt(forKey: .manualPushState) + ?? container.decodeLossyInt(forKey: .legacyManualPushState) + ?? 0 + viewsCount = try container.decodeLossyInt(forKey: .viewsCount) ?? 0 + } + + /// 类型化直播状态。 + var liveStatus: LiveStatus { LiveStatus(rawValue: status) ?? .unknown } + + /// 标题兜底。 + var displayTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "暂无标题" : title } + + /// 当前操作按钮标题。 + var controlTitle: String { liveStatus == .living ? "暂停" : "开始" } +} + +/// 直播分页响应,对齐 Android `ItemListResponse`。 +struct LiveListResponse: Decodable, Sendable, Equatable { + let page: Int + let pageSize: Int + let total: Int + let totalPages: Int + let items: [LiveItem] + + enum CodingKeys: String, CodingKey { + case page + case pageSize = "page_size" + case total + case totalPages = "total_pages" + case items + } + + init(page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0, items: [LiveItem] = []) { + self.page = page + self.pageSize = pageSize + self.total = total + self.totalPages = totalPages + self.items = items + } +} + +/// 新增直播请求。 +struct LiveCreateRequest: Encodable, Sendable, Equatable { + let scenicId: String + let title: String + let coverImg: String + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case title + case coverImg = "cover_img" + } +} + +/// 直播控制请求。 +struct LiveControlRequest: Encodable, Sendable, Equatable { + let liveId: Int + + enum CodingKeys: String, CodingKey { + case liveId = "live_id" + } +} + +/// 直播推流模式请求。 +struct LivePushModeRequest: Encodable, Sendable, Equatable { + let liveId: Int + let manualPushMode: Int + + enum CodingKeys: String, CodingKey { + case liveId = "live_id" + case manualPushMode = "manual_push_mode" + } +} + +/// 直播相册文件实体,对齐 Android `AliveAlbumFileEntity`。 +struct LiveAlbumFile: Codable, Sendable, Equatable, Hashable, Identifiable { + let url: String + let type: Int + let size: Int64 + let coverImg: String? + let id: Int + + enum CodingKeys: String, CodingKey { + case url + case type + case size + case coverImg = "cover_img" + case id + } + + init(url: String, type: Int, size: Int64, coverImg: String? = nil, id: Int = 0) { + self.url = url.unescapedSlashes + self.type = type + self.size = size + self.coverImg = coverImg?.unescapedSlashes + self.id = id + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + url = try container.decodeLossyString(forKey: .url).unescapedSlashes + type = try container.decodeLossyInt(forKey: .type) ?? 2 + size = try container.decodeLossyInt64(forKey: .size) ?? 0 + let cover = try container.decodeIfPresent(String.self, forKey: .coverImg) + coverImg = cover?.unescapedSlashes + id = try container.decodeLossyInt(forKey: .id) ?? 0 + } +} + +/// 直播相册文件夹实体,对齐 Android `AliveAlbumFolderEntity`。 +struct LiveAlbumFolder: Decodable, Sendable, Equatable, Hashable, Identifiable { + let id: Int + let albumId: Int + let name: String + let creator: String + let items: [LiveAlbumFile] + + enum CodingKeys: String, CodingKey { + case id + case albumId = "album_id" + case name + case creator + case items + } + + init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFile] = []) { + self.id = id + self.albumId = albumId + self.name = name + self.creator = creator + self.items = items + } + + /// 标题兜底。 + var displayName: String { name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "暂无标题" : name } +} + +/// 直播相册列表响应。 +struct LiveAlbumFolderListResponse: Decodable, Sendable, Equatable { + let items: [LiveAlbumFolder] + let page: Int + let pageSize: Int + let total: Int + let totalPages: Int + + enum CodingKeys: String, CodingKey { + case items + case page + case pageSize = "page_size" + case total + case totalPages = "total_pages" + } + + init(items: [LiveAlbumFolder] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) { + self.items = items + self.page = page + self.pageSize = pageSize + self.total = total + self.totalPages = totalPages + } +} + +/// 创建直播相册文件夹请求。 +struct LiveAlbumFolderCreateRequest: Encodable, Sendable, Equatable { + let scenicId: String + let name: String + let items: [LiveAlbumFile] + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case name + case items + } +} + +/// 删除直播相册文件夹请求。 +struct LiveAlbumFolderDeleteRequest: Encodable, Sendable, Equatable { + let folderId: Int + + enum CodingKeys: String, CodingKey { + case folderId = "folder_id" + } +} + +/// 删除直播相册文件请求。 +struct LiveAlbumFileDeleteRequest: Encodable, Sendable, Equatable { + let folderId: Int + let fileIds: [Int] + + enum CodingKeys: String, CodingKey { + case folderId = "folder_id" + case fileIds = "file_ids" + } +} + +/// 本地待上传直播相册素材。 +struct LivePickedMedia: Sendable, Equatable { + let data: Data + let fileName: String + let size: Int64 + let width: Int + let height: Int +} + +private extension KeyedDecodingContainer { + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Int64.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + return "" + } + + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int64.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { return intValue } + if let doubleValue = Double(text) { return Int(doubleValue) } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } + + func decodeLossyInt64(forKey key: Key) throws -> Int64? { + if let value = try? decodeIfPresent(Int64.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return Int64(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int64(text) { return intValue } + if let doubleValue = Double(text) { return Int64(doubleValue) } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int64(value) + } + return nil + } +} + +private extension String { + var unescapedSlashes: String { + replacingOccurrences(of: "\\/", with: "/") + } +} diff --git a/suixinkan/Features/Live/ViewModels/LiveViewModels.swift b/suixinkan/Features/Live/ViewModels/LiveViewModels.swift new file mode 100644 index 0000000..211b40d --- /dev/null +++ b/suixinkan/Features/Live/ViewModels/LiveViewModels.swift @@ -0,0 +1,582 @@ +// +// LiveViewModels.swift +// suixinkan +// + +import Foundation + +/// 直播 OSS 上传协议,便于直播 ViewModel 在测试中注入替身。 +@MainActor +protocol LiveOSSUploading { + /// 上传直播封面并返回 OSS URL。 + func uploadLiveCover(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传直播相册素材并返回 OSS URL。 + func uploadLiveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String +} + +extension OSSUploadService: LiveOSSUploading {} + +/// 直播列表页 ViewModel,负责分页、新建和直播控制。 +final class LiveManageViewModel { + private(set) var items: [LiveItem] = [] + private(set) var isLoading = false + private(set) var isRefreshing = false + private(set) var canLoadMore = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onLiveCreated: (() -> Void)? + + private let currentScenicIdProvider: () -> Int + private var currentPage = 1 + private var totalCount = 0 + private let pageSize = 10 + + /// 初始化直播管理 ViewModel。 + init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { + self.currentScenicIdProvider = currentScenicIdProvider + } + + /// 首次加载直播列表。 + func loadInitial(api: any LiveServing) async { + await load(reset: true, showLoading: true, api: api) + } + + /// 下拉刷新直播列表。 + func refresh(api: any LiveServing) async { + isRefreshing = true + notifyStateChange() + await load(reset: true, showLoading: false, api: api) + } + + /// 滚动到底部附近时加载更多。 + func loadMoreIfNeeded(lastVisibleIndex: Int, api: any LiveServing) async { + guard lastVisibleIndex >= items.count - 3 else { return } + await load(reset: false, showLoading: false, api: api) + } + + /// 新建直播并刷新第一页。 + func createLive(title: String, coverUrl: String, api: any LiveServing) async { + let name = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + onShowMessage?("请输入直播标题") + return + } + guard !coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + onShowMessage?("请上传封面图片") + return + } + guard let scenicId = scenicIdText() else { return } + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + try await api.createLive(scenicId: scenicId, title: name, coverImg: coverUrl) + onLiveCreated?() + await load(reset: true, showLoading: true, api: api) + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "新增直播失败" : error.localizedDescription) + } + } + + /// 复制直播地址。 + func copyPushURL(_ item: LiveItem) -> String { + item.pushUrl + } + + /// 根据当前直播状态执行开始或暂停。 + func controlLive(_ item: LiveItem, api: any LiveServing) async { + guard item.liveStatus.isOperable else { return } + await performAndReload(api: api) { + if item.liveStatus == .living { + try await api.stopLive(liveId: item.id) + } else if item.liveStatus == .pending || item.liveStatus == .paused { + try await api.startLive(liveId: item.id) + } + } + } + + /// 结束直播。 + func finishLive(_ item: LiveItem, api: any LiveServing) async { + guard item.liveStatus.isOperable else { return } + await performAndReload(api: api) { + try await api.finishLive(liveId: item.id) + } + } + + private func load(reset: Bool, showLoading: Bool, api: any LiveServing) async { + guard let scenicId = scenicIdText() else { return } + if reset { + currentPage = 1 + canLoadMore = false + } else { + guard canLoadMore, !isLoading else { return } + currentPage += 1 + } + isLoading = showLoading + notifyStateChange() + defer { + isLoading = false + isRefreshing = false + notifyStateChange() + } + do { + let response = try await api.liveList(scenicId: scenicId, page: currentPage, pageSize: pageSize) + totalCount = response.total + items = reset ? response.items : items + response.items + canLoadMore = items.count < totalCount + } catch is CancellationError { + return + } catch { + if !reset, currentPage > 1 { currentPage -= 1 } + if reset { + items = [] + totalCount = 0 + canLoadMore = false + } + onShowMessage?(error.localizedDescription.isEmpty ? "获取直播列表失败" : error.localizedDescription) + } + } + + private func performAndReload(api: any LiveServing, operation: () async throws -> Void) async { + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + try await operation() + await load(reset: true, showLoading: true, api: api) + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription) + } + } + + private func scenicIdText() -> String? { + let scenicId = currentScenicIdProvider() + guard scenicId > 0 else { + onShowMessage?("请先选择景区") + return nil + } + return String(scenicId) + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 直播详情页 ViewModel,负责详情加载、控制和推流模式切换。 +final class LiveDetailViewModel { + private(set) var item: LiveItem? + private(set) var pushMode: LivePushMode = .clarityFirst + private(set) var isLoading = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + let liveId: Int + + /// 初始化直播详情 ViewModel。 + init(liveId: Int) { + self.liveId = liveId + } + + /// 加载直播详情。 + func load(api: any LiveServing) async { + guard liveId > 0 else { + onShowMessage?("直播ID无效") + return + } + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + let detail = try await api.liveDetail(liveId: liveId) + item = detail + pushMode = LivePushMode(rawValue: detail.manualPushMode) ?? .clarityFirst + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "获取直播信息失败" : error.localizedDescription) + } + } + + /// 设置推流模式。 + func changeMode(_ mode: LivePushMode, api: any LiveServing) async { + guard pushMode != mode, item?.liveStatus.isOperable == true else { return } + await performAndReload(api: api) { + try await api.setPushMode(liveId: liveId, mode: mode.rawValue) + } + } + + /// 根据当前直播状态执行开始或暂停。 + func controlLive(api: any LiveServing) async { + guard let item, item.liveStatus.isOperable else { return } + await performAndReload(api: api) { + if item.liveStatus == .living { + try await api.stopLive(liveId: item.id) + } else if item.liveStatus == .pending || item.liveStatus == .paused { + try await api.startLive(liveId: item.id) + } + } + } + + /// 结束直播。 + func finishLive(api: any LiveServing) async { + guard let item, item.liveStatus.isOperable else { return } + await performAndReload(api: api) { + try await api.finishLive(liveId: item.id) + } + } + + private func performAndReload(api: any LiveServing, operation: () async throws -> Void) async { + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + try await operation() + await load(api: api) + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription) + } + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 直播相册列表页 ViewModel,负责日期筛选、分页和文件夹删除。 +final class LiveAlbumListViewModel { + private(set) var folders: [LiveAlbumFolder] = [] + private(set) var startDate: Date? + private(set) var endDate: Date? + private(set) var isLoading = false + private(set) var isRefreshing = false + private(set) var canLoadMore = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + private let currentScenicIdProvider: () -> Int + private var currentPage = 1 + private var totalCount = 0 + private let pageSize = 10 + + /// 初始化直播相册列表 ViewModel。 + init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { + self.currentScenicIdProvider = currentScenicIdProvider + } + + /// 首次加载相册列表。 + func loadInitial(api: any LiveServing) async { + await load(reset: true, showLoading: true, api: api) + } + + /// 下拉刷新相册列表。 + func refresh(api: any LiveServing) async { + isRefreshing = true + notifyStateChange() + await load(reset: true, showLoading: false, api: api) + } + + /// 滚动到底部附近时加载更多。 + func loadMoreIfNeeded(lastVisibleIndex: Int, api: any LiveServing) async { + guard lastVisibleIndex >= folders.count - 3 else { return } + await load(reset: false, showLoading: false, api: api) + } + + /// 更新开始时间。 + func setStartDate(_ date: Date, api: any LiveServing) async { + if let endDate, date.startOfDay > endDate.startOfDay { + onShowMessage?("开始时间不能大于结束时间") + return + } + startDate = date.startOfDay + notifyStateChange() + await load(reset: true, showLoading: false, api: api) + } + + /// 更新时间结束。 + func setEndDate(_ date: Date, api: any LiveServing) async { + if let startDate, date.startOfDay < startDate.startOfDay { + onShowMessage?("结束时间不能小于开始时间") + return + } + endDate = date.startOfDay + notifyStateChange() + await load(reset: true, showLoading: false, api: api) + } + + /// 删除相册文件夹。 + func deleteFolder(_ folder: LiveAlbumFolder, api: any LiveServing) async { + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + try await api.deleteAlbumFolder(folderId: folder.id) + await load(reset: true, showLoading: true, api: api) + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "删除相册失败" : error.localizedDescription) + } + } + + private func load(reset: Bool, showLoading: Bool, api: any LiveServing) async { + guard let scenicId = scenicIdText() else { return } + if reset { + currentPage = 1 + canLoadMore = false + } else { + guard canLoadMore, !isLoading else { return } + currentPage += 1 + } + isLoading = showLoading + notifyStateChange() + defer { + isLoading = false + isRefreshing = false + notifyStateChange() + } + do { + let response = try await api.albumList( + scenicId: scenicId, + startTime: startDate.map(Self.dateString), + endTime: endDate.map(Self.dateString), + page: currentPage, + pageSize: pageSize + ) + totalCount = response.total + folders = reset ? response.items : folders + response.items + canLoadMore = folders.count < totalCount + } catch is CancellationError { + return + } catch { + if !reset, currentPage > 1 { currentPage -= 1 } + if reset { + folders = [] + totalCount = 0 + canLoadMore = false + } + onShowMessage?(error.localizedDescription.isEmpty ? "获取直播相册失败" : error.localizedDescription) + } + } + + private func scenicIdText() -> String? { + let scenicId = currentScenicIdProvider() + guard scenicId > 0 else { + onShowMessage?("请先选择景区") + return nil + } + return String(scenicId) + } + + static func dateString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 直播相册上传页 ViewModel,负责本地素材上传和创建相册。 +final class LiveAlbumAddViewModel { + private(set) var title = "" + private(set) var files: [LiveAlbumFile] = [] + private(set) var isLoading = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onCreateSuccess: (() -> Void)? + + private let currentScenicIdProvider: () -> Int + + /// 初始化直播相册上传 ViewModel。 + init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { + self.currentScenicIdProvider = currentScenicIdProvider + } + + /// 更新作品名称,最多保留 20 个字符。 + func updateTitle(_ title: String) { + self.title = String(title.prefix(20)) + notifyStateChange() + } + + /// 上传本地素材并追加到列表。 + func addLocalMedia(_ media: LivePickedMedia, uploader: any LiveOSSUploading) async { + guard let scenicId = scenicId() else { return } + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + let url = try await uploader.uploadLiveAlbumFile( + data: media.data, + fileName: media.fileName, + fileType: 2, + scenicId: scenicId, + onProgress: { _ in } + ) + files.append(LiveAlbumFile(url: url, type: 2, size: media.size, coverImg: "")) + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription) + } + } + + /// 删除本地已上传素材。 + func deleteLocalFile(at index: Int) { + guard files.indices.contains(index) else { return } + files.remove(at: index) + notifyStateChange() + } + + /// 创建直播相册。 + func create(api: any LiveServing) async { + let name = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + onShowMessage?("请输入作品名称") + return + } + guard !files.isEmpty else { + onShowMessage?("请上传素材") + return + } + guard let scenicId = scenicId().map(String.init) else { return } + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + try await api.createAlbumFolder(scenicId: scenicId, name: name, items: files) + onCreateSuccess?() + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "创建相册失败" : error.localizedDescription) + } + } + + private func scenicId() -> Int? { + let scenicId = currentScenicIdProvider() + guard scenicId > 0 else { + onShowMessage?("请先选择景区") + return nil + } + return scenicId + } + + private func notifyStateChange() { + onStateChange?() + } +} + +/// 直播相册预览页 ViewModel,负责详情加载、翻页和删除当前素材。 +final class LiveAlbumPreviewViewModel { + private(set) var files: [LiveAlbumFile] = [] + private(set) var currentIndex: Int + private(set) var isLoading = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + let folderId: Int + + /// 初始化直播相册预览 ViewModel。 + init(folderId: Int, startIndex: Int) { + self.folderId = folderId + self.currentIndex = max(startIndex, 0) + } + + /// 加载相册详情。 + func load(api: any LiveServing) async { + guard folderId > 0 else { + onShowMessage?("相册ID无效") + return + } + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + let detail = try await api.albumFolderDetail(folderId: folderId) + files = detail.items + currentIndex = files.isEmpty ? 0 : min(currentIndex, files.count - 1) + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "获取相册详情失败" : error.localizedDescription) + } + } + + /// 更新当前预览位置。 + func updateCurrentIndex(_ index: Int) { + currentIndex = max(0, min(index, max(files.count - 1, 0))) + notifyStateChange() + } + + /// 删除当前素材并重新加载详情。 + func deleteCurrentFile(api: any LiveServing) async { + guard files.indices.contains(currentIndex) else { return } + let file = files[currentIndex] + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + do { + try await api.deleteAlbumFile(folderId: folderId, fileId: file.id) + currentIndex = 0 + await load(api: api) + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription.isEmpty ? "删除素材失败" : error.localizedDescription) + } + } + + private func notifyStateChange() { + onStateChange?() + } +} + +private extension Date { + var startOfDay: Date { + Calendar.current.startOfDay(for: self) + } +} diff --git a/suixinkan/UI/Live/LiveAlbumViewControllers.swift b/suixinkan/UI/Live/LiveAlbumViewControllers.swift new file mode 100644 index 0000000..fea7cb8 --- /dev/null +++ b/suixinkan/UI/Live/LiveAlbumViewControllers.swift @@ -0,0 +1,700 @@ +// +// LiveAlbumViewControllers.swift +// suixinkan +// + +import Kingfisher +import PhotosUI +import SnapKit +import UIKit + +/// 直播相册列表页,对齐 Android `AliveAlbumScreen`。 +final class LiveAlbumListViewController: BaseViewController, UITableViewDelegate { + private enum Section { + case main + } + + private let viewModel = LiveAlbumListViewModel() + private let liveAPI: any LiveServing + private let filterCard = UIView() + private let startButton = UIButton(type: .system) + private let endButton = UIButton(type: .system) + private let tableView = UITableView(frame: .zero, style: .plain) + private let refreshControl = UIRefreshControl() + private let emptyLabel = UILabel() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let bottomBar = UIView() + private let uploadButton = AppButton(title: "上传素材") + private var dataSource: UITableViewDiffableDataSource! + + /// 初始化直播相册列表页。 + @MainActor + init(liveAPI: (any LiveServing)? = nil) { + self.liveAPI = liveAPI ?? NetworkServices.shared.liveAPI + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "直播相册" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + filterCard.backgroundColor = .white + filterCard.layer.cornerRadius = 12 + configureDateButton(startButton, title: "开始时间") + configureDateButton(endButton, title: "结束时间") + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.delegate = self + tableView.refreshControl = refreshControl + tableView.register(LiveAlbumFolderCell.self, forCellReuseIdentifier: LiveAlbumFolderCell.reuseIdentifier) + tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, folder in + guard let cell = tableView.dequeueReusableCell(withIdentifier: LiveAlbumFolderCell.reuseIdentifier, for: indexPath) as? LiveAlbumFolderCell else { + return UITableViewCell() + } + cell.apply(folder) + cell.onDelete = { [weak self] in self?.confirmDelete(folder) } + cell.onImageSelected = { [weak self] index in + self?.navigationController?.pushViewController( + LiveAlbumPreviewViewController(folderId: folder.id, startIndex: index), + animated: true + ) + } + return cell + } + emptyLabel.text = "暂无素材" + emptyLabel.font = .systemFont(ofSize: 14) + emptyLabel.textColor = UIColor(hex: 0x4B5563) + emptyLabel.isHidden = true + loadingIndicator.hidesWhenStopped = true + loadingIndicator.color = AppColor.primary + bottomBar.backgroundColor = .white + + view.addSubview(filterCard) + filterCard.addSubview(startButton) + filterCard.addSubview(endButton) + view.addSubview(tableView) + view.addSubview(emptyLabel) + view.addSubview(loadingIndicator) + view.addSubview(bottomBar) + bottomBar.addSubview(uploadButton) + } + + override func setupConstraints() { + filterCard.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(59) + } + startButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.height.equalTo(35) + } + endButton.snp.makeConstraints { make in + make.leading.equalTo(startButton.snp.trailing).offset(8) + make.trailing.equalToSuperview().inset(12) + make.centerY.width.height.equalTo(startButton) + } + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + uploadButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(48) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(filterCard.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(bottomBar.snp.top) + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) + startButton.addTarget(self, action: #selector(selectStartDate), for: .touchUpInside) + endButton.addTarget(self, action: #selector(selectEndDate), for: .touchUpInside) + uploadButton.addTarget(self, action: #selector(openUpload), for: .touchUpInside) + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.loadInitial(api: liveAPI) } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + Task { await viewModel.refresh(api: liveAPI) } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let last = tableView.indexPathsForVisibleRows?.map(\.row).max() else { return } + Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: last, api: liveAPI) } + } + + @objc private func refreshTriggered() { + Task { await viewModel.refresh(api: liveAPI) } + } + + @objc private func selectStartDate() { + presentDatePicker(initialDate: viewModel.startDate ?? Date()) { [weak self] date in + guard let self else { return } + Task { await self.viewModel.setStartDate(date, api: self.liveAPI) } + } + } + + @objc private func selectEndDate() { + presentDatePicker(initialDate: viewModel.endDate ?? Date()) { [weak self] date in + guard let self else { return } + Task { await self.viewModel.setEndDate(date, api: self.liveAPI) } + } + } + + @objc private func openUpload() { + navigationController?.pushViewController(LiveAlbumAddViewController(), animated: true) + } + + private func applyViewModel() { + let startText = LiveUIFormatter.date(viewModel.startDate) + let endText = LiveUIFormatter.date(viewModel.endDate) + setDateButtonTitle(startButton, text: startText.isEmpty ? "开始时间" : startText) + setDateButtonTitle(endButton, text: endText.isEmpty ? "结束时间" : endText) + if viewModel.isRefreshing { + if !refreshControl.isRefreshing { refreshControl.beginRefreshing() } + } else { + refreshControl.endRefreshing() + } + viewModel.isLoading && viewModel.folders.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() + emptyLabel.isHidden = viewModel.isLoading || !viewModel.folders.isEmpty + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(viewModel.folders) + dataSource.apply(snapshot, animatingDifferences: true) + } + + private func confirmDelete(_ folder: LiveAlbumFolder) { + showAlert(title: "确认删除", message: "是否确认删除该相册?删除后将无法恢复。") { [weak self] in + guard let self else { return } + Task { await self.viewModel.deleteFolder(folder, api: self.liveAPI) } + } + } + + private func configureDateButton(_ button: UIButton, title: String) { + button.backgroundColor = UIColor(hex: 0xF9FAFB) + button.layer.cornerRadius = 4 + button.setTitleColor(AppColor.textPrimary, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 13) + button.setImage(UIImage(systemName: "calendar"), for: .normal) + button.tintColor = AppColor.textPrimary + button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) + setDateButtonTitle(button, text: title) + } + + private func setDateButtonTitle(_ button: UIButton, text: String) { + button.setTitle(text, for: .normal) + } + + private func presentDatePicker(initialDate: Date, onConfirm: @escaping (Date) -> Void) { + let alert = UIAlertController(title: nil, message: "\n\n\n\n\n\n\n\n", preferredStyle: .actionSheet) + let picker = UIDatePicker() + picker.datePickerMode = .date + picker.preferredDatePickerStyle = .wheels + picker.locale = Locale(identifier: "zh_CN") + picker.date = initialDate + alert.view.addSubview(picker) + picker.snp.makeConstraints { make in + make.top.equalToSuperview().offset(8) + make.leading.trailing.equalToSuperview().inset(8) + make.height.equalTo(216) + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in onConfirm(picker.date) }) + present(alert, animated: true) + } +} + +/// 直播相册文件夹 Cell,对齐 Android `AliveAlbumFolderView`。 +private final class LiveAlbumFolderCell: UITableViewCell { + static let reuseIdentifier = "LiveAlbumFolderCell" + + var onDelete: (() -> Void)? + var onImageSelected: ((Int) -> Void)? + + private let container = UIView() + private let titleLabel = UILabel() + private let deleteButton = UIButton(type: .system) + private let gridStack = UIStackView() + private var imageViews: [LiveCoverImageView] = [] + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + onDelete = nil + onImageSelected = nil + imageViews.forEach { $0.removeFromSuperview() } + imageViews.removeAll() + gridStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + } + + /// 应用直播相册文件夹。 + func apply(_ folder: LiveAlbumFolder) { + titleLabel.text = folder.displayName + buildGrid(items: folder.items) + } + + private func setup() { + selectionStyle = .none + backgroundColor = .clear + container.backgroundColor = .white + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) + deleteButton.tintColor = AppColor.danger + gridStack.axis = .vertical + gridStack.spacing = 12 + + contentView.addSubview(container) + container.addSubview(titleLabel) + container.addSubview(deleteButton) + container.addSubview(gridStack) + container.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.leading.trailing.equalToSuperview() + } + titleLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(16) + make.trailing.lessThanOrEqualTo(deleteButton.snp.leading).offset(-8) + } + deleteButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(8) + make.size.equalTo(44) + } + gridStack.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(12) + make.bottom.equalToSuperview().inset(12) + } + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + } + + private func buildGrid(items: [LiveAlbumFile]) { + let rows = stride(from: 0, to: items.count, by: 2).map { start in + Array(items[start ..< min(start + 2, items.count)]) + } + for (rowIndex, rowItems) in rows.enumerated() { + let rowStack = UIStackView() + rowStack.axis = .horizontal + rowStack.spacing = 12 + rowStack.distribution = .fillEqually + gridStack.addArrangedSubview(rowStack) + for (columnIndex, item) in rowItems.enumerated() { + let imageView = LiveCoverImageView(cornerRadius: 12) + imageView.setURL(item.url) + imageView.isUserInteractionEnabled = true + imageView.tag = rowIndex * 2 + columnIndex + imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:)))) + rowStack.addArrangedSubview(imageView) + imageViews.append(imageView) + imageView.snp.makeConstraints { make in + make.height.equalTo(imageView.snp.width).multipliedBy(9.0 / 16.0) + } + } + if rowItems.count == 1 { + rowStack.addArrangedSubview(UIView()) + } + } + } + + @objc private func deleteTapped() { + onDelete?() + } + + @objc private func imageTapped(_ recognizer: UITapGestureRecognizer) { + guard let view = recognizer.view else { return } + onImageSelected?(view.tag) + } +} + +/// 上传直播相册素材页,对齐 Android `AliveAlbumAddScreen`。 +final class LiveAlbumAddViewController: BaseViewController, PHPickerViewControllerDelegate { + private let viewModel = LiveAlbumAddViewModel() + private let liveAPI: any LiveServing + private let uploader: any LiveOSSUploading + private let scrollView = UIScrollView() + private let cardView = UIView() + private let stackView = UIStackView() + private let titleField = UITextField() + private let uploadView = LiveDashedUploadView(title: "点击上传图片", subtitle: "支持 jpg、png、mp4 格式") + private let filesStack = UIStackView() + private let bottomBar = UIView() + private let confirmButton = AppButton(title: "确认") + + /// 初始化上传直播相册素材页。 + @MainActor + init( + liveAPI: (any LiveServing)? = nil, + uploader: (any LiveOSSUploading)? = nil + ) { + self.liveAPI = liveAPI ?? NetworkServices.shared.liveAPI + self.uploader = uploader ?? NetworkServices.shared.ossUploadService + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "上传素材" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 12 + stackView.axis = .vertical + stackView.spacing = 8 + filesStack.axis = .vertical + filesStack.spacing = 12 + titleField.placeholder = "请输入作品名称" + titleField.font = .systemFont(ofSize: 14) + titleField.layer.borderWidth = 1 + titleField.layer.borderColor = AppColor.border.cgColor + titleField.layer.cornerRadius = 8 + titleField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + titleField.leftViewMode = .always + bottomBar.backgroundColor = .white + + view.addSubview(scrollView) + scrollView.addSubview(cardView) + cardView.addSubview(stackView) + addRequiredTitle("直播标题", to: stackView) + stackView.addArrangedSubview(titleField) + addRequiredTitle("封面图片", suffix: "(单次最多可上传 20 张图片)", to: stackView) + stackView.addArrangedSubview(uploadView) + stackView.addArrangedSubview(filesStack) + view.addSubview(bottomBar) + bottomBar.addSubview(confirmButton) + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + confirmButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(48) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + make.width.equalTo(scrollView).offset(-32) + } + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + titleField.snp.makeConstraints { make in + make.height.equalTo(44) + } + uploadView.snp.makeConstraints { make in + make.height.equalTo(120) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onCreateSuccess = { [weak self] in + Task { @MainActor in self?.navigationController?.popViewController(animated: true) } + } + titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged) + uploadView.addTarget(self, action: #selector(pickImage), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let result = results.first else { return } + Task { + do { + let media = try await LivePhotoPickerLoader.loadImage(from: result) + await viewModel.addLocalMedia(media, uploader: uploader) + } catch { + showToast(error.localizedDescription) + } + } + } + + @objc private func titleChanged() { + viewModel.updateTitle(titleField.text ?? "") + if titleField.text != viewModel.title { + titleField.text = viewModel.title + } + } + + @objc private func pickImage() { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = 1 + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + + @objc private func confirmTapped() { + Task { await viewModel.create(api: liveAPI) } + } + + private func applyViewModel() { + viewModel.isLoading ? showLoading() : hideLoading() + filesStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for (index, file) in viewModel.files.enumerated() { + let view = LiveAlbumUploadedFileView(file: file) + view.onDelete = { [weak self] in self?.confirmDeleteLocalFile(at: index) } + filesStack.addArrangedSubview(view) + view.snp.makeConstraints { make in + make.height.equalTo(view.snp.width).multipliedBy(9.0 / 16.0) + } + } + } + + private func confirmDeleteLocalFile(at index: Int) { + showAlert(title: "确认删除", message: "是否确认删除该相册?删除后将无法恢复。") { [weak self] in + self?.viewModel.deleteLocalFile(at: index) + } + } + + private func addRequiredTitle(_ title: String, suffix: String? = nil, to stack: UIStackView) { + let label = UILabel() + let text = NSMutableAttributedString( + string: "\(title) *", + attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .medium), .foregroundColor: AppColor.textPrimary] + ) + text.addAttribute(.foregroundColor, value: UIColor.red, range: NSRange(location: title.count + 1, length: 1)) + if let suffix { + text.append(NSAttributedString(string: suffix, attributes: [.font: UIFont.systemFont(ofSize: 14), .foregroundColor: AppColor.textPrimary])) + } + label.attributedText = text + stack.addArrangedSubview(label) + } +} + +/// 已上传直播素材预览行。 +private final class LiveAlbumUploadedFileView: UIView { + var onDelete: (() -> Void)? + + private let imageView = LiveCoverImageView() + private let deleteButton = UIButton(type: .system) + + /// 初始化已上传素材视图。 + init(file: LiveAlbumFile) { + super.init(frame: .zero) + imageView.setURL(file.url) + deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + deleteButton.tintColor = .gray + addSubview(imageView) + addSubview(deleteButton) + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + deleteButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(4) + make.size.equalTo(28) + } + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func deleteTapped() { + onDelete?() + } +} + +/// 直播相册预览页,对齐 Android `AliveAlbumPreviewScreen`。 +final class LiveAlbumPreviewViewController: BaseViewController, UIScrollViewDelegate { + private let viewModel: LiveAlbumPreviewViewModel + private let liveAPI: any LiveServing + private let pagingScrollView = UIScrollView() + private let pageControl = UIPageControl() + private let emptyLabel = UILabel() + private let bottomBar = UIView() + private let deleteButton = LiveActionButton(title: "删除素材") + + /// 初始化直播相册预览页。 + @MainActor + init(folderId: Int, startIndex: Int, liveAPI: (any LiveServing)? = nil) { + viewModel = LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex) + self.liveAPI = liveAPI ?? NetworkServices.shared.liveAPI + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "预览" + navigationController?.navigationBar.barTintColor = .black + navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.white] + navigationController?.navigationBar.tintColor = .white + } + + override func setupUI() { + view.backgroundColor = .black + pagingScrollView.isPagingEnabled = true + pagingScrollView.showsHorizontalScrollIndicator = false + pagingScrollView.delegate = self + pageControl.currentPageIndicatorTintColor = .white + pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.4) + emptyLabel.text = "没有可预览的图片" + emptyLabel.textColor = .white + emptyLabel.font = .systemFont(ofSize: 16) + emptyLabel.textAlignment = .center + bottomBar.backgroundColor = .black + deleteButton.buttonStyle = .danger + view.addSubview(pagingScrollView) + view.addSubview(pageControl) + view.addSubview(emptyLabel) + view.addSubview(bottomBar) + bottomBar.addSubview(deleteButton) + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + deleteButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(48) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + } + pagingScrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + pageControl.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(pagingScrollView).inset(8) + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(pagingScrollView) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + deleteButton.addTarget(self, action: #selector(confirmDelete), for: .touchUpInside) + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.load(api: liveAPI) } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.navigationBar.barTintColor = nil + navigationController?.navigationBar.titleTextAttributes = nil + navigationController?.navigationBar.tintColor = nil + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutPages() + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + let index = Int(round(scrollView.contentOffset.x / max(scrollView.bounds.width, 1))) + viewModel.updateCurrentIndex(index) + } + + private func applyViewModel() { + viewModel.isLoading && viewModel.files.isEmpty ? showLoading() : hideLoading() + pageControl.numberOfPages = viewModel.files.count + pageControl.currentPage = viewModel.currentIndex + emptyLabel.isHidden = !viewModel.files.isEmpty + deleteButton.isEnabled = !viewModel.files.isEmpty + buildPages() + } + + private func buildPages() { + pagingScrollView.subviews.forEach { $0.removeFromSuperview() } + for (index, file) in viewModel.files.enumerated() { + let imageView = LiveCoverImageView(cornerRadius: 0) + imageView.contentMode = .scaleAspectFit + imageView.backgroundColor = .black + imageView.setURL(file.url) + imageView.tag = index + pagingScrollView.addSubview(imageView) + } + layoutPages() + } + + private func layoutPages() { + let size = pagingScrollView.bounds.size + guard size.width > 0 else { return } + for (index, view) in pagingScrollView.subviews.enumerated() { + view.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height) + } + pagingScrollView.contentSize = CGSize(width: size.width * CGFloat(viewModel.files.count), height: size.height) + pagingScrollView.setContentOffset(CGPoint(x: CGFloat(viewModel.currentIndex) * size.width, y: 0), animated: false) + } + + @objc private func confirmDelete() { + showAlert(title: "确认删除", message: "是否确认删除该素材?删除后将无法恢复。") { [weak self] in + guard let self else { return } + Task { await self.viewModel.deleteCurrentFile(api: self.liveAPI) } + } + } +} diff --git a/suixinkan/UI/Live/LiveDetailViewController.swift b/suixinkan/UI/Live/LiveDetailViewController.swift new file mode 100644 index 0000000..bdcfbc4 --- /dev/null +++ b/suixinkan/UI/Live/LiveDetailViewController.swift @@ -0,0 +1,361 @@ +// +// LiveDetailViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 直播信息页,对齐 Android `AliveDetailsScreen`。 +final class LiveDetailViewController: BaseViewController { + private let viewModel: LiveDetailViewModel + private let liveAPI: any LiveServing + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let coverImageView = LiveCoverImageView(cornerRadius: 0) + private let infoCard = LiveInfoCardView() + private let settingsCard = LiveSettingsCardView() + private let bottomBar = UIView() + private let controlButton = LiveActionButton(title: "开始", image: UIImage(systemName: "play.fill")) + private let finishButton = LiveActionButton(title: "结束", image: UIImage(systemName: "stop.fill")) + + /// 初始化直播信息页。 + @MainActor + init(liveId: Int, liveAPI: (any LiveServing)? = nil) { + viewModel = LiveDetailViewModel(liveId: liveId) + self.liveAPI = liveAPI ?? NetworkServices.shared.liveAPI + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "直播信息" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + contentStack.axis = .vertical + contentStack.spacing = 16 + scrollView.backgroundColor = AppColor.pageBackground + bottomBar.backgroundColor = .white + finishButton.buttonStyle = .danger + + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + contentStack.addArrangedSubview(coverImageView) + contentStack.addArrangedSubview(infoCard) + contentStack.addArrangedSubview(settingsCard) + view.addSubview(bottomBar) + bottomBar.addSubview(controlButton) + bottomBar.addSubview(finishButton) + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + controlButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.equalToSuperview().offset(16) + make.height.equalTo(48) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + } + finishButton.snp.makeConstraints { make in + make.leading.equalTo(controlButton.snp.trailing).offset(8) + make.trailing.equalToSuperview().inset(16) + make.top.bottom.width.equalTo(controlButton) + } + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(scrollView) + } + coverImageView.snp.makeConstraints { make in + make.height.equalTo(coverImageView.snp.width).multipliedBy(9.0 / 16.0) + } + infoCard.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + } + settingsCard.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + controlButton.addTarget(self, action: #selector(controlTapped), for: .touchUpInside) + finishButton.addTarget(self, action: #selector(finishTapped), for: .touchUpInside) + settingsCard.onCopy = { [weak self] text in + UIPasteboard.general.string = text + self?.showToast("已复制") + } + settingsCard.onModeSelected = { [weak self] mode in + guard let self else { return } + Task { await self.viewModel.changeMode(mode, api: self.liveAPI) } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.load(api: liveAPI) } + } + + private func applyViewModel() { + viewModel.isLoading && viewModel.item == nil ? showLoading() : hideLoading() + guard let item = viewModel.item else { return } + coverImageView.setURL(item.coverImg) + infoCard.apply(item) + settingsCard.apply(item: item, pushMode: viewModel.pushMode) + controlButton.setTitle(item.controlTitle, for: .normal) + controlButton.setImage(UIImage(systemName: item.liveStatus == .living ? "pause.fill" : "play.fill"), for: .normal) + controlButton.buttonStyle = item.liveStatus == .living ? .warning : .primary + controlButton.isEnabled = item.liveStatus.isOperable + finishButton.isEnabled = item.liveStatus.isOperable + } + + @objc private func controlTapped() { + Task { await viewModel.controlLive(api: liveAPI) } + } + + @objc private func finishTapped() { + showAlert(title: "确认结束", message: "是否确认结束该直播?") { [weak self] in + guard let self else { return } + Task { await self.viewModel.finishLive(api: self.liveAPI) } + } + } +} + +/// 直播信息卡片,显示时长和观众数。 +private final class LiveInfoCardView: UIView { + private let titleLabel = UILabel() + private let durationRow = LiveIconTextRow(iconName: "clock", text: "") + private let watchRow = LiveIconTextRow(iconName: "eye", text: "") + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 应用直播详情。 + func apply(_ item: LiveItem) { + durationRow.text = "直播时长: \(LiveUIFormatter.duration(item.duration))" + watchRow.text = "在线观众: \(item.viewsCount)" + } + + private func setup() { + backgroundColor = .white + layer.cornerRadius = 12 + titleLabel.text = "直播信息" + titleLabel.font = .systemFont(ofSize: 14, weight: .bold) + titleLabel.textColor = AppColor.textPrimary + addSubview(titleLabel) + addSubview(durationRow) + addSubview(watchRow) + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + } + durationRow.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(6) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(22) + } + watchRow.snp.makeConstraints { make in + make.top.equalTo(durationRow.snp.bottom).offset(4) + make.leading.trailing.bottom.equalToSuperview().inset(16) + make.height.equalTo(22) + } + } +} + +/// 直播推流设置卡片,显示推流地址和模式选择。 +private final class LiveSettingsCardView: UIView { + var onCopy: ((String) -> Void)? + var onModeSelected: ((LivePushMode) -> Void)? + + private let titleLabel = UILabel() + private let addressTitleLabel = UILabel() + private let addressContainer = UIView() + private let addressLabel = UILabel() + private let copyButton = UIButton(type: .system) + private let modeTitleLabel = UILabel() + private let clarityButton = UIButton(type: .system) + private let smoothButton = UIButton(type: .system) + private var pushURL = "" + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 应用直播详情与当前推流模式。 + func apply(item: LiveItem, pushMode: LivePushMode) { + pushURL = item.pushUrl + addressLabel.text = item.pushUrl + setMode(pushMode) + clarityButton.isEnabled = item.liveStatus.isOperable + smoothButton.isEnabled = item.liveStatus.isOperable + } + + private func setup() { + backgroundColor = .white + layer.cornerRadius = 12 + titleLabel.text = "推流设置" + titleLabel.font = .systemFont(ofSize: 14, weight: .bold) + titleLabel.textColor = AppColor.textPrimary + addressTitleLabel.text = "推流地址" + addressTitleLabel.font = .systemFont(ofSize: 14) + addressTitleLabel.textColor = UIColor(hex: 0x4B5563) + addressContainer.backgroundColor = AppColor.inputBackground + addressContainer.layer.borderWidth = 1 + addressContainer.layer.borderColor = AppColor.border.cgColor + addressContainer.layer.cornerRadius = 4 + addressLabel.font = .systemFont(ofSize: 14) + addressLabel.textColor = AppColor.textPrimary + addressLabel.lineBreakMode = .byTruncatingMiddle + copyButton.setTitle("复制", for: .normal) + copyButton.titleLabel?.font = .systemFont(ofSize: 14) + copyButton.backgroundColor = AppColor.primary + copyButton.setTitleColor(.white, for: .normal) + copyButton.layer.cornerRadius = 4 + modeTitleLabel.text = "直播模式" + modeTitleLabel.font = .systemFont(ofSize: 14) + modeTitleLabel.textColor = UIColor(hex: 0x4B5563) + [clarityButton, smoothButton].forEach { button in + button.titleLabel?.font = .systemFont(ofSize: 14) + button.layer.cornerRadius = 4 + button.contentEdgeInsets = UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12) + } + clarityButton.setTitle(LivePushMode.clarityFirst.title, for: .normal) + smoothButton.setTitle(LivePushMode.smoothFirst.title, for: .normal) + + addSubview(titleLabel) + addSubview(addressTitleLabel) + addSubview(addressContainer) + addressContainer.addSubview(addressLabel) + addSubview(copyButton) + addSubview(modeTitleLabel) + addSubview(clarityButton) + addSubview(smoothButton) + + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + } + addressTitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(16) + } + addressContainer.snp.makeConstraints { make in + make.top.equalTo(addressTitleLabel.snp.bottom).offset(4) + make.leading.equalToSuperview().offset(16) + make.height.equalTo(28) + } + addressLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(8) + make.centerY.equalToSuperview() + } + copyButton.snp.makeConstraints { make in + make.leading.equalTo(addressContainer.snp.trailing).offset(4) + make.trailing.equalToSuperview().inset(16) + make.top.bottom.equalTo(addressContainer) + make.width.equalTo(52) + } + modeTitleLabel.snp.makeConstraints { make in + make.top.equalTo(addressContainer.snp.bottom).offset(8) + make.leading.equalToSuperview().offset(16) + make.bottom.equalToSuperview().inset(16) + } + smoothButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.centerY.equalTo(modeTitleLabel) + make.height.equalTo(28) + } + clarityButton.snp.makeConstraints { make in + make.trailing.equalTo(smoothButton.snp.leading).offset(-4) + make.centerY.height.equalTo(smoothButton) + } + + copyButton.addTarget(self, action: #selector(copyTapped), for: .touchUpInside) + clarityButton.addTarget(self, action: #selector(clarityTapped), for: .touchUpInside) + smoothButton.addTarget(self, action: #selector(smoothTapped), for: .touchUpInside) + } + + private func setMode(_ mode: LivePushMode) { + updateModeButton(clarityButton, selected: mode == .clarityFirst) + updateModeButton(smoothButton, selected: mode == .smoothFirst) + } + + private func updateModeButton(_ button: UIButton, selected: Bool) { + button.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground + button.setTitleColor(selected ? .white : UIColor(hex: 0xB6BECA), for: .normal) + } + + @objc private func copyTapped() { + onCopy?(pushURL) + } + + @objc private func clarityTapped() { + onModeSelected?(.clarityFirst) + } + + @objc private func smoothTapped() { + onModeSelected?(.smoothFirst) + } +} + +/// 图标文字行,用于直播详情信息项。 +private final class LiveIconTextRow: UIView { + var text: String = "" { + didSet { label.text = text } + } + + private let iconView = UIImageView() + private let label = UILabel() + + /// 初始化图标文字行。 + init(iconName: String, text: String) { + super.init(frame: .zero) + iconView.image = UIImage(systemName: iconName) + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + label.text = text + label.font = .systemFont(ofSize: 14) + label.textColor = UIColor(hex: 0x4B5563) + addSubview(iconView) + addSubview(label) + iconView.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.size.equalTo(16) + } + label.snp.makeConstraints { make in + make.leading.equalTo(iconView.snp.trailing).offset(8) + make.trailing.centerY.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/suixinkan/UI/Live/LiveManageViewController.swift b/suixinkan/UI/Live/LiveManageViewController.swift new file mode 100644 index 0000000..3b16986 --- /dev/null +++ b/suixinkan/UI/Live/LiveManageViewController.swift @@ -0,0 +1,503 @@ +// +// LiveManageViewController.swift +// suixinkan +// + +import Kingfisher +import PhotosUI +import SnapKit +import UIKit + +/// 直播管理页,对齐 Android `AliveManageScreen`。 +final class LiveManageViewController: BaseViewController, UITableViewDelegate { + private enum Section { + case main + } + + private let viewModel = LiveManageViewModel() + private let liveAPI: any LiveServing + private let uploader: any LiveOSSUploading + private let tableView = UITableView(frame: .zero, style: .plain) + private let refreshControl = UIRefreshControl() + private let emptyLabel = UILabel() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let bottomBar = UIView() + private let addButton = AppButton(title: "添加") + private var dataSource: UITableViewDiffableDataSource! + + /// 初始化直播管理页。 + @MainActor + init( + liveAPI: (any LiveServing)? = nil, + uploader: (any LiveOSSUploading)? = nil + ) { + self.liveAPI = liveAPI ?? NetworkServices.shared.liveAPI + self.uploader = uploader ?? NetworkServices.shared.ossUploadService + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "直播管理" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.delegate = self + tableView.refreshControl = refreshControl + tableView.register(LiveItemCell.self, forCellReuseIdentifier: LiveItemCell.reuseIdentifier) + tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) + + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in + guard let cell = tableView.dequeueReusableCell(withIdentifier: LiveItemCell.reuseIdentifier, for: indexPath) as? LiveItemCell else { + return UITableViewCell() + } + cell.apply(item) + cell.onCopy = { [weak self] in self?.copyPushURL(item) } + cell.onControl = { [weak self] in self?.controlLive(item) } + cell.onFinish = { [weak self] in self?.confirmFinish(item) } + return cell + } + + emptyLabel.text = "暂无直播" + emptyLabel.font = .systemFont(ofSize: 14) + emptyLabel.textColor = UIColor(hex: 0x4B5563) + emptyLabel.textAlignment = .center + emptyLabel.isHidden = true + loadingIndicator.hidesWhenStopped = true + loadingIndicator.color = AppColor.primary + + bottomBar.backgroundColor = .white + view.addSubview(tableView) + view.addSubview(emptyLabel) + view.addSubview(loadingIndicator) + view.addSubview(bottomBar) + bottomBar.addSubview(addButton) + } + + override func setupConstraints() { + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + addButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + make.height.equalTo(48) + } + tableView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onLiveCreated = { [weak self] in + Task { @MainActor in self?.dismiss(animated: true) } + } + refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged) + addButton.addTarget(self, action: #selector(showAddLiveSheet), for: .touchUpInside) + } + + override func viewDidLoad() { + super.viewDidLoad() + Task { await viewModel.loadInitial(api: liveAPI) } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + navigationController?.pushViewController(LiveDetailViewController(liveId: item.id), animated: true) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + UITableView.automaticDimension + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let last = tableView.indexPathsForVisibleRows?.map(\.row).max() else { return } + Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: last, api: liveAPI) } + } + + @objc private func refreshTriggered() { + Task { await viewModel.refresh(api: liveAPI) } + } + + @objc private func showAddLiveSheet() { + let controller = AddLiveSheetViewController(uploader: uploader) + controller.onConfirm = { [weak self] title, cover in + guard let self else { return } + Task { await self.viewModel.createLive(title: title, coverUrl: cover, api: self.liveAPI) } + } + controller.onMessage = { [weak self] message in + self?.showToast(message) + } + if let sheet = controller.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + } + present(controller, animated: true) + } + + private func applyViewModel() { + if viewModel.isRefreshing { + if !refreshControl.isRefreshing { refreshControl.beginRefreshing() } + } else { + refreshControl.endRefreshing() + } + viewModel.isLoading && viewModel.items.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() + emptyLabel.isHidden = viewModel.isLoading || !viewModel.items.isEmpty + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(viewModel.items) + dataSource.apply(snapshot, animatingDifferences: true) + } + + private func copyPushURL(_ item: LiveItem) { + UIPasteboard.general.string = viewModel.copyPushURL(item) + showToast("已复制") + } + + private func controlLive(_ item: LiveItem) { + Task { await viewModel.controlLive(item, api: liveAPI) } + } + + private func confirmFinish(_ item: LiveItem) { + showAlert(title: "确认结束", message: "是否确认结束该直播?") { [weak self] in + guard let self else { return } + Task { await self.viewModel.finishLive(item, api: self.liveAPI) } + } + } +} + +/// 直播列表项 Cell,对齐 Android `AliveItem`。 +private final class LiveItemCell: UITableViewCell { + static let reuseIdentifier = "LiveItemCell" + + var onCopy: (() -> Void)? + var onControl: (() -> Void)? + var onFinish: (() -> Void)? + + private let container = UIView() + private let coverImageView = LiveCoverImageView() + private let statusOverlay = UIView() + private let statusDot = UIView() + private let statusLabel = UILabel() + private let titleLabel = UILabel() + private let timeLabel = UILabel() + private let durationLabel = UILabel() + private let copyButton = UIButton(type: .system) + private let controlButton = LiveActionButton(title: "开始", image: UIImage(systemName: "play.fill")) + private let finishButton = LiveActionButton(title: "结束", image: UIImage(systemName: "stop.fill")) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + onCopy = nil + onControl = nil + onFinish = nil + } + + /// 应用直播列表项。 + func apply(_ item: LiveItem) { + coverImageView.setURL(item.coverImg) + statusLabel.text = item.statusLabel + statusDot.backgroundColor = item.liveStatus == .living ? UIColor(hex: 0xF91616) : .white + titleLabel.text = item.displayTitle + timeLabel.text = "直播时间:\(LiveUIFormatter.timeRange(start: item.startTime, end: item.endTime))" + durationLabel.text = "时长:\(LiveUIFormatter.duration(item.duration))" + controlButton.setTitle(item.controlTitle, for: .normal) + controlButton.setImage(UIImage(systemName: item.liveStatus == .living ? "pause.fill" : "play.fill"), for: .normal) + controlButton.buttonStyle = item.liveStatus == .living ? .warning : .primary + controlButton.isEnabled = item.liveStatus.isOperable + finishButton.buttonStyle = .danger + finishButton.isEnabled = item.liveStatus.isOperable + } + + private func setup() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + container.backgroundColor = .white + + statusOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.35) + statusOverlay.layer.cornerRadius = 8 + statusOverlay.clipsToBounds = true + statusDot.layer.cornerRadius = 3 + statusLabel.font = .systemFont(ofSize: 12) + statusLabel.textColor = .white + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + titleLabel.numberOfLines = 1 + timeLabel.font = .systemFont(ofSize: 12) + timeLabel.textColor = AppColor.textPrimary + durationLabel.font = .systemFont(ofSize: 12) + durationLabel.textColor = AppColor.textPrimary + copyButton.setTitle("复制地址", for: .normal) + copyButton.titleLabel?.font = .systemFont(ofSize: 12) + copyButton.setTitleColor(.white, for: .normal) + copyButton.backgroundColor = AppColor.primary + copyButton.layer.cornerRadius = 4 + finishButton.buttonStyle = .danger + + contentView.addSubview(container) + container.addSubview(coverImageView) + coverImageView.addSubview(statusOverlay) + statusOverlay.addSubview(statusDot) + statusOverlay.addSubview(statusLabel) + container.addSubview(titleLabel) + container.addSubview(timeLabel) + container.addSubview(durationLabel) + container.addSubview(copyButton) + container.addSubview(controlButton) + container.addSubview(finishButton) + + container.snp.makeConstraints { make in + make.top.bottom.equalToSuperview() + make.leading.trailing.equalToSuperview() + } + coverImageView.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(16) + make.size.equalTo(CGSize(width: 120, height: 68)) + } + statusOverlay.snp.makeConstraints { make in + make.leading.bottom.equalToSuperview().inset(6) + make.height.equalTo(20) + } + statusDot.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(6) + make.centerY.equalToSuperview() + make.size.equalTo(6) + } + statusLabel.snp.makeConstraints { make in + make.leading.equalTo(statusDot.snp.trailing).offset(2) + make.trailing.equalToSuperview().inset(6) + make.centerY.equalToSuperview() + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(coverImageView) + make.leading.equalTo(coverImageView.snp.trailing).offset(12) + make.trailing.equalToSuperview().inset(16) + } + timeLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.trailing.equalTo(titleLabel) + } + durationLabel.snp.makeConstraints { make in + make.leading.equalTo(titleLabel) + make.bottom.equalTo(coverImageView) + } + copyButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.centerY.equalTo(durationLabel) + make.height.equalTo(22) + make.width.equalTo(64) + } + controlButton.snp.makeConstraints { make in + make.top.equalTo(coverImageView.snp.bottom).offset(20) + make.leading.equalToSuperview().offset(16) + make.height.equalTo(40) + make.bottom.equalToSuperview().inset(16) + } + finishButton.snp.makeConstraints { make in + make.leading.equalTo(controlButton.snp.trailing).offset(8) + make.trailing.equalToSuperview().inset(16) + make.top.bottom.width.equalTo(controlButton) + } + + copyButton.addTarget(self, action: #selector(copyTapped), for: .touchUpInside) + controlButton.addTarget(self, action: #selector(controlTapped), for: .touchUpInside) + finishButton.addTarget(self, action: #selector(finishTapped), for: .touchUpInside) + } + + @objc private func copyTapped() { + onCopy?() + } + + @objc private func controlTapped() { + onControl?() + } + + @objc private func finishTapped() { + onFinish?() + } +} + +/// 新增直播底部弹窗,对齐 Android `AddAliveModal`。 +private final class AddLiveSheetViewController: UIViewController, PHPickerViewControllerDelegate { + var onConfirm: ((String, String) -> Void)? + var onMessage: ((String) -> Void)? + + private let uploader: any LiveOSSUploading + private let titleLabel = UILabel() + private let titleField = UITextField() + private let coverTitleLabel = UILabel() + private let uploadView = LiveDashedUploadView() + private let coverImageView = LiveCoverImageView() + private let confirmButton = AppButton(title: "确认") + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private var coverURL = "" + + /// 初始化新增直播弹窗。 + init(uploader: any LiveOSSUploading) { + self.uploader = uploader + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setup() + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let result = results.first else { return } + Task { + do { + let media = try await LivePhotoPickerLoader.loadImage(from: result) + await uploadCover(media) + } catch { + onMessage?(error.localizedDescription) + } + } + } + + private func setup() { + view.backgroundColor = .white + let headingLabel = UILabel() + headingLabel.text = "新增直播" + headingLabel.font = .systemFont(ofSize: 18, weight: .semibold) + headingLabel.textColor = AppColor.textPrimary + headingLabel.textAlignment = .center + titleLabel.text = "直播标题" + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + titleField.placeholder = "请输入直播标题" + titleField.font = .systemFont(ofSize: 14) + titleField.borderStyle = .none + titleField.backgroundColor = .white + titleField.layer.borderWidth = 1 + titleField.layer.borderColor = AppColor.border.cgColor + titleField.layer.cornerRadius = 8 + titleField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + titleField.leftViewMode = .always + coverTitleLabel.text = "封面图片" + coverTitleLabel.font = .systemFont(ofSize: 16, weight: .medium) + coverTitleLabel.textColor = AppColor.textPrimary + coverImageView.isHidden = true + loadingIndicator.hidesWhenStopped = true + loadingIndicator.color = AppColor.primary + + view.addSubview(headingLabel) + view.addSubview(titleLabel) + view.addSubview(titleField) + view.addSubview(coverTitleLabel) + view.addSubview(uploadView) + view.addSubview(coverImageView) + view.addSubview(loadingIndicator) + view.addSubview(confirmButton) + + headingLabel.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(12) + make.leading.trailing.equalToSuperview().inset(16) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(headingLabel.snp.bottom).offset(18) + make.leading.trailing.equalToSuperview().inset(16) + } + titleField.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(44) + } + coverTitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleField.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(16) + } + uploadView.snp.makeConstraints { make in + make.top.equalTo(coverTitleLabel.snp.bottom).offset(8) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(uploadView.snp.width).multipliedBy(9.0 / 16.0) + } + coverImageView.snp.makeConstraints { make in + make.edges.equalTo(uploadView) + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(uploadView) + } + confirmButton.snp.makeConstraints { make in + make.top.equalTo(uploadView.snp.bottom).offset(32) + make.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(48) + make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(16) + } + + uploadView.addTarget(self, action: #selector(pickCover), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + } + + @objc private func pickCover() { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = 1 + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + + @objc private func confirmTapped() { + onConfirm?(titleField.text ?? "", coverURL) + } + + private func uploadCover(_ media: LivePickedMedia) async { + loadingIndicator.startAnimating() + defer { loadingIndicator.stopAnimating() } + do { + let scenicId = AppStore.shared.currentScenicId + let url = try await uploader.uploadLiveCover( + data: media.data, + fileName: media.fileName, + scenicId: scenicId, + onProgress: { _ in } + ) + coverURL = url + coverImageView.image = UIImage(data: media.data) + coverImageView.isHidden = false + uploadView.isHidden = true + } catch { + onMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription) + } + } +} diff --git a/suixinkan/UI/Live/LiveUIComponents.swift b/suixinkan/UI/Live/LiveUIComponents.swift new file mode 100644 index 0000000..ed5455c --- /dev/null +++ b/suixinkan/UI/Live/LiveUIComponents.swift @@ -0,0 +1,242 @@ +// +// LiveUIComponents.swift +// suixinkan +// + +import Kingfisher +import PhotosUI +import SnapKit +import UIKit + +/// 直播 UI 格式化工具,集中处理 Android 页面使用的日期和时长文案。 +enum LiveUIFormatter { + /// 格式化直播时间范围。 + static func timeRange(start: Int64, end: Int64) -> String { + guard start > 0 || end > 0 else { return "" } + let startText = timestampText(start) + let endText = timestampText(end) + if startText.isEmpty { return endText } + if endText.isEmpty { return startText } + return "\(startText) - \(endText)" + } + + /// 格式化秒级时长。 + static func duration(_ seconds: Int64) -> String { + let value = max(seconds, 0) + let hour = value / 3600 + let minute = (value % 3600) / 60 + let second = value % 60 + if hour > 0 { + return String(format: "%02lld:%02lld:%02lld", hour, minute, second) + } + return String(format: "%02lld:%02lld", minute, second) + } + + /// 格式化日期。 + static func date(_ date: Date?) -> String { + guard let date else { return "" } + return LiveAlbumListViewModel.dateString(date) + } + + private static func timestampText(_ timestamp: Int64) -> String { + guard timestamp > 0 else { return "" } + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) + } +} + +/// 直播模块按钮,支持 Android 页面中的主按钮、暂停和危险操作色。 +final class LiveActionButton: UIButton { + enum Style { + case primary + case warning + case danger + case disabled + } + + var buttonStyle: Style = .primary { + didSet { updateAppearance() } + } + + override var isEnabled: Bool { + didSet { updateAppearance() } + } + + /// 初始化直播操作按钮。 + init(title: String, image: UIImage? = nil) { + super.init(frame: .zero) + setTitle(title, for: .normal) + setImage(image, for: .normal) + titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + layer.cornerRadius = 8 + clipsToBounds = true + tintColor = .white + imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) + updateAppearance() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateAppearance() { + let effectiveStyle: Style = isEnabled ? buttonStyle : .disabled + switch effectiveStyle { + case .primary: + backgroundColor = UIColor(hex: 0x0066FF) + setTitleColor(.white, for: .normal) + tintColor = .white + case .warning: + backgroundColor = AppColor.warning + setTitleColor(.white, for: .normal) + tintColor = .white + case .danger: + backgroundColor = AppColor.danger + setTitleColor(.white, for: .normal) + tintColor = .white + case .disabled: + backgroundColor = AppColor.inputBackground + setTitleColor(UIColor(hex: 0xB6BECA), for: .normal) + tintColor = UIColor(hex: 0xB6BECA) + } + } +} + +/// 直播模块封面视图,统一 Kingfisher 加载和占位展示。 +final class LiveCoverImageView: UIImageView { + /// 初始化封面图片视图。 + init(cornerRadius: CGFloat = 8) { + super.init(frame: .zero) + contentMode = .scaleAspectFill + clipsToBounds = true + backgroundColor = UIColor(hex: 0xF4F4F4) + layer.cornerRadius = cornerRadius + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 应用网络图片地址。 + func setURL(_ text: String) { + if let url = URL(string: text), !text.isEmpty { + kf.setImage(with: url, placeholder: placeholderImage()) + } else { + image = placeholderImage() + } + } + + private func placeholderImage() -> UIImage? { + UIImage(named: "img_square_loading_big") ?? UIImage(systemName: "photo") + } +} + +/// 虚线上传区域,复用直播封面和相册素材上传。 +final class LiveDashedUploadView: UIControl { + private let plusContainer = UIView() + private let plusImageView = UIImageView(image: UIImage(systemName: "plus")) + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private var dashLayer: CAShapeLayer? + + /// 初始化虚线上传区域。 + init(title: String = "点击上传图片", subtitle: String? = nil) { + super.init(frame: .zero) + setup(title: title, subtitle: subtitle) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + dashLayer?.removeFromSuperlayer() + let layer = CAShapeLayer() + layer.strokeColor = AppColor.border.cgColor + layer.fillColor = UIColor.clear.cgColor + layer.lineWidth = 2 + layer.lineDashPattern = [6, 6] + layer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 8).cgPath + self.layer.insertSublayer(layer, at: 0) + dashLayer = layer + } + + private func setup(title: String, subtitle: String?) { + backgroundColor = .white + plusContainer.backgroundColor = AppColor.primary + plusContainer.layer.cornerRadius = 16 + plusImageView.tintColor = .white + plusImageView.contentMode = .scaleAspectFit + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 14) + titleLabel.textColor = subtitle == nil ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x4B5563) + subtitleLabel.text = subtitle + subtitleLabel.font = .systemFont(ofSize: 12) + subtitleLabel.textColor = UIColor(hex: 0xB6BECA) + subtitleLabel.textAlignment = .center + + addSubview(plusContainer) + plusContainer.addSubview(plusImageView) + addSubview(titleLabel) + addSubview(subtitleLabel) + + plusContainer.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(subtitle == nil ? -14 : -24) + make.size.equalTo(32) + } + plusImageView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(18) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(plusContainer.snp.bottom).offset(12) + make.centerX.equalToSuperview() + } + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(8) + make.centerX.equalToSuperview() + } + } +} + +/// 直播模块图片选择与上传数据加载工具。 +enum LivePhotoPickerLoader { + /// 从 PHPicker 结果加载图片数据。 + static func loadImage(from result: PHPickerResult) async throws -> LivePickedMedia { + try await withCheckedThrowingContinuation { continuation in + let provider = result.itemProvider + guard provider.canLoadObject(ofClass: UIImage.self) else { + continuation.resume(throwing: OSSUploadError.unsupportedFileType) + return + } + provider.loadObject(ofClass: UIImage.self) { object, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { + continuation.resume(throwing: OSSUploadError.emptyFile) + return + } + continuation.resume( + returning: LivePickedMedia( + data: data, + fileName: "image_\(UUID().uuidString).jpg", + size: Int64(data.count), + width: Int(image.size.width), + height: Int(image.size.height) + ) + ) + } + } + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index 31f5729..54cb2f5 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -9,8 +9,9 @@ import UIKit /// 我的举报列表页,支持按状态筛选并进入举报详情。 final class WildPhotographerReportListViewController: BaseViewController { private let viewModel: WildPhotographerReportListViewModel - private let segmentedControl = UISegmentedControl(items: WildReportListFilter.allCases.map(\.rawValue)) - private let tableView = UITableView(frame: .zero, style: .plain) + private let scrollView = UIScrollView() + private let stack = UIStackView() + private let filterTabBar = WildReportFilterTabBarView() init(homeViewModel: WildPhotographerReportHomeViewModel) { self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) @@ -28,132 +29,134 @@ final class WildPhotographerReportListViewController: BaseViewController { override func setupUI() { view.backgroundColor = AppColor.pageBackgroundSoft - segmentedControl.selectedSegmentIndex = 0 - segmentedControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged) + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceVertical = true + stack.axis = .vertical + stack.spacing = 14 - tableView.backgroundColor = .clear - tableView.separatorStyle = .none - tableView.delegate = self - tableView.dataSource = self - tableView.register(WildReportListCell.self, forCellReuseIdentifier: WildReportListCell.reuseIdentifier) - tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.lg, right: 0) + filterTabBar.onSelect = { [weak self] filter in + self?.viewModel.selectFilter(filter) + } - view.addSubview(segmentedControl) - view.addSubview(tableView) + view.addSubview(scrollView) + scrollView.addSubview(stack) + reloadContent(animated: false) } override func setupConstraints() { - segmentedControl.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.height.equalTo(36) + scrollView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) } - tableView.snp.makeConstraints { make in - make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.xs) - make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide) + stack.snp.makeConstraints { make in + make.top.equalTo(scrollView.contentLayoutGuide).offset(14) + make.leading.equalTo(scrollView.contentLayoutGuide).offset(18) + make.trailing.equalTo(scrollView.contentLayoutGuide).inset(18) + make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24) + make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) } } override func bindActions() { viewModel.onStateChange = { [weak self] in - Task { @MainActor in self?.tableView.reloadData() } + Task { @MainActor in self?.reloadContent(animated: true) } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } } - @objc private func filterChanged() { - let filter = WildReportListFilter.allCases[segmentedControl.selectedSegmentIndex] - viewModel.selectFilter(filter) - } -} - -extension WildPhotographerReportListViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let count = viewModel.filteredReports.count - tableView.backgroundView = count == 0 ? WildReportEmptyView(text: "暂无相关举报记录") : nil - return count + @MainActor + private func reloadContent(animated: Bool) { + let changes: () -> Void = { [weak self] in + self?.rebuildContent() + } + guard animated else { + changes() + return + } + UIView.transition( + with: stack, + duration: 0.22, + options: [.transitionCrossDissolve, .allowUserInteraction], + animations: changes + ) } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: WildReportListCell.reuseIdentifier, for: indexPath) as! WildReportListCell - let record = viewModel.filteredReports[indexPath.row] - cell.apply(record: record) - cell.onShare = { [weak self] in self?.viewModel.shareReport(record) } - return cell + private func rebuildContent() { + stack.arrangedSubviews.forEach { view in + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + + filterTabBar.apply(selectedFilter: viewModel.selectedFilter) + stack.addArrangedSubview(filterTabBar) + + let reports = viewModel.filteredReports + if reports.isEmpty { + stack.addArrangedSubview(WildReportListEmptyView()) + } else { + reports.forEach { record in + let card = WildReportListCardView(record: record) + card.onOpen = { [weak self] in self?.openDetail(record) } + card.onShare = { [weak self] in self?.viewModel.shareReport(record) } + stack.addArrangedSubview(card) + } + } + + let footer = WildReportUI.label(viewModel.footerTip, font: .app(.caption), color: UIColor(hex: 0x9CA3AF), lines: 0) + footer.textAlignment = .center + let footerWrap = UIView() + footerWrap.addSubview(footer) + footer.snp.makeConstraints { make in + make.top.equalToSuperview().offset(8) + make.leading.trailing.bottom.equalToSuperview() + } + stack.addArrangedSubview(footerWrap) } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - let record = viewModel.filteredReports[indexPath.row] + private func openDetail(_ record: WildReportRecord) { navigationController?.pushViewController( WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel), animated: true ) } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - UITableView.automaticDimension - } } -/// 我的举报列表卡片 Cell。 -final class WildReportListCell: UITableViewCell { - static let reuseIdentifier = "WildReportListCell" +/// 我的举报筛选栏,使用四段等宽胶囊按钮展示状态过滤。 +private final class WildReportFilterTabBarView: UIView { + var onSelect: ((WildReportListFilter) -> Void)? + private let stack = UIStackView() + private var buttons: [WildReportListFilter: UIButton] = [:] - private let card = WildReportCardView(spacing: AppSpacing.sm) - private let titleLabel = UILabel() - private let statusView = WildReportStatusView() - private let typeLabel = UILabel() - private let locationLabel = UILabel() - private let timeLabel = UILabel() - private let descLabel = UILabel() - private let materialLabel = UILabel() - var onShare: (() -> Void)? + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = 12 + layer.borderWidth = 1 + layer.borderColor = AppColor.cardOutline.cgColor - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - selectionStyle = .none - backgroundColor = .clear - contentView.addSubview(card) - card.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.xs, left: AppSpacing.md, bottom: AppSpacing.xs, right: AppSpacing.md)) + stack.axis = .horizontal + stack.spacing = 0 + stack.distribution = .fillEqually + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(4) } - let header = UIStackView() - header.axis = .horizontal - header.alignment = .center - header.spacing = AppSpacing.sm - titleLabel.font = .app(.title) - titleLabel.textColor = AppColor.textPrimary - header.addArrangedSubview(titleLabel) - header.addArrangedSubview(UIView()) - statusView.snp.makeConstraints { make in - make.width.equalTo(64) - make.height.equalTo(24) - } - header.addArrangedSubview(statusView) - - [typeLabel, locationLabel, timeLabel, descLabel, materialLabel].forEach { label in - label.font = .app(.body) - label.textColor = AppColor.textSecondary - label.numberOfLines = 0 + WildReportListFilter.allCases.forEach { filter in + let button = UIButton(type: .system) + button.setTitle(filter.rawValue, for: .normal) + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.titleLabel?.minimumScaleFactor = 0.85 + button.layer.cornerRadius = 8 + button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside) + buttons[filter] = button + stack.addArrangedSubview(button) } - let shareButton = UIButton(type: .system) - shareButton.setTitle("分享", for: .normal) - shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal) - shareButton.titleLabel?.font = .app(.captionMedium) - shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside) - - card.stack.addArrangedSubview(header) - card.stack.addArrangedSubview(typeLabel) - card.stack.addArrangedSubview(locationLabel) - card.stack.addArrangedSubview(timeLabel) - card.stack.addArrangedSubview(descLabel) - card.stack.addArrangedSubview(materialLabel) - card.stack.addArrangedSubview(shareButton) + snp.makeConstraints { make in + make.height.equalTo(48) + } } @available(*, unavailable) @@ -161,14 +164,202 @@ final class WildReportListCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - func apply(record: WildReportRecord) { - titleLabel.text = record.title - statusView.apply(status: record.status) - typeLabel.text = "举报类型:\(record.reportType.rawValue)" - locationLabel.text = "位置:\(record.location)" - timeLabel.text = "提交时间:\(record.submitTime)" - descLabel.text = record.description - materialLabel.text = "材料:\(record.imageCount)张图片 \(record.videoCount)段视频" + func apply(selectedFilter: WildReportListFilter) { + buttons.forEach { filter, button in + let selected = filter == selectedFilter + button.backgroundColor = selected ? UIColor(hex: 0xEAF3FF) : .clear + button.setTitleColor(selected ? AppColor.primary : UIColor(hex: 0x6B7280), for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 13, weight: selected ? .bold : .medium) + } + } + + @objc private func filterTapped(_ sender: UIButton) { + guard let filter = buttons.first(where: { $0.value == sender })?.key else { return } + onSelect?(filter) + } +} + +/// 我的举报列表卡片,展示一条举报记录并提供详情与分享入口。 +private final class WildReportListCardView: UIControl { + var onOpen: (() -> Void)? + var onShare: (() -> Void)? + + private let record: WildReportRecord + private let scenicName: String + private let detailAddress: String + private let statusColor: UIColor + + init(record: WildReportRecord) { + self.record = record + let location = WildReportLocationParser.split(record.location) + self.scenicName = location.scenic + self.detailAddress = location.detail + self.statusColor = record.status.displayColor + super.init(frame: .zero) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.82 : 1 + } + } + + private func setupUI() { + backgroundColor = .white + layer.cornerRadius = 16 + layer.borderWidth = 1 + layer.borderColor = AppColor.cardOutline.cgColor + addTarget(self, action: #selector(openTapped), for: .touchUpInside) + + let contentStack = UIStackView() + contentStack.axis = .vertical + contentStack.spacing = 13 + addSubview(contentStack) + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(15) + } + + contentStack.addArrangedSubview(makeHeader()) + contentStack.addArrangedSubview(makeMetaRows()) + contentStack.addArrangedSubview(makeLocationRow()) + } + + private func makeHeader() -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .top + row.spacing = 12 + + let iconBox = UIView() + iconBox.backgroundColor = statusColor.withAlphaComponent(0.12) + iconBox.layer.cornerRadius = 12 + let icon = UIImageView(image: UIImage(systemName: "camera.fill")) + icon.tintColor = statusColor + icon.contentMode = .scaleAspectFit + iconBox.addSubview(icon) + icon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(20) + } + iconBox.snp.makeConstraints { make in + make.size.equalTo(44) + } + row.addArrangedSubview(iconBox) + + let textStack = UIStackView() + textStack.axis = .vertical + textStack.spacing = 8 + let titleStatusRow = UIStackView() + titleStatusRow.axis = .horizontal + titleStatusRow.alignment = .top + titleStatusRow.spacing = 8 + + let titleLabel = WildReportUI.label(record.title, font: .app(.title), color: AppColor.textPrimary, lines: 2) + titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + titleStatusRow.addArrangedSubview(titleLabel) + titleStatusRow.addArrangedSubview(makeStatusAndShareGroup()) + textStack.addArrangedSubview(titleStatusRow) + textStack.addArrangedSubview(makeScenicPill()) + row.addArrangedSubview(textStack) + return row + } + + private func makeStatusAndShareGroup() -> UIView { + let group = UIStackView() + group.axis = .horizontal + group.alignment = .center + group.spacing = 6 + group.setContentHuggingPriority(.required, for: .horizontal) + + let status = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)) + status.text = record.status.rawValue + status.font = .systemFont(ofSize: 11, weight: .semibold) + status.textColor = statusColor + status.textAlignment = .center + status.backgroundColor = statusColor.withAlphaComponent(0.12) + status.layer.cornerRadius = 12 + status.clipsToBounds = true + status.snp.makeConstraints { make in + make.height.equalTo(24) + } + + let shareButton = UIButton(type: .system) + shareButton.backgroundColor = AppColor.primaryLight + shareButton.layer.cornerRadius = 14 + shareButton.tintColor = AppColor.primary + shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal) + shareButton.accessibilityLabel = "分享举报" + shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside) + shareButton.snp.makeConstraints { make in + make.size.equalTo(28) + } + + group.addArrangedSubview(status) + group.addArrangedSubview(shareButton) + return group + } + + private func makeScenicPill() -> UIView { + let label = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)) + label.text = scenicName + label.font = .systemFont(ofSize: 12, weight: .semibold) + label.textColor = AppColor.primary + label.backgroundColor = AppColor.primary.withAlphaComponent(0.10) + label.layer.cornerRadius = 11 + label.clipsToBounds = true + label.snp.makeConstraints { make in + make.height.equalTo(22) + } + return label + } + + private func makeMetaRows() -> UIView { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 8 + stack.addArrangedSubview(WildReportListMetaRowView(title: "举报编号", value: record.id)) + stack.addArrangedSubview(WildReportListMetaRowView(title: "举报时间", value: record.submitTime)) + return stack + } + + private func makeLocationRow() -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.alignment = .center + row.spacing = 6 + row.layoutMargins = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0) + row.isLayoutMarginsRelativeArrangement = true + + let icon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse")) + icon.tintColor = UIColor(hex: 0x9CA3AF) + icon.contentMode = .scaleAspectFit + icon.snp.makeConstraints { make in + make.size.equalTo(14) + } + let addressLabel = WildReportUI.label(detailAddress, font: .app(.caption), color: AppColor.textSecondary) + addressLabel.lineBreakMode = .byTruncatingTail + let chevron = UIImageView(image: UIImage(systemName: "chevron.right")) + chevron.tintColor = UIColor(hex: 0xC4CAD4) + chevron.contentMode = .scaleAspectFit + chevron.snp.makeConstraints { make in + make.size.equalTo(13) + } + + row.addArrangedSubview(icon) + row.addArrangedSubview(addressLabel) + row.addArrangedSubview(UIView()) + row.addArrangedSubview(chevron) + return row + } + + @objc private func openTapped() { + onOpen?() } @objc private func shareTapped() { @@ -176,6 +367,68 @@ final class WildReportListCell: UITableViewCell { } } +/// 我的举报列表信息行,展示举报编号、举报时间等左右结构内容。 +private final class WildReportListMetaRowView: UIView { + init(title: String, value: String) { + super.init(frame: .zero) + + let titleLabel = WildReportUI.label(title, font: .app(.caption), color: UIColor(hex: 0x9CA3AF)) + let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 13, weight: .medium), color: AppColor.textPrimary) + valueLabel.textAlignment = .right + valueLabel.adjustsFontSizeToFitWidth = true + valueLabel.minimumScaleFactor = 0.8 + + addSubview(titleLabel) + addSubview(valueLabel) + titleLabel.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + } + valueLabel.snp.makeConstraints { make in + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12) + make.trailing.top.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 我的举报列表空态卡片。 +private final class WildReportListEmptyView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = 16 + + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 10 + let icon = UIImageView(image: UIImage(systemName: "doc.text.magnifyingglass")) + icon.tintColor = UIColor(hex: 0xB6BECA) + icon.contentMode = .scaleAspectFit + icon.snp.makeConstraints { make in + make.size.equalTo(36) + } + let label = WildReportUI.label("暂无相关举报记录", font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary) + stack.addArrangedSubview(icon) + stack.addArrangedSubview(label) + + addSubview(stack) + stack.snp.makeConstraints { make in + make.top.bottom.equalToSuperview().inset(48) + make.leading.trailing.equalToSuperview().inset(16) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + /// 举报详情页。 final class WildPhotographerReportDetailViewController: BaseViewController { private let viewModel: WildPhotographerReportDetailViewModel diff --git a/suixinkanTests/LiveAPITests.swift b/suixinkanTests/LiveAPITests.swift new file mode 100644 index 0000000..5a58879 --- /dev/null +++ b/suixinkanTests/LiveAPITests.swift @@ -0,0 +1,101 @@ +// +// LiveAPITests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 直播 API 测试。 +final class LiveAPITests: XCTestCase { + func testManualLiveListAndDetailBuildAndroidQueries() async throws { + let session = MockURLSession(responses: [ + envelopeJSON(#"{"page":1,"page_size":10,"total":0,"total_pages":0,"items":[]}"#), + envelopeJSON(#"{"id":7,"title":"直播"}"#), + ]) + let api = LiveAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.liveList(scenicId: "12", page: 2, pageSize: 10) + _ = try await api.liveDetail(liveId: 7) + + XCTAssertEqual(session.requests[0].httpMethod, "GET") + XCTAssertEqual(session.requests[0].url?.path, "/api/app/manual-live/list") + let listQuery = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(listQuery?.first { $0.name == "scenic_id" }?.value, "12") + XCTAssertEqual(listQuery?.first { $0.name == "page" }?.value, "2") + XCTAssertEqual(listQuery?.first { $0.name == "page_size" }?.value, "10") + + XCTAssertEqual(session.requests[1].httpMethod, "GET") + XCTAssertEqual(session.requests[1].url?.path, "/api/app/manual-live/detail") + let detailQuery = URLComponents(url: session.requests[1].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(detailQuery?.first { $0.name == "live_id" }?.value, "7") + } + + func testManualLiveControlBodies() async throws { + let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}"), envelopeJSON("{}"), envelopeJSON("{}")]) + let api = LiveAPI(client: APIClient(environment: .testing, session: session)) + + try await api.createLive(scenicId: "3", title: "直播A", coverImg: "https://cdn/c.jpg") + try await api.startLive(liveId: 8) + try await api.finishLive(liveId: 8) + try await api.setPushMode(liveId: 8, mode: 2) + + XCTAssertEqual(session.requests[0].url?.path, "/api/app/manual-live/create") + let createBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[0].httpBody)) as? [String: Any] + XCTAssertEqual(createBody?["scenic_id"] as? String, "3") + XCTAssertEqual(createBody?["title"] as? String, "直播A") + XCTAssertEqual(createBody?["cover_img"] as? String, "https://cdn/c.jpg") + + XCTAssertEqual(session.requests[1].url?.path, "/api/app/manual-live/start") + let startBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[1].httpBody)) as? [String: Any] + XCTAssertEqual(startBody?["live_id"] as? Int, 8) + + XCTAssertEqual(session.requests[2].url?.path, "/api/app/manual-live/finish") + XCTAssertEqual(session.requests[3].url?.path, "/api/app/manual-live/set-push-mode") + let modeBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[3].httpBody)) as? [String: Any] + XCTAssertEqual(modeBody?["live_id"] as? Int, 8) + XCTAssertEqual(modeBody?["manual_push_mode"] as? Int, 2) + } + + func testAlbumRequestsMatchAndroidPaths() async throws { + let session = MockURLSession(responses: [ + envelopeJSON(#"{"items":[],"page":1,"page_size":10,"total":0,"total_pages":0}"#), + envelopeJSON("{}"), + envelopeJSON(#"{"id":4,"items":[]}"#), + envelopeJSON("{}"), + ]) + let api = LiveAPI(client: APIClient(environment: .testing, session: session)) + + _ = try await api.albumList(scenicId: "9", startTime: "2026-07-01", endTime: "2026-07-08", page: 3, pageSize: 10) + try await api.createAlbumFolder(scenicId: "9", name: "作品", items: [LiveAlbumFile(url: "https://cdn/a.jpg", type: 2, size: 10)]) + _ = try await api.albumFolderDetail(folderId: 4) + try await api.deleteAlbumFile(folderId: 4, fileId: 11) + + XCTAssertEqual(session.requests[0].url?.path, "/api/app/view-album/folders") + let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "9") + XCTAssertEqual(query?.first { $0.name == "start_time" }?.value, "2026-07-01") + XCTAssertEqual(query?.first { $0.name == "end_time" }?.value, "2026-07-08") + XCTAssertEqual(query?.first { $0.name == "page" }?.value, "3") + XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "10") + + XCTAssertEqual(session.requests[1].url?.path, "/api/app/view-album/create-folder") + let createBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[1].httpBody)) as? [String: Any] + XCTAssertEqual(createBody?["scenic_id"] as? String, "9") + XCTAssertEqual(createBody?["name"] as? String, "作品") + XCTAssertEqual((createBody?["items"] as? [[String: Any]])?.first?["url"] as? String, "https://cdn/a.jpg") + + XCTAssertEqual(session.requests[2].url?.path, "/api/app/view-album/folder-detail") + XCTAssertEqual(session.requests[3].url?.path, "/api/app/view-album/delete-files") + let deleteBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[3].httpBody)) as? [String: Any] + XCTAssertEqual(deleteBody?["folder_id"] as? Int, 4) + XCTAssertEqual(deleteBody?["file_ids"] as? [Int], [11]) + } + + private func envelopeJSON(_ dataJSON: String) -> Data { + """ + {"code":100000,"msg":"success","data":\(dataJSON)} + """.data(using: .utf8)! + } +} diff --git a/suixinkanTests/LiveModelsTests.swift b/suixinkanTests/LiveModelsTests.swift new file mode 100644 index 0000000..5f3eb29 --- /dev/null +++ b/suixinkanTests/LiveModelsTests.swift @@ -0,0 +1,46 @@ +// +// LiveModelsTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 直播模型测试。 +final class LiveModelsTests: XCTestCase { + func testLiveItemDecodesLegacyAndCorrectManualPushKeys() throws { + let legacyJSON = """ + {"id":"3","title":"直播","cover_img":"https:\\/\\/cdn\\/c.jpg","push_url":"rtmp:\\/\\/push","status":2,"status_label":"直播中","manaul_push_mode":2,"manaul_push_state":"1","views_count":"9"} + """.data(using: .utf8)! + let legacy = try JSONDecoder().decode(LiveItem.self, from: legacyJSON) + XCTAssertEqual(legacy.id, 3) + XCTAssertEqual(legacy.coverImg, "https://cdn/c.jpg") + XCTAssertEqual(legacy.pushUrl, "rtmp://push") + XCTAssertEqual(legacy.manualPushMode, 2) + XCTAssertEqual(legacy.manualPushState, 1) + XCTAssertEqual(legacy.viewsCount, 9) + XCTAssertEqual(legacy.liveStatus, .living) + + let correctJSON = """ + {"id":4,"manual_push_mode":1,"manual_push_state":0} + """.data(using: .utf8)! + let correct = try JSONDecoder().decode(LiveItem.self, from: correctJSON) + XCTAssertEqual(correct.manualPushMode, 1) + XCTAssertEqual(correct.manualPushState, 0) + XCTAssertEqual(correct.displayTitle, "暂无标题") + } + + func testAlbumFolderDecodesItemsAndDefaults() throws { + let json = """ + {"id":6,"album_id":5,"name":"","creator":"A","items":[{"id":"7","url":"https:\\/\\/cdn\\/a.jpg","type":"2","size":"1024","cover_img":""}]} + """.data(using: .utf8)! + + let folder = try JSONDecoder().decode(LiveAlbumFolder.self, from: json) + + XCTAssertEqual(folder.id, 6) + XCTAssertEqual(folder.displayName, "暂无标题") + XCTAssertEqual(folder.items.first?.id, 7) + XCTAssertEqual(folder.items.first?.url, "https://cdn/a.jpg") + XCTAssertEqual(folder.items.first?.size, 1024) + } +} diff --git a/suixinkanTests/LiveViewModelTests.swift b/suixinkanTests/LiveViewModelTests.swift new file mode 100644 index 0000000..1c17a83 --- /dev/null +++ b/suixinkanTests/LiveViewModelTests.swift @@ -0,0 +1,212 @@ +// +// LiveViewModelTests.swift +// suixinkanTests +// + +import Foundation +import XCTest +@testable import suixinkan + +@MainActor +/// 直播 ViewModel 测试。 +final class LiveViewModelTests: XCTestCase { + func testLiveManageLoadsMoreCreatesAndControls() async { + let api = MockLiveAPI() + api.liveListResponses = [ + LiveListResponse(total: 12, items: (0 ..< 10).map { liveItem(id: $0) }), + LiveListResponse(total: 12, items: [liveItem(id: 10), liveItem(id: 11)]), + LiveListResponse(total: 1, items: [liveItem(id: 99)]), + LiveListResponse(total: 1, items: [liveItem(id: 99, status: 2)]), + LiveListResponse(total: 1, items: [liveItem(id: 99, status: 4)]), + ] + let viewModel = LiveManageViewModel(currentScenicIdProvider: { 3 }) + + await viewModel.loadInitial(api: api) + await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api) + await viewModel.createLive(title: " 新直播 ", coverUrl: "https://cdn/c.jpg", api: api) + await viewModel.controlLive(liveItem(id: 99, status: 1), api: api) + await viewModel.finishLive(liveItem(id: 99, status: 2), api: api) + + XCTAssertEqual(api.liveListCalls.map(\.page), [1, 2, 1, 1, 1]) + XCTAssertEqual(api.createLiveCalls.first?.title, "新直播") + XCTAssertEqual(api.startCalls, [99]) + XCTAssertEqual(api.finishCalls, [99]) + } + + func testLiveDetailChangesModeAndStopsLiving() async { + let api = MockLiveAPI() + api.detailResponses = [ + liveItem(id: 8, status: 2, mode: 1), + liveItem(id: 8, status: 2, mode: 2), + liveItem(id: 8, status: 3, mode: 2), + ] + let viewModel = LiveDetailViewModel(liveId: 8) + + await viewModel.load(api: api) + await viewModel.changeMode(.smoothFirst, api: api) + await viewModel.controlLive(api: api) + + XCTAssertEqual(viewModel.pushMode, .smoothFirst) + XCTAssertEqual(api.modeCalls.first?.mode, 2) + XCTAssertEqual(api.stopCalls, [8]) + } + + func testAlbumDateValidationAndDeleteReload() async { + let api = MockLiveAPI() + api.albumListResponses = [ + LiveAlbumFolderListResponse(items: [folder(id: 1)], total: 1), + LiveAlbumFolderListResponse(items: [folder(id: 2)], total: 1), + LiveAlbumFolderListResponse(items: [], total: 0), + ] + let viewModel = LiveAlbumListViewModel(currentScenicIdProvider: { 5 }) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + let start = date("2026-07-08") + let before = date("2026-07-01") + + await viewModel.loadInitial(api: api) + await viewModel.setStartDate(start, api: api) + await viewModel.setEndDate(before, api: api) + await viewModel.deleteFolder(folder(id: 2), api: api) + + XCTAssertEqual(messages.last, "结束时间不能小于开始时间") + XCTAssertEqual(api.albumListCalls.map(\.startTime), [nil, "2026-07-08", "2026-07-08"]) + XCTAssertEqual(api.deleteFolderCalls, [2]) + } + + func testAlbumAddValidationUploadsAndCreates() async { + let api = MockLiveAPI() + let uploader = MockLiveUploader(urls: ["https://cdn/a.jpg"]) + let viewModel = LiveAlbumAddViewModel(currentScenicIdProvider: { 6 }) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.create(api: api) + viewModel.updateTitle("作品") + await viewModel.create(api: api) + await viewModel.addLocalMedia(LivePickedMedia(data: Data(repeating: 1, count: 10), fileName: "a.jpg", size: 10, width: 1, height: 1), uploader: uploader) + await viewModel.create(api: api) + + XCTAssertEqual(messages.prefix(2), ["请输入作品名称", "请上传素材"]) + XCTAssertEqual(api.createAlbumCalls.first?.name, "作品") + XCTAssertEqual(api.createAlbumCalls.first?.items.first?.url, "https://cdn/a.jpg") + } + + func testPreviewDeleteReloadsFolderDetail() async { + let api = MockLiveAPI() + api.folderDetailResponses = [ + folder(id: 4, items: [LiveAlbumFile(url: "a", type: 2, size: 1, id: 10)]), + folder(id: 4, items: []), + ] + let viewModel = LiveAlbumPreviewViewModel(folderId: 4, startIndex: 0) + + await viewModel.load(api: api) + await viewModel.deleteCurrentFile(api: api) + + XCTAssertEqual(api.deleteFileCalls.first?.folderId, 4) + XCTAssertEqual(api.deleteFileCalls.first?.fileId, 10) + XCTAssertTrue(viewModel.files.isEmpty) + } +} + +@MainActor +private final class MockLiveAPI: LiveServing { + var liveListResponses: [LiveListResponse] = [] + var detailResponses: [LiveItem] = [] + var albumListResponses: [LiveAlbumFolderListResponse] = [] + var folderDetailResponses: [LiveAlbumFolder] = [] + + var liveListCalls: [(scenicId: String, page: Int, pageSize: Int)] = [] + var createLiveCalls: [(scenicId: String, title: String, coverImg: String)] = [] + var startCalls: [Int] = [] + var stopCalls: [Int] = [] + var finishCalls: [Int] = [] + var modeCalls: [(liveId: Int, mode: Int)] = [] + var albumListCalls: [(scenicId: String, startTime: String?, endTime: String?, page: Int, pageSize: Int)] = [] + var createAlbumCalls: [(scenicId: String, name: String, items: [LiveAlbumFile])] = [] + var deleteFolderCalls: [Int] = [] + var deleteFileCalls: [(folderId: Int, fileId: Int)] = [] + + func liveList(scenicId: String, page: Int, pageSize: Int) async throws -> LiveListResponse { + liveListCalls.append((scenicId, page, pageSize)) + return liveListResponses.isEmpty ? LiveListResponse() : liveListResponses.removeFirst() + } + + func liveDetail(liveId: Int) async throws -> LiveItem { + detailResponses.isEmpty ? liveItem(id: liveId) : detailResponses.removeFirst() + } + + func createLive(scenicId: String, title: String, coverImg: String) async throws { + createLiveCalls.append((scenicId, title, coverImg)) + } + + func startLive(liveId: Int) async throws { + startCalls.append(liveId) + } + + func stopLive(liveId: Int) async throws { + stopCalls.append(liveId) + } + + func finishLive(liveId: Int) async throws { + finishCalls.append(liveId) + } + + func setPushMode(liveId: Int, mode: Int) async throws { + modeCalls.append((liveId, mode)) + } + + func albumList(scenicId: String, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse { + albumListCalls.append((scenicId, startTime, endTime, page, pageSize)) + return albumListResponses.isEmpty ? LiveAlbumFolderListResponse() : albumListResponses.removeFirst() + } + + func createAlbumFolder(scenicId: String, name: String, items: [LiveAlbumFile]) async throws { + createAlbumCalls.append((scenicId, name, items)) + } + + func deleteAlbumFolder(folderId: Int) async throws { + deleteFolderCalls.append(folderId) + } + + func albumFolderDetail(folderId: Int) async throws -> LiveAlbumFolder { + folderDetailResponses.isEmpty ? folder(id: folderId) : folderDetailResponses.removeFirst() + } + + func deleteAlbumFile(folderId: Int, fileId: Int) async throws { + deleteFileCalls.append((folderId, fileId)) + } +} + +@MainActor +private final class MockLiveUploader: LiveOSSUploading { + private var urls: [String] + + init(urls: [String]) { + self.urls = urls + } + + func uploadLiveCover(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst() + } + + func uploadLiveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst() + } +} + +private func liveItem(id: Int, status: Int = 1, mode: Int = 1) -> LiveItem { + LiveItem(id: id, title: "直播\(id)", coverImg: "", pushUrl: "rtmp://push/\(id)", duration: 60, status: status, statusLabel: "", manualPushMode: mode) +} + +private func folder(id: Int, items: [LiveAlbumFile] = []) -> LiveAlbumFolder { + LiveAlbumFolder(id: id, albumId: id, name: "相册\(id)", creator: "", items: items) +} + +private func date(_ text: String) -> Date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "zh_CN") + return formatter.date(from: text)! +}