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

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

995 lines
38 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
}
}