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