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,393 @@
//
// CreateTravelAlbumSheetViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Sheet Android `CreateTravelAlbumModal`
final class CreateTravelAlbumSheetViewController: BaseViewController {
var onDismissed: (() -> Void)?
private let viewModel: TravelAlbumEntryViewModel
private let api: any TravelAlbumServing
private var mode: TravelAlbumEntryViewModel.CreateMode = .preShoot
private var selectedOrder: TravelAlbumAvailableOrder?
private let scrollView = UIScrollView()
private let contentView = UIView()
private let titleLabel = UILabel()
private let preShootCard = TravelAlbumModeOptionView()
private let preOrderCard = TravelAlbumModeOptionView()
private let fieldsStack = UIStackView()
private let freeCountField = UITextField()
private let singlePriceField = UITextField()
private let packagePriceField = UITextField()
private let orderButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
sheetPresentationController.detents = [.medium(), .large()]
sheetPresentationController.prefersGrabberVisible = false
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {}
override func setupUI() {
view.backgroundColor = .white
titleLabel.text = "新建相册任务"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
preShootCard.apply(
title: "先拍再买",
desc: "拍完后分享给用户,用户在小程序上选择性购买",
selected: true
)
preOrderCard.apply(
title: "买了再拍",
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
selected: false
)
fieldsStack.axis = .vertical
fieldsStack.spacing = 14
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
orderButton.contentHorizontalAlignment = .left
orderButton.setTitle("请选择订单", for: .normal)
orderButton.setTitleColor(AppColor.textTertiary, for: .normal)
orderButton.titleLabel?.font = .systemFont(ofSize: 14)
orderButton.layer.cornerRadius = 8
orderButton.layer.borderColor = AppColor.border.cgColor
orderButton.layer.borderWidth = 1
orderButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
view.addSubview(scrollView)
scrollView.addSubview(contentView)
[titleLabel, preShootCard, preOrderCard, fieldsStack, cancelButton, confirmButton].forEach(contentView.addSubview)
rebuildFields()
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
contentView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(18)
make.leading.trailing.equalToSuperview().inset(20)
}
preShootCard.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(16)
}
preOrderCard.snp.makeConstraints { make in
make.top.equalTo(preShootCard.snp.bottom).offset(10)
make.leading.trailing.equalTo(preShootCard)
}
fieldsStack.snp.makeConstraints { make in
make.top.equalTo(preOrderCard.snp.bottom).offset(18)
make.leading.trailing.equalTo(preShootCard)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(fieldsStack.snp.bottom).offset(22)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(48)
make.width.equalTo(confirmButton)
make.bottom.equalToSuperview().offset(-16)
}
confirmButton.snp.makeConstraints { make in
make.top.height.width.equalTo(cancelButton)
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
}
}
override func bindActions() {
preShootCard.addTarget(self, action: #selector(preShootTapped), for: .touchUpInside)
preOrderCard.addTarget(self, action: #selector(preOrderTapped), for: .touchUpInside)
orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
[freeCountField, singlePriceField, packagePriceField].forEach {
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isBeingDismissed {
onDismissed?()
}
}
private func configureTextField(_ field: UITextField, placeholder: String, keyboardType: UIKeyboardType) {
field.placeholder = placeholder
field.keyboardType = keyboardType
field.font = .systemFont(ofSize: 14)
field.layer.cornerRadius = 8
field.layer.borderWidth = 1
field.layer.borderColor = AppColor.border.cgColor
field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1))
field.leftViewMode = .always
field.snp.makeConstraints { make in
make.height.equalTo(44)
}
}
private func configureActionButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(titleColor, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = backgroundColor
button.layer.cornerRadius = 10
}
private func rebuildFields() {
fieldsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if mode == .preShoot {
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
} else {
fieldsStack.addArrangedSubview(makeOrderGroup())
}
}
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
let container = UIView()
let label = UILabel()
label.attributedText = fieldTitle(title, required: required)
container.addSubview(label)
container.addSubview(field)
label.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
field.snp.makeConstraints { make in
make.top.equalTo(label.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
return container
}
private func makeOrderGroup() -> UIView {
let container = UIView()
let label = UILabel()
label.attributedText = fieldTitle("绑定订单", required: false)
container.addSubview(label)
container.addSubview(orderButton)
label.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
orderButton.snp.makeConstraints { make in
make.top.equalTo(label.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
make.height.greaterThanOrEqualTo(46)
}
return container
}
private func fieldTitle(_ text: String, required: Bool) -> NSAttributedString {
let result = NSMutableAttributedString(
string: text,
attributes: [.font: UIFont.systemFont(ofSize: 14, weight: .medium), .foregroundColor: AppColor.textPrimary]
)
if required {
result.append(NSAttributedString(string: " *", attributes: [.foregroundColor: UIColor.red]))
}
return result
}
private func updateMode(_ newMode: TravelAlbumEntryViewModel.CreateMode) {
mode = newMode
preShootCard.apply(
title: "先拍再买",
desc: "拍完后分享给用户,用户在小程序上选择性购买",
selected: mode == .preShoot
)
preOrderCard.apply(
title: "买了再拍",
desc: "表示用户已经在小程序上下过单了,绑定对应订单上传后用户可以直接选片",
selected: mode == .preOrder
)
rebuildFields()
}
private func updateOrderButton() {
guard let order = selectedOrder else {
orderButton.setTitle("请选择订单", for: .normal)
orderButton.setTitleColor(AppColor.textTertiary, for: .normal)
return
}
let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName)\n手机号:\(order.userPhone)\n订单号:\(order.orderNumber)"
orderButton.setTitle(title, for: .normal)
orderButton.setTitleColor(AppColor.textPrimary, for: .normal)
orderButton.titleLabel?.numberOfLines = 3
}
@objc private func preShootTapped() {
updateMode(.preShoot)
}
@objc private func preOrderTapped() {
updateMode(.preOrder)
}
@objc private func orderTapped() {
let alert = UIAlertController(title: "绑定订单", message: nil, preferredStyle: .actionSheet)
if viewModel.availableOrders.isEmpty {
alert.addAction(UIAlertAction(title: "暂无可绑定订单", style: .default))
} else {
viewModel.availableOrders.forEach { order in
let title = "\(order.projectName.isEmpty ? "未命名项目" : order.projectName) \(order.userPhone)"
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
self?.selectedOrder = order
self?.updateOrderButton()
})
}
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
@objc private func confirmTapped() {
Task {
await viewModel.confirmCreate(
mode: mode,
freeCount: freeCountField.text ?? "",
singlePrice: singlePriceField.text ?? "",
packagePrice: packagePriceField.text ?? "",
order: selectedOrder,
api: api
)
await MainActor.run {
if !viewModel.isCreateSheetVisible {
dismiss(animated: true)
}
}
}
}
@objc private func textFieldEditingChanged(_ field: UITextField) {
let text = field.text ?? ""
if field === freeCountField {
field.text = text.filter(\.isNumber)
} else {
field.text = sanitizeMoneyInput(text)
}
}
private func sanitizeMoneyInput(_ value: String) -> String {
var result = ""
var hasDot = false
for char in value {
if char.isNumber {
result.append(char)
} else if char == ".", !hasDot {
result.append(char)
hasDot = true
}
}
guard !result.isEmpty else { return "" }
if result.hasPrefix(".") {
result = "0" + result
}
guard let dotIndex = result.firstIndex(of: ".") else {
let trimmed = result.drop { $0 == "0" }
return trimmed.isEmpty ? "0" : String(trimmed)
}
let integerPart = result[..<dotIndex].drop { $0 == "0" }
let decimalStart = result.index(after: dotIndex)
let decimal = result[decimalStart...].prefix(2)
return "\(integerPart.isEmpty ? "0" : String(integerPart)).\(decimal)"
}
}
///
private final class TravelAlbumModeOptionView: UIControl {
private let dotView = UIView()
private let innerDotView = UIView()
private let titleLabel = UILabel()
private let descLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 10
layer.borderWidth = 1
dotView.layer.cornerRadius = 10
innerDotView.backgroundColor = .white
innerDotView.layer.cornerRadius = 3.5
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
descLabel.font = .systemFont(ofSize: 12)
descLabel.textColor = AppColor.textSecondary
descLabel.numberOfLines = 0
addSubview(dotView)
dotView.addSubview(innerDotView)
addSubview(titleLabel)
addSubview(descLabel)
dotView.snp.makeConstraints { make in
make.leading.top.equalToSuperview().offset(14)
make.size.equalTo(20)
}
innerDotView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(7)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.leading.equalTo(dotView.snp.trailing).offset(10)
make.trailing.equalToSuperview().offset(-14)
}
descLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(titleLabel)
make.bottom.equalToSuperview().offset(-14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String, desc: String, selected: Bool) {
titleLabel.text = title
descLabel.text = desc
backgroundColor = selected ? AppColor.primary.withAlphaComponent(0.06) : .white
layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
titleLabel.textColor = selected ? AppColor.primary : AppColor.textPrimary
dotView.backgroundColor = selected ? AppColor.primary : .clear
dotView.layer.borderWidth = selected ? 0 : 2
dotView.layer.borderColor = AppColor.border.cgColor
innerDotView.isHidden = !selected
}
}

