实现我的 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

@ -5,14 +5,15 @@
import Foundation
/// Token 广
/// Token广
enum AuthSessionHelper {
/// Token SceneDelegate
/// Token SceneDelegate
static func completeLogin(
with response: V9AuthResponse,
username: String,
privacyAgreementAccepted: Bool
privacyAgreementAccepted: Bool,
selectedAccount: AccountSwitchAccount
) {
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { return }
@ -20,7 +21,98 @@ enum AuthSessionHelper {
AppStore.shared.saveToken(token)
AppStore.shared.lastLoginUsername = username
AppStore.shared.privacyAgreementAccepted = privacyAgreementAccepted
applyAccountSession(from: selectedAccount, in: response)
NotificationCenter.default.post(name: NotificationName.userDidLogin, object: nil)
}
///
static func applyAccountSwitch(
with response: V9AuthResponse,
account: AccountSwitchAccount
) {
let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { return }
AppStore.shared.saveToken(token)
applyAccountSession(from: account, in: response)
NotificationCenter.default.post(name: NotificationName.accountDidSwitch, object: nil)
}
/// v9 AppStore
static func applyAccountSession(from account: AccountSwitchAccount, in response: V9AuthResponse) {
AppStore.shared.applyAccount(account)
if let scenicUser = response.scenicUsers.first(where: { matches($0, account: account) }) {
applyScenicUser(scenicUser)
return
}
if let storeUser = response.storeUsers.first(where: { matches($0, account: account) }) {
applyStoreUser(storeUser)
}
}
/// v9 `isCurrent`
static func resolveCurrentAccount(from response: V9AuthResponse) -> AccountSwitchAccount? {
let accounts = response.accounts.filter { $0.businessUserId > 0 }
if let current = accounts.first(where: \.isCurrent) {
return current
}
return accounts.first
}
private static func applyScenicUser(_ user: V9ScenicUser) {
AppStore.shared.userId = String(user.businessUserId)
AppStore.shared.accountType = V9ScenicUser.accountTypeValue
AppStore.shared.accountDisplayName = firstNonEmpty(
user.scenicName, user.nickname, user.realName, user.username
)
AppStore.shared.userName = firstNonEmpty(user.nickname, user.username, user.realName)
AppStore.shared.realName = firstNonEmpty(user.realName, user.nickname, user.username)
AppStore.shared.phone = user.phone
AppStore.shared.currentScenicId = user.scenicId
AppStore.shared.currentScenicName = user.scenicName
AppStore.shared.currentStoreId = 0
if !user.appRoleCode.isEmpty {
AppStore.shared.roleCode = user.appRoleCode
}
if !user.appRoleName.isEmpty {
AppStore.shared.roleName = user.appRoleName
}
}
private static func applyStoreUser(_ user: V9StoreUser) {
AppStore.shared.userId = String(user.businessUserId)
AppStore.shared.accountType = V9StoreUser.accountTypeValue
AppStore.shared.accountDisplayName = firstNonEmpty(
user.storeName, user.scenicName, user.realName, user.userName, user.username
)
AppStore.shared.userName = firstNonEmpty(user.userName, user.username, user.realName)
AppStore.shared.realName = firstNonEmpty(user.realName, user.userName, user.username)
AppStore.shared.phone = user.phone
AppStore.shared.avatar = user.avatar
AppStore.shared.currentScenicId = user.scenicId
AppStore.shared.currentScenicName = user.scenicName
AppStore.shared.currentStoreId = user.storeId
if !user.appRoleCode.isEmpty {
AppStore.shared.roleCode = user.appRoleCode
}
if !user.appRoleName.isEmpty {
AppStore.shared.roleName = user.appRoleName
}
}
private static func matches(_ user: V9ScenicUser, account: AccountSwitchAccount) -> Bool {
user.businessUserId == account.businessUserId
|| account.id == "\(V9ScenicUser.accountTypeValue)_\(user.businessUserId)"
}
private static func matches(_ user: V9StoreUser, account: AccountSwitchAccount) -> Bool {
user.businessUserId == account.businessUserId
|| account.id == "\(V9StoreUser.accountTypeValue)_\(user.businessUserId)"
}
private static func firstNonEmpty(_ values: String...) -> String {
values.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? ""
}
}

View File

@ -0,0 +1,88 @@
//
// AppRoleCode.swift
// suixinkan
//
import Foundation
/// Android `AppRoleCode`
enum AppRoleCode: String, CaseIterable, Equatable {
case driver = "driver"
case floristBuilder = "florist_builder"
case clerk = "clerk"
case liveStream = "live_stream"
case frontDesk = "front_desk"
case makeupArtist = "makeup_artist"
case photographerAssistant = "photographer_assistant"
case assistantManager = "assistant_manager"
case scenicOperation = "scenic_operation"
case scenicAdmin = "scenic_admin"
case scenicCS = "scenic_cs"
case editor = "editor"
case storeAdmin = "store_admin"
case photographer = "photographer"
var displayName: String {
switch self {
case .driver: "司机"
case .floristBuilder: "搭建花艺师"
case .clerk: "店员"
case .liveStream: "直播"
case .frontDesk: "前台"
case .makeupArtist: "化妆师"
case .photographerAssistant: "摄影师助理"
case .assistantManager: "副店长"
case .scenicOperation: "景区运营"
case .scenicAdmin: "景区管理员"
case .scenicCS: "飞手"
case .editor: "剪辑师"
case .storeAdmin: "店铺管理员"
case .photographer: "摄影师"
}
}
var isPhotographer: Bool {
self == .photographer
}
/// role_code
static func fromCode(_ code: String?) -> AppRoleCode? {
let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !normalized.isEmpty else { return nil }
return AppRoleCode(rawValue: normalized)
}
/// role_id
static func fromLegacyRoleId(_ roleId: Int) -> AppRoleCode? {
switch roleId {
case 41: .photographer
case 46: .storeAdmin
case 47: .editor
case 52: .scenicCS
case 53: .scenicAdmin
case 54: .scenicOperation
default: nil
}
}
/// role_name
static func fromRoleName(_ roleName: String?) -> AppRoleCode? {
let name = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !name.isEmpty else { return nil }
if name.contains("摄影师助理") { return .photographerAssistant }
if name.contains("副店长") { return .assistantManager }
if name.contains("搭建花艺") || name.contains("花艺师") { return .floristBuilder }
if name.contains("店铺管理") || name.contains("店长") { return .storeAdmin }
if name.contains("景区管理") { return .scenicAdmin }
if name.contains("景区运营") { return .scenicOperation }
if name.contains("景区客服") || name.contains("飞手") { return .scenicCS }
if name.contains("剪辑") { return .editor }
if name.contains("化妆师") { return .makeupArtist }
if name.contains("摄影师") { return .photographer }
if name.contains("司机") { return .driver }
if name.contains("店员") { return .clerk }
if name.contains("直播") { return .liveStream }
if name.contains("前台") { return .frontDesk }
return AppRoleCode.allCases.first { name.contains($0.displayName) }
}
}

View File

@ -55,7 +55,7 @@ enum LoginValidationError: Equatable {
///
enum LoginResolution {
case completed(V9AuthResponse)
case completed(V9AuthResponse, AccountSwitchAccount)
case needsAccountSelection(AccountSelectionPayload)
}
@ -201,6 +201,8 @@ struct V9ScenicUser: Decodable, Equatable {
let phone: String
let scenicId: Int
let scenicName: String
let appRoleCode: String
let appRoleName: String
let isCurrent: Bool
var businessUserId: Int {
@ -241,6 +243,8 @@ struct V9ScenicUser: Decodable, Equatable {
case phone
case scenicId = "scenic_id"
case scenicName = "scenic_name"
case appRoleCode = "app_role_code"
case appRoleName = "app_role_name"
case isCurrent = "is_current"
}
@ -257,6 +261,8 @@ struct V9ScenicUser: Decodable, Equatable {
phone = try container.decodeLossyString(forKey: .phone)
scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0
scenicName = try container.decodeLossyString(forKey: .scenicName)
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
}
}
@ -278,6 +284,8 @@ struct V9StoreUser: Decodable, Equatable {
let scenicName: String
let storeId: Int
let storeName: String
let appRoleCode: String
let appRoleName: String
let isCurrent: Bool
var businessUserId: Int {
@ -320,6 +328,8 @@ struct V9StoreUser: Decodable, Equatable {
case scenicName = "scenic_name"
case storeId = "store_id"
case storeName = "store_name"
case appRoleCode = "app_role_code"
case appRoleName = "app_role_name"
case isCurrent = "is_current"
}
@ -338,6 +348,8 @@ struct V9StoreUser: Decodable, Equatable {
scenicName = try container.decodeLossyString(forKey: .scenicName)
storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0
storeName = try container.decodeLossyString(forKey: .storeName)
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
}
}

View File

@ -0,0 +1,125 @@
//
// ProfileAPI.swift
// suixinkan
//
import Foundation
@MainActor
/// API
final class ProfileAPI {
private let client: APIClient
init(client: APIClient) {
self.client = client
}
///
func userInfo() async throws -> UserInfoResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/userinfo")
)
}
///
func switchableAccounts() async throws -> V9AuthResponse {
try await client.send(
APIRequest(method: .get, path: "/api/app/v9/accounts")
)
}
///
func realNameInfo() async throws -> RealNameInfoResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/real-name/info")
)
}
///
func bankCardInfo() async throws -> BankCardInfoResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-card/info")
)
}
///
func bankList() async throws -> BankListResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-list")
)
}
///
func areas() async throws -> [AreasResponse] {
try await client.send(
APIRequest(method: .get, path: "/api/app/config/areas")
)
}
///
func updateUserInfo(nickname: String? = nil, password: String? = nil, avatar: String? = nil) async throws {
let request = UpdateInfoRequest(nickname: nickname, password: password, avatar: avatar)
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/userinfo-update",
body: request
)
)
}
/// URL OSS
func updateUserAvatarURL(_ fileURL: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/userinfo-update-avatar-url",
queryItems: [URLQueryItem(name: "file_url", value: fileURL)]
)
)
}
///
func realNameSmsVerifyCode() async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/real-name/sms-verify-code",
body: EmptyPayload()
)
)
}
///
func realNameSubmit(_ request: RealNameAuthRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/real-name/submit",
body: request
)
)
}
///
func bankCardSmsVerifyCode() async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/wallet/bank-card/sms-verify-code",
body: EmptyPayload()
)
)
}
///
func updateBankCard(_ request: UpdateBankInfoRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/wallet/bank-card/update",
body: request
)
)
}
}

