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,245 @@
//
// AccountSwitchViewController.swift
// suixinkan
//
import SnapKit
import UIKit
private typealias AccountSwitchSection = Int
private typealias AccountSwitchItem = String
///
final class AccountSwitchViewController: BaseViewController, UITableViewDelegate {
private let viewModel = AccountSwitchViewModel()
private let profileAPI = NetworkServices.shared.profileAPI
private let authAPI = NetworkServices.shared.authAPI
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .plain)
table.backgroundColor = UIColor(hex: 0xF5F7FB)
table.separatorStyle = .none
table.delegate = self
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
return table
}()
private let confirmButton = AppButton(title: "确认切换")
private var tableDataSource: UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>!
override func setupNavigationBar() {
title = "账号切换"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F7FB)
configureTableDataSource()
view.addSubview(tableView)
view.addSubview(confirmButton)
}
override func setupConstraints() {
tableView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(confirmButton.snp.top).offset(-12)
}
confirmButton.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?.applyTableSnapshot(reconfigure: true)
self?.updateConfirmButton()
}
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
Task { await loadAccounts() }
}
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
guard let self else { return UITableViewCell() }
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
guard let account = self.viewModel.accounts.first(where: { $0.id == item }) else { return cell }
cell.configure(
account: account,
selected: account.id == self.viewModel.selectedAccountId,
isCurrent: account.isCurrent || self.isCurrentAccount(account)
)
return cell
}
applyTableSnapshot(animated: false)
}
private func buildSnapshot() -> NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem> {
var snapshot = NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.accounts.map(\.id), toSection: 0)
return snapshot
}
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
private func updateConfirmButton() {
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
confirmButton.isEnabled = enabled
}
private var currentAccountId: String? {
let store = AppStore.shared
if store.currentStoreId > 0 {
return "\(V9StoreUser.accountTypeValue)_\(store.currentStoreId)"
}
if store.currentScenicId > 0 {
return "\(V9ScenicUser.accountTypeValue)_\(store.userId)"
}
return store.userId.isEmpty ? nil : store.userId
}
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
viewModel.isCurrent(account, currentAccountId: currentAccountId)
}
private func loadAccounts(force: Bool = false) async {
showLoading()
defer { hideLoading() }
do {
try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId)
} catch {
showToast(error.localizedDescription)
}
}
@objc private func confirmTapped() {
guard let account = viewModel.selectedAccount else { return }
if account.isCurrent || isCurrentAccount(account) {
navigationController?.popViewController(animated: true)
return
}
Task { await switchAccount(account) }
}
private func switchAccount(_ account: AccountSwitchAccount) async {
showLoading()
defer { hideLoading() }
do {
let response = try await viewModel.switchAccount(account, api: authAPI)
AuthSessionHelper.applyAccountSwitch(with: response, account: account)
showToast("账号已切换")
navigationController?.popViewController(animated: true)
} catch {
showToast(error.localizedDescription)
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = tableDataSource.itemIdentifier(for: indexPath),
let account = viewModel.accounts.first(where: { $0.id == item }) else { return }
viewModel.select(account)
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 92 }
}
private final class AccountSwitchCell: UITableViewCell {
static let reuseID = "AccountSwitchCell"
private let card = UIView()
private let avatarView = UIImageView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let phoneLabel = UILabel()
private let tagLabel = UILabel()
private let checkView = UIImageView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
card.backgroundColor = .white
card.layer.cornerRadius = 12
contentView.addSubview(card)
avatarView.layer.cornerRadius = 25
avatarView.clipsToBounds = true
avatarView.contentMode = .scaleAspectFill
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.text666
phoneLabel.font = .systemFont(ofSize: 12)
phoneLabel.textColor = UIColor(hex: 0x9AA1AA)
tagLabel.font = .systemFont(ofSize: 11, weight: .semibold)
tagLabel.textAlignment = .center
tagLabel.layer.cornerRadius = 4
tagLabel.clipsToBounds = true
card.addSubview(avatarView)
card.addSubview(titleLabel)
card.addSubview(subtitleLabel)
card.addSubview(phoneLabel)
card.addSubview(tagLabel)
card.addSubview(checkView)
card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
}
avatarView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview().inset(14)
make.width.height.equalTo(50)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(avatarView.snp.trailing).offset(13)
make.top.equalTo(avatarView).offset(2)
make.trailing.lessThanOrEqualTo(tagLabel.snp.leading).offset(-8)
}
subtitleLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel)
make.top.equalTo(titleLabel.snp.bottom).offset(4)
}
phoneLabel.snp.makeConstraints { make in
make.leading.equalTo(titleLabel)
make.top.equalTo(subtitleLabel.snp.bottom).offset(4)
}
tagLabel.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(14)
make.top.equalToSuperview().offset(14)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(40)
}
checkView.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview().inset(14)
make.width.height.equalTo(22)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
titleLabel.text = account.title
if isCurrent {
titleLabel.text = (account.title) + " (当前)"
}
subtitleLabel.text = account.subtitle
phoneLabel.text = account.phone
tagLabel.text = account.isStoreUser ? "门店" : "景区"
tagLabel.textColor = account.isStoreUser ? UIColor(hex: 0x0F9F6E) : UIColor(hex: 0x7C3AED)
tagLabel.backgroundColor = account.isStoreUser ? UIColor(hex: 0xE8F8F1) : UIColor(hex: 0xF3ECFF)
checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
checkView.tintColor = selected ? AppColor.primary : UIColor(hex: 0xB6BECA)
avatarView.loadRemoteImage(urlString: account.avatar)
}
}

