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

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