Files
suixinkan_uikit/suixinkan/UI/Live/LiveManageViewController.swift
汉秋 005349f8e6 模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:01:08 +08:00

504 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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.session.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)
}
}
}