View File

@ -0,0 +1,135 @@
//
// ProfileInfoRowView.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class ProfileInfoRowView: UIControl {
enum AccessoryStyle {
case none
case chevron
case action(String)
}
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let accessoryLabel = UILabel()
private let chevronView = UIImageView()
private let divider = UIView()
private let badgeContainer = UIView()
private var badgeView: ProfileStatusBadgeView?
var showsDivider = true {
didSet { divider.isHidden = !showsDivider }
}
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(
value: String?,
valueColor: UIColor = AppColor.text333,
accessory: AccessoryStyle = .none,
badge: ProfileStatusBadgeView.Style? = nil
) {
valueLabel.text = value
valueLabel.textColor = valueColor
valueLabel.isHidden = badge != nil
badgeContainer.isHidden = badge == nil
if let badge {
if badgeView == nil {
let view = ProfileStatusBadgeView()
badgeContainer.addSubview(view)
view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
badgeView = view
}
badgeView?.apply(style: badge, text: value ?? "")
}
accessoryLabel.isHidden = true
chevronView.isHidden = true
switch accessory {
case .none:
break
case .chevron:
chevronView.isHidden = false
case .action(let text):
accessoryLabel.text = text
accessoryLabel.isHidden = false
chevronView.isHidden = false
}
}
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 1
accessoryLabel.font = .systemFont(ofSize: 14)
accessoryLabel.textColor = AppColor.primary
chevronView.image = UIImage(systemName: "chevron.right")
chevronView.tintColor = AppColor.text666
chevronView.contentMode = .scaleAspectFit
divider.backgroundColor = UIColor(hex: 0xE5E7EB)
addSubview(titleLabel)
addSubview(valueLabel)
addSubview(badgeContainer)
addSubview(accessoryLabel)
addSubview(chevronView)
addSubview(divider)
snp.makeConstraints { make in
make.height.equalTo(52)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview()
make.centerY.equalToSuperview()
make.width.equalTo(102)
}
chevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview()
make.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
accessoryLabel.snp.makeConstraints { make in
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
make.centerY.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
make.centerY.equalToSuperview()
}
badgeContainer.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
}
divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(0.5)
}
}
}

View File

@ -0,0 +1,62 @@
//
// ProfileStatusBadgeView.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class ProfileStatusBadgeView: UIView {
enum Style {
case accountActive
case scenic
case auditPending
case auditApproved
case auditRejected
case bankPending
case bankApproved
case bankRejected
}
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
label.font = .systemFont(ofSize: 12, weight: .medium)
label.textAlignment = .center
addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
}
layer.cornerRadius = 4
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(style: Style, text: String) {
label.text = text
switch style {
case .accountActive:
label.textColor = UIColor(hex: 0x22C55E)
backgroundColor = UIColor(hex: 0xF0FDF4)
case .scenic:
label.textColor = AppColor.primary
backgroundColor = UIColor(hex: 0xEFF6FF)
case .auditPending, .bankPending:
label.textColor = UIColor(hex: 0xFF7B00)
backgroundColor = UIColor(hex: 0xFFF0E2)
case .auditApproved, .bankApproved:
label.textColor = AppColor.primary
backgroundColor = UIColor(hex: 0xEFF6FF)
case .auditRejected, .bankRejected:
label.textColor = UIColor(hex: 0xEF4444)
backgroundColor = UIColor(hex: 0xFFE7E7)
}
}
}