View File

@ -0,0 +1,162 @@
//
// TravelAlbumCodeDialogViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `TravelAlbumCodeDialog`
final class TravelAlbumCodeDialogViewController: UIViewController {
var onClose: (() -> Void)?
var onDownload: (() -> Void)?
private let state: TravelAlbumEntryViewModel.AlbumCodeState
private let dimView = UIView()
private let cardView = UIView()
private let titleLabel = UILabel()
private let closeButton = UIButton(type: .system)
private let albumNameLabel = UILabel()
private let qrImageView = UIImageView()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let hintLabel = UILabel()
private let downloadButton = UIButton(type: .system)
init(state: TravelAlbumEntryViewModel.AlbumCodeState) {
self.state = state
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
applyState()
}
private func setupUI() {
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 16
cardView.clipsToBounds = true
titleLabel.text = "相册码"
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
closeButton.tintColor = AppColor.textSecondary
albumNameLabel.font = .systemFont(ofSize: 18, weight: .medium)
albumNameLabel.textColor = AppColor.textPrimary
albumNameLabel.textAlignment = .center
qrImageView.contentMode = .scaleAspectFit
qrImageView.layer.cornerRadius = 8
qrImageView.clipsToBounds = true
hintLabel.textAlignment = .center
hintLabel.font = .systemFont(ofSize: 13)
hintLabel.textColor = AppColor.textSecondary
hintLabel.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
hintLabel.layer.cornerRadius = 8
hintLabel.layer.borderWidth = 1
hintLabel.layer.borderColor = AppColor.primary.withAlphaComponent(0.35).cgColor
hintLabel.clipsToBounds = true
let hint = NSMutableAttributedString(string: "微信", attributes: [.foregroundColor: AppColor.textSecondary])
hint.append(NSAttributedString(string: "扫一扫", attributes: [.foregroundColor: AppColor.primary, .font: UIFont.systemFont(ofSize: 13, weight: .medium)]))
hint.append(NSAttributedString(string: ",查看下载照片", attributes: [.foregroundColor: AppColor.textSecondary]))
hintLabel.attributedText = hint
downloadButton.setTitle("下载图片", for: .normal)
downloadButton.setTitleColor(.white, for: .normal)
downloadButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
downloadButton.backgroundColor = AppColor.primary
downloadButton.layer.cornerRadius = 23
view.addSubview(dimView)
view.addSubview(cardView)
[titleLabel, closeButton, albumNameLabel, qrImageView, loadingIndicator, hintLabel, downloadButton].forEach(cardView.addSubview)
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
downloadButton.addTarget(self, action: #selector(downloadTapped), for: .touchUpInside)
}
private func setupConstraints() {
dimView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.width.equalToSuperview().multipliedBy(0.78)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(18)
make.leading.trailing.equalToSuperview().inset(48)
}
closeButton.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview().offset(-20)
make.size.equalTo(28)
}
albumNameLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(20)
}
qrImageView.snp.makeConstraints { make in
make.top.equalTo(albumNameLabel.snp.bottom).offset(18)
make.centerX.equalToSuperview()
make.size.equalTo(190)
}
loadingIndicator.snp.makeConstraints { make in
make.center.equalTo(qrImageView)
}
hintLabel.snp.makeConstraints { make in
make.top.equalTo(qrImageView.snp.bottom).offset(18)
make.centerX.equalToSuperview()
make.height.equalTo(34)
make.leading.greaterThanOrEqualToSuperview().offset(20)
}
downloadButton.snp.makeConstraints { make in
make.top.equalTo(hintLabel.snp.bottom).offset(20)
make.leading.trailing.equalToSuperview().inset(20)
make.height.equalTo(46)
make.bottom.equalToSuperview().offset(-18)
}
}
private func applyState() {
albumNameLabel.text = state.albumName.isEmpty ? "未命名相册" : state.albumName
if state.isLoading {
loadingIndicator.startAnimating()
qrImageView.image = nil
} else {
loadingIndicator.stopAnimating()
if let url = URL(string: state.qrImageUrl), !state.qrImageUrl.isEmpty {
qrImageView.kf.setImage(with: url)
} else {
qrImageView.image = UIImage(systemName: "qrcode")
qrImageView.tintColor = AppColor.textSecondary
}
}
downloadButton.isEnabled = !state.isLoading && !state.qrImageUrl.isEmpty
downloadButton.alpha = downloadButton.isEnabled ? 1 : 0.5
}
func updateDownloading(_ isDownloading: Bool) {
downloadButton.setTitle(isDownloading ? "保存中..." : "下载图片", for: .normal)
downloadButton.isEnabled = !isDownloading
}
@objc private func closeTapped() {
onClose?()
dismiss(animated: true)
}
@objc private func downloadTapped() {
onDownload?()
}
}

