实现我的 Tab、对齐 Android 流程并接入 OSS 上传

This commit is contained in:
2026-07-06 16:01:05 +08:00
parent caf737b09b
commit b7bc62e94b
36 changed files with 4077 additions and 57 deletions

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