View File

@ -3,26 +3,430 @@
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
/// Tab
/// Tab
final class ProfileViewController: BaseViewController {
private let titleLabel = UILabel()
private let viewModel = ProfileViewModel()
private let profileAPI = NetworkServices.shared.profileAPI
private let ossUploadService = NetworkServices.shared.ossUploadService
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let refreshControl = UIRefreshControl()
private let avatarImageView = UIImageView()
private let nicknameField = UITextField()
private let uidLabel = UILabel()
private let editButton = AppButton(title: "编辑", style: .primary)
private let infoCard = UIStackView()
private let nameRow = ProfileInfoRowView(title: "姓名")
private let accountRow = ProfileInfoRowView(title: "当前账号")
private let phoneRow = ProfileInfoRowView(title: "手机号")
private let withdrawalRow = ProfileInfoRowView(title: "提现设置")
private let passwordRow = ProfileInfoRowView(title: "修改密码")
private let realNameRow = ProfileInfoRowView(title: "认证状态")
private let statusRow = ProfileInfoRowView(title: "账号状态")
private let scenicRow = ProfileInfoRowView(title: "当前景区")
private let logoutButton = UIButton(type: .system)
override func setupNavigationBar() {
title = "个人信息"
}
override func setupUI() {
title = "我的"
titleLabel.text = "我的"
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
titleLabel.textAlignment = .center
titleLabel.textColor = AppColor.text333
view.addSubview(titleLabel)
view.backgroundColor = UIColor(hex: 0xF7FAFF)
scrollView.alwaysBounceVertical = true
scrollView.refreshControl = refreshControl
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.axis = .vertical
contentStack.spacing = 24
contentStack.layoutMargins = UIEdgeInsets(top: 16, left: 15, bottom: 24, right: 15)
contentStack.isLayoutMarginsRelativeArrangement = true
setupHeader()
setupInfoCard()
setupLogoutButton()
contentStack.addArrangedSubview(headerContainer)
contentStack.addArrangedSubview(infoCardWrapper)
contentStack.addArrangedSubview(logoutButton)
}
override func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalTo(scrollView.snp.width)
}
logoutButton.snp.makeConstraints { make in
make.height.equalTo(46)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
self?.applyViewModel()
}
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
logoutButton.addTarget(self, action: #selector(logoutTapped), for: .touchUpInside)
accountRow.addTarget(self, action: #selector(accountSwitchTapped), for: .touchUpInside)
passwordRow.addTarget(self, action: #selector(changePasswordTapped), for: .touchUpInside)
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged)
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
avatarImageView.isUserInteractionEnabled = true
avatarImageView.addGestureRecognizer(avatarTap)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAccountDidSwitch),
name: NotificationName.accountDidSwitch,
object: nil
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { await reloadProfile(showLoading: false) }
}
private let headerContainer = UIView()
private let infoCardWrapper = UIView()
private func setupHeader() {
avatarImageView.layer.cornerRadius = 43
avatarImageView.clipsToBounds = true
avatarImageView.contentMode = .scaleAspectFill
nicknameField.font = .systemFont(ofSize: 18, weight: .medium)
nicknameField.textColor = AppColor.text333
nicknameField.borderStyle = .none
nicknameField.isEnabled = false
uidLabel.font = .systemFont(ofSize: 14, weight: .semibold)
uidLabel.textColor = UIColor(hex: 0x9CA3AF)
editButton.layer.cornerRadius = 18
editButton.snp.updateConstraints { make in
make.height.equalTo(36)
}
editButton.titleLabel?.font = .systemFont(ofSize: 12)
headerContainer.addSubview(avatarImageView)
headerContainer.addSubview(nicknameField)
headerContainer.addSubview(uidLabel)
headerContainer.addSubview(editButton)
avatarImageView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview()
make.width.height.equalTo(86)
}
nicknameField.snp.makeConstraints { make in
make.leading.equalTo(avatarImageView.snp.trailing).offset(16)
make.trailing.lessThanOrEqualTo(editButton.snp.leading).offset(-8)
make.top.equalTo(avatarImageView).offset(12)
}
uidLabel.snp.makeConstraints { make in
make.leading.equalTo(nicknameField)
make.top.equalTo(nicknameField.snp.bottom).offset(8)
}
editButton.snp.makeConstraints { make in
make.trailing.equalToSuperview()
make.centerY.equalTo(avatarImageView)
make.width.equalTo(80)
make.height.equalTo(36)
}
headerContainer.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(86)
}
}
private func setupInfoCard() {
infoCard.axis = .vertical
infoCard.backgroundColor = .white
infoCard.layer.cornerRadius = 8
infoCard.clipsToBounds = true
infoCard.isLayoutMarginsRelativeArrangement = true
infoCard.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
infoCardWrapper.addSubview(infoCard)
infoCard.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
[
nameRow, accountRow, phoneRow, withdrawalRow,
passwordRow, realNameRow, statusRow, scenicRow
].forEach { infoCard.addArrangedSubview($0) }
nameRow.isUserInteractionEnabled = false
phoneRow.isUserInteractionEnabled = false
statusRow.isUserInteractionEnabled = false
scenicRow.isUserInteractionEnabled = false
scenicRow.showsDivider = false
}
private func setupLogoutButton() {
logoutButton.setTitle("退出登录", for: .normal)
logoutButton.setTitleColor(UIColor(hex: 0xEF4444), for: .normal)
logoutButton.titleLabel?.font = .systemFont(ofSize: 14)
logoutButton.backgroundColor = .white
logoutButton.layer.cornerRadius = 8
}
private func applyViewModel() {
nicknameField.text = viewModel.isEditingProfile ? viewModel.editingNickname : viewModel.displayNickname
nicknameField.isEnabled = viewModel.isEditingProfile
uidLabel.text = "UID:\(viewModel.displayUID)"
if let pendingData = viewModel.pendingAvatarData, let image = UIImage(data: pendingData) {
avatarImageView.image = image
} else {
avatarImageView.loadRemoteImage(urlString: viewModel.displayAvatarURL)
}
editButton.setTitle(viewModel.isEditingProfile ? "完成" : "编辑", for: .normal)
editButton.isEnabled = !viewModel.isSaving
avatarImageView.alpha = viewModel.isEditingProfile ? 1 : 1
nameRow.configure(value: viewModel.displayRealName)
accountRow.configure(
value: viewModel.accountDisplayName,
accessory: .action("切换账号")
)
phoneRow.configure(value: viewModel.displayPhone)
withdrawalRow.isHidden = !viewModel.showPhotographerFields
realNameRow.isHidden = !viewModel.showPhotographerFields
if viewModel.showPhotographerFields {
configureWithdrawalRow()
configureRealNameRow()
}
passwordRow.configure(value: "点击修改密码", valueColor: UIColor(hex: 0x9CA3AF), accessory: .chevron)
statusRow.configure(
value: viewModel.accountStatusText,
badge: viewModel.accountStatusText == "--" ? nil : .accountActive
)
scenicRow.configure(
value: viewModel.currentScenicName,
badge: .scenic
)
if viewModel.isLoading, !refreshControl.isRefreshing {
showLoading()
} else {
hideLoading()
}
}
private func configureWithdrawalRow() {
let info = viewModel.bankCardInfo
let text = viewModel.bankCardDisplayText
var valueColor = UIColor(hex: 0x9CA3AF)
var badge: ProfileStatusBadgeView.Style?
if let info {
switch info.auditStatus {
case 2:
valueColor = AppColor.text333
case 3:
valueColor = UIColor(hex: 0xEF4444)
badge = .bankRejected
default:
valueColor = UIColor(hex: 0xFF7B00)
badge = .bankPending
}
}
withdrawalRow.configure(
value: text,
valueColor: valueColor,
accessory: .chevron,
badge: badge
)
_ = info
}
private func configureRealNameRow() {
if let info = viewModel.realNameInfo {
let style: ProfileStatusBadgeView.Style
switch info.auditStatus {
case 2: style = .auditApproved
case 3: style = .auditRejected
default: style = .auditPending
}
realNameRow.configure(
value: viewModel.realNameStatusText,
accessory: .chevron,
badge: style
)
} else {
realNameRow.configure(
value: "点击去实名认证",
valueColor: UIColor(hex: 0x9CA3AF),
accessory: .chevron
)
}
}
@objc private func refreshPulled() {
Task {
await reloadProfile(showLoading: false)
refreshControl.endRefreshing()
}
}
@objc private func handleAccountDidSwitch() {
Task { await reloadProfile(showLoading: true) }
}
@objc private func nicknameChanged() {
viewModel.updateEditingNickname(nicknameField.text ?? "")
}
@objc private func editTapped() {
if viewModel.isEditingProfile {
Task { await saveProfile() }
} else {
viewModel.beginEditing()
}
}
@objc private func pickAvatar() {
guard viewModel.isEditingProfile else { return }
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 accountSwitchTapped() {
navigationController?.pushViewController(AccountSwitchViewController(), animated: true)
}
@objc private func changePasswordTapped() {
let alert = UIAlertController(title: "修改密码", message: "请输入新密码(至少 6 位)", preferredStyle: .alert)
alert.addTextField { field in
field.isSecureTextEntry = true
field.placeholder = "新密码"
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
guard let password = alert.textFields?.first?.text else { return }
Task { await self?.updatePassword(password) }
})
present(alert, animated: true)
}
@objc private func withdrawalTapped() {
if let bankCard = viewModel.bankCardInfo, bankCard.auditStatus != 0 {
navigationController?.pushViewController(
WithdrawalSettingsAuditViewController(bankCard: bankCard),
animated: true
)
return
}
if viewModel.realNameInfo?.verified != true {
showToast("请实名认证成功后再试")
return
}
navigationController?.pushViewController(WithdrawalSettingsViewController(), animated: true)
}
@objc private func realNameTapped() {
if viewModel.realNameInfo == nil {
navigationController?.pushViewController(RealNameAuthViewController(), animated: true)
} else if let info = viewModel.realNameInfo {
navigationController?.pushViewController(
RealNameAuthAuditViewController(info: info),
animated: true
)
}
}
@objc private func logoutTapped() {
let alert = UIAlertController(title: "确定退出登录?", message: "退出后将需要重新登录", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "确定", style: .destructive) { _ in
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
})
present(alert, animated: true)
}
private func reloadProfile(showLoading: Bool) async {
if showLoading { self.showLoading() }
defer {
if showLoading { hideLoading() }
}
do {
try await viewModel.reload(api: profileAPI)
} catch {
showToast(error.localizedDescription)
}
}
private func saveProfile() async {
let scenicId = AppStore.shared.currentScenicId
guard scenicId > 0 else {
showToast("当前景区信息缺失")
return
}
showLoading()
defer { hideLoading() }
do {
try await viewModel.saveProfile(
api: profileAPI,
uploader: ossUploadService,
scenicId: scenicId
)
showToast("保存成功")
} catch {
showToast(error.localizedDescription)
}
}
private func updatePassword(_ password: String) async {
showLoading()
defer { hideLoading() }
do {
try await viewModel.updatePassword(password, api: profileAPI)
showToast("密码修改成功")
} catch {
showToast(error.localizedDescription)
}
}
}
extension ProfileViewController: 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.prepareAvatarImage(data: data)
} catch {
self.showToast(error.localizedDescription)
}
}
}
}
}

