对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
995 lines
38 KiB
Swift
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
|
||
}
|
||
}
|