新增线下收款记录和 ai 修图优化

This commit is contained in:
han xin
2026-08-21 15:51:52 +08:00
parent 6fe9928b49
commit 5cf4409bad
40 changed files with 5482 additions and 56 deletions
@@ -0,0 +1,416 @@
//
// TravelAlbumAutoRetouchSettingSheetViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 自动 AI 修图设置 Sheet,先选修图方式,选择 AI 修图后再选带效果图的模板。
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController, UITableViewDelegate {
private enum Step: Equatable {
case mode
case template
}
private enum Item: Hashable {
case mode(TravelAlbumRetouchMode)
case template(TravelAlbumEditPreset)
}
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
var onCancelled: (() -> Void)?
private let startsWithModeSelection: Bool
private let backButton = UIButton(type: .system)
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain)
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private var dataSource: UITableViewDiffableDataSource<Int, Item>!
private var step: Step
private var pendingConfiguration: TravelAlbumAutoRetouchConfiguration
private var selectedItem: Item?
private var previewImages: [TravelAlbumEditPreset.Effect: UIImage] = [:]
private var didConfirm = false
/// 创建自动修图设置 Sheet。
/// - Parameters:
/// - configuration: 当前配置,用于回显已选方式和模板。
/// - startsWithModeSelection: 是否先展示“不修图 / AI 修图”两个一级选项。
init(
configuration: TravelAlbumAutoRetouchConfiguration,
startsWithModeSelection: Bool
) {
self.startsWithModeSelection = startsWithModeSelection
step = startsWithModeSelection ? .mode : .template
pendingConfiguration = configuration
if startsWithModeSelection {
selectedItem = .mode(configuration.isEnabled ? .aiRetouch : .disabled)
} else if let template = configuration.template {
selectedItem = .template(template)
}
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
let identifier = UISheetPresentationController.Detent.Identifier("travelAlbumAutoRetouchSetting")
sheetPresentationController.detents = [
.custom(identifier: identifier) { context in
min(660, context.maximumDetentValue)
},
.large(),
]
sheetPresentationController.selectedDetentIdentifier = identifier
sheetPresentationController.prefersGrabberVisible = false
sheetPresentationController.preferredCornerRadius = AppRadius.xl
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {}
override func setupUI() {
view.backgroundColor = .white
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = AppColor.textPrimary
backButton.accessibilityLabel = "返回修图方式"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
subtitleLabel.font = .app(.caption)
subtitleLabel.textColor = AppColor.textSecondary
subtitleLabel.textAlignment = .center
subtitleLabel.numberOfLines = 0
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.showsVerticalScrollIndicator = false
tableView.delegate = self
tableView.register(
TravelAlbumAutoRetouchOptionCell.self,
forCellReuseIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier
)
dataSource = UITableViewDiffableDataSource<Int, Item>(tableView: tableView) {
[weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(
withIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier,
for: indexPath
) as! TravelAlbumAutoRetouchOptionCell
guard let self else { return cell }
switch item {
case .mode(let mode):
let iconName = mode == .disabled ? "photo" : "wand.and.stars"
let detail = mode == .disabled
? "保留照片原效果"
: (self.pendingConfiguration.template.map { "当前模板:\($0.title)" } ?? "选择后需再选一个修图模板")
cell.apply(
title: mode.title,
detail: detail,
previewImage: UIImage(systemName: iconName),
usesTemplateImage: true,
selected: self.selectedItem == item
)
case .template(let preset):
cell.apply(
title: preset.title,
detail: preset.autoRetouchEffectDescription,
previewImage: self.previewImage(for: preset),
usesTemplateImage: false,
selected: self.selectedItem == item
)
}
return cell
}
configureActionButton(cancelButton, title: "取消", isPrimary: false)
configureActionButton(confirmButton, title: "确定", isPrimary: true)
let buttonStack = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
buttonStack.axis = .horizontal
buttonStack.spacing = AppSpacing.sm
buttonStack.distribution = .fillEqually
view.addSubview(backButton)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(tableView)
view.addSubview(buttonStack)
backButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.md)
make.centerY.equalTo(titleLabel)
make.size.equalTo(AppSpacing.minTouchTarget)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.lg)
make.leading.trailing.equalToSuperview().inset(60)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.equalToSuperview().inset(AppSpacing.lg)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalTo(buttonStack.snp.top).offset(-AppSpacing.sm)
}
buttonStack.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-AppSpacing.md)
make.height.equalTo(AppSpacing.minTouchTarget)
}
refreshContent()
}
override func bindActions() {
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isBeingDismissed, !didConfirm {
onCancelled?()
}
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
step == .mode ? 80 : 108
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .mode(.disabled):
pendingConfiguration = .disabled
selectedItem = item
applySnapshot()
updateConfirmButton()
case .mode(.aiRetouch):
step = .template
selectedItem = pendingConfiguration.template.map(Item.template)
refreshContent()
case .template(let preset):
pendingConfiguration = .enabled(templateID: preset.id)
selectedItem = item
applySnapshot()
updateConfirmButton()
}
}
private func refreshContent() {
switch step {
case .mode:
titleLabel.text = "选择修图方式"
subtitleLabel.text = "照片上传前可选择保留原图,或使用 AI 自动修图"
backButton.isHidden = true
case .template:
titleLabel.text = "选择修图模板"
subtitleLabel.text = "缩略图为模板实际效果,后续上传的照片将自动套用"
backButton.isHidden = !startsWithModeSelection
}
applySnapshot()
updateConfirmButton()
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
snapshot.appendSections([0])
switch step {
case .mode:
snapshot.appendItems(TravelAlbumRetouchMode.allCases.map(Item.mode))
case .template:
snapshot.appendItems(TravelAlbumEditPreset.autoRetouchOptions.map(Item.template))
}
// 选中状态存放在页面状态中,item 本身的标识不会变化。
// Diffable Data Source 不会主动重新配置相同 item,因此需显式刷新仍在当前列表中的选项。
let currentItems = Set(dataSource.snapshot().itemIdentifiers)
let retainedItems = snapshot.itemIdentifiers.filter(currentItems.contains)
snapshot.reconfigureItems(retainedItems)
dataSource.apply(snapshot, animatingDifferences: false)
}
private func updateConfirmButton() {
let isEnabled: Bool
switch selectedItem {
case .mode(.disabled):
isEnabled = step == .mode
case .mode(.aiRetouch):
isEnabled = step == .mode && pendingConfiguration.isEnabled && pendingConfiguration.isValid
case .template:
isEnabled = step == .template && pendingConfiguration.isEnabled && pendingConfiguration.isValid
case nil:
isEnabled = false
}
confirmButton.isEnabled = isEnabled
confirmButton.alpha = isEnabled ? 1 : 0.45
}
private func configureActionButton(_ button: UIButton, title: String, isPrimary: Bool) {
button.setTitle(title, for: .normal)
button.setTitleColor(isPrimary ? .white : AppColor.textSecondary, for: .normal)
button.titleLabel?.font = .app(.subtitle)
button.backgroundColor = isPrimary ? AppColor.primary : AppColor.inputBackground
button.layer.cornerRadius = AppRadius.md
}
private func previewImage(for preset: TravelAlbumEditPreset) -> UIImage? {
if let image = previewImages[preset.effect] {
return image
}
guard let source = makePreviewSourceImage() else { return nil }
let image = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: source)
previewImages[preset.effect] = image
return image
}
private func makePreviewSourceImage() -> UIImage? {
guard let source = UIImage(named: "purchased_lakeside_flowers") else { return nil }
let size = CGSize(width: 176, height: 176)
return UIGraphicsImageRenderer(size: size).image { _ in
let scale = max(size.width / source.size.width, size.height / source.size.height)
let drawSize = CGSize(width: source.size.width * scale, height: source.size.height * scale)
let origin = CGPoint(x: (size.width - drawSize.width) / 2, y: (size.height - drawSize.height) / 2)
source.draw(in: CGRect(origin: origin, size: drawSize))
}
}
@objc private func backTapped() {
guard startsWithModeSelection else { return }
step = .mode
selectedItem = .mode(pendingConfiguration.isEnabled ? .aiRetouch : .disabled)
refreshContent()
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
@objc private func confirmTapped() {
guard confirmButton.isEnabled else { return }
didConfirm = true
onConfirm?(pendingConfiguration)
dismiss(animated: true)
}
}
/// 自动修图选项行,一级方式显示图标,模板显示本地生成的真实效果缩略图。
private final class TravelAlbumAutoRetouchOptionCell: UITableViewCell {
static let reuseIdentifier = "TravelAlbumAutoRetouchOptionCell"
private let cardView = UIView()
private let previewImageView = UIImageView()
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let selectionImageView = UIImageView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
cardView.layer.cornerRadius = AppRadius.sm
cardView.layer.borderWidth = 1
previewImageView.clipsToBounds = true
previewImageView.layer.cornerRadius = AppRadius.xs
titleLabel.font = .app(.bodyMedium)
titleLabel.textColor = AppColor.textPrimary
detailLabel.font = .app(.caption)
detailLabel.textColor = AppColor.textTertiary
detailLabel.numberOfLines = 2
selectionImageView.contentMode = .scaleAspectFit
contentView.addSubview(cardView)
cardView.addSubview(previewImageView)
cardView.addSubview(titleLabel)
cardView.addSubview(detailLabel)
cardView.addSubview(selectionImageView)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
}
previewImageView.snp.makeConstraints { make in
make.top.bottom.leading.equalToSuperview().inset(8)
make.width.equalTo(88)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(previewImageView.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-AppSpacing.sm)
make.bottom.equalTo(cardView.snp.centerY).offset(-2)
}
detailLabel.snp.makeConstraints { make in
make.top.equalTo(cardView.snp.centerY).offset(2)
make.leading.trailing.equalTo(titleLabel)
}
selectionImageView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-AppSpacing.md)
make.centerY.equalToSuperview()
make.size.equalTo(22)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 刷新选项文案、效果图与单选外观。
func apply(
title: String,
detail: String,
previewImage: UIImage?,
usesTemplateImage: Bool,
selected: Bool
) {
titleLabel.text = title
detailLabel.text = detail
previewImageView.image = usesTemplateImage
? previewImage?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 28, weight: .medium))
: previewImage
previewImageView.contentMode = usesTemplateImage ? .center : .scaleAspectFill
previewImageView.tintColor = AppColor.primary
previewImageView.backgroundColor = usesTemplateImage ? AppColor.primaryLight : AppColor.pageBackground
cardView.backgroundColor = selected ? AppColor.primaryLight : .white
cardView.layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
selectionImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionImageView.tintColor = selected ? AppColor.primary : AppColor.textTertiary
accessibilityLabel = "\(title),\(detail)"
accessibilityValue = selected ? "已选择" : "未选择"
}
}
private extension TravelAlbumEditPreset {
/// 模板效果的简短说明,与缩略图一起帮助用户判断效果。
var autoRetouchEffectDescription: String {
switch effect {
case .original:
return "保留原图色彩"
case .portrait:
return "明亮柔和的人像质感"
case .vintage:
return "低饱和复古色调"
case .brocade:
return "鲜活通透的旅拍风格"
case .distantMountain:
return "自然淡雅的远山色调"
case .mist:
return "轻雾低对比氛围"
case .summer:
return "温暖明亮的油画色彩"
case .rich:
return "高饱和浓郁油画质感"
}
}
}