新增线下收款记录和 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
+47 -6
View File
@@ -273,12 +273,53 @@ final class LoginViewController: BaseViewController {
do {
let resolution = try await viewModel.login(authAPI: authAPI)
switch resolution {
case let .completed(response, account):
completeLogin(with: response, account: account)
case .needsAccountSelection:
break
}
handleLoginResolution(resolution)
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func handleLoginResolution(_ resolution: LoginResolution) {
switch resolution {
case let .completed(response, account):
completeLogin(with: response, account: account)
case .needsAccountSelection:
break
case let .accountDeletionPending(response, request):
presentAccountDeletionRecovery(response: response, request: request)
}
}
private func presentAccountDeletionRecovery(
response: V9AuthResponse,
request: AccountDeletionRequest
) {
let deletionTime = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt)
let alert = UIAlertController(
title: "账号正在注销中",
message: "账号将在 \(deletionTime) 永久注销。继续登录将取消本次注销申请。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "暂不登录", style: .cancel))
alert.addAction(UIAlertAction(title: "恢复账号并登录", style: .default) { [weak self] _ in
self?.resumeLoginAfterCancelingDeletion(response: response)
})
present(alert, animated: true)
}
private func resumeLoginAfterCancelingDeletion(response: V9AuthResponse) {
Task {
showLoading()
defer { hideLoading() }
do {
let resolution = try await viewModel.resumeLoginAfterCancelingDeletion(
verifiedResponse: response,
authAPI: authAPI
)
handleLoginResolution(resolution)
} catch is CancellationError {
return
} catch {
+25
View File
@@ -8,6 +8,8 @@ import Foundation
/// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。
final class LoginViewModel {
private let accountDeletionService: any AccountDeletionServing
private(set) var account = ""
private(set) var password = ""
private(set) var isPrivacyChecked = false
@@ -16,6 +18,11 @@ final class LoginViewModel {
private(set) var isSelectingAccount = false
private(set) var pendingAccountSelection: AccountSelectionPayload?
/// 创建登录 ViewModel,并注入注销状态服务以支持7天内恢复账号。
init(accountDeletionService: any AccountDeletionServing = AccountDeletionMockService.shared) {
self.accountDeletionService = accountDeletionService
}
var isLoginEnabled: Bool {
isValidPhone && !trimmedPassword.isEmpty
}
@@ -122,9 +129,27 @@ final class LoginViewModel {
username: normalizedUsername,
password: trimmedPassword
)
switch accountDeletionService.loginState(for: normalizedUsername) {
case let .pending(request):
return .accountDeletionPending(response, request)
case .completed:
throw AccountDeletionError.deletionCompleted
case .none:
break
}
return try await resolveLoginResponse(response, authAPI: authAPI)
}
/// 用户确认恢复账号后取消注销,并继续处理已经通过密码验证的登录响应。
func resumeLoginAfterCancelingDeletion(
verifiedResponse: V9AuthResponse,
authAPI: AuthAPI
) async throws -> LoginResolution {
_ = try accountDeletionService.cancelDeletion(username: normalizedUsername)
return try await resolveLoginResponse(verifiedResponse, authAPI: authAPI)
}
/// 选择一个账号并调用 set-user 换取正式 token。
func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse {
guard let payload = pendingAccountSelection, payload.hasTempToken else {
@@ -0,0 +1,683 @@
//
// OfflineCollectionDailyViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 日清列表的分区。
enum OfflineDailyListSection: Hashable {
case records
case batches
case empty
}
/// 日清列表的条目数据。
enum OfflineDailyListItem: Hashable {
case record(OfflineCollectionRecord)
case batch(OfflineSettlementBatch)
case empty(isToday: Bool)
}
/// 线下收款日清页,展示单营业日汇总、明细与独立补缴流水。
final class OfflineCollectionDailyViewController: BaseViewController {
private let viewModel: OfflineCollectionDailyViewModel
private let headerStack = UIStackView()
private let dateControl = UIButton(type: .system)
private let summaryCard = UIView()
private let summaryStatusLabel = UILabel()
private let totalMetric = OfflineDailyMetricView(title: "线下收款总额")
private let countMetric = OfflineDailyMetricView(title: "登记笔数")
private let settledMetric = OfflineDailyMetricView(title: "已补缴")
private let pendingMetric = OfflineDailyMetricView(title: "待补缴")
private let overdueBanner = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain)
private var dataSource: UITableViewDiffableDataSource<OfflineDailyListSection, OfflineDailyListItem>!
private var currentSections: [OfflineDailyListSection] = []
private let bottomContainer = UIView()
private let settlementButton = UIButton(type: .system)
private var isFeedbackPresented = false
/// 使用指定营业日、收款上下文与 Mock 服务创建日清页。
init(
businessDate: String,
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
viewModel = OfflineCollectionDailyViewModel(
businessDate: businessDate,
context: context,
service: service
)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "线下收款日清"
#if DEBUG
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "模拟失败",
style: .plain,
target: self,
action: #selector(simulateFailureTapped)
)
#endif
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
headerStack.axis = .vertical
headerStack.spacing = AppSpacing.sm
dateControl.contentHorizontalAlignment = .leading
dateControl.showsMenuAsPrimaryAction = true
var dateConfiguration = UIButton.Configuration.plain()
dateConfiguration.image = UIImage(systemName: "calendar")
dateConfiguration.imagePadding = AppSpacing.sm
dateConfiguration.baseForegroundColor = AppColor.textPrimary
dateConfiguration.background.backgroundColor = .white
dateConfiguration.background.cornerRadius = AppRadius.lg
dateConfiguration.contentInsets = NSDirectionalEdgeInsets(
top: 12,
leading: AppSpacing.md,
bottom: 12,
trailing: AppSpacing.md
)
dateControl.configuration = dateConfiguration
dateControl.accessibilityIdentifier = "offlineCollection.businessDate"
summaryCard.backgroundColor = .white
summaryCard.layer.cornerRadius = AppRadius.lg
summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold)
summaryStatusLabel.textAlignment = .center
summaryStatusLabel.textColor = AppColor.success
summaryStatusLabel.backgroundColor = UIColor(hex: 0xECFDF3)
summaryStatusLabel.layer.cornerRadius = 12
summaryStatusLabel.clipsToBounds = true
let firstMetricRow = UIStackView(arrangedSubviews: [totalMetric, countMetric])
firstMetricRow.axis = .horizontal
firstMetricRow.distribution = .fillEqually
let secondMetricRow = UIStackView(arrangedSubviews: [settledMetric, pendingMetric])
secondMetricRow.axis = .horizontal
secondMetricRow.distribution = .fillEqually
let metricStack = UIStackView(arrangedSubviews: [firstMetricRow, secondMetricRow])
metricStack.axis = .vertical
metricStack.spacing = AppSpacing.md
summaryCard.addSubview(metricStack)
summaryCard.addSubview(summaryStatusLabel)
metricStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(AppSpacing.md)
}
summaryStatusLabel.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(24)
}
overdueBanner.text = "该营业日存在逾期未补缴,请尽快完成补缴"
overdueBanner.font = .systemFont(ofSize: 13, weight: .medium)
overdueBanner.textColor = AppColor.danger
overdueBanner.backgroundColor = UIColor(hex: 0xFFF1F0)
overdueBanner.layer.cornerRadius = AppRadius.md
overdueBanner.clipsToBounds = true
overdueBanner.textAlignment = .center
overdueBanner.numberOfLines = 0
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 92
tableView.delegate = self
tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier)
tableView.register(OfflineSettlementBatchCell.self, forCellReuseIdentifier: OfflineSettlementBatchCell.reuseIdentifier)
tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier)
dataSource = makeDataSource()
bottomContainer.backgroundColor = .white
bottomContainer.layer.shadowColor = UIColor.black.cgColor
bottomContainer.layer.shadowOpacity = 0.06
bottomContainer.layer.shadowRadius = 8
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2)
var settlementConfiguration = UIButton.Configuration.filled()
settlementConfiguration.title = "补缴今日全部"
settlementConfiguration.baseBackgroundColor = AppColor.primary
settlementConfiguration.baseForegroundColor = .white
settlementConfiguration.cornerStyle = .medium
settlementConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
return outgoing
}
settlementButton.configuration = settlementConfiguration
settlementButton.accessibilityIdentifier = "offlineCollection.settle"
view.addSubview(headerStack)
[dateControl, summaryCard, overdueBanner].forEach(headerStack.addArrangedSubview)
view.addSubview(tableView)
view.addSubview(bottomContainer)
bottomContainer.addSubview(settlementButton)
}
override func setupConstraints() {
headerStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
dateControl.snp.makeConstraints { make in
make.height.equalTo(50)
}
summaryCard.snp.makeConstraints { make in
make.height.equalTo(172)
}
overdueBanner.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(48)
}
bottomContainer.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
settlementButton.snp.makeConstraints { make in
make.top.equalToSuperview().inset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.height.equalTo(50)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(headerStack.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomContainer.snp.top)
}
}
override func bindActions() {
settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.load()
}
@MainActor
private func applyViewModel() {
let datePrefix = viewModel.isToday ? "今日 · " : "营业日 · "
dateControl.configuration?.title = datePrefix + viewModel.businessDate
dateControl.menu = makeDateMenu()
totalMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.totalAmountFen))
countMetric.setValue("\(viewModel.summary.totalCount) 笔")
settledMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.settledAmountFen))
pendingMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen), highlighted: viewModel.summary.pendingAmountFen > 0)
let isSettled = viewModel.summary.totalCount > 0 && viewModel.summary.pendingAmountFen == 0
summaryStatusLabel.text = " 本日已结清 "
summaryStatusLabel.isHidden = !isSettled
overdueBanner.isHidden = !viewModel.summary.hasOverdue
let isProcessing = viewModel.settlementState == .processing
settlementButton.isEnabled = viewModel.canSettle
var configuration = settlementButton.configuration
configuration?.showsActivityIndicator = isProcessing
if isProcessing {
configuration?.title = "补缴中"
} else if isSettled {
configuration?.title = "本日已全部补缴"
} else if viewModel.summary.pendingAmountFen == 0 {
configuration?.title = "当日暂无待补缴"
} else {
let prefix = viewModel.isToday ? "补缴今日全部" : "补缴本日全部"
configuration?.title = "\(prefix) \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))"
}
settlementButton.configuration = configuration
settlementButton.alpha = settlementButton.isEnabled || isProcessing ? 1 : 0.5
applySnapshot()
presentSettlementFeedbackIfNeeded()
}
@MainActor
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<OfflineDailyListSection, OfflineDailyListItem>()
currentSections = []
if viewModel.records.isEmpty {
currentSections.append(.empty)
snapshot.appendSections([.empty])
snapshot.appendItems([.empty(isToday: viewModel.isToday)], toSection: .empty)
} else {
currentSections.append(.records)
snapshot.appendSections([.records])
snapshot.appendItems(viewModel.records.map(OfflineDailyListItem.record), toSection: .records)
}
if !viewModel.batches.isEmpty {
currentSections.append(.batches)
snapshot.appendSections([.batches])
snapshot.appendItems(viewModel.batches.map(OfflineDailyListItem.batch), toSection: .batches)
}
dataSource.apply(snapshot, animatingDifferences: view.window != nil)
}
private func makeDataSource() -> UITableViewDiffableDataSource<OfflineDailyListSection, OfflineDailyListItem> {
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
switch item {
case let .record(record):
guard let cell = tableView.dequeueReusableCell(
withIdentifier: OfflineCollectionRecordCell.reuseIdentifier,
for: indexPath
) as? OfflineCollectionRecordCell else { return UITableViewCell() }
cell.apply(record: record)
return cell
case let .batch(batch):
guard let cell = tableView.dequeueReusableCell(
withIdentifier: OfflineSettlementBatchCell.reuseIdentifier,
for: indexPath
) as? OfflineSettlementBatchCell else { return UITableViewCell() }
cell.apply(batch: batch)
return cell
case let .empty(isToday):
guard let cell = tableView.dequeueReusableCell(
withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier,
for: indexPath
) as? OfflineCollectionEmptyCell else { return UITableViewCell() }
cell.apply(isToday: isToday)
cell.onRegister = { [weak self] in self?.openRegistration() }
return cell
}
}
}
private func makeDateMenu() -> UIMenu {
let actions = viewModel.availableBusinessDates.map { [weak self] date in
let isCurrent = date == self?.viewModel.businessDate
let title = date == self?.viewModel.service.todayBusinessDate ? "今日 \(date)" : date
return UIAction(title: title, state: isCurrent ? .on : .off) { [weak self] _ in
self?.viewModel.selectBusinessDate(date)
}
}
return UIMenu(title: "选择营业日", children: actions)
}
@MainActor
private func presentSettlementFeedbackIfNeeded() {
guard !isFeedbackPresented else { return }
switch viewModel.settlementState {
case .idle, .processing:
return
case let .success(batch):
isFeedbackPresented = true
let message = """
已补缴 \(OfflineCollectionMoney.display(batch.amountFen))
共结清 \(batch.recordCount) 笔线下收款
补缴流水号:\(batch.id)
"""
let alert = UIAlertController(title: "补缴成功", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in
self?.isFeedbackPresented = false
self?.viewModel.clearSettlementFeedback()
})
present(alert, animated: true)
case let .failed(message):
isFeedbackPresented = true
let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in
self?.isFeedbackPresented = false
self?.viewModel.cancelPreparedSettlement()
self?.viewModel.clearSettlementFeedback()
})
alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in
guard let self else { return }
self.isFeedbackPresented = false
self.viewModel.clearSettlementFeedback()
Task { await self.viewModel.confirmSettlement() }
})
present(alert, animated: true)
}
}
private func openRegistration() {
guard viewModel.isToday else { return }
navigationController?.pushViewController(
OfflineCollectionRegistrationViewController(
context: viewModel.context,
service: viewModel.service
),
animated: true
)
}
@objc private func settlementTapped() {
do {
let request = try viewModel.prepareSettlement()
let message = """
营业日:\(request.businessDate)
待补缴记录:\(request.recordIds.count) 笔
本次补缴金额:\(OfflineCollectionMoney.display(request.amountFen))
补缴成功后,本批次记录将统一变为“已补缴”。
"""
let alert = UIAlertController(title: "确认补缴", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
self?.viewModel.cancelPreparedSettlement()
})
alert.addAction(UIAlertAction(
title: "确认补缴 \(OfflineCollectionMoney.display(request.amountFen))",
style: .default
) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.confirmSettlement() }
})
present(alert, animated: true)
} catch {
showToast(error.localizedDescription)
viewModel.load()
}
}
@objc private func simulateFailureTapped() {
viewModel.simulateNextSettlementFailure()
showToast("下一次补缴将模拟失败,可验收失败后重试")
}
}
extension OfflineCollectionDailyViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
currentSections[safe: section] == .empty ? 0 : 40
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let listSection = currentSections[safe: section], listSection != .empty else { return nil }
let container = UIView()
container.backgroundColor = AppColor.pageBackground
let label = UILabel()
label.text = listSection == .records ? "线下收款明细" : "线下收款补缴流水"
label.font = .systemFont(ofSize: 15, weight: .semibold)
label.textColor = AppColor.textPrimary
container.addSubview(label)
label.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.bottom.equalToSuperview().inset(AppSpacing.xs)
}
return container
}
}
/// 日清汇总卡片中的单项指标视图。
final class OfflineDailyMetricView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
/// 使用指标名称创建汇总视图。
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新指标值及是否使用警示色强调。
func setValue(_ value: String, highlighted: Bool = false) {
valueLabel.text = value
valueLabel.textColor = highlighted ? UIColor(hex: 0xD97706) : AppColor.textPrimary
}
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 12)
titleLabel.textColor = AppColor.textTertiary
valueLabel.font = .systemFont(ofSize: 19, weight: .bold)
valueLabel.textColor = AppColor.textPrimary
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.75
let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel])
stack.axis = .vertical
stack.spacing = 4
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4))
}
}
}
/// 线下收款明细单元格。
final class OfflineCollectionRecordCell: UITableViewCell {
static let reuseIdentifier = "OfflineCollectionRecordCell"
private let cardView = UIView()
private let timeLabel = UILabel()
private let methodLabel = UILabel()
private let identifierLabel = UILabel()
private let amountLabel = UILabel()
private let statusLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 用线下收款记录刷新单元格。
func apply(record: OfflineCollectionRecord) {
timeLabel.text = OfflineCollectionDate.timeText(record.registeredAt)
methodLabel.text = record.paymentMethod.displayName
identifierLabel.text = record.id
amountLabel.text = OfflineCollectionMoney.display(record.amountFen)
statusLabel.text = " \(record.status.displayName) "
let color: UIColor
let background: UIColor
switch record.status {
case .pending:
color = UIColor(hex: 0xD97706)
background = UIColor(hex: 0xFFF7E6)
case .settled:
color = AppColor.success
background = UIColor(hex: 0xECFDF3)
case .overdue:
color = AppColor.danger
background = UIColor(hex: 0xFFF1F0)
}
statusLabel.textColor = color
statusLabel.backgroundColor = background
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
cardView.backgroundColor = .white
cardView.layer.cornerRadius = AppRadius.lg
timeLabel.font = .systemFont(ofSize: 15, weight: .semibold)
timeLabel.textColor = AppColor.textPrimary
methodLabel.font = .systemFont(ofSize: 14)
methodLabel.textColor = AppColor.textSecondary
identifierLabel.font = .systemFont(ofSize: 11)
identifierLabel.textColor = AppColor.textTertiary
amountLabel.font = .systemFont(ofSize: 19, weight: .bold)
amountLabel.textColor = AppColor.textPrimary
amountLabel.textAlignment = .right
statusLabel.font = .systemFont(ofSize: 12, weight: .semibold)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 11
statusLabel.clipsToBounds = true
let leftStack = UIStackView(arrangedSubviews: [timeLabel, methodLabel, identifierLabel])
leftStack.axis = .vertical
leftStack.spacing = 4
let rightStack = UIStackView(arrangedSubviews: [amountLabel, statusLabel])
rightStack.axis = .vertical
rightStack.alignment = .trailing
rightStack.spacing = 8
cardView.addSubview(leftStack)
cardView.addSubview(rightStack)
contentView.addSubview(cardView)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(5)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
leftStack.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
make.trailing.lessThanOrEqualTo(rightStack.snp.leading).offset(-AppSpacing.sm)
}
rightStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
}
statusLabel.snp.makeConstraints { make in
make.height.equalTo(22)
}
}
}
/// 线下收款补缴流水单元格。
final class OfflineSettlementBatchCell: UITableViewCell {
static let reuseIdentifier = "OfflineSettlementBatchCell"
private let cardView = UIView()
private let identifierLabel = UILabel()
private let amountLabel = UILabel()
private let detailLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 用成功补缴流水刷新单元格。
func apply(batch: OfflineSettlementBatch) {
identifierLabel.text = batch.id
amountLabel.text = OfflineCollectionMoney.display(batch.amountFen)
detailLabel.text = "\(OfflineCollectionDate.dateTimeText(batch.paidAt)) · \(batch.payerName) · 关联 \(batch.recordCount) 笔"
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
cardView.backgroundColor = UIColor(hex: 0xF4FBF7)
cardView.layer.cornerRadius = AppRadius.lg
cardView.layer.borderColor = UIColor(hex: 0xCDEED8).cgColor
cardView.layer.borderWidth = 1
identifierLabel.font = .systemFont(ofSize: 13, weight: .semibold)
identifierLabel.textColor = AppColor.success
amountLabel.font = .systemFont(ofSize: 18, weight: .bold)
amountLabel.textColor = AppColor.textPrimary
amountLabel.textAlignment = .right
detailLabel.font = .systemFont(ofSize: 12)
detailLabel.textColor = AppColor.textSecondary
detailLabel.numberOfLines = 0
let topRow = UIStackView(arrangedSubviews: [identifierLabel, amountLabel])
topRow.axis = .horizontal
topRow.distribution = .fillEqually
let stack = UIStackView(arrangedSubviews: [topRow, detailLabel])
stack.axis = .vertical
stack.spacing = AppSpacing.sm
cardView.addSubview(stack)
contentView.addSubview(cardView)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(5)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.md)
}
}
}
/// 日清页在指定营业日无记录时展示的空状态单元格。
final class OfflineCollectionEmptyCell: UITableViewCell {
static let reuseIdentifier = "OfflineCollectionEmptyCell"
var onRegister: (() -> Void)?
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let registerButton = UIButton(type: .system)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新空状态,仅今日允许前往登记。
func apply(isToday: Bool) {
registerButton.isHidden = !isToday
}
override func prepareForReuse() {
super.prepareForReuse()
onRegister = nil
}
private func setupUI() {
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
titleLabel.text = "当日暂无线下收款记录"
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
detailLabel.text = "完成线下收款后,请及时登记"
detailLabel.font = .systemFont(ofSize: 13)
detailLabel.textColor = AppColor.textSecondary
detailLabel.textAlignment = .center
registerButton.setTitle("去登记", for: .normal)
registerButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
registerButton.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, registerButton])
stack.axis = .vertical
stack.spacing = AppSpacing.sm
stack.alignment = .center
contentView.addSubview(stack)
stack.snp.makeConstraints { make in
make.top.equalToSuperview().inset(48)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.bottom.equalToSuperview().inset(32)
}
}
@objc private func registerTapped() {
onRegister?()
}
}
private extension Collection {
/// 安全按下标读取元素,超出范围时返回 nil。
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
@@ -0,0 +1,340 @@
//
// OfflineCollectionRegistrationViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 线下收款登记页,仅收集金额和线下收款方式。
final class OfflineCollectionRegistrationViewController: BaseViewController {
private let viewModel: OfflineCollectionRegistrationViewModel
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let amountCard = UIView()
private let amountField = UITextField()
private let methodCard = UIView()
private let methodStack = UIStackView()
private var methodButtons: [OfflineCollectionPaymentMethod: OfflinePaymentMethodButton] = [:]
private let explanationCard = UIView()
private let contextLabel = UILabel()
private let bottomContainer = UIView()
private let submitButton = UIButton(type: .system)
private var hasFocusedAmountField = false
/// 使用指定的收款上下文与 Mock 服务创建登记页。
init(
context: OfflineCollectionContext = .current(),
service: OfflineCollectionMockService = .shared
) {
viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service)
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
scrollView.keyboardDismissMode = .interactive
scrollView.alwaysBounceVertical = true
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md
configureCard(amountCard)
let amountTitleLabel = makeTitleLabel("收款金额")
let currencyLabel = UILabel()
currencyLabel.text = "¥"
currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold)
currencyLabel.textColor = AppColor.textPrimary
amountField.placeholder = "0.00"
amountField.font = .systemFont(ofSize: 38, weight: .bold)
amountField.textColor = AppColor.textPrimary
amountField.keyboardType = .decimalPad
amountField.clearButtonMode = .whileEditing
amountField.delegate = self
amountField.accessibilityLabel = "收款金额"
let amountHelperLabel = UILabel()
amountHelperLabel.text = "单笔金额 0.01~99,999.99 元,最多两位小数"
amountHelperLabel.font = .systemFont(ofSize: 12)
amountHelperLabel.textColor = AppColor.textTertiary
let amountRow = UIStackView(arrangedSubviews: [currencyLabel, amountField])
amountRow.axis = .horizontal
amountRow.alignment = .center
amountRow.spacing = AppSpacing.sm
let amountStack = UIStackView(arrangedSubviews: [amountTitleLabel, amountRow, amountHelperLabel])
amountStack.axis = .vertical
amountStack.spacing = AppSpacing.sm
amountCard.addSubview(amountStack)
amountStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.lg)
}
amountField.snp.makeConstraints { make in
make.height.equalTo(58)
}
configureCard(methodCard)
let methodTitleLabel = makeTitleLabel("收款方式")
methodStack.axis = .horizontal
methodStack.distribution = .fillEqually
methodStack.spacing = AppSpacing.sm
for method in OfflineCollectionPaymentMethod.allCases {
let button = OfflinePaymentMethodButton(method: method)
button.addTarget(self, action: #selector(methodTapped(_:)), for: .touchUpInside)
methodButtons[method] = button
methodStack.addArrangedSubview(button)
}
let methodContentStack = UIStackView(arrangedSubviews: [methodTitleLabel, methodStack])
methodContentStack.axis = .vertical
methodContentStack.spacing = AppSpacing.md
methodCard.addSubview(methodContentStack)
methodContentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.lg)
}
methodStack.snp.makeConstraints { make in
make.height.equalTo(64)
}
configureCard(explanationCard)
let explanationIcon = UIImageView(image: UIImage(systemName: "info.circle.fill"))
explanationIcon.tintColor = AppColor.primary
explanationIcon.contentMode = .scaleAspectFit
let explanationLabel = UILabel()
explanationLabel.text = "登记后将计入今日待补缴,不生成订单。"
explanationLabel.font = .systemFont(ofSize: 14)
explanationLabel.textColor = AppColor.textSecondary
explanationLabel.numberOfLines = 0
explanationCard.addSubview(explanationIcon)
explanationCard.addSubview(explanationLabel)
explanationIcon.snp.makeConstraints { make in
make.leading.top.equalToSuperview().inset(AppSpacing.md)
make.width.height.equalTo(20)
}
explanationLabel.snp.makeConstraints { make in
make.leading.equalTo(explanationIcon.snp.trailing).offset(AppSpacing.sm)
make.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
make.top.equalToSuperview().inset(AppSpacing.md)
}
contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)"
contextLabel.font = .systemFont(ofSize: 12)
contextLabel.textColor = AppColor.textTertiary
contextLabel.numberOfLines = 0
contextLabel.textAlignment = .center
bottomContainer.backgroundColor = .white
bottomContainer.layer.shadowColor = UIColor.black.cgColor
bottomContainer.layer.shadowOpacity = 0.06
bottomContainer.layer.shadowRadius = 8
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2)
var submitConfiguration = UIButton.Configuration.filled()
submitConfiguration.title = "确认登记"
submitConfiguration.baseBackgroundColor = AppColor.primary
submitConfiguration.baseForegroundColor = .white
submitConfiguration.cornerStyle = .medium
submitConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
return outgoing
}
submitButton.configuration = submitConfiguration
submitButton.accessibilityIdentifier = "offlineCollection.submit"
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
[amountCard, methodCard, explanationCard, contextLabel].forEach(contentStack.addArrangedSubview)
view.addSubview(bottomContainer)
bottomContainer.addSubview(submitButton)
}
override func setupConstraints() {
bottomContainer.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
submitButton.snp.makeConstraints { make in
make.top.equalToSuperview().inset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.height.equalTo(50)
make.bottom.equalToSuperview().inset(AppSpacing.md)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomContainer.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
}
}
override func bindActions() {
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
submitButton.addTarget(self, action: #selector(submitTapped), 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.onRegistrationSuccess = { [weak self] receipt in
Task { @MainActor in self?.showRegistrationSuccess(receipt) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guard !hasFocusedAmountField else { return }
hasFocusedAmountField = true
amountField.becomeFirstResponder()
}
@MainActor
private func applyViewModel() {
if amountField.text != viewModel.amountText {
amountField.text = viewModel.amountText
}
methodButtons.forEach { method, button in
button.setSelected(method == viewModel.paymentMethod)
}
submitButton.isEnabled = viewModel.canSubmit
submitButton.configuration?.showsActivityIndicator = viewModel.isSubmitting
submitButton.configuration?.title = viewModel.isSubmitting ? "登记中" : "确认登记"
submitButton.alpha = submitButton.isEnabled || viewModel.isSubmitting ? 1 : 0.45
}
@MainActor
private func showRegistrationSuccess(_ receipt: OfflineCollectionRegistrationReceipt) {
amountField.resignFirstResponder()
let message = """
本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen))
今日累计线下收款 \(OfflineCollectionMoney.display(receipt.summary.totalAmountFen))
今日待补缴 \(OfflineCollectionMoney.display(receipt.summary.pendingAmountFen))
"""
let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in
self?.viewModel.startAnotherRegistration()
self?.amountField.becomeFirstResponder()
})
alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in
guard let self else { return }
let controller = OfflineCollectionDailyViewController(
businessDate: receipt.summary.businessDate,
context: self.viewModel.context,
service: self.viewModel.service
)
self.navigationController?.pushViewController(controller, animated: true)
})
present(alert, animated: true)
}
private func makeTitleLabel(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 16, weight: .semibold)
label.textColor = AppColor.textPrimary
return label
}
private func configureCard(_ card: UIView) {
card.backgroundColor = .white
card.layer.cornerRadius = AppRadius.lg
}
@objc private func amountChanged() {
viewModel.updateAmount(amountField.text ?? "")
}
@objc private func methodTapped(_ sender: OfflinePaymentMethodButton) {
viewModel.selectPaymentMethod(sender.method)
}
@objc private func submitTapped() {
Task { await viewModel.submit() }
}
}
extension OfflineCollectionRegistrationViewController: UITextFieldDelegate {
func textField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String
) -> Bool {
guard let current = textField.text,
let textRange = Range(range, in: current) else { return false }
let next = current.replacingCharacters(in: textRange, with: string)
return OfflineCollectionMoney.acceptsEditingText(next)
}
}
/// 登记页的收款方式单选按钮。
final class OfflinePaymentMethodButton: UIControl {
let method: OfflineCollectionPaymentMethod
private let iconView = UIImageView()
private let titleLabel = UILabel()
/// 使用指定收款方式创建单选按钮。
init(method: OfflineCollectionPaymentMethod) {
self.method = method
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 更新选中态视觉。
func setSelected(_ selected: Bool) {
isSelected = selected
backgroundColor = selected ? UIColor(hex: 0xEEF5FF) : AppColor.inputBackground
layer.borderColor = (selected ? AppColor.primary : UIColor.clear).cgColor
layer.borderWidth = selected ? 1.5 : 0
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
}
private func setupUI() {
layer.cornerRadius = AppRadius.md
clipsToBounds = true
accessibilityLabel = method.displayName
iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal)
iconView.contentMode = .scaleAspectFit
titleLabel.text = method.displayName
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 5
stack.isUserInteractionEnabled = false
addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(22)
}
setSelected(false)
}
}
@@ -0,0 +1,245 @@
//
// OfflineCollectionHomeView.swift
// suixinkan
//
import SnapKit
import UIKit
/// 收款首页中的线下收款入口、今日汇总和历史逾期提醒区域。
final class OfflineCollectionHomeView: UIView {
var onRegister: (() -> Void)?
var onOpenToday: (() -> Void)?
var onOpenOverdue: (() -> Void)?
private let contentStack = UIStackView()
private let sectionTitleLabel = UILabel()
private let overdueControl = UIControl()
private let overdueTitleLabel = UILabel()
private let overdueDetailLabel = UILabel()
private let overdueActionLabel = UILabel()
private let registerControl = UIControl()
private let registerIconView = UIImageView()
private let registerTitleLabel = UILabel()
private let registerSubtitleLabel = UILabel()
private let registerActionLabel = UILabel()
private let summaryControl = UIControl()
private let summaryTitleLabel = UILabel()
private let summaryAmountLabel = UILabel()
private let summaryCountLabel = UILabel()
private let summaryRegisteredLabel = UILabel()
private let summaryStatusLabel = UILabel()
private let summaryActionLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupConstraints()
bindActions()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 根据今日与历史逾期汇总刷新页面内容。
func apply(
today: OfflineDailySummary,
overdueDayCount: Int,
overdueRecordCount: Int,
overdueAmountFen: Int
) {
let hasOverdue = overdueDayCount > 0 && overdueAmountFen > 0
overdueControl.isHidden = !hasOverdue
overdueDetailLabel.text = "\(overdueDayCount) 个营业日,共 \(overdueRecordCount) 笔,待补缴 \(OfflineCollectionMoney.display(overdueAmountFen))"
summaryAmountLabel.text = OfflineCollectionMoney.display(today.pendingAmountFen)
summaryCountLabel.text = "\(today.pendingCount) 笔线下收款"
summaryRegisteredLabel.text = "今日已登记 \(OfflineCollectionMoney.display(today.totalAmountFen))"
let isSettled = today.totalCount > 0 && today.pendingAmountFen == 0
summaryStatusLabel.text = isSettled ? "今日已结清" : ""
summaryStatusLabel.isHidden = !isSettled
summaryActionLabel.text = today.pendingAmountFen > 0 ? "去补缴" : "查看明细"
summaryActionLabel.backgroundColor = today.pendingAmountFen > 0
? AppColor.primary
: UIColor(hex: 0xEEF2F7)
summaryActionLabel.textColor = today.pendingAmountFen > 0
? .white
: AppColor.textSecondary
}
private func setupUI() {
backgroundColor = .clear
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.md
sectionTitleLabel.text = "线下收款"
sectionTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
sectionTitleLabel.textColor = AppColor.textPrimary
configureCard(overdueControl, backgroundColor: UIColor(hex: 0xFFF1F0))
overdueControl.layer.borderColor = UIColor(hex: 0xFFCCC7).cgColor
overdueControl.layer.borderWidth = 1
overdueTitleLabel.text = "存在逾期未补缴"
overdueTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
overdueTitleLabel.textColor = AppColor.danger
overdueDetailLabel.font = .systemFont(ofSize: 13)
overdueDetailLabel.textColor = UIColor(hex: 0x8C2F27)
overdueDetailLabel.numberOfLines = 0
configureActionLabel(overdueActionLabel, title: "立即处理", background: AppColor.danger, foreground: .white)
configureCard(registerControl, backgroundColor: .white)
registerIconView.image = UIImage(systemName: "plus.circle.fill")
registerIconView.tintColor = AppColor.primary
registerIconView.contentMode = .scaleAspectFit
registerTitleLabel.text = "线下收款登记"
registerTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
registerTitleLabel.textColor = AppColor.textPrimary
registerSubtitleLabel.text = "线下收款后,请及时登记并在当日完成补缴"
registerSubtitleLabel.font = .systemFont(ofSize: 13)
registerSubtitleLabel.textColor = AppColor.textSecondary
registerSubtitleLabel.numberOfLines = 0
configureActionLabel(registerActionLabel, title: "去登记", background: UIColor(hex: 0xEEF5FF), foreground: AppColor.primary)
configureCard(summaryControl, backgroundColor: .white)
summaryTitleLabel.text = "今日待补缴"
summaryTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
summaryTitleLabel.textColor = AppColor.textPrimary
summaryAmountLabel.font = .systemFont(ofSize: 30, weight: .bold)
summaryAmountLabel.textColor = AppColor.textPrimary
summaryCountLabel.font = .systemFont(ofSize: 13)
summaryCountLabel.textColor = UIColor(hex: 0xD97706)
summaryRegisteredLabel.font = .systemFont(ofSize: 13)
summaryRegisteredLabel.textColor = AppColor.textSecondary
summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold)
summaryStatusLabel.textColor = AppColor.success
configureActionLabel(summaryActionLabel, title: "查看明细", background: UIColor(hex: 0xEEF2F7), foreground: AppColor.textSecondary)
addSubview(contentStack)
[sectionTitleLabel, overdueControl, registerControl, summaryControl].forEach(contentStack.addArrangedSubview)
let overdueTextStack = UIStackView(arrangedSubviews: [overdueTitleLabel, overdueDetailLabel])
overdueTextStack.axis = .vertical
overdueTextStack.spacing = 5
overdueControl.addSubview(overdueTextStack)
overdueControl.addSubview(overdueActionLabel)
let registerTextStack = UIStackView(arrangedSubviews: [registerTitleLabel, registerSubtitleLabel])
registerTextStack.axis = .vertical
registerTextStack.spacing = 5
registerControl.addSubview(registerIconView)
registerControl.addSubview(registerTextStack)
registerControl.addSubview(registerActionLabel)
let summaryTextStack = UIStackView(arrangedSubviews: [
summaryTitleLabel,
summaryAmountLabel,
summaryCountLabel,
summaryRegisteredLabel,
summaryStatusLabel,
])
summaryTextStack.axis = .vertical
summaryTextStack.spacing = 6
summaryControl.addSubview(summaryTextStack)
summaryControl.addSubview(summaryActionLabel)
}
private func setupConstraints() {
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
sectionTitleLabel.snp.makeConstraints { make in
make.height.equalTo(24)
}
overdueControl.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(84)
}
overdueControl.subviews.first?.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
make.trailing.lessThanOrEqualTo(overdueActionLabel.snp.leading).offset(-AppSpacing.sm)
}
overdueActionLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.height.equalTo(32)
}
registerControl.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(96)
}
registerIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.width.height.equalTo(34)
}
registerTitleLabel.superview?.snp.makeConstraints { make in
make.leading.equalTo(registerIconView.snp.trailing).offset(AppSpacing.sm)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(registerActionLabel.snp.leading).offset(-AppSpacing.sm)
}
registerActionLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview()
make.height.equalTo(32)
}
summaryControl.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(180)
}
summaryTitleLabel.superview?.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
make.trailing.lessThanOrEqualTo(summaryActionLabel.snp.leading).offset(-AppSpacing.sm)
}
summaryActionLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(34)
}
}
private func bindActions() {
overdueControl.addTarget(self, action: #selector(overdueTapped), for: .touchUpInside)
registerControl.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
summaryControl.addTarget(self, action: #selector(summaryTapped), for: .touchUpInside)
}
private func configureCard(_ control: UIControl, backgroundColor: UIColor) {
control.backgroundColor = backgroundColor
control.layer.cornerRadius = AppRadius.lg
control.clipsToBounds = true
}
private func configureActionLabel(
_ label: UILabel,
title: String,
background: UIColor,
foreground: UIColor
) {
label.text = " \(title) "
label.font = .systemFont(ofSize: 13, weight: .semibold)
label.textAlignment = .center
label.textColor = foreground
label.backgroundColor = background
label.layer.cornerRadius = 16
label.clipsToBounds = true
label.setContentCompressionResistancePriority(.required, for: .horizontal)
}
@objc private func overdueTapped() {
onOpenOverdue?()
}
@objc private func registerTapped() {
onRegister?()
}
@objc private func summaryTapped() {
onOpenToday?()
}
}
@@ -12,6 +12,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let viewModel = PaymentCollectionDetailsViewModel()
private let paymentAPI = NetworkServices.shared.paymentAPI
private let offlineCollectionViewModel = OfflineCollectionHomeViewModel()
private let scrollView = UIScrollView()
private let contentContainerView = UIView()
@@ -48,6 +49,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let voiceCardView = UIView()
private let voiceTitleLabel = UILabel()
private let voiceSwitch = UISwitch()
private let offlineCollectionView = OfflineCollectionHomeView()
private var amountDialog: PaymentSetAmountDialogView?
private var appliedBrandConfig: PayPageConfig?
@@ -100,9 +102,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func setupUI() {
let usesBranding = viewModel.usesNalatiBranding
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
scrollView.showsVerticalScrollIndicator = !usesBranding
scrollView.isScrollEnabled = !usesBranding
scrollView.alwaysBounceVertical = false
scrollView.showsVerticalScrollIndicator = true
scrollView.isScrollEnabled = true
scrollView.alwaysBounceVertical = true
contentStack.axis = .vertical
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
@@ -201,6 +203,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
contentStack.addArrangedSubview(recordRow)
contentStack.addArrangedSubview(voiceCardView)
}
// 线下收款与原有真实收款功能并列,不替换二维码或收款记录入口。
contentStack.addArrangedSubview(offlineCollectionView)
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
let qrContentStack = UIStackView(arrangedSubviews: [
@@ -330,15 +334,14 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width)
if viewModel.usesNalatiBranding {
make.height.equalTo(scrollView.snp.height)
make.height.greaterThanOrEqualTo(scrollView.snp.height)
}
}
contentStack.snp.makeConstraints { make in
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
if viewModel.usesNalatiBranding {
make.centerX.centerY.equalToSuperview()
make.top.greaterThanOrEqualToSuperview()
make.bottom.lessThanOrEqualToSuperview()
make.centerX.equalToSuperview()
make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
} else {
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
}
@@ -352,6 +355,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
offlineCollectionViewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyOfflineCollection() }
}
offlineCollectionView.onRegister = { [weak self] in
self?.openOfflineCollectionRegistration()
}
offlineCollectionView.onOpenToday = { [weak self] in
self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.todayBusinessDate)
}
offlineCollectionView.onOpenOverdue = { [weak self] in
self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.earliestOverdueBusinessDate)
}
setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside)
saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside)
@@ -369,6 +384,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
applyBrandNavigationAppearanceIfNeeded()
offlineCollectionViewModel.load()
}
override func viewWillDisappear(_ animated: Bool) {
@@ -432,6 +448,17 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
amountDialog?.dismiss()
amountDialog = nil
}
applyOfflineCollection()
}
@MainActor
private func applyOfflineCollection() {
offlineCollectionView.apply(
today: offlineCollectionViewModel.todaySummary,
overdueDayCount: offlineCollectionViewModel.overdueSummaries.count,
overdueRecordCount: offlineCollectionViewModel.overdueRecordCount,
overdueAmountFen: offlineCollectionViewModel.overdueAmountFen
)
}
private func setActionRowAvailable(_ isAvailable: Bool) {
@@ -668,6 +695,24 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func voiceSwitchChanged() {
viewModel.toggleReceiveVoice()
}
private func openOfflineCollectionRegistration() {
let controller = OfflineCollectionRegistrationViewController(
context: offlineCollectionViewModel.context,
service: offlineCollectionViewModel.service
)
navigationController?.pushViewController(controller, animated: true)
}
private func openOfflineCollectionDaily(date: String?) {
guard let date else { return }
let controller = OfflineCollectionDailyViewController(
businessDate: date,
context: offlineCollectionViewModel.context,
service: offlineCollectionViewModel.service
)
navigationController?.pushViewController(controller, animated: true)
}
}
/// 收款详情信息行。
@@ -0,0 +1,761 @@
//
// AccountDeletionViewControllers.swift
// suixinkan
//
import SnapKit
import UIKit
/// 注销账号资产核验页,中性展示账号资产与注销后果。
final class AccountDeletionViewController: BaseViewController {
private let viewModel: AccountDeletionViewModel
private let scrollView = UIScrollView()
private let contentView = UIView()
private let contentStack = UIStackView()
private let warningView = AccountDeletionWarningView()
private let assetCardView = AccountDeletionAssetCardView()
private let detailButton = UIButton(type: .system)
private let consequenceCardView = AccountDeletionConsequenceCardView()
private let bottomPanel = UIView()
private let acknowledgementButton = UIButton(type: .system)
private let continueButton = AppButton(title: "继续注销", style: .primary)
private var lastErrorMessage: String?
/// 创建注销资产核验页。
init(viewModel: AccountDeletionViewModel = AccountDeletionViewModel()) {
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() {
title = "注销账号"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
scrollView.alwaysBounceVertical = true
scrollView.showsVerticalScrollIndicator = false
contentStack.axis = .vertical
contentStack.spacing = 16
contentStack.isLayoutMarginsRelativeArrangement = true
contentStack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
detailButton.setTitle("查看资产明细", for: .normal)
detailButton.setTitleColor(AppColor.primary, for: .normal)
detailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
bottomPanel.backgroundColor = AppColor.pageBackground
acknowledgementButton.contentHorizontalAlignment = .leading
acknowledgementButton.tintColor = AppColor.primary
acknowledgementButton.setTitleColor(AppColor.textPrimary, for: .normal)
acknowledgementButton.titleLabel?.font = .systemFont(ofSize: 14)
acknowledgementButton.titleLabel?.numberOfLines = 0
acknowledgementButton.setTitle(" 我已知晓并自愿放弃以上资产和权益", for: .normal)
acknowledgementButton.accessibilityLabel = "我已知晓并自愿放弃以上资产和权益"
view.addSubview(scrollView)
scrollView.addSubview(contentView)
contentView.addSubview(contentStack)
view.addSubview(bottomPanel)
bottomPanel.addSubview(acknowledgementButton)
bottomPanel.addSubview(continueButton)
[warningView, assetCardView, detailButton, consequenceCardView].forEach(contentStack.addArrangedSubview)
applyViewModel()
}
override func setupConstraints() {
bottomPanel.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
acknowledgementButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(8)
make.leading.trailing.equalToSuperview().inset(16)
make.height.greaterThanOrEqualTo(44)
}
continueButton.snp.makeConstraints { make in
make.top.equalTo(acknowledgementButton.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomPanel.snp.top)
}
contentView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
warningView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(64)
}
detailButton.snp.makeConstraints { make in
make.height.equalTo(36)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
acknowledgementButton.addTarget(self, action: #selector(acknowledgementTapped), for: .touchUpInside)
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
showLoading()
await viewModel.load()
hideLoading()
}
}
private func applyViewModel() {
assetCardView.configure(assets: viewModel.assets)
consequenceCardView.configure(items: viewModel.consequences)
acknowledgementButton.setImage(
UIImage(systemName: viewModel.isAcknowledged ? "checkmark.square.fill" : "square"),
for: .normal
)
acknowledgementButton.accessibilityValue = viewModel.isAcknowledged ? "已勾选" : "未勾选"
continueButton.isEnabled = viewModel.isContinueButtonEnabled
if let errorMessage = viewModel.errorMessage,
!errorMessage.isEmpty,
errorMessage != lastErrorMessage {
lastErrorMessage = errorMessage
showToast(errorMessage)
}
}
@objc private func acknowledgementTapped() {
viewModel.toggleAcknowledgement()
}
@objc private func continueTapped() {
do {
let verificationViewModel = try viewModel.makeVerificationViewModel()
navigationController?.pushViewController(
AccountDeletionVerificationViewController(viewModel: verificationViewModel),
animated: true
)
} catch {
showToast(error.localizedDescription)
}
}
@objc private func detailTapped() {
let message = viewModel.assets
.map { "\($0.title):\($0.valueText)" }
.joined(separator: "\n")
showAlert(title: "当前账号资产", message: message)
}
}
/// 注销手机号验证页,使用短信验证码确认是账号本人操作。
final class AccountDeletionVerificationViewController: BaseViewController {
private let viewModel: AccountDeletionVerificationViewModel
private let contentStack = UIStackView()
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let descriptionLabel = UILabel()
private let codeContainer = UIView()
private let codeField = UITextField()
private let resendButton = UIButton(type: .system)
private let demoHintLabel = UILabel()
private let noticeView = AccountDeletionNoticeView()
private let submitButton = AppButton(title: "提交注销申请", style: .primary)
private var lastErrorMessage: String?
/// 创建注销手机号验证页。
init(viewModel: AccountDeletionVerificationViewModel) {
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() {
title = "验证手机号"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
contentStack.axis = .vertical
contentStack.alignment = .fill
contentStack.spacing = 16
iconView.image = UIImage(systemName: "message.badge.filled.fill")
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
titleLabel.text = "确认是你本人操作"
titleLabel.font = .systemFont(ofSize: 22, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
descriptionLabel.text = "验证码将发送至 \(viewModel.context.maskedPhone)"
descriptionLabel.font = .systemFont(ofSize: 15)
descriptionLabel.textColor = AppColor.textSecondary
descriptionLabel.textAlignment = .center
codeContainer.backgroundColor = .white
codeContainer.layer.cornerRadius = 12
codeContainer.layer.borderWidth = 1
codeContainer.layer.borderColor = AppColor.border.cgColor
codeField.placeholder = "请输入6位验证码"
codeField.font = .systemFont(ofSize: 18, weight: .medium)
codeField.textColor = AppColor.textPrimary
codeField.keyboardType = .numberPad
codeField.textContentType = .oneTimeCode
codeField.clearButtonMode = .whileEditing
resendButton.setTitle("重新发送", for: .normal)
resendButton.setTitleColor(AppColor.primary, for: .normal)
resendButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
demoHintLabel.text = "演示验证码:123456"
demoHintLabel.font = .systemFont(ofSize: 13)
demoHintLabel.textColor = AppColor.warning
demoHintLabel.textAlignment = .center
noticeView.configure(
iconName: "calendar.badge.exclamationmark",
text: "提交成功后账号将退出,7天内再次登录可取消注销。"
)
view.addSubview(contentStack)
view.addSubview(submitButton)
codeContainer.addSubview(codeField)
codeContainer.addSubview(resendButton)
[iconView, titleLabel, descriptionLabel, codeContainer, demoHintLabel, noticeView]
.forEach(contentStack.addArrangedSubview)
applyViewModel()
}
override func setupConstraints() {
contentStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(32)
make.leading.trailing.equalToSuperview().inset(24)
}
iconView.snp.makeConstraints { make in
make.height.equalTo(64)
}
codeContainer.snp.makeConstraints { make in
make.height.equalTo(56)
}
codeField.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.top.bottom.equalToSuperview()
make.trailing.equalTo(resendButton.snp.leading).offset(-8)
}
resendButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalToSuperview()
make.width.equalTo(76)
make.height.equalTo(44)
}
noticeView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(64)
}
submitButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged)
resendButton.addTarget(self, action: #selector(resendTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
await viewModel.sendVerificationCode()
if viewModel.hasSentCode {
showToast("验证码已发送")
}
}
}
private func applyViewModel() {
if codeField.text != viewModel.verificationCode {
codeField.text = viewModel.verificationCode
}
resendButton.isEnabled = !viewModel.isSendingCode && !viewModel.isSubmitting
resendButton.setTitle(viewModel.isSendingCode ? "发送中" : "重新发送", for: .normal)
submitButton.isEnabled = viewModel.isSubmitEnabled
if let errorMessage = viewModel.errorMessage,
!errorMessage.isEmpty,
errorMessage != lastErrorMessage {
lastErrorMessage = errorMessage
showToast(errorMessage)
}
}
@objc private func codeChanged() {
viewModel.updateVerificationCode(codeField.text ?? "")
}
@objc private func resendTapped() {
Task {
await viewModel.sendVerificationCode()
if viewModel.hasSentCode {
showToast("验证码已重新发送")
}
}
}
@objc private func submitTapped() {
let alert = UIAlertController(
title: "确认提交注销申请?",
message: "提交后账号将退出,7天内再次登录可以取消注销。",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "再想想", style: .cancel))
alert.addAction(UIAlertAction(title: "确认提交", style: .destructive) { [weak self] _ in
self?.submitDeletion()
})
present(alert, animated: true)
}
private func submitDeletion() {
Task {
showLoading()
defer { hideLoading() }
do {
let request = try await viewModel.submit()
navigationController?.pushViewController(
AccountDeletionSuccessViewController(request: request),
animated: true
)
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
}
/// 注销申请提交成功页,明确展示计划删除时间和恢复方式。
final class AccountDeletionSuccessViewController: BaseViewController {
private let request: AccountDeletionRequest
private let iconView = UIImageView()
private let titleLabel = UILabel()
private let descriptionLabel = UILabel()
private let dateCardView = UIView()
private let dateTitleLabel = UILabel()
private let dateValueLabel = UILabel()
private let recoveryLabel = UILabel()
private let exitButton = AppButton(title: "退出并返回登录页", style: .primary)
/// 创建注销申请提交成功页。
init(request: AccountDeletionRequest) {
self.request = request
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "注销账号"
navigationItem.hidesBackButton = true
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
iconView.image = UIImage(systemName: "checkmark.circle.fill")
iconView.tintColor = AppColor.success
iconView.contentMode = .scaleAspectFit
titleLabel.text = "注销申请已提交"
titleLabel.font = .systemFont(ofSize: 24, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
descriptionLabel.text = "账号已进入7天注销冷静期"
descriptionLabel.font = .systemFont(ofSize: 15)
descriptionLabel.textColor = AppColor.textSecondary
descriptionLabel.textAlignment = .center
dateCardView.backgroundColor = .white
dateCardView.layer.cornerRadius = 12
dateTitleLabel.text = "计划永久注销时间"
dateTitleLabel.font = .systemFont(ofSize: 14)
dateTitleLabel.textColor = AppColor.textSecondary
dateValueLabel.text = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt)
dateValueLabel.font = .systemFont(ofSize: 20, weight: .semibold)
dateValueLabel.textColor = AppColor.danger
dateValueLabel.textAlignment = .right
recoveryLabel.text = "在上述时间前再次使用当前手机号和密码登录,确认恢复账号后即可取消注销。"
recoveryLabel.font = .systemFont(ofSize: 15)
recoveryLabel.textColor = AppColor.textSecondary
recoveryLabel.numberOfLines = 0
recoveryLabel.textAlignment = .center
[iconView, titleLabel, descriptionLabel, dateCardView, recoveryLabel, exitButton].forEach(view.addSubview)
dateCardView.addSubview(dateTitleLabel)
dateCardView.addSubview(dateValueLabel)
}
override func setupConstraints() {
iconView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(56)
make.centerX.equalToSuperview()
make.width.height.equalTo(72)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(iconView.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(24)
}
descriptionLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(10)
make.leading.trailing.equalToSuperview().inset(24)
}
dateCardView.snp.makeConstraints { make in
make.top.equalTo(descriptionLabel.snp.bottom).offset(32)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(72)
}
dateTitleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
}
dateValueLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(dateTitleLabel.snp.trailing).offset(12)
}
recoveryLabel.snp.makeConstraints { make in
make.top.equalTo(dateCardView.snp.bottom).offset(24)
make.leading.trailing.equalToSuperview().inset(32)
}
exitButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
}
override func bindActions() {
exitButton.addTarget(self, action: #selector(exitTapped), for: .touchUpInside)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
@objc private func exitTapped() {
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
}
}
/// 注销页顶部风险提示条。
private final class AccountDeletionWarningView: UIView {
private let iconView = UIImageView()
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.dangerBackground
layer.cornerRadius = 12
layer.borderWidth = 1
layer.borderColor = AppColor.danger.withAlphaComponent(0.25).cgColor
iconView.image = UIImage(systemName: "exclamationmark.shield.fill")
iconView.tintColor = AppColor.danger
iconView.contentMode = .scaleAspectFit
label.text = "注销后,账号及相关数据将永久删除"
label.font = .systemFont(ofSize: 15, weight: .medium)
label.textColor = AppColor.danger
label.numberOfLines = 0
addSubview(iconView)
addSubview(label)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.width.height.equalTo(28)
}
label.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
make.top.bottom.equalToSuperview().inset(14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 注销资产汇总卡片,以列表形式中性展示名称和数量。
private final class AccountDeletionAssetCardView: UIView {
private let stackView = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
stackView.axis = .vertical
addSubview(stackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 使用最新资产快照刷新卡片。
func configure(assets: [AccountDeletionAssetSummary]) {
stackView.arrangedSubviews.forEach {
stackView.removeArrangedSubview($0)
$0.removeFromSuperview()
}
assets.enumerated().forEach { index, asset in
stackView.addArrangedSubview(
AccountDeletionAssetRowView(asset: asset, showsDivider: index < assets.count - 1)
)
}
}
}
/// 注销资产列表行,仅显示客观资产名称和当前值。
private final class AccountDeletionAssetRowView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let divider = UIView()
init(asset: AccountDeletionAssetSummary, showsDivider: Bool) {
super.init(frame: .zero)
titleLabel.text = asset.title
titleLabel.font = .systemFont(ofSize: 17, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
valueLabel.text = asset.valueText
valueLabel.font = .systemFont(ofSize: 18, weight: .semibold)
valueLabel.textColor = AppColor.textPrimary
valueLabel.textAlignment = .right
divider.backgroundColor = AppColor.border
divider.isHidden = !showsDivider
[titleLabel, valueLabel, divider].forEach(addSubview)
snp.makeConstraints { make in
make.height.equalTo(76)
}
titleLabel.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
}
divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(0.5)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 注销影响说明卡片,展示账号解除、内容删除和7天恢复规则。
private final class AccountDeletionConsequenceCardView: UIView {
private let titleLabel = UILabel()
private let stackView = UIStackView()
private let iconNames = [
"person.crop.circle.badge.xmark",
"folder",
"calendar.badge.exclamationmark",
]
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
titleLabel.text = "注销后将发生"
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
stackView.axis = .vertical
addSubview(titleLabel)
addSubview(stackView)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
stackView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 8, right: 16))
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 使用最新注销影响文案刷新卡片。
func configure(items: [String]) {
stackView.arrangedSubviews.forEach {
stackView.removeArrangedSubview($0)
$0.removeFromSuperview()
}
items.enumerated().forEach { index, text in
stackView.addArrangedSubview(
AccountDeletionConsequenceRowView(
iconName: iconNames.indices.contains(index) ? iconNames[index] : "info.circle",
text: text,
showsDivider: index < items.count - 1
)
)
}
}
}
/// 注销影响说明列表行。
private final class AccountDeletionConsequenceRowView: UIView {
private let iconBackgroundView = UIView()
private let iconView = UIImageView()
private let label = UILabel()
private let divider = UIView()
init(iconName: String, text: String, showsDivider: Bool) {
super.init(frame: .zero)
iconBackgroundView.backgroundColor = AppColor.primaryLight
iconBackgroundView.layer.cornerRadius = 18
iconView.image = UIImage(systemName: iconName)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
label.text = text
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.textPrimary
label.numberOfLines = 0
divider.backgroundColor = AppColor.border
divider.isHidden = !showsDivider
addSubview(iconBackgroundView)
iconBackgroundView.addSubview(iconView)
addSubview(label)
addSubview(divider)
snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(58)
}
iconBackgroundView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.width.height.equalTo(36)
}
iconView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(8)
}
label.snp.makeConstraints { make in
make.leading.equalTo(iconBackgroundView.snp.trailing).offset(12)
make.trailing.equalToSuperview()
make.centerY.equalToSuperview()
}
divider.snp.makeConstraints { make in
make.leading.equalTo(label)
make.trailing.bottom.equalToSuperview()
make.height.equalTo(0.5)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 注销流程通用说明条。
private final class AccountDeletionNoticeView: UIView {
private let iconView = UIImageView()
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.infoBackground
layer.cornerRadius = 12
addSubview(iconView)
addSubview(label)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.textSecondary
label.numberOfLines = 0
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.width.height.equalTo(24)
}
label.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
make.top.bottom.equalToSuperview().inset(14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 配置说明条图标与文案。
func configure(iconName: String, text: String) {
iconView.image = UIImage(systemName: iconName)
label.text = text
}
}
@@ -14,6 +14,12 @@ final class SettingViewController: BaseViewController {
private let contentView = UIView()
private let cardView = UIView()
private let rowsStack = UIStackView()
private let dangerCardView = UIView()
private let accountDeletionRow = SettingMenuRow(
title: "注销账号",
titleColor: AppColor.danger,
showsDivider: false
)
private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
private let copyrightLabel = UILabel()
@@ -41,12 +47,18 @@ final class SettingViewController: BaseViewController {
view.addSubview(contentView)
contentView.addSubview(cardView)
cardView.addSubview(rowsStack)
contentView.addSubview(dangerCardView)
dangerCardView.addSubview(accountDeletionRow)
contentView.addSubview(copyrightLabel)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
dangerCardView.backgroundColor = .white
dangerCardView.layer.cornerRadius = 12
dangerCardView.clipsToBounds = true
rowsStack.axis = .vertical
copyrightLabel.text = "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
@@ -68,6 +80,7 @@ final class SettingViewController: BaseViewController {
rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
accountDeletionRow.addTarget(self, action: #selector(accountDeletionTapped), for: .touchUpInside)
}
override func setupConstraints() {
@@ -80,6 +93,13 @@ final class SettingViewController: BaseViewController {
rowsStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
}
dangerCardView.snp.makeConstraints { make in
make.top.equalTo(cardView.snp.bottom).offset(16)
make.leading.trailing.equalToSuperview()
}
accountDeletionRow.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
}
copyrightLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalToSuperview().offset(-16)
@@ -119,6 +139,10 @@ final class SettingViewController: BaseViewController {
openAgreement(.privacyPolicy)
}
@objc private func accountDeletionTapped() {
navigationController?.pushViewController(AccountDeletionViewController(), animated: true)
}
private func openAgreement(_ kind: SettingAgreementKind) {
let destination = viewModel.agreementDestination(for: kind)
navigationController?.pushViewController(
@@ -141,6 +165,7 @@ final class SettingMenuRow: UIControl {
init(
title: String,
value: String? = nil,
titleColor: UIColor = UIColor(hex: 0x4B5563),
valueColor: UIColor = AppColor.textPrimary,
showsChevron: Bool = true,
showsDivider: Bool = true
@@ -150,6 +175,7 @@ final class SettingMenuRow: UIControl {
setupUI()
setupConstraints()
titleLabel.text = title
titleLabel.textColor = titleColor
valueLabel.text = value
valueLabel.textColor = valueColor
chevronImageView.isHidden = !showsChevron
@@ -175,7 +201,6 @@ final class SettingMenuRow: UIControl {
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary
@@ -22,8 +22,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
private let freeCountField = UITextField()
private let singlePriceField = UITextField()
private let packagePriceField = UITextField()
private let autoRetouchSectionView = UIView()
private let autoRetouchTitleLabel = UILabel()
private let autoRetouchDetailLabel = UILabel()
private let noRetouchOption = TravelAlbumModeOptionView()
private let aiRetouchOption = TravelAlbumModeOptionView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
self.viewModel = viewModel
@@ -31,8 +37,16 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
sheetPresentationController.detents = [.medium(), .large()]
let formDetent = UISheetPresentationController.Detent.Identifier("createTravelAlbumForm")
sheetPresentationController.detents = [
.custom(identifier: formDetent) { context in
min(590, context.maximumDetentValue)
},
.large(),
]
sheetPresentationController.selectedDetentIdentifier = formDetent
sheetPresentationController.prefersGrabberVisible = false
sheetPresentationController.preferredCornerRadius = AppRadius.xl
}
}
@@ -62,6 +76,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
configureAutoRetouchSection()
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
@@ -112,6 +127,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
[freeCountField, singlePriceField, packagePriceField].forEach {
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
}
noRetouchOption.addTarget(self, action: #selector(noRetouchTapped), for: .touchUpInside)
aiRetouchOption.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
}
override func viewDidDisappear(_ animated: Bool) {
@@ -148,6 +165,45 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
fieldsStack.addArrangedSubview(autoRetouchSectionView)
}
private func configureAutoRetouchSection() {
autoRetouchSectionView.backgroundColor = .white
autoRetouchSectionView.layer.cornerRadius = AppRadius.sm
autoRetouchSectionView.layer.borderWidth = 1
autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor
autoRetouchSectionView.clipsToBounds = true
autoRetouchTitleLabel.text = "修图方式"
autoRetouchTitleLabel.font = .app(.bodyMedium)
autoRetouchTitleLabel.textColor = AppColor.textPrimary
autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板"
autoRetouchDetailLabel.font = .app(.caption)
autoRetouchDetailLabel.textColor = AppColor.textSecondary
let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption])
optionsStack.axis = .horizontal
optionsStack.spacing = AppSpacing.xs
optionsStack.distribution = .fillEqually
autoRetouchSectionView.addSubview(autoRetouchTitleLabel)
autoRetouchSectionView.addSubview(autoRetouchDetailLabel)
autoRetouchSectionView.addSubview(optionsStack)
autoRetouchTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
autoRetouchDetailLabel.snp.makeConstraints { make in
make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(AppSpacing.xxs)
make.leading.trailing.equalTo(autoRetouchTitleLabel)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.sm)
make.height.equalTo(72)
}
updateAutoRetouchSection()
}
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
@@ -188,6 +244,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
freeCount: freeCountField.text ?? "",
singlePrice: singlePriceField.text ?? "",
packagePrice: packagePriceField.text ?? "",
autoRetouchConfiguration: autoRetouchConfiguration,
order: nil,
api: api
)
@@ -208,6 +265,43 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
}
}
@objc private func noRetouchTapped() {
autoRetouchConfiguration = .disabled
updateAutoRetouchSection()
}
@objc private func aiRetouchTapped() {
presentTemplatePicker()
}
private func presentTemplatePicker() {
guard presentedViewController == nil else { return }
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
configuration: autoRetouchConfiguration,
startsWithModeSelection: false
)
controller.onConfirm = { [weak self] configuration in
guard let self else { return }
self.autoRetouchConfiguration = configuration
self.updateAutoRetouchSection()
}
present(controller, animated: true)
}
private func updateAutoRetouchSection() {
let isEnabled = autoRetouchConfiguration.isEnabled
noRetouchOption.apply(
title: TravelAlbumRetouchMode.disabled.title,
desc: "保留原图",
selected: !isEnabled
)
aiRetouchOption.apply(
title: TravelAlbumRetouchMode.aiRetouch.title,
desc: autoRetouchConfiguration.template?.title ?? "点击选模板",
selected: isEnabled
)
}
private func sanitizeMoneyInput(_ value: String) -> String {
var result = ""
var hasDot = false
@@ -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 "高饱和浓郁油画质感"
}
}
}
@@ -40,7 +40,7 @@ final class WiredCameraTransferViewController: BaseViewController {
private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel()
private let chipsStack = UIStackView()
private let retouchButton = WiredTransferSettingChipButton()
private let retouchButton = WiredTransferSettingChipButton(showsChevron: true)
private let formatButton = WiredTransferSettingChipButton()
private let modeButton = WiredTransferSettingChipButton(showsChevron: true)
private let settingsStatsDivider = UIView()
@@ -161,7 +161,6 @@ final class WiredCameraTransferViewController: BaseViewController {
[retouchButton, formatButton, modeButton].forEach {
chipsStack.addArrangedSubview($0)
}
retouchButton.isUserInteractionEnabled = false
formatButton.isUserInteractionEnabled = false
settingsStatsDivider.backgroundColor = AppColor.border
@@ -387,6 +386,7 @@ final class WiredCameraTransferViewController: BaseViewController {
Task { @MainActor in self?.showToast(message) }
}
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
retouchButton.addTarget(self, action: #selector(retouchTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside)
@@ -567,6 +567,7 @@ final class WiredCameraTransferViewController: BaseViewController {
let selected = viewModel.selectedPhotoIds.contains(item.id)
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
cell.onRetryRetouch = { [weak self] in self?.viewModel.retryAutoRetouch(photoId: item.id) }
cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) }
}
@@ -711,6 +712,17 @@ final class WiredCameraTransferViewController: BaseViewController {
viewModel.refreshCameraFiles()
}
@objc private func retouchTapped() {
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
configuration: viewModel.autoRetouchConfiguration,
startsWithModeSelection: true
)
controller.onConfirm = { [weak self] configuration in
self?.viewModel.updateAutoRetouchConfiguration(configuration)
}
present(controller, animated: true)
}
@objc private func batchTapped() {
viewModel.onBatchUploadButtonClick()
}
@@ -1126,6 +1138,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
private let selectionIconView = UIImageView()
private let imageView = UIImageView()
private let retouchBadgeLabel = UILabel()
private let statusLabel = UILabel()
private let titleLabel = UILabel()
private let sizeLabel = UILabel()
@@ -1134,6 +1147,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
private let separatorView = UIView()
var onRetry: (() -> Void)?
var onRetryRetouch: (() -> Void)?
var onDelete: (() -> Void)?
override init(frame: CGRect) {
@@ -1151,6 +1165,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.kf.cancelDownloadTask()
imageView.image = nil
onRetry = nil
onRetryRetouch = nil
onDelete = nil
menuButton.menu = nil
}
@@ -1178,16 +1193,21 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
titleLabel.alpha = rowAlpha
sizeLabel.text = item.fileSizeText
sizeLabel.alpha = rowAlpha
statusLabel.text = statusText(item.status)
statusLabel.textColor = statusTextColor(item.status)
statusLabel.backgroundColor = statusBackgroundColor(item.status)
progressView.isHidden = item.status != .uploading && item.status != .transferring
progressView.progress = Float(item.progress) / 100.0
statusLabel.text = statusText(item)
statusLabel.textColor = statusTextColor(item)
statusLabel.backgroundColor = statusBackgroundColor(item)
retouchBadgeLabel.isHidden = item.autoRetouchState != .completed
let isProcessing = item.autoRetouchState == .processing
progressView.isHidden = !isProcessing && item.status != .uploading && item.status != .transferring
progressView.progress = isProcessing ? 0.62 : Float(item.progress) / 100.0
selectionIconView.isHidden = !selectionMode
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
menuButton.isHidden = selectionMode
menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending)
menuButton.menu = makeMenu(
canRetryUpload: item.status == .failed || item.status == .pending,
canRetryRetouch: item.autoRetouchState == .failed
)
updateImageConstraints(selectionMode: selectionMode)
}
@@ -1197,6 +1217,14 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 6
retouchBadgeLabel.text = "修"
retouchBadgeLabel.font = .systemFont(ofSize: 9, weight: .semibold)
retouchBadgeLabel.textColor = .white
retouchBadgeLabel.textAlignment = .center
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x7C3AED)
retouchBadgeLabel.layer.cornerRadius = 8
retouchBadgeLabel.clipsToBounds = true
retouchBadgeLabel.isHidden = true
statusLabel.font = .systemFont(ofSize: 9)
statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 3
@@ -1219,6 +1247,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
contentView.addSubview(selectionIconView)
contentView.addSubview(imageView)
contentView.addSubview(retouchBadgeLabel)
contentView.addSubview(titleLabel)
contentView.addSubview(statusLabel)
contentView.addSubview(sizeLabel)
@@ -1232,6 +1261,11 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
make.size.equalTo(20)
}
updateImageConstraints(selectionMode: true)
retouchBadgeLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(2)
make.trailing.equalTo(imageView).offset(-2)
make.size.equalTo(16)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(1)
make.leading.equalTo(imageView.snp.trailing).offset(8)
@@ -1266,15 +1300,19 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
}
}
private func makeMenu(canRetry: Bool) -> UIMenu {
private func makeMenu(canRetryUpload: Bool, canRetryRetouch: Bool) -> UIMenu {
let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
self?.onRetry?()
}
retry.attributes = canRetry ? [] : [.disabled]
retry.attributes = canRetryUpload ? [] : [.disabled]
let retryRetouch = UIAction(title: "重新修图", image: UIImage(systemName: "wand.and.stars")) { [weak self] _ in
self?.onRetryRetouch?()
}
retryRetouch.attributes = canRetryRetouch ? [] : [.disabled]
let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.onDelete?()
}
return UIMenu(children: [retry, delete])
return UIMenu(children: [retry, retryRetouch, delete])
}
private func updateImageConstraints(selectionMode: Bool) {
@@ -1290,8 +1328,13 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
}
}
private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String {
switch status {
private func statusText(_ item: TravelAlbumOTGPhotoItem) -> String {
switch item.autoRetouchState {
case .processing: return "修图中"
case .failed: return "修图失败"
case .none, .completed: break
}
switch item.status {
case .pending: return "待上传"
case .transferring: return "传输中"
case .uploading: return "上传中"
@@ -1300,8 +1343,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
}
}
private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
switch status {
private func statusTextColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor {
if item.autoRetouchState == .processing { return AppColor.primary }
if item.autoRetouchState == .failed { return AppColor.danger }
switch item.status {
case .pending: return AppColor.textSecondary
case .transferring, .uploading: return AppColor.primary
case .uploaded: return UIColor(hex: 0x16A34A)
@@ -1309,8 +1354,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
}
}
private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
switch status {
private func statusBackgroundColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor {
if item.autoRetouchState == .processing { return AppColor.primary.withAlphaComponent(0.12) }
if item.autoRetouchState == .failed { return AppColor.danger.withAlphaComponent(0.12) }
switch item.status {
case .pending:
return AppColor.pageBackground
case .transferring, .uploading: