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:
2026-07-06 16:01:05 +08:00
parent 02a80764ea
commit 005cac3f78
36 changed files with 4077 additions and 57 deletions

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