feat: add live module and optimize report list

This commit is contained in:
2026-07-08 14:25:14 +08:00
parent 8ca8bcf948
commit 4a78a0c21a
14 changed files with 3712 additions and 102 deletions

View File

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

View File

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

View File

@ -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:

View File

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

View File

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

View File

@ -0,0 +1,582 @@
//
// LiveViewModels.swift
// suixinkan
//
import Foundation
/// OSS 便 ViewModel
@MainActor
protocol LiveOSSUploading {
/// OSS URL
func uploadLiveCover(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
/// OSS URL
func uploadLiveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
}
extension OSSUploadService: LiveOSSUploading {}
/// ViewModel
final class LiveManageViewModel {
private(set) var items: [LiveItem] = []
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var canLoadMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onLiveCreated: (() -> Void)?
private let currentScenicIdProvider: () -> Int
private var currentPage = 1
private var totalCount = 0
private let pageSize = 10
/// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) {
self.currentScenicIdProvider = currentScenicIdProvider
}
///
func loadInitial(api: any LiveServing) async {
await load(reset: true, showLoading: true, api: api)
}
///
func refresh(api: any LiveServing) async {
isRefreshing = true
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any LiveServing) async {
guard lastVisibleIndex >= items.count - 3 else { return }
await load(reset: false, showLoading: false, api: api)
}
///
func createLive(title: String, coverUrl: String, api: any LiveServing) async {
let name = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else {
onShowMessage?("请输入直播标题")
return
}
guard !coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
onShowMessage?("请上传封面图片")
return
}
guard let scenicId = scenicIdText() else { return }
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
try await api.createLive(scenicId: scenicId, title: name, coverImg: coverUrl)
onLiveCreated?()
await load(reset: true, showLoading: true, api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "新增直播失败" : error.localizedDescription)
}
}
///
func copyPushURL(_ item: LiveItem) -> String {
item.pushUrl
}
///
func controlLive(_ item: LiveItem, api: any LiveServing) async {
guard item.liveStatus.isOperable else { return }
await performAndReload(api: api) {
if item.liveStatus == .living {
try await api.stopLive(liveId: item.id)
} else if item.liveStatus == .pending || item.liveStatus == .paused {
try await api.startLive(liveId: item.id)
}
}
}
///
func finishLive(_ item: LiveItem, api: any LiveServing) async {
guard item.liveStatus.isOperable else { return }
await performAndReload(api: api) {
try await api.finishLive(liveId: item.id)
}
}
private func load(reset: Bool, showLoading: Bool, api: any LiveServing) async {
guard let scenicId = scenicIdText() else { return }
if reset {
currentPage = 1
canLoadMore = false
} else {
guard canLoadMore, !isLoading else { return }
currentPage += 1
}
isLoading = showLoading
notifyStateChange()
defer {
isLoading = false
isRefreshing = false
notifyStateChange()
}
do {
let response = try await api.liveList(scenicId: scenicId, page: currentPage, pageSize: pageSize)
totalCount = response.total
items = reset ? response.items : items + response.items
canLoadMore = items.count < totalCount
} catch is CancellationError {
return
} catch {
if !reset, currentPage > 1 { currentPage -= 1 }
if reset {
items = []
totalCount = 0
canLoadMore = false
}
onShowMessage?(error.localizedDescription.isEmpty ? "获取直播列表失败" : error.localizedDescription)
}
}
private func performAndReload(api: any LiveServing, operation: () async throws -> Void) async {
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
try await operation()
await load(reset: true, showLoading: true, api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
}
}
private func scenicIdText() -> String? {
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return nil
}
return String(scenicId)
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class LiveDetailViewModel {
private(set) var item: LiveItem?
private(set) var pushMode: LivePushMode = .clarityFirst
private(set) var isLoading = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
let liveId: Int
/// ViewModel
init(liveId: Int) {
self.liveId = liveId
}
///
func load(api: any LiveServing) async {
guard liveId > 0 else {
onShowMessage?("直播ID无效")
return
}
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let detail = try await api.liveDetail(liveId: liveId)
item = detail
pushMode = LivePushMode(rawValue: detail.manualPushMode) ?? .clarityFirst
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "获取直播信息失败" : error.localizedDescription)
}
}
///
func changeMode(_ mode: LivePushMode, api: any LiveServing) async {
guard pushMode != mode, item?.liveStatus.isOperable == true else { return }
await performAndReload(api: api) {
try await api.setPushMode(liveId: liveId, mode: mode.rawValue)
}
}
///
func controlLive(api: any LiveServing) async {
guard let item, item.liveStatus.isOperable else { return }
await performAndReload(api: api) {
if item.liveStatus == .living {
try await api.stopLive(liveId: item.id)
} else if item.liveStatus == .pending || item.liveStatus == .paused {
try await api.startLive(liveId: item.id)
}
}
}
///
func finishLive(api: any LiveServing) async {
guard let item, item.liveStatus.isOperable else { return }
await performAndReload(api: api) {
try await api.finishLive(liveId: item.id)
}
}
private func performAndReload(api: any LiveServing, operation: () async throws -> Void) async {
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
try await operation()
await load(api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class LiveAlbumListViewModel {
private(set) var folders: [LiveAlbumFolder] = []
private(set) var startDate: Date?
private(set) var endDate: Date?
private(set) var isLoading = false
private(set) var isRefreshing = false
private(set) var canLoadMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private let currentScenicIdProvider: () -> Int
private var currentPage = 1
private var totalCount = 0
private let pageSize = 10
/// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) {
self.currentScenicIdProvider = currentScenicIdProvider
}
///
func loadInitial(api: any LiveServing) async {
await load(reset: true, showLoading: true, api: api)
}
///
func refresh(api: any LiveServing) async {
isRefreshing = true
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any LiveServing) async {
guard lastVisibleIndex >= folders.count - 3 else { return }
await load(reset: false, showLoading: false, api: api)
}
///
func setStartDate(_ date: Date, api: any LiveServing) async {
if let endDate, date.startOfDay > endDate.startOfDay {
onShowMessage?("开始时间不能大于结束时间")
return
}
startDate = date.startOfDay
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func setEndDate(_ date: Date, api: any LiveServing) async {
if let startDate, date.startOfDay < startDate.startOfDay {
onShowMessage?("结束时间不能小于开始时间")
return
}
endDate = date.startOfDay
notifyStateChange()
await load(reset: true, showLoading: false, api: api)
}
///
func deleteFolder(_ folder: LiveAlbumFolder, api: any LiveServing) async {
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
try await api.deleteAlbumFolder(folderId: folder.id)
await load(reset: true, showLoading: true, api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "删除相册失败" : error.localizedDescription)
}
}
private func load(reset: Bool, showLoading: Bool, api: any LiveServing) async {
guard let scenicId = scenicIdText() else { return }
if reset {
currentPage = 1
canLoadMore = false
} else {
guard canLoadMore, !isLoading else { return }
currentPage += 1
}
isLoading = showLoading
notifyStateChange()
defer {
isLoading = false
isRefreshing = false
notifyStateChange()
}
do {
let response = try await api.albumList(
scenicId: scenicId,
startTime: startDate.map(Self.dateString),
endTime: endDate.map(Self.dateString),
page: currentPage,
pageSize: pageSize
)
totalCount = response.total
folders = reset ? response.items : folders + response.items
canLoadMore = folders.count < totalCount
} catch is CancellationError {
return
} catch {
if !reset, currentPage > 1 { currentPage -= 1 }
if reset {
folders = []
totalCount = 0
canLoadMore = false
}
onShowMessage?(error.localizedDescription.isEmpty ? "获取直播相册失败" : error.localizedDescription)
}
}
private func scenicIdText() -> String? {
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return nil
}
return String(scenicId)
}
static func dateString(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class LiveAlbumAddViewModel {
private(set) var title = ""
private(set) var files: [LiveAlbumFile] = []
private(set) var isLoading = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onCreateSuccess: (() -> Void)?
private let currentScenicIdProvider: () -> Int
/// ViewModel
init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) {
self.currentScenicIdProvider = currentScenicIdProvider
}
/// 20
func updateTitle(_ title: String) {
self.title = String(title.prefix(20))
notifyStateChange()
}
///
func addLocalMedia(_ media: LivePickedMedia, uploader: any LiveOSSUploading) async {
guard let scenicId = scenicId() else { return }
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let url = try await uploader.uploadLiveAlbumFile(
data: media.data,
fileName: media.fileName,
fileType: 2,
scenicId: scenicId,
onProgress: { _ in }
)
files.append(LiveAlbumFile(url: url, type: 2, size: media.size, coverImg: ""))
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription)
}
}
///
func deleteLocalFile(at index: Int) {
guard files.indices.contains(index) else { return }
files.remove(at: index)
notifyStateChange()
}
///
func create(api: any LiveServing) async {
let name = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else {
onShowMessage?("请输入作品名称")
return
}
guard !files.isEmpty else {
onShowMessage?("请上传素材")
return
}
guard let scenicId = scenicId().map(String.init) else { return }
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
try await api.createAlbumFolder(scenicId: scenicId, name: name, items: files)
onCreateSuccess?()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "创建相册失败" : error.localizedDescription)
}
}
private func scenicId() -> Int? {
let scenicId = currentScenicIdProvider()
guard scenicId > 0 else {
onShowMessage?("请先选择景区")
return nil
}
return scenicId
}
private func notifyStateChange() {
onStateChange?()
}
}
/// ViewModel
final class LiveAlbumPreviewViewModel {
private(set) var files: [LiveAlbumFile] = []
private(set) var currentIndex: Int
private(set) var isLoading = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
let folderId: Int
/// ViewModel
init(folderId: Int, startIndex: Int) {
self.folderId = folderId
self.currentIndex = max(startIndex, 0)
}
///
func load(api: any LiveServing) async {
guard folderId > 0 else {
onShowMessage?("相册ID无效")
return
}
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let detail = try await api.albumFolderDetail(folderId: folderId)
files = detail.items
currentIndex = files.isEmpty ? 0 : min(currentIndex, files.count - 1)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "获取相册详情失败" : error.localizedDescription)
}
}
///
func updateCurrentIndex(_ index: Int) {
currentIndex = max(0, min(index, max(files.count - 1, 0)))
notifyStateChange()
}
///
func deleteCurrentFile(api: any LiveServing) async {
guard files.indices.contains(currentIndex) else { return }
let file = files[currentIndex]
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
try await api.deleteAlbumFile(folderId: folderId, fileId: file.id)
currentIndex = 0
await load(api: api)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription.isEmpty ? "删除素材失败" : error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}
private extension Date {
var startOfDay: Date {
Calendar.current.startOfDay(for: self)
}
}

View File

@ -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<Section, LiveAlbumFolder>!
///
@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<Section, LiveAlbumFolder>(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<Section, LiveAlbumFolder>()
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) }
}
}
}

View File

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

View File

@ -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<Section, LiveItem>!
///
@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<Section, LiveItem>(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<Section, LiveItem>()
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)
}
}
}

View File

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

View File

@ -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