import SnapKit 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? = nil) -> UILabel { let label = UILabel() label.text = text label.font = .systemFont(ofSize: size, weight: weight) label.textColor = color ?? textPrimary label.numberOfLines = 0 return label } /// 创建统一间距的纵向内容组。 static func stack(_ views: [UIView], spacing: CGFloat = 12) -> UIStackView { let stack = UIStackView(arrangedSubviews: views) stack.axis = .vertical stack.spacing = spacing return stack } /// 白色圆角卡片,内容由页面提供。 static func card(_ content: UIView, background: UIColor = .white) -> UIView { let card = UIView() 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 } /// 统一主次按钮;禁用态及加载期间不依赖系统默认的灰底样式。 static func button(_ title: String, id: String, primary: Bool = true) -> UIButton { let button = UIButton(type: .system) button.accessibilityIdentifier = id configure(button, title: title, primary: primary) button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) } return button } /// 更新按钮角色和文案,保留清晰的主次关系。 static func configure(_ button: UIButton, title: String, primary: Bool = true) { var config = primary ? UIButton.Configuration.filled() : .plain() config.title = title 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 attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold) return attributes } button.configuration = config button.configurationUpdateHandler = { button in let enabled = button.isEnabled var updated = button.configuration updated?.background.backgroundColorTransformer = UIConfigurationColorTransformer { _ in 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 : StoreAccountDeregistrationStyle.primary) : StoreAccountDeregistrationStyle.textSecondary return attributes } button.configuration = updated } } /// 以统一的SF Symbol展示提示图标,不加载额外图片资源。 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 = 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() } } }