View File

@ -0,0 +1,479 @@
//
// TravelAlbumDetailViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `TravelAlbumDetailScreen`
final class TravelAlbumDetailViewController: BaseViewController {
private let viewModel: TravelAlbumDetailViewModel
private let api: any TravelAlbumServing
private let scrollContainer = UIView()
private let infoCard = TravelAlbumInfoCard()
private let manageCard = UIView()
private let tabStack = UIStackView()
private let allTabButton = UIButton(type: .system)
private let purchasedTabButton = UIButton(type: .system)
private let sortButton = UIButton(type: .system)
private let selectButton = UIButton(type: .system)
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
private let bottomBar = UIView()
private let deleteSelectedButton = UIButton(type: .system)
private let uploadButton = UIButton(type: .system)
init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
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 = "相册管理"
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"),
menu: UIMenu(children: [
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.confirmDeleteAlbum()
},
])
)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
manageCard.backgroundColor = .white
manageCard.layer.cornerRadius = 12
manageCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
manageCard.layer.shadowOpacity = 1
manageCard.layer.shadowRadius = 6
manageCard.layer.shadowOffset = CGSize(width: 0, height: 2)
tabStack.axis = .horizontal
tabStack.spacing = 8
configurePillButton(allTabButton)
configurePillButton(purchasedTabButton)
configureIconButton(sortButton, image: UIImage(systemName: "line.3.horizontal.decrease.circle.fill"))
configureIconButton(selectButton, image: UIImage(systemName: "circle"))
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white
collectionView.delegate = self
collectionView.register(TravelAlbumMaterialCell.self, forCellWithReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier)
dataSource = UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>(collectionView: collectionView) {
[weak self] collectionView, indexPath, material in
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: TravelAlbumMaterialCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumMaterialCell
cell.apply(
material: material,
selectionMode: self?.viewModel.isSelectionMode == true,
selected: self?.viewModel.selectedMaterialIds.contains(material.id) == true
)
return cell
}
bottomBar.backgroundColor = AppColor.pageBackground
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: UIColor(hex: 0xE53935))
configureBottomButton(uploadButton, title: "上传照片", color: AppColor.primary)
deleteSelectedButton.isHidden = true
view.addSubview(scrollContainer)
scrollContainer.addSubview(infoCard)
scrollContainer.addSubview(manageCard)
manageCard.addSubview(tabStack)
tabStack.addArrangedSubview(allTabButton)
tabStack.addArrangedSubview(purchasedTabButton)
manageCard.addSubview(sortButton)
manageCard.addSubview(selectButton)
manageCard.addSubview(collectionView)
view.addSubview(bottomBar)
bottomBar.addSubview(deleteSelectedButton)
bottomBar.addSubview(uploadButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
deleteSelectedButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.trailing.equalToSuperview().inset(20)
make.height.equalTo(48)
}
uploadButton.snp.makeConstraints { make in
make.top.equalTo(deleteSelectedButton.snp.bottom).offset(8)
make.leading.trailing.equalTo(deleteSelectedButton)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
scrollContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
}
infoCard.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.greaterThanOrEqualTo(76)
}
manageCard.snp.makeConstraints { make in
make.top.equalTo(infoCard.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview()
}
tabStack.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12)
make.height.equalTo(32)
}
sortButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack)
make.trailing.equalTo(selectButton.snp.leading).offset(-8)
make.size.equalTo(32)
}
selectButton.snp.makeConstraints { make in
make.centerY.equalTo(tabStack)
make.trailing.equalToSuperview().offset(-12)
make.size.equalTo(32)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(tabStack.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
}
}
override func bindActions() {
infoCard.onCall = { [weak self] phone in self?.call(phone) }
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
sortButton.addTarget(self, action: #selector(sortTapped), for: .touchUpInside)
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onDeletedAlbum = { [weak self] _ in
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refreshAll(api: api) }
}
@MainActor
private func applyViewModel() {
if let album = viewModel.album {
infoCard.apply(album: album)
}
configureTabButton(allTabButton, title: "全部照片\(viewModel.allPhotoCount)", selected: viewModel.selectedTab == .all)
configureTabButton(purchasedTabButton, title: "已购照片", selected: viewModel.selectedTab == .purchased)
selectButton.isHidden = viewModel.selectedTab != .all
selectButton.setImage(UIImage(systemName: viewModel.isSelectionMode ? "checkmark.circle.fill" : "circle"), for: .normal)
selectButton.tintColor = viewModel.isSelectionMode ? AppColor.primary : AppColor.textTertiary
deleteSelectedButton.isHidden = !(viewModel.isSelectionMode && !viewModel.selectedMaterialIds.isEmpty)
deleteSelectedButton.setTitle("删除选中(\(viewModel.selectedMaterialIds.count))", for: .normal)
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.materials)
dataSource.apply(snapshot, animatingDifferences: true)
if viewModel.isLoading && viewModel.album == nil {
showLoading()
} else {
hideLoading()
}
}
private func configurePillButton(_ button: UIButton) {
button.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
button.layer.cornerRadius = 16
button.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14)
}
private func configureTabButton(_ button: UIButton, title: String, selected: Bool) {
button.setTitle(title, for: .normal)
button.backgroundColor = selected ? AppColor.primary : UIColor(hex: 0xEAF4FF)
button.setTitleColor(selected ? .white : AppColor.primary, for: .normal)
}
private func configureIconButton(_ button: UIButton, image: UIImage?) {
button.setImage(image, for: .normal)
button.backgroundColor = UIColor(hex: 0xEAF4FF)
button.tintColor = AppColor.primary
button.layer.cornerRadius = 16
}
private func configureBottomButton(_ button: UIButton, title: String, color: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = color
button.layer.cornerRadius = 10
}
private func makeLayout() -> UICollectionViewCompositionalLayout {
UICollectionViewCompositionalLayout { _, environment in
let spacing: CGFloat = 8
let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3
let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 38))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 38))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 12
return section
}
}
private func call(_ phone: String) {
guard !phone.isEmpty, let url = URL(string: "tel:\(phone)") else { return }
UIApplication.shared.open(url)
}
private func confirmDeleteAlbum() {
let alert = UIAlertController(title: "删除相册", message: "确定删除该相册吗?已有购买素材的相册无法删除。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.deleteAlbum(api: self.api) }
})
present(alert, animated: true)
}
@objc private func allTabTapped() {
Task { await viewModel.selectTab(.all, api: api) }
}
@objc private func purchasedTabTapped() {
Task { await viewModel.selectTab(.purchased, api: api) }
}
@objc private func sortTapped() {
let alert = UIAlertController(title: "排序", message: nil, preferredStyle: .actionSheet)
TravelAlbumDetailViewModel.SortOption.allCases.forEach { option in
alert.addAction(UIAlertAction(title: option.title, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.setSortOption(option, api: self.api) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
@objc private func selectTapped() {
viewModel.toggleSelectionMode()
}
@objc private func deleteSelectedTapped() {
let count = viewModel.selectedMaterialIds.count
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.deleteSelectedMaterials(api: self.api) }
})
present(alert, animated: true)
}
@objc private func uploadTapped() {
let album = viewModel.album
let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel(
albumId: album?.id ?? viewModel.albumId,
albumTitle: album?.name ?? "",
headerPhone: album?.displayPhone ?? ""
)
)
navigationController?.pushViewController(controller, animated: true)
}
}
extension TravelAlbumDetailViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let material = dataSource.itemIdentifier(for: indexPath) else { return }
viewModel.toggleMaterialSelection(material)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let visible = collectionView.indexPathsForVisibleItems.map(\.item).max() ?? 0
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: visible, api: api) }
}
}
///
private final class TravelAlbumInfoCard: UIView {
var onCall: ((String) -> Void)?
private var phone = ""
private let iconView = UIImageView(image: UIImage(systemName: "checklist"))
private let phoneLabel = UILabel()
private let timeLabel = UILabel()
private let callButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor
layer.shadowOpacity = 1
layer.shadowRadius = 6
layer.shadowOffset = CGSize(width: 0, height: 2)
let iconBox = UIView()
iconBox.backgroundColor = AppColor.primary
iconBox.layer.cornerRadius = 10
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
phoneLabel.font = .systemFont(ofSize: 14, weight: .medium)
phoneLabel.textColor = AppColor.textPrimary
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = AppColor.textTertiary
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary
callButton.backgroundColor = UIColor(hex: 0xEAF4FF)
callButton.layer.cornerRadius = 20
addSubview(iconBox)
iconBox.addSubview(iconView)
addSubview(phoneLabel)
addSubview(timeLabel)
addSubview(callButton)
iconBox.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(14)
make.size.equalTo(48)
}
iconView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalTo(iconBox.snp.trailing).offset(12)
make.trailing.equalTo(callButton.snp.leading).offset(-12)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(phoneLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(phoneLabel)
make.bottom.equalToSuperview().offset(-16)
}
callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-14)
make.centerY.equalToSuperview()
make.size.equalTo(40)
}
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(album: TravelAlbum) {
phone = album.displayPhone
phoneLabel.text = "手机号 \(TravelAlbumDisplayFormatter.maskPhone(album.displayPhone))"
timeLabel.text = "创建时间 \(album.createdAt)"
}
@objc private func callTapped() {
onCall?(phone)
}
}
/// cell
private final class TravelAlbumMaterialCell: UICollectionViewCell {
static let reuseIdentifier = "TravelAlbumMaterialCell"
private let imageView = UIImageView()
private let statusLabel = UILabel()
private let checkImageView = UIImageView()
private let nameLabel = UILabel()
private let sizeLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 8
statusLabel.text = "已上传"
statusLabel.font = .systemFont(ofSize: 10)
statusLabel.textColor = .white
statusLabel.backgroundColor = UIColor(hex: 0x34C759)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
checkImageView.tintColor = .white
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
checkImageView.layer.cornerRadius = 10
nameLabel.font = .systemFont(ofSize: 11)
nameLabel.textColor = AppColor.textPrimary
nameLabel.lineBreakMode = .byTruncatingMiddle
sizeLabel.font = .systemFont(ofSize: 10)
sizeLabel.textColor = AppColor.textTertiary
contentView.addSubview(imageView)
imageView.addSubview(statusLabel)
imageView.addSubview(checkImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(sizeLabel)
imageView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(imageView.snp.width)
}
statusLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(4)
make.height.equalTo(18)
make.width.equalTo(48)
}
checkImageView.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(20)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(imageView.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview()
}
sizeLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(material: TravelAlbumMaterial, selectionMode: Bool, selected: Bool) {
let urlString = material.coverUrl.isEmpty ? material.fileUrl : material.coverUrl
if let url = URL(string: urlString), !urlString.isEmpty {
imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
} else {
imageView.image = UIImage(systemName: "photo")
imageView.tintColor = AppColor.primary
imageView.backgroundColor = UIColor(hex: 0xEAF2FF)
}
checkImageView.isHidden = !selectionMode
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkImageView.tintColor = selected ? AppColor.primary : .white
nameLabel.text = material.fileName
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
}
}

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

