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