将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
504 lines
20 KiB
Swift
504 lines
20 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|
||
}
|