Implement profile tab with Android-aligned flows and OSS upload.
Add personal info page, account switch, real-name auth, withdrawal settings, session cache extensions, AlibabaCloudOSS SPM, and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
255
suixinkan/UI/Profile/RealNameAuthViewController.swift
Normal file
255
suixinkan/UI/Profile/RealNameAuthViewController.swift
Normal file
@ -0,0 +1,255 @@
|
||||
//
|
||||
// RealNameAuthViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 实名认证页面,展示表单并允许提交基础实名资料。
|
||||
final class RealNameAuthViewController: BaseViewController {
|
||||
|
||||
private let viewModel = RealNameAuthViewModel()
|
||||
private let profileAPI = NetworkServices.shared.profileAPI
|
||||
private let ossUploadService = NetworkServices.shared.ossUploadService
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let realNameField = UITextField()
|
||||
private let idCardField = UITextField()
|
||||
private let smsCodeField = UITextField()
|
||||
private let frontImageView = UIImageView()
|
||||
private let backImageView = UIImageView()
|
||||
private let longValidSwitch = UISwitch()
|
||||
private let startDatePicker = UIDatePicker()
|
||||
private let endDatePicker = UIDatePicker()
|
||||
private let submitButton = AppButton(title: "提交审核")
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "实名认证"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
view.addSubview(submitButton)
|
||||
|
||||
[realNameField, idCardField, smsCodeField].forEach {
|
||||
$0.borderStyle = .roundedRect
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
}
|
||||
realNameField.placeholder = "请输入姓名"
|
||||
idCardField.placeholder = "请输入身份证号码"
|
||||
smsCodeField.placeholder = "请输入短信验证码"
|
||||
smsCodeField.keyboardType = .numberPad
|
||||
|
||||
[frontImageView, backImageView].forEach {
|
||||
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.clipsToBounds = true
|
||||
$0.isUserInteractionEnabled = true
|
||||
$0.snp.makeConstraints { make in make.height.equalTo(160) }
|
||||
}
|
||||
frontImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickFront)))
|
||||
backImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickBack)))
|
||||
|
||||
startDatePicker.datePickerMode = .date
|
||||
endDatePicker.datePickerMode = .date
|
||||
if #available(iOS 17.0, *) {
|
||||
startDatePicker.preferredDatePickerStyle = .compact
|
||||
endDatePicker.preferredDatePickerStyle = .compact
|
||||
}
|
||||
|
||||
let sendCodeButton = UIButton(type: .system)
|
||||
sendCodeButton.setTitle("获取验证码", for: .normal)
|
||||
sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
|
||||
|
||||
contentStack.addArrangedSubview(makeSectionTitle("身份证国徽面"))
|
||||
contentStack.addArrangedSubview(backImageView)
|
||||
contentStack.addArrangedSubview(makeSectionTitle("身份证人像面"))
|
||||
contentStack.addArrangedSubview(frontImageView)
|
||||
contentStack.addArrangedSubview(labeledField("姓名", realNameField))
|
||||
contentStack.addArrangedSubview(labeledField("身份证号码", idCardField))
|
||||
|
||||
let longValidRow = UIStackView(arrangedSubviews: [UILabel(text: "长期有效"), longValidSwitch])
|
||||
longValidRow.axis = .horizontal
|
||||
contentStack.addArrangedSubview(longValidRow)
|
||||
contentStack.addArrangedSubview(labeledField("起始日期", startDatePicker))
|
||||
contentStack.addArrangedSubview(labeledField("结束日期", endDatePicker))
|
||||
|
||||
let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton])
|
||||
smsRow.axis = .horizontal
|
||||
smsRow.spacing = 8
|
||||
smsCodeField.snp.makeConstraints { make in make.width.equalTo(200) }
|
||||
contentStack.addArrangedSubview(labeledField("短信验证码", smsRow))
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(submitButton.snp.top).offset(-12)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
submitButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in self?.applyViewModel() }
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
longValidSwitch.addTarget(self, action: #selector(longValidChanged), for: .valueChanged)
|
||||
realNameField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged)
|
||||
idCardField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged)
|
||||
smsCodeField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged)
|
||||
startDatePicker.addTarget(self, action: #selector(dateChanged), for: .valueChanged)
|
||||
endDatePicker.addTarget(self, action: #selector(dateChanged), for: .valueChanged)
|
||||
Task { await loadInfo() }
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
realNameField.text = viewModel.realName
|
||||
idCardField.text = viewModel.idCardNo
|
||||
smsCodeField.text = viewModel.smsCode
|
||||
longValidSwitch.isOn = viewModel.isLongValid
|
||||
startDatePicker.date = viewModel.startDate
|
||||
endDatePicker.date = viewModel.endDate
|
||||
endDatePicker.isEnabled = !viewModel.isLongValid
|
||||
submitButton.isEnabled = !viewModel.submitting
|
||||
|
||||
if let data = viewModel.pendingFrontImageData, let image = UIImage(data: data) {
|
||||
frontImageView.image = image
|
||||
} else {
|
||||
frontImageView.loadRemoteImage(urlString: viewModel.frontUrl)
|
||||
}
|
||||
if let data = viewModel.pendingBackImageData, let image = UIImage(data: data) {
|
||||
backImageView.image = image
|
||||
} else {
|
||||
backImageView.loadRemoteImage(urlString: viewModel.backUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func fieldChanged() {
|
||||
viewModel.updateRealName(realNameField.text ?? "")
|
||||
viewModel.updateIdCardNo(idCardField.text ?? "")
|
||||
viewModel.updateSmsCode(smsCodeField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func dateChanged() {
|
||||
viewModel.updateStartDate(startDatePicker.date)
|
||||
viewModel.updateEndDate(endDatePicker.date)
|
||||
}
|
||||
|
||||
@objc private func longValidChanged() {
|
||||
viewModel.updateLongValid(longValidSwitch.isOn)
|
||||
}
|
||||
|
||||
@objc private func sendCodeTapped() {
|
||||
Task {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.sendCode(api: profileAPI)
|
||||
showToast(viewModel.statusMessage ?? "验证码已发送")
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
Task { await submit() }
|
||||
}
|
||||
|
||||
@objc private func pickFront() { pickImage(side: .front) }
|
||||
@objc private func pickBack() { pickImage(side: .back) }
|
||||
|
||||
private var pickingSide: RealNameImageSide = .front
|
||||
|
||||
private func pickImage(side: RealNameImageSide) {
|
||||
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 loadInfo() async {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.load(api: profileAPI)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
let scenicId = AppStore.shared.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
showToast("当前景区信息缺失")
|
||||
return
|
||||
}
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.submit(api: profileAPI, uploader: ossUploadService, scenicId: scenicId)
|
||||
showToast("提交成功")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSectionTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
return label
|
||||
}
|
||||
|
||||
private func labeledField(_ title: String, _ field: UIView) -> UIStackView {
|
||||
let titleLabel = UILabel(text: title)
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, field])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
return stack
|
||||
}
|
||||
}
|
||||
|
||||
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, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try self.viewModel.prepareIdentityImage(data: data, side: self.pickingSide)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UILabel {
|
||||
convenience init(text: String) {
|
||||
self.init()
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user