View File

@ -0,0 +1,113 @@
//
// RealNameAuthAuditViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class RealNameAuthAuditViewController: BaseViewController {
private let info: RealNameInfo
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let statusBadge = ProfileStatusBadgeView()
private let rejectLabel = UILabel()
private let frontImageView = UIImageView()
private let backImageView = UIImageView()
init(info: RealNameInfo) {
self.info = info
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "认证状态"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
contentStack.axis = .vertical
contentStack.spacing = 16
rejectLabel.numberOfLines = 0
rejectLabel.font = .systemFont(ofSize: 14)
rejectLabel.textColor = UIColor(hex: 0xEF4444)
[frontImageView, backImageView].forEach {
$0.contentMode = .scaleAspectFit
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
$0.layer.cornerRadius = 8
$0.clipsToBounds = true
$0.snp.makeConstraints { make in make.height.equalTo(160) }
}
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(makeTitle("审核状态"))
contentStack.addArrangedSubview(statusBadge)
if let reason = info.rejectReason, !reason.isEmpty, info.auditStatus == 3 {
rejectLabel.text = "驳回原因:\(reason)"
contentStack.addArrangedSubview(rejectLabel)
}
contentStack.addArrangedSubview(makeInfoRow("姓名", info.realName))
contentStack.addArrangedSubview(makeInfoRow("身份证号", maskID(info.idCardNo)))
contentStack.addArrangedSubview(makeTitle("身份证国徽面"))
contentStack.addArrangedSubview(backImageView)
contentStack.addArrangedSubview(makeTitle("身份证人像面"))
contentStack.addArrangedSubview(frontImageView)
applyStatus()
frontImageView.loadRemoteImage(urlString: info.frontUrl)
backImageView.loadRemoteImage(urlString: info.backUrl)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
}
private func applyStatus() {
switch info.auditStatus {
case 2:
statusBadge.apply(style: .auditApproved, text: "已实名认证")
case 3:
statusBadge.apply(style: .auditRejected, text: "审核不通过")
default:
statusBadge.apply(style: .auditPending, text: info.auditStatusText ?? "审核中")
}
}
private func makeTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 15, weight: .semibold)
return label
}
private func makeInfoRow(_ title: String, _ value: String) -> UILabel {
let label = UILabel()
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.text666
label.text = "\(title)\(value)"
return label
}
private func maskID(_ value: String) -> String {
guard value.count > 8 else { return value }
let prefix = value.prefix(4)
let suffix = value.suffix(4)
return "\(prefix)**********\(suffix)"
}
}

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

