Files
suixinkan_uikit/suixinkan/UI/Profile/RealNameAuthViewController.swift
lujiuyinandCursor 5138c1c11a 完善实名认证、钱包与打卡点模块 UI 与业务逻辑。
对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:28:10 +08:00

995 lines
38 KiB
Swift

//
// RealNameAuthViewController.swift
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
/// 实名认证页面,对齐 Android 两步提交与审核状态的完整视觉和交互。
final class RealNameAuthViewController: BaseViewController {
private let viewModel: RealNameAuthViewModel
private let profileAPI = NetworkServices.shared.profileAPI
private let ossUploadService = NetworkServices.shared.ossUploadService
private let rootStack = UIStackView()
private let statusView = RealNameAuditStatusView()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let formStack = UIStackView()
private let stepOneStack = UIStackView()
private let stepTwoStack = UIStackView()
private let nationalEmblemUploadView = RealNameIDCardUploadView(title: "身份证国徽面")
private let portraitUploadView = RealNameIDCardUploadView(title: "身份证人像面")
private let realNameField = RealNameLabeledTextField(title: "姓名", placeholder: "请输入真实名称")
private let idCardField = RealNameLabeledTextField(title: "身份证号码", placeholder: "请输入18位身份证号码")
private let validitySelectionView = RealNameValiditySelectionView()
private let startDateButton = RealNameDateButton(placeholder: "起始日期")
private let endDateButton = RealNameDateButton(placeholder: "截至日期")
private let readonlyNameField = RealNameReadOnlyField(title: "姓名")
private let readonlyIDField = RealNameReadOnlyField(title: "身份证号码")
private let verificationStack = UIStackView()
private let phoneLabel = UILabel()
private let smsCodeField = RealNameBorderTextField(placeholder: "请输入验证码")
private let sendCodeButton = UIButton(type: .system)
private let actionButton = AppButton(title: "下一步")
private let agreementRow = AgreementRowView()
private let agreementContainer = UIView()
private var pickingSide: RealNameImageSide = .front
/// 使用可选审核信息创建页面;无信息时进入首次认证模式。
init(info: RealNameInfo? = nil) {
viewModel = RealNameAuthViewModel(info: info)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
let titleLabel = UILabel()
titleLabel.text = "实名认证"
titleLabel.font = .systemFont(ofSize: 18, weight: .medium)
titleLabel.textColor = AppColor.text333
navigationItem.titleView = titleLabel
let backImage = UIImage(systemName: "arrow.left")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
)
navigationItem.leftBarButtonItem = UIBarButtonItem(
image: backImage,
style: .plain,
target: self,
action: #selector(backTapped)
)
navigationItem.leftBarButtonItem?.tintColor = AppColor.text333
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF9FAFB)
setupRootLayout()
setupFormCard()
setupSupplementaryCards()
setupInputs()
applyViewModel()
}
override func setupConstraints() {
rootStack.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
contentStack.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16))
make.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
realNameField.textField.addTarget(self, action: #selector(realNameChanged), for: .editingChanged)
idCardField.textField.addTarget(self, action: #selector(idCardChanged), for: .editingChanged)
smsCodeField.addTarget(self, action: #selector(smsCodeChanged), for: .editingChanged)
actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
nationalEmblemUploadView.onSelect = { [weak self] in self?.pickImage(side: .back) }
nationalEmblemUploadView.onDelete = { [weak self] in self?.viewModel.deleteIdentityImage(side: .back) }
portraitUploadView.onSelect = { [weak self] in self?.pickImage(side: .front) }
portraitUploadView.onDelete = { [weak self] in self?.viewModel.deleteIdentityImage(side: .front) }
validitySelectionView.onToggle = { [weak self] in
guard let self else { return }
self.viewModel.updateLongValid(!self.viewModel.isLongValid)
}
startDateButton.onTap = { [weak self] in self?.presentDatePicker(for: .start) }
endDateButton.onTap = { [weak self] in self?.presentDatePicker(for: .end) }
agreementRow.onCheckedChange = { [weak self] in self?.viewModel.toggleAgreement() }
agreementRow.onUserAgreementTap = { [weak self] in
self?.openAgreement(title: "用户协议", url: AppAgreementURLs.walletUserNotice)
}
agreementRow.onPrivacyPolicyTap = { [weak self] in
self?.openAgreement(title: "隐私政策", url: AppAgreementURLs.walletPrivacyPolicy)
}
}
private func setupRootLayout() {
rootStack.axis = .vertical
rootStack.spacing = 0
scrollView.alwaysBounceVertical = true
contentStack.axis = .vertical
contentStack.spacing = 8
view.addSubview(rootStack)
rootStack.addArrangedSubview(statusView)
rootStack.addArrangedSubview(scrollView)
scrollView.addSubview(contentStack)
}
private func setupFormCard() {
formStack.axis = .vertical
formStack.spacing = 8
stepOneStack.axis = .vertical
stepOneStack.spacing = 8
stepTwoStack.axis = .vertical
stepTwoStack.spacing = 8
stepOneStack.addArrangedSubview(nationalEmblemUploadView)
stepOneStack.addArrangedSubview(portraitUploadView)
stepOneStack.addArrangedSubview(realNameField)
stepOneStack.addArrangedSubview(idCardField)
stepOneStack.addArrangedSubview(validitySelectionView)
let dateRow = UIStackView(arrangedSubviews: [startDateButton, endDateButton])
dateRow.axis = .horizontal
dateRow.spacing = 13
dateRow.distribution = .fillEqually
stepOneStack.addArrangedSubview(dateRow)
verificationStack.axis = .vertical
verificationStack.spacing = 8
phoneLabel.font = .systemFont(ofSize: 12, weight: .medium)
phoneLabel.textColor = AppColor.text666
let codeRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton])
codeRow.axis = .horizontal
codeRow.alignment = .fill
codeRow.spacing = 8
sendCodeButton.snp.makeConstraints { make in make.width.equalTo(100) }
verificationStack.addArrangedSubview(phoneLabel)
verificationStack.addArrangedSubview(codeRow)
stepTwoStack.addArrangedSubview(readonlyNameField)
stepTwoStack.addArrangedSubview(readonlyIDField)
stepTwoStack.addArrangedSubview(verificationStack)
let actionContainer = UIView()
actionContainer.addSubview(actionButton)
actionButton.titleLabel?.font = .systemFont(ofSize: 17)
actionButton.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(4)
}
agreementContainer.addSubview(agreementRow)
agreementRow.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(4)
make.leading.trailing.equalToSuperview().inset(4)
}
formStack.addArrangedSubview(stepOneStack)
formStack.addArrangedSubview(stepTwoStack)
formStack.setCustomSpacing(12, after: stepTwoStack)
formStack.addArrangedSubview(actionContainer)
formStack.addArrangedSubview(agreementContainer)
contentStack.addArrangedSubview(makeCard(containing: formStack, insets: UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16)))
}
private func setupSupplementaryCards() {
let privileges = UIStackView()
privileges.axis = .vertical
privileges.spacing = 16
privileges.addArrangedSubview(makeHeading("实名认证特权"))
privileges.addArrangedSubview(
RealNamePrivilegeItemView(
imageName: "ic_accept_orders",
title: "开启接单权限",
detail: "完成实名认证后可立即接单,实名状态对游客可见"
)
)
privileges.addArrangedSubview(
RealNamePrivilegeItemView(
imageName: "ic_marketing",
title: "加入营销群",
detail: "专业摄影师交流群,获取更多流量与技术支持"
)
)
privileges.addArrangedSubview(
RealNamePrivilegeItemView(
imageName: "ic_area_authority",
title: "景区选择权限",
detail: "可自主选择运营景区,获得独家运营资源"
)
)
contentStack.addArrangedSubview(makeCard(containing: privileges, insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)))
let hints = UIStackView()
hints.axis = .vertical
hints.spacing = 4
hints.addArrangedSubview(makeHeading("温馨提示:"))
hints.setCustomSpacing(8, after: hints.arrangedSubviews[0])
[
"• 请确保填写的信息真实有效",
"• 身份信息仅用于实名认证,我们将严格保护您的隐私",
"• 如有疑问请联系客服",
].forEach { text in
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.text666
label.numberOfLines = 0
hints.addArrangedSubview(label)
}
contentStack.addArrangedSubview(makeCard(containing: hints, insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)))
}
private func setupInputs() {
realNameField.textField.textContentType = .name
realNameField.textField.returnKeyType = .next
idCardField.textField.keyboardType = .asciiCapable
idCardField.textField.returnKeyType = .done
smsCodeField.keyboardType = .numberPad
sendCodeButton.backgroundColor = AppColor.primary
sendCodeButton.layer.cornerRadius = 10
sendCodeButton.clipsToBounds = true
sendCodeButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
sendCodeButton.setTitleColor(.white, for: .normal)
sendCodeButton.setTitleColor(UIColor.white.withAlphaComponent(0.7), for: .disabled)
sendCodeButton.snp.makeConstraints { make in make.height.equalTo(46) }
}
private func applyViewModel() {
let readOnly = viewModel.mode.isReadOnly
statusView.isHidden = viewModel.info == nil
if let info = viewModel.info {
statusView.configure(info: info)
}
stepOneStack.isHidden = viewModel.currentStep != .identity
stepTwoStack.isHidden = viewModel.currentStep != .verification
if realNameField.textField.text != viewModel.realName {
realNameField.textField.text = viewModel.realName
}
if idCardField.textField.text != viewModel.idCardNo {
idCardField.textField.text = viewModel.idCardNo
}
realNameField.setReadOnly(readOnly)
idCardField.setReadOnly(readOnly)
nationalEmblemUploadView.configure(
imageData: viewModel.backPreviewImageData,
url: viewModel.backUrl,
uploading: viewModel.isBackUploading,
progress: viewModel.backUploadProgress,
canEdit: viewModel.mode.isEditable
)
portraitUploadView.configure(
imageData: viewModel.frontPreviewImageData,
url: viewModel.frontUrl,
uploading: viewModel.isFrontUploading,
progress: viewModel.frontUploadProgress,
canEdit: viewModel.mode.isEditable
)
validitySelectionView.configure(isLongValid: viewModel.isLongValid, enabled: viewModel.mode.isEditable)
startDateButton.configure(date: viewModel.startDate, readOnly: readOnly, enabled: viewModel.mode.isEditable)
endDateButton.configure(
date: viewModel.endDate,
replacementText: viewModel.isLongValid ? "长期有效" : nil,
readOnly: readOnly || viewModel.isLongValid,
enabled: viewModel.mode.isEditable && !viewModel.isLongValid
)
readonlyNameField.setValue(viewModel.realName)
readonlyIDField.setValue(viewModel.idCardNo)
phoneLabel.text = "验证码将发送至:\(AppStore.shared.session.phone)"
verificationStack.isHidden = readOnly
if smsCodeField.text != viewModel.smsCode {
smsCodeField.text = viewModel.smsCode
}
let codeTitle = viewModel.otpCountdown > 0 ? "\(viewModel.otpCountdown)秒" : "获取验证码"
sendCodeButton.setTitle(codeTitle, for: .normal)
sendCodeButton.setTitle(codeTitle, for: .disabled)
sendCodeButton.isEnabled = viewModel.canRequestCode
sendCodeButton.backgroundColor = viewModel.canRequestCode
? AppColor.primary
: (viewModel.otpCountdown > 0 ? AppColor.primary.withAlphaComponent(0.7) : UIColor(hex: 0xE5E7EB))
switch viewModel.currentStep {
case .identity:
actionButton.setTitle("下一步", for: .normal)
actionButton.isEnabled = readOnly || viewModel.canGoNextStep
case .verification:
actionButton.setTitle("确认提交", for: .normal)
actionButton.isEnabled = viewModel.canSubmit
}
agreementContainer.isHidden = readOnly
agreementRow.isChecked = viewModel.isAgreed
}
@objc private func backTapped() {
if viewModel.currentStep == .verification {
viewModel.backToIdentityStep()
} else {
navigationController?.popViewController(animated: true)
}
}
@objc private func realNameChanged() {
viewModel.updateRealName(realNameField.textField.text ?? "")
}
@objc private func idCardChanged() {
viewModel.updateIdCardNo(idCardField.textField.text ?? "")
}
@objc private func smsCodeChanged() {
viewModel.updateSmsCode(smsCodeField.text ?? "")
}
@objc private func actionTapped() {
switch viewModel.currentStep {
case .identity:
if let message = viewModel.goToVerificationStep() {
showToast(message)
}
case .verification:
Task { await submit() }
}
}
@objc private func sendCodeTapped() {
Task {
showLoading()
defer { hideLoading() }
do {
try await viewModel.sendCode(api: profileAPI)
showToast(viewModel.statusMessage ?? "验证码发送成功")
} catch {
showToast("验证码发送失败,请稍后重试")
}
}
}
private func submit() async {
showLoading()
defer { hideLoading() }
do {
try await viewModel.submit(api: profileAPI)
showToast(viewModel.statusMessage ?? "认证成功")
try? await Task.sleep(for: .milliseconds(350))
navigationController?.popViewController(animated: true)
} catch {
showToast(error.localizedDescription)
}
}
private func pickImage(side: RealNameImageSide) {
guard viewModel.mode.isEditable else { return }
pickingSide = side
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
private func presentDatePicker(for field: RealNameDateField) {
guard viewModel.mode.isEditable else { return }
let selectedDate = field == .start ? viewModel.startDate : viewModel.endDate
let controller = RealNameDatePickerViewController(selectedDate: selectedDate)
controller.onConfirm = { [weak self] date in
if field == .start {
self?.viewModel.updateStartDate(date)
} else {
self?.viewModel.updateEndDate(date)
}
}
present(controller, animated: false)
}
private func openAgreement(title: String, url: URL) {
navigationController?.pushViewController(AgreementWebViewController(title: title, url: url), animated: true)
}
private func makeCard(containing stack: UIStackView, insets: UIEdgeInsets) -> UIView {
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = 12
card.clipsToBounds = true
card.addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(insets) }
return card
}
private func makeHeading(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 16, weight: .medium)
label.textColor = AppColor.text333
return label
}
}
extension RealNameAuthViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
guard let self else { return }
if let error {
Task { @MainActor in self.showToast(error.localizedDescription) }
return
}
guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.95) else { return }
Task { @MainActor in
let scenicId = AppStore.shared.session.currentScenicId
self.showLoading()
defer { self.hideLoading() }
do {
try await self.viewModel.uploadIdentityImage(
data: data,
side: self.pickingSide,
uploader: self.ossUploadService,
scenicId: scenicId
)
} catch {
self.showToast(error.localizedDescription)
}
}
}
}
}
/// 实名认证日期字段类型。
private enum RealNameDateField {
case start
case end
}
/// 带标题和边框的实名认证输入框。
private final class RealNameLabeledTextField: UIView {
let textField = RealNameBorderTextField(placeholder: "")
private let titleLabel = UILabel()
init(title: String, placeholder: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = .black
textField.placeholder = placeholder
let stack = UIStackView(arrangedSubviews: [titleLabel, textField])
stack.axis = .vertical
stack.spacing = 8
addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview() }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setReadOnly(_ readOnly: Bool) {
textField.isEnabled = !readOnly
textField.backgroundColor = readOnly ? UIColor(hex: 0xEEEEEE) : .white
}
}
/// Android 边框输入框的 UIKit 实现。
private final class RealNameBorderTextField: UITextField {
private let horizontalInset: CGFloat = 8
init(placeholder: String) {
super.init(frame: .zero)
self.placeholder = placeholder
font = .systemFont(ofSize: 14)
textColor = AppColor.text333
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
layer.cornerRadius = 8
clipsToBounds = true
clearButtonMode = .whileEditing
snp.makeConstraints { make in make.height.equalTo(46) }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func textRect(forBounds bounds: CGRect) -> CGRect {
bounds.insetBy(dx: horizontalInset, dy: 0)
}
override func editingRect(forBounds bounds: CGRect) -> CGRect {
bounds.insetBy(dx: horizontalInset, dy: 0)
}
override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
bounds.insetBy(dx: horizontalInset, dy: 0)
}
}
/// 灰底只读字段,对齐 Android 第二步信息展示。
private final class RealNameReadOnlyField: UIView {
private let valueLabel = UILabel()
init(title: String) {
super.init(frame: .zero)
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = .black
let valueBox = UIView()
valueBox.backgroundColor = UIColor(hex: 0xEEEEEE)
valueBox.layer.borderWidth = 1
valueBox.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor
valueBox.layer.cornerRadius = 8
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.text333
valueBox.addSubview(valueLabel)
valueBox.snp.makeConstraints { make in make.height.equalTo(45) }
valueLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(8); make.centerY.equalToSuperview() }
let stack = UIStackView(arrangedSubviews: [titleLabel, valueBox])
stack.axis = .vertical
stack.spacing = 4
addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview() }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setValue(_ value: String) {
valueLabel.text = value
}
}
/// 长期有效选择行。
private final class RealNameValiditySelectionView: UIView {
private let checkboxButton = UIButton(type: .custom)
var onToggle: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
let titleLabel = UILabel()
titleLabel.text = "有效期"
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = .black
checkboxButton.setTitle(" 长期有效", for: .normal)
checkboxButton.setTitleColor(UIColor(hex: 0xB6BECA), for: .normal)
checkboxButton.titleLabel?.font = .systemFont(ofSize: 14)
checkboxButton.contentHorizontalAlignment = .right
checkboxButton.addTarget(self, action: #selector(tapped), for: .touchUpInside)
let row = UIStackView(arrangedSubviews: [titleLabel, checkboxButton])
row.axis = .horizontal
row.alignment = .center
row.distribution = .equalSpacing
addSubview(row)
row.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(28) }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(isLongValid: Bool, enabled: Bool) {
checkboxButton.setImage(UIImage(named: isLongValid ? "ic_checked" : "ic_unchecked"), for: .normal)
checkboxButton.isEnabled = enabled
checkboxButton.alpha = enabled ? 1 : 0.75
}
@objc private func tapped() {
onToggle?()
}
}
/// 有效期日期按钮。
private final class RealNameDateButton: UIButton {
private let placeholder: String
var onTap: (() -> Void)?
init(placeholder: String) {
self.placeholder = placeholder
super.init(frame: .zero)
layer.borderWidth = 1
layer.borderColor = AppColor.border.cgColor
layer.cornerRadius = 4
titleLabel?.font = .systemFont(ofSize: 14)
addTarget(self, action: #selector(tapped), for: .touchUpInside)
snp.makeConstraints { make in make.height.equalTo(45) }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(date: Date?, replacementText: String? = nil, readOnly: Bool, enabled: Bool) {
let text = replacementText ?? date.map(RealNameDateTextFormatter.string(from:)) ?? placeholder
setTitle(text, for: .normal)
setTitleColor(date == nil && replacementText == nil ? UIColor(hex: 0xB6BECA) : AppColor.text333, for: .normal)
backgroundColor = readOnly ? UIColor(hex: 0xEEEEEE) : .white
isEnabled = enabled
}
@objc private func tapped() {
onTap?()
}
}
/// 身份证图片上传区域,负责虚线框、预览、删除和上传遮罩。
private final class RealNameIDCardUploadView: UIView {
private let titleLabel = UILabel()
private let canvas = RealNameDashedBorderView()
private let imageView = UIImageView()
private let placeholderStack = UIStackView()
private let placeholderLabel = UILabel()
private let deleteButton = UIButton(type: .custom)
private let progressOverlay = UIView()
private let progressIndicator = UIActivityIndicatorView(style: .medium)
private let progressLabel = UILabel()
private var canEdit = false
private var hasImage = false
private var loadedURL = ""
var onSelect: (() -> Void)?
var onDelete: (() -> Void)?
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = .black
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.isAccessibilityElement = true
imageView.accessibilityLabel = title
let plusView = UIImageView(image: UIImage(systemName: "plus"))
plusView.tintColor = AppColor.primary
plusView.contentMode = .scaleAspectFit
plusView.snp.makeConstraints { make in make.size.equalTo(32) }
placeholderLabel.text = "点击上传图片"
placeholderLabel.textColor = AppColor.text666
placeholderLabel.font = .systemFont(ofSize: 12)
placeholderStack.axis = .vertical
placeholderStack.alignment = .center
placeholderStack.spacing = 8
placeholderStack.addArrangedSubview(plusView)
placeholderStack.addArrangedSubview(placeholderLabel)
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.5)
deleteButton.layer.cornerRadius = 10
deleteButton.setImage(UIImage(systemName: "xmark")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 10, weight: .medium)), for: .normal)
deleteButton.tintColor = .white
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
progressOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.45)
progressIndicator.color = .white
progressLabel.font = .systemFont(ofSize: 12, weight: .medium)
progressLabel.textColor = .white
let progressStack = UIStackView(arrangedSubviews: [progressIndicator, progressLabel])
progressStack.axis = .vertical
progressStack.alignment = .center
progressStack.spacing = 6
canvas.addSubview(imageView)
canvas.addSubview(placeholderStack)
canvas.addSubview(deleteButton)
canvas.addSubview(progressOverlay)
progressOverlay.addSubview(progressStack)
canvas.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(canvasTapped)))
let stack = UIStackView(arrangedSubviews: [titleLabel, canvas])
stack.axis = .vertical
stack.spacing = 4
addSubview(stack)
stack.snp.makeConstraints { make in make.edges.equalToSuperview() }
canvas.snp.makeConstraints { make in make.height.equalTo(canvas.snp.width).dividedBy(2.1) }
imageView.snp.makeConstraints { make in make.edges.equalToSuperview() }
placeholderStack.snp.makeConstraints { make in make.center.equalToSuperview() }
deleteButton.snp.makeConstraints { make in make.top.trailing.equalToSuperview().inset(4); make.size.equalTo(20) }
progressOverlay.snp.makeConstraints { make in make.edges.equalToSuperview() }
progressStack.snp.makeConstraints { make in make.center.equalToSuperview() }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(imageData: Data?, url: String, uploading: Bool, progress: Int, canEdit: Bool) {
self.canEdit = canEdit
hasImage = imageData != nil || !url.isEmpty
if let imageData, let image = UIImage(data: imageData) {
imageView.image = image
loadedURL = ""
} else if !url.isEmpty, loadedURL != url {
loadedURL = url
imageView.loadRemoteImage(urlString: url, placeholderColor: .white)
} else if url.isEmpty {
loadedURL = ""
imageView.image = nil
}
placeholderStack.isHidden = hasImage
placeholderLabel.text = canEdit ? "点击上传图片" : "暂无图片"
deleteButton.isHidden = !hasImage || !canEdit || uploading
progressOverlay.isHidden = !uploading
progressLabel.text = "\(progress)%"
uploading ? progressIndicator.startAnimating() : progressIndicator.stopAnimating()
canvas.accessibilityTraits = canEdit && !hasImage ? .button : .image
}
@objc private func canvasTapped() {
guard canEdit, !hasImage else { return }
onSelect?()
}
@objc private func deleteTapped() {
onDelete?()
}
}
/// 带圆角虚线边框的身份证图片容器。
private final class RealNameDashedBorderView: UIView {
private let borderLayer = CAShapeLayer()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 8
clipsToBounds = true
borderLayer.fillColor = UIColor.clear.cgColor
borderLayer.strokeColor = AppColor.border.cgColor
borderLayer.lineWidth = 2
borderLayer.lineDashPattern = [6, 6]
layer.addSublayer(borderLayer)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
borderLayer.frame = bounds
borderLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), cornerRadius: 8).cgPath
}
}
/// 实名认证特权说明行。
private final class RealNamePrivilegeItemView: UIView {
init(imageName: String, title: String, detail: String) {
super.init(frame: .zero)
let iconContainer = UIView()
iconContainer.backgroundColor = AppColor.primary
iconContainer.layer.cornerRadius = 12
iconContainer.snp.makeConstraints { make in make.size.equalTo(24) }
let iconView = UIImageView(image: UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate))
iconView.tintColor = .white
iconView.contentMode = .scaleAspectFit
iconContainer.addSubview(iconView)
iconView.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(16) }
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = AppColor.text333
let detailLabel = UILabel()
detailLabel.text = detail
detailLabel.font = .systemFont(ofSize: 12)
detailLabel.textColor = AppColor.text666
detailLabel.numberOfLines = 0
let textStack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
textStack.axis = .vertical
textStack.spacing = 2
let row = UIStackView(arrangedSubviews: [iconContainer, textStack])
row.axis = .horizontal
row.alignment = .top
row.spacing = 12
addSubview(row)
row.snp.makeConstraints { make in make.edges.equalToSuperview() }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 审核状态顶部信息条。
private final class RealNameAuditStatusView: UIView {
private let stack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .vertical
stack.spacing = 8
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20))
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(info: RealNameInfo) {
stack.arrangedSubviews.forEach { view in
stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
let color: UIColor
switch info.auditStatus {
case 2:
color = AppColor.primary
backgroundColor = UIColor(hex: 0xEFF6FF)
case 3:
color = UIColor(hex: 0xEF4444)
backgroundColor = UIColor(hex: 0xFFE7E7)
default:
color = UIColor(hex: 0xFF7B00)
backgroundColor = UIColor(hex: 0xFFF0E2)
}
let statusText = info.auditStatusText?.nonEmpty ?? Self.fallbackStatusText(for: info.auditStatus)
stack.addArrangedSubview(makeRow(label: "审核状态", value: statusText, color: color))
if info.auditStatus == 2 {
stack.addArrangedSubview(makeRow(label: "审核人:", value: info.auditor?.name ?? "--", color: color))
stack.addArrangedSubview(makeRow(label: "审核时间:", value: info.auditAt ?? "--", color: color))
}
stack.addArrangedSubview(makeRow(label: "审核备注:", value: info.rejectReason ?? "--", color: color))
}
private func makeRow(label: String, value: String, color: UIColor) -> UIView {
let labelView = UILabel()
labelView.text = label
labelView.font = .systemFont(ofSize: 14)
labelView.textColor = color
let valueView = UILabel()
valueView.text = value
valueView.font = .systemFont(ofSize: 14)
valueView.textColor = color
valueView.textAlignment = .right
valueView.numberOfLines = 0
valueView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let row = UIStackView(arrangedSubviews: [labelView, valueView])
row.axis = .horizontal
row.alignment = .center
row.distribution = .equalSpacing
row.spacing = 12
return row
}
private static func fallbackStatusText(for status: Int) -> String {
switch status {
case 2: "审核通过"
case 3: "审核驳回"
default: "审核中"
}
}
}
/// Android 滚轮日期弹窗的 UIKit 实现。
private final class RealNameDatePickerViewController: UIViewController {
private let datePicker = UIDatePicker()
private let selectedDate: Date?
var onConfirm: ((Date) -> Void)?
init(selectedDate: Date?) {
self.selectedDate = selectedDate
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
let dismissButton = UIButton(type: .custom)
dismissButton.backgroundColor = UIColor.black.withAlphaComponent(0.35)
dismissButton.addTarget(self, action: #selector(backgroundTapped), for: .touchUpInside)
view.addSubview(dismissButton)
dismissButton.snp.makeConstraints { make in make.edges.equalToSuperview() }
let container = UIView()
container.backgroundColor = .white
container.layer.cornerRadius = 10
container.clipsToBounds = true
datePicker.datePickerMode = .date
datePicker.preferredDatePickerStyle = .wheels
datePicker.locale = Locale(identifier: "zh_CN")
datePicker.date = selectedDate ?? Date()
let confirmButton = AppButton(title: "确认")
confirmButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
confirmButton.snp.remakeConstraints { make in make.height.equalTo(45) }
let stack = UIStackView(arrangedSubviews: [datePicker, confirmButton])
stack.axis = .vertical
stack.spacing = 24
container.addSubview(stack)
view.addSubview(container)
container.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(30)
}
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 24, left: 15, bottom: 24, right: 15))
}
}
@objc private func confirmTapped() {
onConfirm?(datePicker.date)
dismiss(animated: false)
}
@objc private func backgroundTapped() {
dismiss(animated: false)
}
}
/// 实名认证日期文本格式化工具。
private enum RealNameDateTextFormatter {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
static func string(from date: Date) -> String {
formatter.string(from: date)
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}