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:
249
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
249
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
@ -0,0 +1,249 @@
|
||||
//
|
||||
// ProfileViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
||||
final class ProfileViewModel {
|
||||
|
||||
private(set) var userInfo: UserInfoResponse?
|
||||
private(set) var realNameInfo: RealNameInfo?
|
||||
private(set) var bankCardInfo: BankCardInfo?
|
||||
private(set) var isLoading = false
|
||||
private(set) var isSaving = false
|
||||
private(set) var isEditingProfile = false
|
||||
private(set) var editingNickname = ""
|
||||
private(set) var pendingAvatarData: Data?
|
||||
private(set) var pendingAvatarFileName: String?
|
||||
private(set) var avatarUploadProgress: Int?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
}
|
||||
|
||||
var displayRealName: String {
|
||||
nonEmpty(userInfo?.realName) ?? "--"
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
nonEmpty(userInfo?.phone) ?? AppStore.shared.phone.nonEmpty ?? "--"
|
||||
}
|
||||
|
||||
var displayAvatarURL: String {
|
||||
nonEmpty(userInfo?.avatar) ?? AppStore.shared.avatar.nonEmpty ?? ""
|
||||
}
|
||||
|
||||
var displayUID: String {
|
||||
let uid = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return uid.isEmpty ? "--" : uid
|
||||
}
|
||||
|
||||
var accountDisplayName: String {
|
||||
let name = AppStore.shared.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "--" : name
|
||||
}
|
||||
|
||||
var currentScenicName: String {
|
||||
let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "--" : name
|
||||
}
|
||||
|
||||
var accountStatusText: String {
|
||||
nonEmpty(userInfo?.statusName) ?? "--"
|
||||
}
|
||||
|
||||
var showPhotographerFields: Bool {
|
||||
if AppStore.shared.isPhotographerRole {
|
||||
return true
|
||||
}
|
||||
if let roleName = nonEmpty(userInfo?.roleName) {
|
||||
return AppRoleCode.fromRoleName(roleName)?.isPhotographer == true || roleName == "摄影师"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var realNameStatusText: String {
|
||||
guard let realNameInfo else { return "点击去实名认证" }
|
||||
switch realNameInfo.auditStatus {
|
||||
case 2: return "已实名认证"
|
||||
case 3: return "审核不通过"
|
||||
default: return "审核中"
|
||||
}
|
||||
}
|
||||
|
||||
var bankCardDisplayText: String {
|
||||
guard let bankCardInfo else { return "点击绑定银行卡" }
|
||||
if bankCardInfo.auditStatus == 2 {
|
||||
let masked = Self.maskCardNumber(bankCardInfo.cardNumber)
|
||||
return "\(bankCardInfo.bankName) \(masked)"
|
||||
}
|
||||
let label = bankCardInfo.auditStatusLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return label.isEmpty ? "审核中" : label
|
||||
}
|
||||
|
||||
func updateEditingNickname(_ value: String) {
|
||||
editingNickname = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 重新加载用户资料,摄影师额外拉取实名与银行卡信息。
|
||||
func reload(api: ProfileAPI) async throws {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let info = try await api.userInfo()
|
||||
userInfo = info
|
||||
AppStore.shared.applyUserInfo(info)
|
||||
if !info.roleName.isEmpty {
|
||||
AppStore.shared.roleName = info.roleName
|
||||
}
|
||||
|
||||
if showPhotographerFields {
|
||||
async let realName = api.realNameInfo()
|
||||
async let bankCard = api.bankCardInfo()
|
||||
let extra = try await (realName, bankCard)
|
||||
realNameInfo = extra.0.realNameInfo
|
||||
bankCardInfo = extra.1.bankCard
|
||||
} else {
|
||||
realNameInfo = nil
|
||||
bankCardInfo = nil
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func beginEditing() {
|
||||
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
|
||||
isEditingProfile = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
editingNickname = ""
|
||||
isEditingProfile = false
|
||||
clearPendingAvatar()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
|
||||
pendingAvatarData = processed.data
|
||||
pendingAvatarFileName = processed.fileName
|
||||
if !isEditingProfile {
|
||||
beginEditing()
|
||||
} else {
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !nextNickname.isEmpty else {
|
||||
throw ProfileValidationError.emptyNickname
|
||||
}
|
||||
|
||||
let nicknameChanged = nextNickname != displayNickname
|
||||
let avatarChanged = pendingAvatarData != nil
|
||||
guard nicknameChanged || avatarChanged else {
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
avatarUploadProgress = avatarChanged ? 1 : nil
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSaving = false
|
||||
avatarUploadProgress = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
|
||||
if let uploadedAvatarURL {
|
||||
try await api.updateUserAvatarURL(uploadedAvatarURL)
|
||||
}
|
||||
if nicknameChanged {
|
||||
try await api.updateUserInfo(nickname: nextNickname)
|
||||
}
|
||||
|
||||
let previous = userInfo ?? UserInfoResponse()
|
||||
userInfo = UserInfoResponse(
|
||||
avatar: uploadedAvatarURL ?? previous.avatar,
|
||||
realName: previous.realName,
|
||||
phone: previous.phone,
|
||||
nickname: nextNickname,
|
||||
roleName: previous.roleName,
|
||||
status: previous.status,
|
||||
statusName: previous.statusName
|
||||
)
|
||||
AppStore.shared.userName = nextNickname
|
||||
if let uploadedAvatarURL {
|
||||
AppStore.shared.avatar = uploadedAvatarURL
|
||||
}
|
||||
clearPendingAvatar()
|
||||
cancelEditing()
|
||||
}
|
||||
|
||||
func updatePassword(_ password: String, api: ProfileAPI) async throws {
|
||||
let nextPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard nextPassword.count >= 6 else {
|
||||
throw ProfileValidationError.shortPassword
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSaving = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
try await api.updateUserInfo(password: nextPassword)
|
||||
}
|
||||
|
||||
static func maskCardNumber(_ cardNumber: String) -> String {
|
||||
let digits = cardNumber.filter(\.isNumber)
|
||||
guard digits.count >= 4 else { return cardNumber }
|
||||
return "**** **** **** \(digits.suffix(4))"
|
||||
}
|
||||
|
||||
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
|
||||
guard let pendingAvatarData else { return nil }
|
||||
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
|
||||
self?.avatarUploadProgress = progress
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func clearPendingAvatar() {
|
||||
pendingAvatarData = nil
|
||||
pendingAvatarFileName = nil
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user