View File

@ -0,0 +1,109 @@
//
// WithdrawalSettingsAuditViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class WithdrawalSettingsAuditViewController: BaseViewController {
private let bankCard: BankCardInfo
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let statusBadge = ProfileStatusBadgeView()
private let rejectLabel = UILabel()
private let frontImageView = UIImageView()
private let backImageView = UIImageView()
init(bankCard: BankCardInfo) {
self.bankCard = bankCard
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "提现设置"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
contentStack.axis = .vertical
contentStack.spacing = 16
rejectLabel.numberOfLines = 0
rejectLabel.font = .systemFont(ofSize: 14)
rejectLabel.textColor = UIColor(hex: 0xEF4444)
[frontImageView, backImageView].forEach {
$0.contentMode = .scaleAspectFit
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
$0.layer.cornerRadius = 8
$0.clipsToBounds = true
$0.snp.makeConstraints { make in make.height.equalTo(140) }
}
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(makeTitle("审核状态"))
contentStack.addArrangedSubview(statusBadge)
if !bankCard.rejectReason.isEmpty, bankCard.auditStatus == 3 {
rejectLabel.text = "驳回原因:\(bankCard.rejectReason)"
contentStack.addArrangedSubview(rejectLabel)
}
contentStack.addArrangedSubview(makeInfoRow("持卡人", bankCard.realName))
contentStack.addArrangedSubview(makeInfoRow("开户银行", bankCard.bankName))
contentStack.addArrangedSubview(makeInfoRow("支行", bankCard.branchName))
contentStack.addArrangedSubview(makeInfoRow("卡号", ProfileViewModel.maskCardNumber(bankCard.cardNumber)))
contentStack.addArrangedSubview(makeTitle("银行卡正面"))
contentStack.addArrangedSubview(frontImageView)
contentStack.addArrangedSubview(makeTitle("银行卡反面"))
contentStack.addArrangedSubview(backImageView)
applyStatus()
frontImageView.loadRemoteImage(urlString: bankCard.frontUrl)
backImageView.loadRemoteImage(urlString: bankCard.backUrl)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
}
private func applyStatus() {
let text = bankCard.auditStatusLabel.isEmpty ? "审核中" : bankCard.auditStatusLabel
switch bankCard.auditStatus {
case 2:
statusBadge.apply(style: .bankApproved, text: text)
case 3:
statusBadge.apply(style: .bankRejected, text: text)
default:
statusBadge.apply(style: .bankPending, text: text)
}
}
private func makeTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 15, weight: .semibold)
return label
}
private func makeInfoRow(_ title: String, _ value: String) -> UILabel {
let label = UILabel()
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.text666
label.text = "\(title)\(value)"
return label
}
}

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