feat: add travel album module

This commit is contained in:
2026-07-07 17:23:31 +08:00
parent a80c181bd7
commit 00bda390e8
16 changed files with 3115 additions and 0 deletions

View File

@ -0,0 +1,456 @@
//
// TravelAlbumEntryViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `TravelAlbumEntryScreen`
final class TravelAlbumEntryViewController: BaseViewController {
private let viewModel = TravelAlbumEntryViewModel()
private let api: any TravelAlbumServing
private let heroCard = TravelAlbumHeroCard()
private let titleLabel = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyView = TravelAlbumEmptyView()
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
init(api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "新增相册"
navigationController?.setNavigationBarHidden(false, animated: false)
}
override func setupUI() {
view.backgroundColor = .white
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = .black
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 132
tableView.delegate = self
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Int, TravelAlbum>(tableView: tableView) {
[weak self] tableView, indexPath, album in
let cell = tableView.dequeueReusableCell(
withIdentifier: TravelAlbumTaskCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumTaskCell
cell.apply(album: album)
cell.onShootUpload = { [weak self] in self?.pushWiredTransfer(album: album) }
cell.onAlbumCode = { [weak self] in self?.loadAlbumCode(album) }
cell.onShare = { [weak self] in self?.showShareSheet() }
return cell
}
view.addSubview(heroCard)
view.addSubview(titleLabel)
view.addSubview(tableView)
view.addSubview(emptyView)
}
override func setupConstraints() {
heroCard.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
make.leading.trailing.equalToSuperview().inset(20)
make.height.equalTo(108)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(heroCard.snp.bottom).offset(28)
make.leading.trailing.equalToSuperview().inset(20)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
emptyView.snp.makeConstraints { make in
make.top.equalTo(heroCard.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
}
override func bindActions() {
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadAlbums(api: api) }
}
@MainActor
private func applyViewModel() {
heroCard.isLoading = viewModel.isCreating
titleLabel.text = "我的任务(\(viewModel.albumTotal)"
titleLabel.isHidden = viewModel.albums.isEmpty
tableView.isHidden = viewModel.albums.isEmpty
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.albums)
dataSource.apply(snapshot, animatingDifferences: true)
if viewModel.isLoading && viewModel.albums.isEmpty {
showLoading()
} else {
hideLoading()
}
}
@objc private func createTapped() {
Task {
await viewModel.openCreateSheet(api: api)
await MainActor.run {
guard viewModel.isCreateSheetVisible, presentedViewController == nil else { return }
let sheet = CreateTravelAlbumSheetViewController(viewModel: viewModel, api: api)
sheet.onDismissed = { [weak self] in self?.viewModel.dismissCreateSheet() }
present(sheet, animated: true)
}
}
}
private func loadAlbumCode(_ album: TravelAlbum) {
Task {
await viewModel.openAlbumCode(album: album, api: api)
await MainActor.run { self.presentAlbumCodeIfNeeded() }
}
}
@MainActor
private func presentAlbumCodeIfNeeded() {
guard let state = viewModel.albumCodeState else { return }
let controller = TravelAlbumCodeDialogViewController(state: state)
controller.onClose = { [weak self] in self?.viewModel.dismissAlbumCode() }
controller.onDownload = { [weak self, weak controller] in
guard let self, let url = self.viewModel.albumCodeState?.qrImageUrl, !url.isEmpty else { return }
self.viewModel.setDownloadingQRCode(true)
controller?.updateDownloading(true)
Task {
do {
try await ScenicQueueQRCodeSaver.saveImage(from: url)
await MainActor.run { self.showToast("已保存到相册") }
} catch {
await MainActor.run { self.showToast(error.localizedDescription) }
}
await MainActor.run {
self.viewModel.setDownloadingQRCode(false)
controller?.updateDownloading(false)
}
}
}
present(controller, animated: true)
}
private func showShareSheet() {
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "微信好友", style: .default) { [weak self] _ in
self?.showToast("已选择微信好友")
})
sheet.addAction(UIAlertAction(title: "QQ好友", style: .default) { [weak self] _ in
self?.showToast("已选择QQ好友")
})
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
private func pushWiredTransfer(album: TravelAlbum) {
let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel(
albumId: album.id,
albumTitle: album.name,
headerPhone: album.displayPhone
)
)
navigationController?.pushViewController(controller, animated: true)
}
}
extension TravelAlbumEntryViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let album = dataSource.itemIdentifier(for: indexPath) else { return }
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: album.id), animated: true)
}
}
///
private final class TravelAlbumHeroCard: UIControl {
private let gradientLayer = CAGradientLayer()
private let plusView = UIImageView(image: UIImage(systemName: "plus"))
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let arrowLabel = UILabel()
var isLoading = false {
didSet { titleLabel.text = isLoading ? "新建中..." : "新建相册任务" }
}
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 10
clipsToBounds = true
gradientLayer.colors = [UIColor(hex: 0x007BFF).cgColor, UIColor(hex: 0x3A91FF).cgColor]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
layer.insertSublayer(gradientLayer, at: 0)
plusView.tintColor = AppColor.primary
plusView.contentMode = .scaleAspectFit
plusView.backgroundColor = .white
plusView.layer.cornerRadius = 17
titleLabel.text = "新建相册任务"
titleLabel.font = .systemFont(ofSize: 19, weight: .medium)
titleLabel.textColor = .white
subtitleLabel.text = "创建一个新的相册拍摄任务"
subtitleLabel.font = .systemFont(ofSize: 14)
subtitleLabel.textColor = .white
arrowLabel.text = ""
arrowLabel.textColor = .white
arrowLabel.font = .systemFont(ofSize: 36, weight: .light)
addSubview(plusView)
addSubview(titleLabel)
addSubview(subtitleLabel)
addSubview(arrowLabel)
plusView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(28)
make.centerY.equalToSuperview()
make.size.equalTo(34)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(30)
make.leading.equalTo(plusView.snp.trailing).offset(16)
make.trailing.lessThanOrEqualTo(arrowLabel.snp.leading).offset(-8)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.equalTo(titleLabel)
make.trailing.lessThanOrEqualTo(arrowLabel.snp.leading).offset(-8)
}
arrowLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-18)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
}
}
/// cell
private final class TravelAlbumTaskCell: UITableViewCell {
static let reuseIdentifier = "TravelAlbumTaskCell"
var onShootUpload: (() -> Void)?
var onAlbumCode: (() -> Void)?
var onShare: (() -> Void)?
private let cardView = UIView()
private let coverView = UIImageView()
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
private let nameLabel = UILabel()
private let timeLabel = UILabel()
private let phoneLabel = UILabel()
private let shareButton = UIButton(type: .system)
private let codeButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .white
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 10
cardView.layer.shadowColor = UIColor(hex: 0x2F6BFF).withAlphaComponent(0.10).cgColor
cardView.layer.shadowOpacity = 1
cardView.layer.shadowRadius = 10
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
coverView.backgroundColor = UIColor(hex: 0xEAF2FF)
coverView.layer.cornerRadius = 8
coverView.clipsToBounds = true
coverView.contentMode = .scaleAspectFill
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
nameLabel.font = .systemFont(ofSize: 16, weight: .bold)
nameLabel.textColor = AppColor.textPrimary
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = AppColor.textTertiary
phoneLabel.font = .systemFont(ofSize: 12)
phoneLabel.textColor = AppColor.textTertiary
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
shareButton.tintColor = AppColor.primary
codeButton.setImage(UIImage(systemName: "qrcode"), for: .normal)
codeButton.tintColor = .black
uploadButton.setImage(UIImage(systemName: "camera.fill"), for: .normal)
uploadButton.setTitle(" 拍摄上传", for: .normal)
uploadButton.tintColor = AppColor.primary
uploadButton.setTitleColor(AppColor.primary, for: .normal)
uploadButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .bold)
uploadButton.backgroundColor = UIColor(hex: 0xEAF4FF)
uploadButton.layer.cornerRadius = 8
contentView.addSubview(cardView)
cardView.addSubview(coverView)
coverView.addSubview(iconView)
cardView.addSubview(nameLabel)
cardView.addSubview(timeLabel)
cardView.addSubview(phoneLabel)
cardView.addSubview(shareButton)
cardView.addSubview(codeButton)
cardView.addSubview(uploadButton)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 20, bottom: 6, right: 20))
}
coverView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(10)
make.size.equalTo(86)
}
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(42)
}
shareButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.trailing.equalTo(codeButton.snp.leading).offset(-8)
make.size.equalTo(26)
}
codeButton.snp.makeConstraints { make in
make.top.equalTo(shareButton)
make.trailing.equalToSuperview().offset(-10)
make.size.equalTo(26)
}
nameLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.equalTo(coverView.snp.trailing).offset(12)
make.trailing.equalTo(shareButton.snp.leading).offset(-8)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(7)
make.leading.trailing.equalTo(nameLabel)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(5)
make.leading.trailing.equalTo(nameLabel)
}
uploadButton.snp.makeConstraints { make in
make.top.equalTo(phoneLabel.snp.bottom).offset(9)
make.trailing.equalToSuperview().offset(-10)
make.bottom.equalToSuperview().offset(-10)
make.height.equalTo(34)
make.width.greaterThanOrEqualTo(104)
}
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
codeButton.addTarget(self, action: #selector(codeTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(album: TravelAlbum) {
nameLabel.text = album.name.isEmpty ? "未命名任务" : album.name
timeLabel.text = "创建时间:\(TravelAlbumDisplayFormatter.shortTaskTime(album.createdAt))"
phoneLabel.text = "手机号:\(album.displayPhone.isEmpty ? "--" : album.displayPhone)"
if let url = URL(string: album.coverUrl), !album.coverUrl.isEmpty {
iconView.isHidden = true
coverView.kf.setImage(with: url)
} else {
coverView.image = nil
iconView.isHidden = false
}
}
@objc private func uploadTapped() { onShootUpload?() }
@objc private func shareTapped() { onShare?() }
@objc private func codeTapped() { onAlbumCode?() }
}
///
private final class TravelAlbumEmptyView: UIView {
private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
isHidden = true
imageView.tintColor = AppColor.primary
imageView.contentMode = .scaleAspectFit
titleLabel.text = "暂无任务"
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = .black
titleLabel.textAlignment = .center
subtitleLabel.text = "点击“新建相册任务”开始创建任务"
subtitleLabel.font = .systemFont(ofSize: 14)
subtitleLabel.textColor = AppColor.textTertiary
subtitleLabel.textAlignment = .center
let backdrop = UIView()
backdrop.backgroundColor = UIColor(hex: 0xF2F7FF)
backdrop.layer.cornerRadius = 54
addSubview(backdrop)
addSubview(imageView)
addSubview(titleLabel)
addSubview(subtitleLabel)
backdrop.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-70)
make.width.equalToSuperview().multipliedBy(0.58)
make.height.equalTo(108)
}
imageView.snp.makeConstraints { make in
make.center.equalTo(backdrop)
make.size.equalTo(82)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(backdrop.snp.bottom).offset(22)
make.leading.trailing.equalToSuperview().inset(20)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(10)
make.leading.trailing.equalTo(titleLabel)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}