feat: add live module and optimize report list
This commit is contained in:
700
suixinkan/UI/Live/LiveAlbumViewControllers.swift
Normal file
700
suixinkan/UI/Live/LiveAlbumViewControllers.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
361
suixinkan/UI/Live/LiveDetailViewController.swift
Normal file
361
suixinkan/UI/Live/LiveDetailViewController.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
503
suixinkan/UI/Live/LiveManageViewController.swift
Normal file
503
suixinkan/UI/Live/LiveManageViewController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
242
suixinkan/UI/Live/LiveUIComponents.swift
Normal file
242
suixinkan/UI/Live/LiveUIComponents.swift
Normal 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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,8 +9,9 @@ import UIKit
|
||||
/// 我的举报列表页,支持按状态筛选并进入举报详情。
|
||||
final class WildPhotographerReportListViewController: BaseViewController {
|
||||
private let viewModel: WildPhotographerReportListViewModel
|
||||
private let segmentedControl = UISegmentedControl(items: WildReportListFilter.allCases.map(\.rawValue))
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let scrollView = UIScrollView()
|
||||
private let stack = UIStackView()
|
||||
private let filterTabBar = WildReportFilterTabBarView()
|
||||
|
||||
init(homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||
@ -28,132 +29,134 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackgroundSoft
|
||||
segmentedControl.selectedSegmentIndex = 0
|
||||
segmentedControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.alwaysBounceVertical = true
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 14
|
||||
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.register(WildReportListCell.self, forCellReuseIdentifier: WildReportListCell.reuseIdentifier)
|
||||
tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.lg, right: 0)
|
||||
filterTabBar.onSelect = { [weak self] filter in
|
||||
self?.viewModel.selectFilter(filter)
|
||||
}
|
||||
|
||||
view.addSubview(segmentedControl)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(stack)
|
||||
reloadContent(animated: false)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
segmentedControl.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(36)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(scrollView.contentLayoutGuide).offset(14)
|
||||
make.leading.equalTo(scrollView.contentLayoutGuide).offset(18)
|
||||
make.trailing.equalTo(scrollView.contentLayoutGuide).inset(18)
|
||||
make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24)
|
||||
make.width.equalTo(scrollView.frameLayoutGuide).offset(-36)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.tableView.reloadData() }
|
||||
Task { @MainActor in self?.reloadContent(animated: true) }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func filterChanged() {
|
||||
let filter = WildReportListFilter.allCases[segmentedControl.selectedSegmentIndex]
|
||||
viewModel.selectFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
extension WildPhotographerReportListViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let count = viewModel.filteredReports.count
|
||||
tableView.backgroundView = count == 0 ? WildReportEmptyView(text: "暂无相关举报记录") : nil
|
||||
return count
|
||||
@MainActor
|
||||
private func reloadContent(animated: Bool) {
|
||||
let changes: () -> Void = { [weak self] in
|
||||
self?.rebuildContent()
|
||||
}
|
||||
guard animated else {
|
||||
changes()
|
||||
return
|
||||
}
|
||||
UIView.transition(
|
||||
with: stack,
|
||||
duration: 0.22,
|
||||
options: [.transitionCrossDissolve, .allowUserInteraction],
|
||||
animations: changes
|
||||
)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: WildReportListCell.reuseIdentifier, for: indexPath) as! WildReportListCell
|
||||
let record = viewModel.filteredReports[indexPath.row]
|
||||
cell.apply(record: record)
|
||||
cell.onShare = { [weak self] in self?.viewModel.shareReport(record) }
|
||||
return cell
|
||||
private func rebuildContent() {
|
||||
stack.arrangedSubviews.forEach { view in
|
||||
stack.removeArrangedSubview(view)
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
|
||||
filterTabBar.apply(selectedFilter: viewModel.selectedFilter)
|
||||
stack.addArrangedSubview(filterTabBar)
|
||||
|
||||
let reports = viewModel.filteredReports
|
||||
if reports.isEmpty {
|
||||
stack.addArrangedSubview(WildReportListEmptyView())
|
||||
} else {
|
||||
reports.forEach { record in
|
||||
let card = WildReportListCardView(record: record)
|
||||
card.onOpen = { [weak self] in self?.openDetail(record) }
|
||||
card.onShare = { [weak self] in self?.viewModel.shareReport(record) }
|
||||
stack.addArrangedSubview(card)
|
||||
}
|
||||
}
|
||||
|
||||
let footer = WildReportUI.label(viewModel.footerTip, font: .app(.caption), color: UIColor(hex: 0x9CA3AF), lines: 0)
|
||||
footer.textAlignment = .center
|
||||
let footerWrap = UIView()
|
||||
footerWrap.addSubview(footer)
|
||||
footer.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
stack.addArrangedSubview(footerWrap)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let record = viewModel.filteredReports[indexPath.row]
|
||||
private func openDetail(_ record: WildReportRecord) {
|
||||
navigationController?.pushViewController(
|
||||
WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的举报列表卡片 Cell。
|
||||
final class WildReportListCell: UITableViewCell {
|
||||
static let reuseIdentifier = "WildReportListCell"
|
||||
/// 我的举报筛选栏,使用四段等宽胶囊按钮展示状态过滤。
|
||||
private final class WildReportFilterTabBarView: UIView {
|
||||
var onSelect: ((WildReportListFilter) -> Void)?
|
||||
private let stack = UIStackView()
|
||||
private var buttons: [WildReportListFilter: UIButton] = [:]
|
||||
|
||||
private let card = WildReportCardView(spacing: AppSpacing.sm)
|
||||
private let titleLabel = UILabel()
|
||||
private let statusView = WildReportStatusView()
|
||||
private let typeLabel = UILabel()
|
||||
private let locationLabel = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
private let descLabel = UILabel()
|
||||
private let materialLabel = UILabel()
|
||||
var onShare: (() -> Void)?
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
layer.borderWidth = 1
|
||||
layer.borderColor = AppColor.cardOutline.cgColor
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.addSubview(card)
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.xs, left: AppSpacing.md, bottom: AppSpacing.xs, right: AppSpacing.md))
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 0
|
||||
stack.distribution = .fillEqually
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(4)
|
||||
}
|
||||
|
||||
let header = UIStackView()
|
||||
header.axis = .horizontal
|
||||
header.alignment = .center
|
||||
header.spacing = AppSpacing.sm
|
||||
titleLabel.font = .app(.title)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
header.addArrangedSubview(titleLabel)
|
||||
header.addArrangedSubview(UIView())
|
||||
statusView.snp.makeConstraints { make in
|
||||
make.width.equalTo(64)
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
header.addArrangedSubview(statusView)
|
||||
|
||||
[typeLabel, locationLabel, timeLabel, descLabel, materialLabel].forEach { label in
|
||||
label.font = .app(.body)
|
||||
label.textColor = AppColor.textSecondary
|
||||
label.numberOfLines = 0
|
||||
WildReportListFilter.allCases.forEach { filter in
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(filter.rawValue, for: .normal)
|
||||
button.titleLabel?.adjustsFontSizeToFitWidth = true
|
||||
button.titleLabel?.minimumScaleFactor = 0.85
|
||||
button.layer.cornerRadius = 8
|
||||
button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside)
|
||||
buttons[filter] = button
|
||||
stack.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
let shareButton = UIButton(type: .system)
|
||||
shareButton.setTitle("分享", for: .normal)
|
||||
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
|
||||
shareButton.titleLabel?.font = .app(.captionMedium)
|
||||
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
||||
|
||||
card.stack.addArrangedSubview(header)
|
||||
card.stack.addArrangedSubview(typeLabel)
|
||||
card.stack.addArrangedSubview(locationLabel)
|
||||
card.stack.addArrangedSubview(timeLabel)
|
||||
card.stack.addArrangedSubview(descLabel)
|
||||
card.stack.addArrangedSubview(materialLabel)
|
||||
card.stack.addArrangedSubview(shareButton)
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@ -161,14 +164,202 @@ final class WildReportListCell: UITableViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(record: WildReportRecord) {
|
||||
titleLabel.text = record.title
|
||||
statusView.apply(status: record.status)
|
||||
typeLabel.text = "举报类型:\(record.reportType.rawValue)"
|
||||
locationLabel.text = "位置:\(record.location)"
|
||||
timeLabel.text = "提交时间:\(record.submitTime)"
|
||||
descLabel.text = record.description
|
||||
materialLabel.text = "材料:\(record.imageCount)张图片 \(record.videoCount)段视频"
|
||||
func apply(selectedFilter: WildReportListFilter) {
|
||||
buttons.forEach { filter, button in
|
||||
let selected = filter == selectedFilter
|
||||
button.backgroundColor = selected ? UIColor(hex: 0xEAF3FF) : .clear
|
||||
button.setTitleColor(selected ? AppColor.primary : UIColor(hex: 0x6B7280), for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 13, weight: selected ? .bold : .medium)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func filterTapped(_ sender: UIButton) {
|
||||
guard let filter = buttons.first(where: { $0.value == sender })?.key else { return }
|
||||
onSelect?(filter)
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的举报列表卡片,展示一条举报记录并提供详情与分享入口。
|
||||
private final class WildReportListCardView: UIControl {
|
||||
var onOpen: (() -> Void)?
|
||||
var onShare: (() -> Void)?
|
||||
|
||||
private let record: WildReportRecord
|
||||
private let scenicName: String
|
||||
private let detailAddress: String
|
||||
private let statusColor: UIColor
|
||||
|
||||
init(record: WildReportRecord) {
|
||||
self.record = record
|
||||
let location = WildReportLocationParser.split(record.location)
|
||||
self.scenicName = location.scenic
|
||||
self.detailAddress = location.detail
|
||||
self.statusColor = record.status.displayColor
|
||||
super.init(frame: .zero)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
alpha = isHighlighted ? 0.82 : 1
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 16
|
||||
layer.borderWidth = 1
|
||||
layer.borderColor = AppColor.cardOutline.cgColor
|
||||
addTarget(self, action: #selector(openTapped), for: .touchUpInside)
|
||||
|
||||
let contentStack = UIStackView()
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 13
|
||||
addSubview(contentStack)
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(15)
|
||||
}
|
||||
|
||||
contentStack.addArrangedSubview(makeHeader())
|
||||
contentStack.addArrangedSubview(makeMetaRows())
|
||||
contentStack.addArrangedSubview(makeLocationRow())
|
||||
}
|
||||
|
||||
private func makeHeader() -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .top
|
||||
row.spacing = 12
|
||||
|
||||
let iconBox = UIView()
|
||||
iconBox.backgroundColor = statusColor.withAlphaComponent(0.12)
|
||||
iconBox.layer.cornerRadius = 12
|
||||
let icon = UIImageView(image: UIImage(systemName: "camera.fill"))
|
||||
icon.tintColor = statusColor
|
||||
icon.contentMode = .scaleAspectFit
|
||||
iconBox.addSubview(icon)
|
||||
icon.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
iconBox.snp.makeConstraints { make in
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
row.addArrangedSubview(iconBox)
|
||||
|
||||
let textStack = UIStackView()
|
||||
textStack.axis = .vertical
|
||||
textStack.spacing = 8
|
||||
let titleStatusRow = UIStackView()
|
||||
titleStatusRow.axis = .horizontal
|
||||
titleStatusRow.alignment = .top
|
||||
titleStatusRow.spacing = 8
|
||||
|
||||
let titleLabel = WildReportUI.label(record.title, font: .app(.title), color: AppColor.textPrimary, lines: 2)
|
||||
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
titleStatusRow.addArrangedSubview(titleLabel)
|
||||
titleStatusRow.addArrangedSubview(makeStatusAndShareGroup())
|
||||
textStack.addArrangedSubview(titleStatusRow)
|
||||
textStack.addArrangedSubview(makeScenicPill())
|
||||
row.addArrangedSubview(textStack)
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeStatusAndShareGroup() -> UIView {
|
||||
let group = UIStackView()
|
||||
group.axis = .horizontal
|
||||
group.alignment = .center
|
||||
group.spacing = 6
|
||||
group.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
let status = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8))
|
||||
status.text = record.status.rawValue
|
||||
status.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
status.textColor = statusColor
|
||||
status.textAlignment = .center
|
||||
status.backgroundColor = statusColor.withAlphaComponent(0.12)
|
||||
status.layer.cornerRadius = 12
|
||||
status.clipsToBounds = true
|
||||
status.snp.makeConstraints { make in
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
|
||||
let shareButton = UIButton(type: .system)
|
||||
shareButton.backgroundColor = AppColor.primaryLight
|
||||
shareButton.layer.cornerRadius = 14
|
||||
shareButton.tintColor = AppColor.primary
|
||||
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
|
||||
shareButton.accessibilityLabel = "分享举报"
|
||||
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
||||
shareButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(28)
|
||||
}
|
||||
|
||||
group.addArrangedSubview(status)
|
||||
group.addArrangedSubview(shareButton)
|
||||
return group
|
||||
}
|
||||
|
||||
private func makeScenicPill() -> UIView {
|
||||
let label = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8))
|
||||
label.text = scenicName
|
||||
label.font = .systemFont(ofSize: 12, weight: .semibold)
|
||||
label.textColor = AppColor.primary
|
||||
label.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
|
||||
label.layer.cornerRadius = 11
|
||||
label.clipsToBounds = true
|
||||
label.snp.makeConstraints { make in
|
||||
make.height.equalTo(22)
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeMetaRows() -> UIView {
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
stack.addArrangedSubview(WildReportListMetaRowView(title: "举报编号", value: record.id))
|
||||
stack.addArrangedSubview(WildReportListMetaRowView(title: "举报时间", value: record.submitTime))
|
||||
return stack
|
||||
}
|
||||
|
||||
private func makeLocationRow() -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.alignment = .center
|
||||
row.spacing = 6
|
||||
row.layoutMargins = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
|
||||
row.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
|
||||
icon.tintColor = UIColor(hex: 0x9CA3AF)
|
||||
icon.contentMode = .scaleAspectFit
|
||||
icon.snp.makeConstraints { make in
|
||||
make.size.equalTo(14)
|
||||
}
|
||||
let addressLabel = WildReportUI.label(detailAddress, font: .app(.caption), color: AppColor.textSecondary)
|
||||
addressLabel.lineBreakMode = .byTruncatingTail
|
||||
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
|
||||
chevron.tintColor = UIColor(hex: 0xC4CAD4)
|
||||
chevron.contentMode = .scaleAspectFit
|
||||
chevron.snp.makeConstraints { make in
|
||||
make.size.equalTo(13)
|
||||
}
|
||||
|
||||
row.addArrangedSubview(icon)
|
||||
row.addArrangedSubview(addressLabel)
|
||||
row.addArrangedSubview(UIView())
|
||||
row.addArrangedSubview(chevron)
|
||||
return row
|
||||
}
|
||||
|
||||
@objc private func openTapped() {
|
||||
onOpen?()
|
||||
}
|
||||
|
||||
@objc private func shareTapped() {
|
||||
@ -176,6 +367,68 @@ final class WildReportListCell: UITableViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的举报列表信息行,展示举报编号、举报时间等左右结构内容。
|
||||
private final class WildReportListMetaRowView: UIView {
|
||||
init(title: String, value: String) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
let titleLabel = WildReportUI.label(title, font: .app(.caption), color: UIColor(hex: 0x9CA3AF))
|
||||
let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 13, weight: .medium), color: AppColor.textPrimary)
|
||||
valueLabel.textAlignment = .right
|
||||
valueLabel.adjustsFontSizeToFitWidth = true
|
||||
valueLabel.minimumScaleFactor = 0.8
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(valueLabel)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview()
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
|
||||
make.trailing.top.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的举报列表空态卡片。
|
||||
private final class WildReportListEmptyView: UIView {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 16
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .center
|
||||
stack.spacing = 10
|
||||
let icon = UIImageView(image: UIImage(systemName: "doc.text.magnifyingglass"))
|
||||
icon.tintColor = UIColor(hex: 0xB6BECA)
|
||||
icon.contentMode = .scaleAspectFit
|
||||
icon.snp.makeConstraints { make in
|
||||
make.size.equalTo(36)
|
||||
}
|
||||
let label = WildReportUI.label("暂无相关举报记录", font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary)
|
||||
stack.addArrangedSubview(icon)
|
||||
stack.addArrangedSubview(label)
|
||||
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(48)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报详情页。
|
||||
final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
private let viewModel: WildPhotographerReportDetailViewModel
|
||||
|
||||
Reference in New Issue
Block a user