View File

@ -0,0 +1,353 @@
//
// ProfileModels.swift
// suixinkan
//
import Foundation
///
struct UserInfoResponse: Decodable, Equatable {
let avatar: String
let realName: String
let phone: String
let nickname: String
let roleName: String
let status: Int
let statusName: String
enum CodingKeys: String, CodingKey {
case avatar
case realName = "real_name"
case phone
case nickname
case roleName = "role_name"
case status
case statusName = "status_name"
}
init(
avatar: String = "",
realName: String = "",
phone: String = "",
nickname: String = "",
roleName: String = "",
status: Int = 0,
statusName: String = ""
) {
self.avatar = avatar
self.realName = realName
self.phone = phone
self.nickname = nickname
self.roleName = roleName
self.status = status
self.statusName = statusName
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
avatar = try container.decodeLossyString(forKey: .avatar)
realName = try container.decodeLossyString(forKey: .realName)
phone = try container.decodeLossyString(forKey: .phone)
nickname = try container.decodeLossyString(forKey: .nickname)
roleName = try container.decodeLossyString(forKey: .roleName)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusName = try container.decodeLossyString(forKey: .statusName)
}
}
///
struct UpdateInfoRequest: Encodable {
let nickname: String?
let password: String?
let avatar: String?
}
///
struct RealNameInfoResponse: Decodable, Equatable {
let realNameInfo: RealNameInfo?
enum CodingKeys: String, CodingKey {
case realNameInfo = "real_name_info"
}
}
///
struct RealNameInfo: Decodable, Equatable {
let realName: String
let idCardNo: String
let auditStatus: Int
let auditStatusText: String?
let rejectReason: String?
let startDate: String?
let endDate: String?
let isLongValid: Bool
let frontUrl: String?
let backUrl: String?
let auditorId: Int?
let auditor: RealNameAuditor?
let auditAt: String?
var verified: Bool {
auditStatus == 2
}
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case idCardNo = "id_card_no"
case auditStatus = "audit_status"
case auditStatusText = "audit_status_text"
case rejectReason = "reject_reason"
case startDate = "start_date"
case endDate = "end_date"
case isLongValid = "is_long_valid"
case frontUrl = "front_url"
case backUrl = "back_url"
case auditorId = "auditor_id"
case auditor
case auditAt = "audit_at"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
realName = try container.decodeLossyString(forKey: .realName)
idCardNo = try container.decodeLossyString(forKey: .idCardNo)
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText)
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
startDate = try container.decodeIfPresent(String.self, forKey: .startDate)
endDate = try container.decodeIfPresent(String.self, forKey: .endDate)
isLongValid = (try container.decodeLossyInt(forKey: .isLongValid) ?? 0) == 1
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
auditorId = try container.decodeLossyInt(forKey: .auditorId)
auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor)
auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt)
}
}
///
struct RealNameAuditor: Decodable, Equatable {
let id: Int
let name: String
enum CodingKeys: String, CodingKey {
case id
case name
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
name = try container.decodeLossyString(forKey: .name)
}
}
///
struct RealNameAuthRequest: Encodable, Equatable {
let realName: String
let idCardNo: String
let smsVerifyCode: String
let startDate: String
let endDate: String
let isLongValid: Int
let frontUrl: String
let backUrl: String
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case idCardNo = "id_card_no"
case smsVerifyCode = "sms_verify_code"
case startDate = "start_date"
case endDate = "end_date"
case isLongValid = "is_long_valid"
case frontUrl = "front_url"
case backUrl = "back_url"
}
}
///
struct BankCardInfoResponse: Decodable, Equatable {
let bankCard: BankCardInfo?
enum CodingKeys: String, CodingKey {
case bankCard = "bank_card"
}
}
///
struct BankCardInfo: Decodable, Equatable {
let realName: String
let bankName: String
let branchName: String
let cardNumber: String
let frontUrl: String?
let backUrl: String?
let provinceCode: String?
let cityCode: String?
let rejectReason: String
let auditStatus: Int
let auditStatusLabel: String
let auditAt: String?
let auditorId: Int?
let auditor: RealNameAuditor?
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case bankName = "bank_name"
case branchName = "branch_name"
case cardNumber = "card_number"
case frontUrl = "front_url"
case backUrl = "back_url"
case provinceCode = "province_code"
case cityCode = "city_code"
case rejectReason = "reject_reason"
case auditStatus = "audit_status"
case auditStatusLabel = "audit_status_label"
case auditAt = "audit_at"
case auditorId = "auditor_id"
case auditor
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
realName = try container.decodeLossyString(forKey: .realName)
bankName = try container.decodeLossyString(forKey: .bankName)
branchName = try container.decodeLossyString(forKey: .branchName)
cardNumber = try container.decodeLossyString(forKey: .cardNumber)
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
provinceCode = try container.decodeIfPresent(String.self, forKey: .provinceCode)
cityCode = try container.decodeIfPresent(String.self, forKey: .cityCode)
rejectReason = try container.decodeLossyString(forKey: .rejectReason)
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
auditStatusLabel = try container.decodeLossyString(forKey: .auditStatusLabel)
auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt)
auditorId = try container.decodeLossyInt(forKey: .auditorId)
auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor)
}
}
///
struct BankListResponse: Decodable, Equatable {
let banks: [String]
enum CodingKeys: String, CodingKey {
case banks
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
banks = try container.decodeIfPresent([String].self, forKey: .banks) ?? []
}
}
///
struct AreasResponse: Decodable, Equatable, Identifiable {
let id: Int
let code: String
let name: String
let children: [AreasResponse]
enum CodingKeys: String, CodingKey {
case id
case code
case name
case children
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id)
?? container.decodeLossyInt(forKey: .code)
?? 0
let decodedCode = try container.decodeLossyString(forKey: .code)
.trimmingCharacters(in: .whitespacesAndNewlines)
code = decodedCode.isEmpty ? "\(id)" : decodedCode
name = try container.decodeLossyString(forKey: .name)
children = try container.decodeIfPresent([AreasResponse].self, forKey: .children) ?? []
}
init(id: Int, code: String, name: String, children: [AreasResponse] = []) {
self.id = id
self.code = code
self.name = name
self.children = children
}
}
///
struct UpdateBankInfoRequest: Encodable, Equatable {
let realName: String
let cardNumber: String
let bankName: String
let branchName: String
let frontUrl: String
let backUrl: String
let provinceCode: String
let cityCode: String
let smsVerifyCode: String
enum CodingKeys: String, CodingKey {
case realName = "real_name"
case cardNumber = "card_number"
case bankName = "bank_name"
case branchName = "branch_name"
case frontUrl = "front_url"
case backUrl = "back_url"
case provinceCode = "province_code"
case cityCode = "city_code"
case smsVerifyCode = "sms_verify_code"
}
}
///
enum ProfileValidationError: LocalizedError, Equatable {
case emptyNickname
case shortPassword
var errorDescription: String? {
switch self {
case .emptyNickname:
"请输入昵称"
case .shortPassword:
"密码至少 6 位"
}
}
}
private extension KeyedDecodingContainer {
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "true" : "false"
}
return ""
}
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(String.self, forKey: key) {
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
if let intValue = Int(text) {
return intValue
}
if let doubleValue = Double(text) {
return Int(doubleValue)
}
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
return nil
}
}

