feat: 优化账号注销流程

This commit is contained in:
2026-08-31 15:56:16 +08:00
parent e529bb5942
commit 4444d328df
16 changed files with 982 additions and 208 deletions
@@ -4,13 +4,22 @@ import UIKit
/// 注销页面共用的轻量视觉组件,仅负责颜色、字号和布局,不包含业务状态。
@MainActor
enum StoreAccountDeregistrationStyle {
/// 注销流程独立视觉令牌,与设计稿保持一致且不修改全局主题。
static let primary = UIColor(hex: 0x1677FF)
static let pageBackground = UIColor(hex: 0xF5F7FA)
static let textPrimary = UIColor(hex: 0x172033)
static let textSecondary = UIColor(hex: 0x7A8496)
static let border = UIColor(hex: 0xE7ECF3)
static let infoBackground = UIColor(hex: 0xEEF5FF)
static let danger = UIColor(hex: 0xE5484D)
/// 创建支持多行的系统字体标签。
static func label(_ text: String = "", size: CGFloat = 15, weight: UIFont.Weight = .regular,
color: UIColor = AppColor.textPrimary) -> UILabel {
color: UIColor? = nil) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: size, weight: weight)
label.textColor = color
label.textColor = color ?? textPrimary
label.numberOfLines = 0
return label
}
@@ -24,10 +33,12 @@ enum StoreAccountDeregistrationStyle {
}
/// 白色圆角卡片,内容由页面提供。
static func card(_ content: UIView) -> UIView {
static func card(_ content: UIView, background: UIColor = .white) -> UIView {
let card = UIView()
card.backgroundColor = .white
card.backgroundColor = background
card.layer.cornerRadius = 16
card.layer.borderWidth = background == .white ? 0.5 : 0
card.layer.borderColor = border.cgColor
card.addSubview(content)
content.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
return card
@@ -46,9 +57,13 @@ enum StoreAccountDeregistrationStyle {
static func configure(_ button: UIButton, title: String, primary: Bool = true) {
var config = primary ? UIButton.Configuration.filled() : .plain()
config.title = title
config.baseBackgroundColor = AppColor.primary
config.baseForegroundColor = primary ? .white : AppColor.primary
config.baseBackgroundColor = StoreAccountDeregistrationStyle.primary
config.baseForegroundColor = primary ? .white : StoreAccountDeregistrationStyle.primary
config.background.cornerRadius = 12
if !primary {
config.background.strokeColor = StoreAccountDeregistrationStyle.primary
config.background.strokeWidth = 1
}
config.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
@@ -60,12 +75,12 @@ enum StoreAccountDeregistrationStyle {
let enabled = button.isEnabled
var updated = button.configuration
updated?.background.backgroundColorTransformer = UIConfigurationColorTransformer { _ in
primary ? (enabled ? AppColor.primary : AppColor.primary.withAlphaComponent(0.12)) : .clear
primary ? (enabled ? StoreAccountDeregistrationStyle.primary : StoreAccountDeregistrationStyle.primary.withAlphaComponent(0.12)) : .clear
}
updated?.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
attributes.foregroundColor = enabled ? (primary ? .white : AppColor.primary) : AppColor.textSecondary
attributes.foregroundColor = enabled ? (primary ? .white : StoreAccountDeregistrationStyle.primary) : StoreAccountDeregistrationStyle.textSecondary
return attributes
}
button.configuration = updated
@@ -76,9 +91,174 @@ enum StoreAccountDeregistrationStyle {
static func icon(_ name: String, size: CGFloat = 22) -> UIImageView {
let image = UIImageView(image: UIImage(systemName: name,
withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .medium)))
image.tintColor = AppColor.primary
image.tintColor = primary
image.contentMode = .scaleAspectFit
image.setContentHuggingPriority(.required, for: .horizontal)
return image
}
/// 带浅色圆形底的状态图标,用于冷静期和身份信息强调。
static func iconBadge(_ name: String, iconSize: CGFloat = 30, diameter: CGFloat = 88) -> UIView {
let container = UIView()
container.backgroundColor = infoBackground
container.layer.cornerRadius = diameter / 2
let image = icon(name, size: iconSize)
container.addSubview(image)
container.snp.makeConstraints { $0.width.height.equalTo(diameter) }
image.snp.makeConstraints { $0.center.equalToSuperview(); $0.width.height.equalTo(iconSize + 8) }
return container
}
/// 为危险确认操作应用红色实心按钮样式。
static func configureDestructive(_ button: UIButton, title: String) {
var config = UIButton.Configuration.filled()
config.title = title
config.baseBackgroundColor = danger
config.baseForegroundColor = .white
config.background.cornerRadius = 12
config.contentInsets = .init(top: 13, leading: 16, bottom: 13, trailing: 16)
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
return attributes
}
button.configuration = config
}
}
/// 注销资产及最终提交共用的高保真底部确认弹层。
@MainActor
final class StoreAccountDeregistrationConfirmationSheetViewController: UIViewController {
/// 弹层展示的一行摘要数据。
struct Summary {
let icon: String
let title: String
let value: String
}
private typealias Style = StoreAccountDeregistrationStyle
private let sheetTitle: String
private let summaries: [Summary]
private let notices: [(icon: String, text: String, danger: Bool)]
private let cancelTitle: String
private let confirmTitle: String
private let onConfirm: () -> Void
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private let contentStack = UIStackView()
/// 创建只在用户明确确认后回调的弹层;下拉或取消不会触发业务请求。
init(title: String, summaries: [Summary], notices: [(String, String, Bool)],
cancelTitle: String, confirmTitle: String, onConfirm: @escaping () -> Void) {
sheetTitle = title
self.summaries = summaries
self.notices = notices
self.cancelTitle = cancelTitle
self.confirmTitle = confirmTitle
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
let titleLabel = Style.label(sheetTitle, size: 20, weight: .semibold)
titleLabel.textAlignment = .center
let summaryStack = Style.stack([], spacing: 0)
for (index, summary) in summaries.enumerated() {
let icon = Style.icon(summary.icon, size: 18)
icon.snp.makeConstraints { $0.width.equalTo(24) }
let title = Style.label(summary.title, size: 14, color: Style.textSecondary)
let value = Style.label(summary.value, size: 15, weight: .semibold)
value.textAlignment = .right
let row = UIStackView(arrangedSubviews: [icon, title, UIView(), value])
row.axis = .horizontal
row.alignment = .center
row.spacing = 8
row.snp.makeConstraints { $0.height.greaterThanOrEqualTo(48) }
summaryStack.addArrangedSubview(row)
if index < summaries.count - 1 {
let divider = UIView()
divider.backgroundColor = Style.border
divider.snp.makeConstraints { $0.height.equalTo(0.5) }
summaryStack.addArrangedSubview(divider)
}
}
let summaryCard = Style.card(summaryStack)
summaryCard.layer.cornerRadius = 12
let noticeStack = Style.stack([], spacing: 12)
for notice in notices {
let icon = Style.icon(notice.icon, size: 16)
icon.tintColor = notice.danger ? Style.danger : Style.primary
icon.snp.makeConstraints { $0.width.equalTo(22) }
let text = Style.label(notice.text, size: 13,
color: notice.danger ? Style.danger : Style.textSecondary)
let row = UIStackView(arrangedSubviews: [icon, text])
row.axis = .horizontal
row.alignment = .top
row.spacing = 8
noticeStack.addArrangedSubview(row)
}
Style.configure(cancelButton, title: cancelTitle, primary: false)
Style.configureDestructive(confirmButton, title: confirmTitle)
cancelButton.accessibilityIdentifier = "deregister.sheet.cancel"
confirmButton.accessibilityIdentifier = "deregister.sheet.confirm"
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let actions = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
actions.axis = .horizontal
actions.distribution = .fillEqually
actions.spacing = 12
actions.snp.makeConstraints { $0.height.equalTo(50) }
contentStack.axis = .vertical
contentStack.spacing = 16
contentStack.addArrangedSubview(titleLabel)
contentStack.addArrangedSubview(summaryCard)
if !notices.isEmpty { contentStack.addArrangedSubview(noticeStack) }
contentStack.addArrangedSubview(actions)
view.addSubview(contentStack)
contentStack.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide).offset(28)
$0.leading.trailing.equalToSuperview().inset(16)
$0.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(12)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard let sheet = sheetPresentationController else { return }
view.layoutIfNeeded()
let sheetWidth = presentingViewController?.view.bounds.width ?? view.bounds.width
let contentWidth = max(0, sheetWidth - 32)
let contentHeight = contentStack.systemLayoutSizeFitting(
CGSize(width: contentWidth, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
).height
// 自定义 detent 会由系统计入底部安全区,这里只计算内容和视觉间距,避免重复留白。
let preferredHeight = ceil(28 + contentHeight + 12)
let identifier = UISheetPresentationController.Detent.Identifier("accountDeregistrationConfirmation")
sheet.detents = [.custom(identifier: identifier) { context in
min(preferredHeight, context.maximumDetentValue)
}]
sheet.selectedDetentIdentifier = identifier
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
sheet.preferredCornerRadius = 22
preferredContentSize.height = preferredHeight
}
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func confirmTapped() {
confirmButton.isEnabled = false
dismiss(animated: true) { [onConfirm] in onConfirm() }
}
}