Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,263 @@
|
||||
//
|
||||
// AccountSwitchViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||||
final class AccountSwitchViewController: UIViewController {
|
||||
|
||||
private let viewModel = AccountSwitchViewModel()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .plain)
|
||||
table.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
table.separatorStyle = .none
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var confirmButton: UIButton = {
|
||||
let button = makePrimaryButton(title: "确认切换")
|
||||
button.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "账号切换"
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(confirmButton)
|
||||
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)
|
||||
make.height.equalTo(50)
|
||||
}
|
||||
|
||||
viewModel.onChange = { [weak self] in
|
||||
self?.tableView.reloadData()
|
||||
self?.updateConfirmButton()
|
||||
}
|
||||
Task { await loadAccounts() }
|
||||
}
|
||||
|
||||
private func updateConfirmButton() {
|
||||
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
||||
confirmButton.isEnabled = enabled
|
||||
confirmButton.alpha = enabled ? 1 : 0.5
|
||||
}
|
||||
|
||||
private func loadAccounts(force: Bool = false) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") {
|
||||
try await self.viewModel.load(
|
||||
api: self.appServices.profileAPI,
|
||||
force: force,
|
||||
currentAccountId: self.currentAccountId
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentAccountId: String? {
|
||||
guard let current = appServices.accountContext.profile else { return nil }
|
||||
if let store = appServices.accountContext.currentStore {
|
||||
return "\(V9StoreUser.accountTypeValue)_\(store.id)"
|
||||
}
|
||||
if let scenic = appServices.accountContext.currentScenic {
|
||||
return "\(V9ScenicUser.accountTypeValue)_\(scenic.id)"
|
||||
}
|
||||
return current.userId.isEmpty ? nil : current.userId
|
||||
}
|
||||
|
||||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||||
viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
||||
}
|
||||
|
||||
@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 {
|
||||
do {
|
||||
let response = try await appServices.globalLoading.withLoading(message: "切换中...") {
|
||||
try await self.viewModel.switchAccount(account, api: self.appServices.authAPI)
|
||||
}
|
||||
let username = nonEmpty(appServices.accountContext.profile?.phone)
|
||||
?? nonEmpty(account.phone)
|
||||
?? ""
|
||||
try await appServices.authSessionCoordinator.completeLogin(
|
||||
with: response,
|
||||
username: username,
|
||||
privacyAgreementAccepted: appServices.authSessionCoordinator.loginPreferences().privacyAgreementAccepted,
|
||||
appSession: appServices.appSession,
|
||||
accountContext: appServices.accountContext,
|
||||
permissionContext: appServices.permissionContext,
|
||||
profileAPI: appServices.profileAPI,
|
||||
accountContextAPI: appServices.accountContextAPI
|
||||
)
|
||||
appServices.appRouter.reset()
|
||||
showToast("账号已切换")
|
||||
navigationController?.popToRootViewController(animated: true)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
extension AccountSwitchViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
viewModel.accounts.isEmpty && !viewModel.loading ? 1 : viewModel.accounts.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard !viewModel.accounts.isEmpty else {
|
||||
let cell = UITableViewCell()
|
||||
cell.selectionStyle = .none
|
||||
cell.backgroundColor = .clear
|
||||
cell.contentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
let empty = makeEmptyStateView(
|
||||
title: "暂无可切换账号",
|
||||
message: "当前登录账号下没有其他可切换账号。",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark"
|
||||
)
|
||||
cell.contentView.addSubview(empty)
|
||||
empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) }
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
|
||||
let account = viewModel.accounts[indexPath.row]
|
||||
cell.configure(
|
||||
account: account,
|
||||
selected: viewModel.selectedAccountId == account.id,
|
||||
isCurrent: account.isCurrent || isCurrentAccount(account)
|
||||
)
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.row < viewModel.accounts.count else { return }
|
||||
viewModel.select(viewModel.accounts[indexPath.row])
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
viewModel.accounts.isEmpty ? 280 : 92
|
||||
}
|
||||
}
|
||||
|
||||
private final class AccountSwitchCell: UITableViewCell {
|
||||
static let reuseID = "AccountSwitchCell"
|
||||
|
||||
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
|
||||
|
||||
let card = UIView()
|
||||
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 = AppDesignUIKit.textSecondary
|
||||
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.equalToSuperview().inset(14)
|
||||
make.bottom.equalToSuperview().inset(14)
|
||||
make.width.height.equalTo(22)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
|
||||
let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
titleLabel.text = title.isEmpty ? account.accountTypeLabel : 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 ? AppDesignUIKit.primary : UIColor(hex: 0xB6BECA)
|
||||
avatarView.loadRemoteAvatar(urlString: account.avatar)
|
||||
if isCurrent {
|
||||
titleLabel.text = (titleLabel.text ?? "") + " (当前)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,278 @@
|
||||
//
|
||||
// ProfileViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 个人信息页,展示用户资料、账号状态和设置入口。
|
||||
final class ProfileViewController: UIViewController {
|
||||
|
||||
private let viewModel = ProfileViewModel()
|
||||
|
||||
private let avatarImageView = UIImageView()
|
||||
private let nicknameLabel = UILabel()
|
||||
private let uidLabel = UILabel()
|
||||
private let editButton = UIButton(type: .system)
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private lazy var refreshControl = UIRefreshControl()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "个人信息"
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
setupHeader()
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(150)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
tableView.refreshControl = refreshControl
|
||||
|
||||
viewModel.onChange = { [weak self] in self?.applyViewModel() }
|
||||
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
|
||||
|
||||
let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar))
|
||||
avatarImageView.isUserInteractionEnabled = true
|
||||
avatarImageView.addGestureRecognizer(avatarTap)
|
||||
|
||||
Task { await reloadProfile(showToast: false) }
|
||||
}
|
||||
|
||||
private func setupHeader() {
|
||||
let header = UIView()
|
||||
header.backgroundColor = .white
|
||||
header.layer.cornerRadius = 17
|
||||
view.addSubview(header)
|
||||
|
||||
avatarImageView.layer.cornerRadius = 43
|
||||
avatarImageView.clipsToBounds = true
|
||||
avatarImageView.contentMode = .scaleAspectFill
|
||||
avatarImageView.backgroundColor = AppDesignUIKit.primarySoft
|
||||
|
||||
nicknameLabel.font = .systemFont(ofSize: 24, weight: .semibold)
|
||||
nicknameLabel.textColor = UIColor(hex: 0x252525)
|
||||
uidLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||
uidLabel.textColor = UIColor(hex: 0x606A7A)
|
||||
|
||||
editButton.setImage(UIImage(systemName: "pencil"), for: .normal)
|
||||
editButton.tintColor = .white
|
||||
editButton.backgroundColor = AppDesignUIKit.primary
|
||||
editButton.layer.cornerRadius = 22
|
||||
|
||||
header.addSubview(avatarImageView)
|
||||
header.addSubview(nicknameLabel)
|
||||
header.addSubview(uidLabel)
|
||||
header.addSubview(editButton)
|
||||
|
||||
header.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(19)
|
||||
make.height.equalTo(132)
|
||||
}
|
||||
avatarImageView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(86)
|
||||
}
|
||||
nicknameLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(avatarImageView.snp.trailing).offset(14)
|
||||
make.trailing.lessThanOrEqualTo(editButton.snp.leading).offset(-8)
|
||||
make.top.equalTo(avatarImageView).offset(8)
|
||||
}
|
||||
uidLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(nicknameLabel)
|
||||
make.top.equalTo(nicknameLabel.snp.bottom).offset(8)
|
||||
}
|
||||
editButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(18)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(44)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
nicknameLabel.text = viewModel.displayNickname
|
||||
uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")"
|
||||
if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) {
|
||||
avatarImageView.image = image
|
||||
} else {
|
||||
avatarImageView.loadRemoteAvatar(urlString: viewModel.displayAvatarURL)
|
||||
}
|
||||
editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal)
|
||||
editButton.isEnabled = !viewModel.isSaving
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await reloadProfile(showToast: true)
|
||||
refreshControl.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadProfile(showToast: Bool) async {
|
||||
do {
|
||||
try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") {
|
||||
try await self.viewModel.reload(api: self.appServices.profileAPI)
|
||||
}
|
||||
} catch {
|
||||
if showToast { self.showToast(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func editTapped() {
|
||||
if viewModel.isEditingProfile {
|
||||
Task { await saveProfileEdits() }
|
||||
} else {
|
||||
viewModel.beginEditing()
|
||||
presentNicknameEditor()
|
||||
}
|
||||
}
|
||||
|
||||
private func presentNicknameEditor() {
|
||||
let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { [weak self] field in
|
||||
field.text = self?.viewModel.editingNickname
|
||||
field.placeholder = "请输入昵称"
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.cancelEditing()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.viewModel.editingNickname = alert.textFields?.first?.text ?? ""
|
||||
Task { await self.saveProfileEdits() }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func saveProfileEdits() async {
|
||||
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
||||
showToast("请先选择景区")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "保存中...") {
|
||||
try await self.viewModel.saveProfile(
|
||||
api: self.appServices.profileAPI,
|
||||
uploader: self.appServices.ossUploadService,
|
||||
scenicId: scenicId
|
||||
)
|
||||
}
|
||||
showToast("资料已更新")
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pickAvatar() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
config.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
@objc private func logoutTapped() {
|
||||
let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.appServices.authSessionCoordinator.logout(
|
||||
appSession: self.appServices.appSession,
|
||||
accountContext: self.appServices.accountContext,
|
||||
permissionContext: self.appServices.permissionContext,
|
||||
scenicSpotContext: self.appServices.scenicSpotContext,
|
||||
appRouter: self.appServices.appRouter,
|
||||
toastCenter: self.appServices.toastCenter
|
||||
)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func numberOfSections(in tableView: UITableView) -> Int { 2 }
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 5 : 1
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
if indexPath.section == 1 {
|
||||
cell.textLabel?.text = "退出登录"
|
||||
cell.textLabel?.textColor = UIColor(hex: 0xEF4444)
|
||||
cell.textLabel?.textAlignment = .center
|
||||
return cell
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
cell.textLabel?.text = "姓名"
|
||||
cell.detailTextLabel?.text = viewModel.displayRealName
|
||||
case 1:
|
||||
cell.textLabel?.text = "当前账号"
|
||||
cell.detailTextLabel?.text = appServices.accountContext.profile?.displayName ?? "--"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
case 2:
|
||||
cell.textLabel?.text = "手机号"
|
||||
cell.detailTextLabel?.text = viewModel.displayPhone
|
||||
case 3:
|
||||
cell.textLabel?.text = "实名认证"
|
||||
cell.detailTextLabel?.text = viewModel.realNameStatusText
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
default:
|
||||
cell.textLabel?.text = "设置"
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
cell.selectionStyle = indexPath.row == 0 || indexPath.row == 2 ? .none : .default
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
if indexPath.section == 1 {
|
||||
logoutTapped()
|
||||
return
|
||||
}
|
||||
switch indexPath.row {
|
||||
case 1:
|
||||
HomeMenuRouting.pushProfile(.accountSwitch, from: self)
|
||||
case 3:
|
||||
HomeMenuRouting.pushProfile(.realNameAuth, from: self)
|
||||
case 4:
|
||||
HomeMenuRouting.pushProfile(.settings, from: self)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
try self.viewModel.prepareAvatarImage(data: data)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
//
|
||||
// RealNameAuthViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 实名认证页面,展示审核状态并允许提交基础实名资料。
|
||||
final class RealNameAuthViewController: UIViewController {
|
||||
|
||||
private let viewModel = RealNameAuthViewModel()
|
||||
|
||||
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 statusLabel = UILabel()
|
||||
private let longValidSwitch = UISwitch()
|
||||
private let startDatePicker = UIDatePicker()
|
||||
private let endDatePicker = UIDatePicker()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "实名认证"
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
setupForm()
|
||||
viewModel.onChange = { [weak self] in self?.applyViewModel() }
|
||||
Task { await loadInfo() }
|
||||
}
|
||||
|
||||
private func setupForm() {
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
|
||||
[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)
|
||||
|
||||
let submitButton = makePrimaryButton(title: "下一步")
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.font = .systemFont(ofSize: 13)
|
||||
statusLabel.textColor = AppDesignUIKit.textSecondary
|
||||
|
||||
contentStack.addArrangedSubview(makeSectionTitle("审核状态"))
|
||||
contentStack.addArrangedSubview(statusLabel)
|
||||
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
|
||||
longValidSwitch.addTarget(self, action: #selector(longValidChanged), for: .valueChanged)
|
||||
contentStack.addArrangedSubview(longValidRow)
|
||||
contentStack.addArrangedSubview(startDatePicker)
|
||||
contentStack.addArrangedSubview(endDatePicker)
|
||||
|
||||
let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton])
|
||||
smsRow.axis = .horizontal
|
||||
smsRow.spacing = 8
|
||||
smsCodeField.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
contentStack.addArrangedSubview(smsRow)
|
||||
contentStack.addArrangedSubview(submitButton)
|
||||
}
|
||||
|
||||
private func makeSectionTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
return label
|
||||
}
|
||||
|
||||
private func labeledField(_ title: String, _ field: UITextField) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
let stack = UIStackView(arrangedSubviews: [label, field])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
field.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
return stack
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if let data = viewModel.pendingFrontImageData, let image = UIImage(data: data) {
|
||||
frontImageView.image = image
|
||||
} else if !viewModel.frontUrl.isEmpty {
|
||||
frontImageView.loadRemoteImage(urlString: viewModel.frontUrl, contentMode: .scaleAspectFit)
|
||||
}
|
||||
|
||||
if let data = viewModel.pendingBackImageData, let image = UIImage(data: data) {
|
||||
backImageView.image = image
|
||||
} else if !viewModel.backUrl.isEmpty {
|
||||
backImageView.image = nil
|
||||
backImageView.loadRemoteImage(urlString: viewModel.backUrl, contentMode: .scaleAspectFit)
|
||||
}
|
||||
|
||||
if let info = viewModel.info {
|
||||
statusLabel.text = """
|
||||
审核状态:\(viewModel.auditStatusText(info.auditStatus))
|
||||
审核人:\(info.auditor?.name ?? "--")
|
||||
审核时间:\(info.auditAt ?? "--")
|
||||
审核备注:\(info.rejectReason ?? "--")
|
||||
"""
|
||||
} else {
|
||||
statusLabel.text = "尚未提交实名认证"
|
||||
}
|
||||
|
||||
if let message = viewModel.statusMessage {
|
||||
showToast(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadInfo() async {
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "加载中...") {
|
||||
try await self.viewModel.load(api: self.appServices.profileAPI)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func longValidChanged() {
|
||||
viewModel.isLongValid = longValidSwitch.isOn
|
||||
}
|
||||
|
||||
@objc private func sendCodeTapped() {
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.sendCode(api: appServices.profileAPI)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
viewModel.realName = realNameField.text ?? ""
|
||||
viewModel.idCardNo = idCardField.text ?? ""
|
||||
viewModel.smsCode = smsCodeField.text ?? ""
|
||||
viewModel.startDate = startDatePicker.date
|
||||
viewModel.endDate = endDatePicker.date
|
||||
|
||||
guard let scenicId = appServices.accountContext.currentScenic?.id else {
|
||||
showToast("请先选择景区")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
try await appServices.globalLoading.withLoading(message: "提交中...") {
|
||||
try await self.viewModel.submit(
|
||||
api: self.appServices.profileAPI,
|
||||
uploader: self.appServices.ossUploadService,
|
||||
scenicId: scenicId
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingSide: RealNameImageSide = .front
|
||||
|
||||
@objc private func pickFront() {
|
||||
pendingSide = .front
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
@objc private func pickBack() {
|
||||
pendingSide = .back
|
||||
presentImagePicker()
|
||||
}
|
||||
|
||||
private func presentImagePicker() {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
config.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
DispatchQueue.main.async {
|
||||
do {
|
||||
try self.viewModel.prepareIdentityImage(data: data, side: self.pendingSide)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UILabel {
|
||||
convenience init(text: String) {
|
||||
self.init()
|
||||
self.text = text
|
||||
self.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
//
|
||||
// SettingsViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// 设置中心页面。
|
||||
final class SettingsViewController: UIViewController {
|
||||
|
||||
private var copiedDownloadLink = false
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .insetGrouped)
|
||||
table.dataSource = self
|
||||
table.delegate = self
|
||||
return table
|
||||
}()
|
||||
|
||||
private let rows: [(title: String, action: SettingsRowAction)] = [
|
||||
("关于我们", .agreement(.about)),
|
||||
("系统版本", .version),
|
||||
("App下载", .download),
|
||||
("用户协议", .agreement(.userAgreement)),
|
||||
("隐私政策", .agreement(.privacyPolicy))
|
||||
]
|
||||
|
||||
private enum SettingsRowAction {
|
||||
case agreement(AgreementPage)
|
||||
case version
|
||||
case download
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "设置"
|
||||
view.addSubview(tableView)
|
||||
tableView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
private var downloadLink: String {
|
||||
APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString
|
||||
}
|
||||
|
||||
private func copyDownloadLink() {
|
||||
UIPasteboard.general.string = downloadLink
|
||||
copiedDownloadLink = true
|
||||
showToast("下载链接已复制")
|
||||
tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
self?.copiedDownloadLink = false
|
||||
self?.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsViewController: UITableViewDataSource, UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count }
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .value1, reuseIdentifier: nil)
|
||||
let row = rows[indexPath.row]
|
||||
cell.textLabel?.text = row.title
|
||||
cell.textLabel?.textColor = UIColor(hex: 0x4B5563)
|
||||
switch row.action {
|
||||
case .version:
|
||||
cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText()
|
||||
cell.selectionStyle = .none
|
||||
case .download:
|
||||
cell.detailTextLabel?.text = copiedDownloadLink ? "已复制" : "复制链接"
|
||||
cell.detailTextLabel?.textColor = AppDesignUIKit.primary
|
||||
case .agreement:
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
switch rows[indexPath.row].action {
|
||||
case .agreement(let page):
|
||||
HomeMenuRouting.pushProfile(.agreement(page), from: self)
|
||||
case .download:
|
||||
copyDownloadLink()
|
||||
case .version:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
|
||||
"Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||
}
|
||||
}
|
||||
|
||||
/// 协议 H5 页面。
|
||||
final class AgreementViewController: UIViewController {
|
||||
|
||||
private let page: AgreementPage
|
||||
private let webView = WKWebView(frame: .zero)
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
init(page: AgreementPage) {
|
||||
self.page = page
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = page.title
|
||||
view.backgroundColor = AppDesignUIKit.pageBackground
|
||||
webView.navigationDelegate = self
|
||||
view.addSubview(webView)
|
||||
view.addSubview(loadingIndicator)
|
||||
webView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
loadingIndicator.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||||
loadingIndicator.startAnimating()
|
||||
webView.load(URLRequest(url: page.url))
|
||||
}
|
||||
}
|
||||
|
||||
extension AgreementViewController: WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
loadingIndicator.stopAnimating()
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user