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>
433 lines
16 KiB
Swift
433 lines
16 KiB
Swift
//
|
||
// ProfileViewController.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import PhotosUI
|
||
import SnapKit
|
||
import UIKit
|
||
|
||
/// 我的 Tab 根页面,展示个人信息、账号状态与子流程入口。
|
||
final class ProfileViewController: BaseViewController {
|
||
|
||
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() {
|
||
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() {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|