View File

@ -0,0 +1,77 @@
//
// AccountSwitchViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class AccountSwitchViewModel {
private(set) var accounts: [AccountSwitchAccount] = []
private(set) var selectedAccountId: String?
private(set) var loading = false
private(set) var switching = false
private var didLoad = false
var onStateChange: (() -> Void)?
var selectedAccount: AccountSwitchAccount? {
accounts.first { $0.id == selectedAccountId }
}
func load(api: ProfileAPI, force: Bool = false, currentAccountId: String? = nil) async throws {
guard force || !didLoad else { return }
didLoad = true
loading = true
notifyStateChange()
defer {
loading = false
notifyStateChange()
}
let response = try await api.switchableAccounts()
accounts = response.accounts.filter { $0.businessUserId > 0 }
selectedAccountId = accounts.first(where: { $0.isCurrent || isCurrent($0, currentAccountId: currentAccountId) })?.id
?? accounts.first?.id
notifyStateChange()
}
func select(_ account: AccountSwitchAccount) {
selectedAccountId = account.id
notifyStateChange()
}
func switchAccount(_ account: AccountSwitchAccount, api: AuthAPI) async throws -> V9AuthResponse {
guard !switching else { throw AccountSwitchError.submitting }
guard account.businessUserId > 0 else { throw AccountSwitchError.invalidAccount }
switching = true
notifyStateChange()
defer {
switching = false
notifyStateChange()
}
return try await api.setUser(account.toSetUserRequest())
}
func isCurrent(_ account: AccountSwitchAccount, currentAccountId: String?) -> Bool {
guard let currentAccountId else { return false }
return account.id == currentAccountId || String(account.businessUserId) == currentAccountId
}
private func notifyStateChange() {
onStateChange?()
}
}
enum AccountSwitchError: LocalizedError, Equatable {
case submitting
case invalidAccount
var errorDescription: String? {
switch self {
case .submitting: "账号正在切换,请稍候"
case .invalidAccount: "账号信息异常,请重新选择账号"
}
}
}

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