View File

@ -0,0 +1,311 @@
//
// WiredCameraTransferViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 线 Android UI OTG
final class WiredCameraTransferViewController: BaseViewController {
private let viewModel: WiredCameraTransferViewModel
private let headerView = UIView()
private let backButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let phoneLabel = UILabel()
private let statusCard = UIView()
private let storagePanel = UIView()
private let storageTitleLabel = UILabel()
private let storageValueLabel = UILabel()
private let statusLabel = UILabel()
private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel()
private let chipsStack = UIStackView()
private let statsCard = UIStackView()
private let emptyLabel = UILabel()
private let bottomBar = UIView()
private let batchButton = UIButton(type: .system)
private let specifyButton = UIButton(type: .system)
init(viewModel: WiredCameraTransferViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
navigationController?.setNavigationBarHidden(true, animated: false)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
headerView.backgroundColor = AppColor.primary
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = .white
titleLabel.text = viewModel.albumTitle
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
titleLabel.textColor = .white
phoneLabel.text = viewModel.headerPhone
phoneLabel.font = .systemFont(ofSize: 13)
phoneLabel.textColor = UIColor.white.withAlphaComponent(0.92)
statusCard.backgroundColor = .white
statusCard.layer.cornerRadius = 12
statusCard.layer.shadowColor = UIColor.black.withAlphaComponent(0.10).cgColor
statusCard.layer.shadowOpacity = 1
statusCard.layer.shadowRadius = 8
statusCard.layer.shadowOffset = CGSize(width: 0, height: 3)
storagePanel.layer.cornerRadius = 12
storagePanel.layer.borderColor = AppColor.border.cgColor
storagePanel.layer.borderWidth = 3
storageTitleLabel.text = viewModel.deviceModelName
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
storageTitleLabel.textColor = AppColor.textPrimary
storageTitleLabel.textAlignment = .center
storageValueLabel.text = viewModel.availableStorageText
storageValueLabel.font = .systemFont(ofSize: 10)
storageValueLabel.textColor = AppColor.primary
storageValueLabel.textAlignment = .center
statusLabel.text = viewModel.cameraStatusText
statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
statusLabel.textColor = AppColor.danger
statusLabel.backgroundColor = AppColor.danger.withAlphaComponent(0.10)
statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
refreshButton.setTitleColor(.white, for: .normal)
refreshButton.titleLabel?.font = .systemFont(ofSize: 12)
refreshButton.backgroundColor = AppColor.danger
refreshButton.layer.cornerRadius = 8
helpLabel.attributedText = helpText()
helpLabel.font = .systemFont(ofSize: 10)
helpLabel.numberOfLines = 2
chipsStack.axis = .horizontal
chipsStack.spacing = 6
[viewModel.retouchOption, viewModel.photoFormatOption, viewModel.transferModeOption].forEach {
chipsStack.addArrangedSubview(makeChip($0))
}
statsCard.axis = .horizontal
statsCard.distribution = .fillEqually
["全部", "已上传", "失败"].enumerated().forEach { index, title in
statsCard.addArrangedSubview(makeStat(title: title, count: viewModel.tabCounts[index], selected: index == 0, danger: index == 2))
}
emptyLabel.text = "暂无照片"
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.textAlignment = .center
bottomBar.backgroundColor = .white
configureBottomButton(batchButton, title: "批量上传", filled: true)
configureBottomButton(specifyButton, title: "指定上传", filled: false)
view.addSubview(headerView)
headerView.addSubview(backButton)
headerView.addSubview(titleLabel)
headerView.addSubview(phoneLabel)
view.addSubview(statusCard)
statusCard.addSubview(storagePanel)
storagePanel.addSubview(storageTitleLabel)
storagePanel.addSubview(storageValueLabel)
statusCard.addSubview(statusLabel)
statusCard.addSubview(refreshButton)
statusCard.addSubview(helpLabel)
statusCard.addSubview(chipsStack)
statusCard.addSubview(statsCard)
view.addSubview(emptyLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(batchButton)
bottomBar.addSubview(specifyButton)
}
override func setupConstraints() {
headerView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.height.equalTo(118)
}
backButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(8)
make.bottom.equalToSuperview().offset(-28)
make.size.equalTo(44)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(backButton.snp.trailing).offset(4)
make.trailing.equalToSuperview().offset(-16)
make.top.equalTo(backButton).offset(4)
}
phoneLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(2)
make.leading.trailing.equalTo(titleLabel)
}
statusCard.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom).offset(-10)
make.leading.trailing.equalToSuperview().inset(16)
}
storagePanel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(14)
make.width.greaterThanOrEqualTo(76)
make.height.equalTo(76)
}
storageTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(10)
make.leading.trailing.equalToSuperview().inset(8)
}
storageValueLabel.snp.makeConstraints { make in
make.top.equalTo(storageTitleLabel.snp.bottom).offset(6)
make.leading.trailing.equalTo(storageTitleLabel)
}
statusLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalTo(storagePanel.snp.trailing).offset(10)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(84)
}
refreshButton.snp.makeConstraints { make in
make.centerY.equalTo(statusLabel)
make.trailing.equalToSuperview().offset(-14)
make.height.equalTo(34)
make.width.greaterThanOrEqualTo(72)
}
helpLabel.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(4)
make.leading.equalTo(statusLabel)
make.trailing.equalTo(refreshButton)
}
chipsStack.snp.makeConstraints { make in
make.top.equalTo(storagePanel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(14)
make.height.equalTo(34)
}
statsCard.snp.makeConstraints { make in
make.top.equalTo(chipsStack.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(68)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
batchButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.equalToSuperview().offset(16)
make.height.equalTo(44)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
specifyButton.snp.makeConstraints { make in
make.top.height.width.equalTo(batchButton)
make.leading.equalTo(batchButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
}
emptyLabel.snp.makeConstraints { make in
make.top.equalTo(statusCard.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
}
override func bindActions() {
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
specifyButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside)
}
private func makeChip(_ text: String) -> UIView {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 11)
label.textColor = AppColor.textTertiary
label.backgroundColor = AppColor.pageBackground
label.layer.cornerRadius = 8
label.layer.borderWidth = 1
label.layer.borderColor = AppColor.border.cgColor
label.clipsToBounds = true
label.textAlignment = .center
return label
}
private func makeStat(title: String, count: Int, selected: Bool, danger: Bool) -> UIView {
let container = UIView()
let countLabel = UILabel()
let titleLabel = UILabel()
let underline = UIView()
countLabel.text = "\(count)"
countLabel.font = .systemFont(ofSize: 18, weight: .semibold)
countLabel.textColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary)
countLabel.textAlignment = .center
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 13)
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
titleLabel.textAlignment = .center
underline.backgroundColor = selected ? AppColor.primary : .clear
underline.layer.cornerRadius = 1
container.addSubview(countLabel)
container.addSubview(titleLabel)
container.addSubview(underline)
countLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.equalToSuperview()
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(countLabel.snp.bottom).offset(2)
make.leading.trailing.equalToSuperview()
}
underline.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.centerX.equalToSuperview()
make.width.equalTo(24)
make.height.equalTo(2)
}
return container
}
private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 15)
button.layer.cornerRadius = 10
if filled {
button.backgroundColor = AppColor.primary
button.setTitleColor(.white, for: .normal)
} else {
button.backgroundColor = .white
button.setTitleColor(AppColor.primary, for: .normal)
button.layer.borderWidth = 1
button.layer.borderColor = AppColor.primary.cgColor
}
}
private func helpText() -> NSAttributedString {
let text = NSMutableAttributedString(
string: "未连接,查看",
attributes: [.foregroundColor: AppColor.textSecondary]
)
text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary]))
return text
}
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func uiOnlyTapped() {
viewModel.showUIOnlyMessage()
}
}