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:
2026-07-06 16:01:05 +08:00
parent 02a80764ea
commit 005cac3f78
36 changed files with 4077 additions and 57 deletions

View File

@ -0,0 +1,294 @@
//
// WithdrawalSettingsViewController.swift
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
///
final class WithdrawalSettingsViewController: BaseViewController {
private let viewModel = WithdrawalSettingsViewModel()
private let profileAPI = NetworkServices.shared.profileAPI
private let ossUploadService = NetworkServices.shared.ossUploadService
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let stepLabel = UILabel()
private let holderNameField = UITextField()
private let bankNoField = UITextField()
private let frontImageView = UIImageView()
private let backImageView = UIImageView()
private let bankNameField = UITextField()
private let provinceButton = UIButton(type: .system)
private let cityButton = UIButton(type: .system)
private let branchField = UITextField()
private let smsCodeField = UITextField()
private let agreeSwitch = UISwitch()
private let actionButton = AppButton(title: "下一步")
private var pickingSide: BankCardImageSide = .front
private var stepTwoStack = UIStackView()
override func setupNavigationBar() {
title = "提现设置"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
contentStack.axis = .vertical
contentStack.spacing = 16
stepLabel.font = .systemFont(ofSize: 14, weight: .medium)
stepLabel.textColor = AppColor.text666
[holderNameField, bankNoField, bankNameField, branchField, smsCodeField].forEach {
$0.borderStyle = .roundedRect
$0.font = .systemFont(ofSize: 16)
}
holderNameField.placeholder = "持卡人姓名"
bankNoField.placeholder = "银行卡号"
bankNoField.keyboardType = .numberPad
bankNameField.placeholder = "开户银行"
branchField.placeholder = "支行名称"
smsCodeField.placeholder = "短信验证码"
smsCodeField.keyboardType = .numberPad
provinceButton.contentHorizontalAlignment = .leading
cityButton.contentHorizontalAlignment = .leading
provinceButton.setTitle("请选择省份", for: .normal)
cityButton.setTitle("请选择城市", for: .normal)
[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(140) }
}
frontImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickFront)))
backImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickBack)))
stepTwoStack.axis = .vertical
stepTwoStack.spacing = 16
stepTwoStack.isHidden = true
stepTwoStack.addArrangedSubview(labeled("开户银行", bankNameField))
stepTwoStack.addArrangedSubview(labeled("所在省份", provinceButton))
stepTwoStack.addArrangedSubview(labeled("所在城市", cityButton))
stepTwoStack.addArrangedSubview(labeled("支行名称", branchField))
let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton()])
smsRow.axis = .horizontal
smsRow.spacing = 8
stepTwoStack.addArrangedSubview(labeled("短信验证码", smsRow))
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(actionButton)
contentStack.addArrangedSubview(stepLabel)
contentStack.addArrangedSubview(labeled("持卡人姓名", holderNameField))
contentStack.addArrangedSubview(labeled("银行卡号", bankNoField))
contentStack.addArrangedSubview(labeled("银行卡正面", frontImageView))
contentStack.addArrangedSubview(labeled("银行卡反面", backImageView))
contentStack.addArrangedSubview(agreeRow())
contentStack.addArrangedSubview(stepTwoStack)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(actionButton.snp.top).offset(-12)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
actionButton.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() }
actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
provinceButton.addTarget(self, action: #selector(selectProvince), for: .touchUpInside)
cityButton.addTarget(self, action: #selector(selectCity), for: .touchUpInside)
agreeSwitch.addTarget(self, action: #selector(agreeChanged), for: .valueChanged)
holderNameField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
bankNoField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
bankNameField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
branchField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
smsCodeField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
Task { await loadData() }
}
private func applyViewModel() {
stepLabel.text = viewModel.currentStep == 1 ? "步骤 1/2填写银行卡信息" : "步骤 2/2完善开户信息"
holderNameField.text = viewModel.holderName
bankNoField.text = viewModel.bankNo
bankNameField.text = viewModel.bankName
branchField.text = viewModel.branchName
smsCodeField.text = viewModel.verificationCode
agreeSwitch.isOn = viewModel.isAgree
stepTwoStack.isHidden = viewModel.currentStep == 1
actionButton.setTitle(viewModel.currentStep == 1 ? "下一步" : "提交", for: .normal)
actionButton.isEnabled = viewModel.currentStep == 1 ? viewModel.canGoNextStep : viewModel.canSubmit
provinceButton.setTitle(viewModel.provinceName.isEmpty ? "请选择省份" : viewModel.provinceName, for: .normal)
cityButton.setTitle(viewModel.cityName.isEmpty ? "请选择城市" : viewModel.cityName, for: .normal)
updateImageView(frontImageView, pending: viewModel.pendingFrontImageData, url: viewModel.bankCardFrontURL)
updateImageView(backImageView, pending: viewModel.pendingBackImageData, url: viewModel.bankCardBackURL)
}
private func updateImageView(_ imageView: UIImageView, pending: Data?, url: String) {
if let pending, let image = UIImage(data: pending) {
imageView.image = image
} else if url != "pending" {
imageView.loadRemoteImage(urlString: url)
}
}
@objc private func fieldsChanged() {
viewModel.updateHolderName(holderNameField.text ?? "")
viewModel.updateBankNo(bankNoField.text ?? "")
viewModel.updateBankName(bankNameField.text ?? "")
viewModel.updateBranchName(branchField.text ?? "")
viewModel.updateVerificationCode(smsCodeField.text ?? "")
}
@objc private func agreeChanged() {
viewModel.updateAgree(agreeSwitch.isOn)
}
@objc private func actionTapped() {
if viewModel.currentStep == 1 {
viewModel.goNextStep()
} else {
Task { await submit() }
}
}
@objc private func pickFront() { pickImage(side: .front) }
@objc private func pickBack() { pickImage(side: .back) }
private func pickImage(side: BankCardImageSide) {
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)
}
@objc private func selectProvince() {
presentAreaPicker(title: "选择省份", options: viewModel.provinceOptions) { [weak self] area in
self?.viewModel.selectProvince(area)
}
}
@objc private func selectCity() {
presentAreaPicker(title: "选择城市", options: viewModel.cityOptions) { [weak self] area in
self?.viewModel.selectCity(area)
}
}
private func presentAreaPicker(title: String, options: [AreasResponse], onSelect: @escaping (AreasResponse) -> Void) {
let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
options.forEach { area in
alert.addAction(UIAlertAction(title: area.name, style: .default) { _ in
onSelect(area)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func loadData() 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 labeled(_ title: String, _ view: UIView) -> UIStackView {
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 14)
let stack = UIStackView(arrangedSubviews: [label, view])
stack.axis = .vertical
stack.spacing = 8
return stack
}
private func agreeRow() -> UIStackView {
let label = UILabel()
label.text = "我已阅读并同意相关协议"
label.font = .systemFont(ofSize: 14)
let row = UIStackView(arrangedSubviews: [label, agreeSwitch])
row.axis = .horizontal
return row
}
private func sendCodeButton() -> UIButton {
let button = UIButton(type: .system)
button.setTitle("获取验证码", for: .normal)
button.addAction(UIAction { [weak self] _ in
Task {
guard let self else { return }
self.showLoading()
defer { self.hideLoading() }
do {
try await self.viewModel.sendCode(api: self.profileAPI)
self.showToast("验证码已发送")
} catch {
self.showToast(error.localizedDescription)
}
}
}, for: .touchUpInside)
return button
}
}
extension WithdrawalSettingsViewController: 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.prepareBankCardImage(data: data, side: self.pickingSide)
} catch {
self.showToast(error.localizedDescription)
}
}
}
}
}