View File

@ -0,0 +1,237 @@
//
// RealNameAuthViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class RealNameAuthViewModel {
private(set) var info: RealNameInfo?
private(set) var realName = ""
private(set) var idCardNo = ""
private(set) var smsCode = ""
private(set) var startDate = Date()
private(set) var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
private(set) var isLongValid = false
private(set) var frontUrl = ""
private(set) var backUrl = ""
private(set) var pendingFrontImageData: Data?
private(set) var pendingBackImageData: Data?
private(set) var loading = false
private(set) var sendingCode = false
private(set) var submitting = false
private(set) var statusMessage: String?
private var pendingFrontFileName: String?
private var pendingBackFileName: String?
var onStateChange: (() -> Void)?
func updateRealName(_ value: String) {
realName = value
notifyStateChange()
}
func updateIdCardNo(_ value: String) {
idCardNo = value
notifyStateChange()
}
func updateSmsCode(_ value: String) {
smsCode = value
notifyStateChange()
}
func updateLongValid(_ value: Bool) {
isLongValid = value
notifyStateChange()
}
func updateStartDate(_ value: Date) {
startDate = value
notifyStateChange()
}
func updateEndDate(_ value: Date) {
endDate = value
notifyStateChange()
}
func load(api: ProfileAPI) async throws {
guard !loading else { return }
loading = true
notifyStateChange()
defer {
loading = false
notifyStateChange()
}
let response = try await api.realNameInfo()
apply(response.realNameInfo)
}
func sendCode(api: ProfileAPI) async throws {
guard !sendingCode else { return }
sendingCode = true
notifyStateChange()
defer {
sendingCode = false
notifyStateChange()
}
try await api.realNameSmsVerifyCode()
statusMessage = "验证码已发送"
notifyStateChange()
}
func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp)
switch side {
case .front:
pendingFrontImageData = processed.data
pendingFrontFileName = processed.fileName
frontUrl = ""
case .back:
pendingBackImageData = processed.data
pendingBackFileName = processed.fileName
backUrl = ""
}
notifyStateChange()
}
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
if info?.verified == true {
statusMessage = "已完成实名认证"
notifyStateChange()
return
}
if let validationMessage {
throw RealNameValidationError.message(validationMessage)
}
submitting = true
notifyStateChange()
defer {
submitting = false
notifyStateChange()
}
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
try await api.realNameSubmit(makeRequest())
statusMessage = "已提交审核"
notifyStateChange()
try await load(api: api)
}
var validationMessage: String? {
if realName.trimmed.isEmpty { return "请输入真实姓名" }
if idCardNo.trimmed.isEmpty { return "请输入身份证号" }
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
if info?.verified != true {
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" }
if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" }
}
if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" }
return nil
}
nonisolated static func normalizedIDCardNumber(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool {
let number = normalizedIDCardNumber(value)
guard number.count == 18 else { return false }
let chars = Array(number)
guard chars.prefix(17).allSatisfy(\.isNumber) else { return false }
guard chars[17].isNumber || chars[17] == "X" else { return false }
let weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
let checkCodes = Array("10X98765432")
let sum = zip(chars.prefix(17), weights).reduce(0) { partial, pair in
partial + (pair.0.wholeNumberValue ?? 0) * pair.1
}
return chars[17] == checkCodes[sum % 11]
}
private func apply(_ info: RealNameInfo?) {
self.info = info
guard let info else {
notifyStateChange()
return
}
realName = info.realName
idCardNo = info.idCardNo
frontUrl = info.frontUrl ?? ""
backUrl = info.backUrl ?? ""
pendingFrontImageData = nil
pendingBackImageData = nil
isLongValid = info.isLongValid
startDate = Self.date(from: info.startDate) ?? startDate
endDate = Self.date(from: info.endDate) ?? endDate
notifyStateChange()
}
private func makeRequest() -> RealNameAuthRequest {
RealNameAuthRequest(
realName: realName.trimmed,
idCardNo: Self.normalizedIDCardNumber(idCardNo),
smsVerifyCode: smsCode.trimmed,
startDate: Self.dateFormatter.string(from: startDate),
endDate: isLongValid ? "" : Self.dateFormatter.string(from: endDate),
isLongValid: isLongValid ? 1 : 0,
frontUrl: frontUrl.trimmed,
backUrl: backUrl.trimmed
)
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
private static func date(from value: String?) -> Date? {
guard let value, !value.isEmpty else { return nil }
return dateFormatter.date(from: value)
}
private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
if let pendingFrontImageData {
frontUrl = try await uploader.uploadRealNameImage(
data: pendingFrontImageData,
fileName: pendingFrontFileName ?? "real_name_front.jpg",
scenicId: scenicId
) { _ in }
self.pendingFrontImageData = nil
}
if let pendingBackImageData {
backUrl = try await uploader.uploadRealNameImage(
data: pendingBackImageData,
fileName: pendingBackFileName ?? "real_name_back.jpg",
scenicId: scenicId
) { _ in }
self.pendingBackImageData = nil
}
}
private func notifyStateChange() {
onStateChange?()
}
}
enum RealNameValidationError: LocalizedError, Equatable {
case message(String)
var errorDescription: String? {
switch self {
case .message(let message): message
}
}
}
private extension String {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@ -0,0 +1,219 @@
//
// WithdrawalSettingsViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class WithdrawalSettingsViewModel {
private(set) var currentStep = 1
private(set) var holderName = ""
private(set) var bankNo = ""
private(set) var bankCardFrontURL = ""
private(set) var bankCardBackURL = ""
private(set) var bankName = ""
private(set) var provinceCode = ""
private(set) var provinceName = ""
private(set) var cityCode = ""
private(set) var cityName = ""
private(set) var branchName = ""
private(set) var verificationCode = ""
private(set) var isAgree = false
private(set) var banks: [String] = []
private(set) var areas: [AreasResponse] = []
private(set) var loading = false
private(set) var sendingCode = false
private(set) var submitting = false
private(set) var pendingFrontImageData: Data?
private(set) var pendingBackImageData: Data?
private var pendingFrontFileName: String?
private var pendingBackFileName: String?
var onStateChange: (() -> Void)?
var canGoNextStep: Bool {
currentStep == 1
&& !holderName.trimmed.isEmpty
&& (12 ... 25).contains(bankNo.filter(\.isNumber).count)
&& !bankCardFrontURL.isEmpty
&& !bankCardBackURL.isEmpty
&& isAgree
}
var canSubmit: Bool {
currentStep == 2
&& !holderName.trimmed.isEmpty
&& (12 ... 25).contains(bankNo.filter(\.isNumber).count)
&& !bankName.trimmed.isEmpty
&& !provinceCode.isEmpty
&& !cityCode.isEmpty
&& !branchName.trimmed.isEmpty
&& !verificationCode.trimmed.isEmpty
&& isAgree
&& !submitting
}
func updateHolderName(_ value: String) {
holderName = value
notifyStateChange()
}
func updateBankNo(_ value: String) {
bankNo = value
notifyStateChange()
}
func updateBankName(_ value: String) {
bankName = value
notifyStateChange()
}
func updateBranchName(_ value: String) {
branchName = value
notifyStateChange()
}
func updateVerificationCode(_ value: String) {
verificationCode = value
notifyStateChange()
}
func updateAgree(_ value: Bool) {
isAgree = value
notifyStateChange()
}
func selectProvince(_ area: AreasResponse) {
provinceCode = area.code
provinceName = area.name
cityCode = ""
cityName = ""
notifyStateChange()
}
func selectCity(_ area: AreasResponse) {
cityCode = area.code
cityName = area.name
notifyStateChange()
}
func goNextStep() {
guard canGoNextStep else { return }
currentStep = 2
notifyStateChange()
}
func prepareBankCardImage(data: Data, side: BankCardImageSide) throws {
let processed = try BankCardImageProcessor.process(data: data, side: side)
switch side {
case .front:
pendingFrontImageData = processed.data
pendingFrontFileName = processed.fileName
bankCardFrontURL = "pending"
case .back:
pendingBackImageData = processed.data
pendingBackFileName = processed.fileName
bankCardBackURL = "pending"
}
notifyStateChange()
}
func load(api: ProfileAPI) async throws {
guard !loading else { return }
loading = true
notifyStateChange()
defer {
loading = false
notifyStateChange()
}
async let bankList = api.bankList()
async let areaList = api.areas()
let result = try await (bankList, areaList)
banks = result.0.banks
areas = result.1
notifyStateChange()
}
func sendCode(api: ProfileAPI) async throws {
guard !sendingCode else { return }
sendingCode = true
notifyStateChange()
defer {
sendingCode = false
notifyStateChange()
}
try await api.bankCardSmsVerifyCode()
}
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
guard canSubmit else {
throw WithdrawalValidationError.message("请完善银行卡信息")
}
submitting = true
notifyStateChange()
defer {
submitting = false
notifyStateChange()
}
try await uploadPendingImages(uploader: uploader, scenicId: scenicId)
let request = UpdateBankInfoRequest(
realName: holderName.trimmed,
cardNumber: bankNo.filter(\.isNumber),
bankName: bankName.trimmed,
branchName: branchName.trimmed,
frontUrl: bankCardFrontURL,
backUrl: bankCardBackURL,
provinceCode: provinceCode,
cityCode: cityCode,
smsVerifyCode: verificationCode.trimmed
)
try await api.updateBankCard(request)
}
var provinceOptions: [AreasResponse] { areas }
var cityOptions: [AreasResponse] {
areas.first { $0.code == provinceCode || $0.name == provinceName }?.children ?? []
}
private func uploadPendingImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
if let pendingFrontImageData {
bankCardFrontURL = try await uploader.uploadBankCardImage(
data: pendingFrontImageData,
fileName: pendingFrontFileName ?? "bank_card_front.jpg",
scenicId: scenicId
) { _ in }
self.pendingFrontImageData = nil
}
if let pendingBackImageData {
bankCardBackURL = try await uploader.uploadBankCardImage(
data: pendingBackImageData,
fileName: pendingBackFileName ?? "bank_card_back.jpg",
scenicId: scenicId
) { _ in }
self.pendingBackImageData = nil
}
}
private func notifyStateChange() {
onStateChange?()
}
}
enum WithdrawalValidationError: LocalizedError, Equatable {
case message(String)
var errorDescription: String? {
switch self {
case .message(let message): message
}
}
}
private extension String {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}