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