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

@ -6,18 +6,23 @@
import Foundation
@MainActor
/// APIClient AuthAPI
/// APIClient API
final class NetworkServices {
static let shared = NetworkServices()
let apiClient: APIClient
let authAPI: AuthAPI
let profileAPI: ProfileAPI
let uploadAPI: UploadAPI
let ossUploadService: OSSUploadService
///
private init() {
let client = APIClient(environment: .current)
apiClient = client
authAPI = AuthAPI(client: client)
profileAPI = ProfileAPI(client: client)
uploadAPI = UploadAPI(client: client)
ossUploadService = OSSUploadService(configService: uploadAPI)
client.bindAuthTokenProvider {
let token = AppStore.shared.token.trimmingCharacters(in: .whitespacesAndNewlines)
return token.isEmpty ? nil : token
@ -28,5 +33,8 @@ final class NetworkServices {
init(apiClient: APIClient) {
self.apiClient = apiClient
authAPI = AuthAPI(client: apiClient)
profileAPI = ProfileAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient)
ossUploadService = OSSUploadService(configService: uploadAPI)
}
}

View File

@ -23,6 +23,9 @@ enum NotificationName {
/// 退
static let userDidLogout = name("userDidLogout")
/// /
static let accountDidSwitch = name("accountDidSwitch")
/// Token
static let sessionDidExpire = name("sessionDidExpire")

View File

@ -0,0 +1,178 @@
//
// OSSUploadService.swift
// suixinkan
//
import Foundation
import UniformTypeIdentifiers
#if canImport(AlibabaCloudOSS)
import AlibabaCloudOSS
#endif
/// OSS Profile
@MainActor
protocol OSSUploadServing {
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
}
@MainActor
/// OSS STS SDK URL
final class OSSUploadService {
private let configService: any OSSConfigServing
init(configService: any OSSConfigServing) {
self.configService = configService
}
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "user_avatar", onProgress: onProgress)
}
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name", onProgress: onProgress)
}
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
}
private func uploadFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
moduleType: String,
onProgress: @escaping (Int) -> Void
) async throws -> String {
onProgress(1)
try OSSUploadPolicy.validate(dataSize: data.count, fileName: fileName)
let config = try await configService.aliyunOSSBucket(bucket: "vipsky")
let objectKey = OSSUploadPolicy.objectKey(fileName: fileName, scenicId: scenicId, moduleType: moduleType)
onProgress(12)
#if canImport(AlibabaCloudOSS)
let credentialsProvider = StaticCredentialsProvider(
accessKeyId: config.credentials.accessKeyId,
accessKeySecret: config.credentials.accessKeySecret,
securityToken: config.credentials.securityToken
)
let clientConfig = Configuration.default()
.withRegion(config.region)
.withCredentialsProvider(credentialsProvider)
if !config.endpoint.isEmpty {
clientConfig.withEndpoint(config.endpoint)
}
onProgress(25)
let client = Client(clientConfig)
_ = try await client.putObject(
PutObjectRequest(
bucket: config.bucket,
key: objectKey,
contentType: OSSUploadPolicy.contentType(for: fileName, fileType: fileType),
body: .data(data),
progress: ProgressClosure { _, transferred, expected in
guard expected > 0 else { return }
let uploadProgress = Double(transferred) / Double(expected)
let progress = max(26, min(99, 25 + Int(uploadProgress * 74)))
onProgress(progress)
}
)
)
onProgress(100)
return OSSUploadPolicy.joinURL(baseURL: config.baseUrl, objectKey: objectKey)
#else
throw OSSUploadError.sdkUnavailable
#endif
}
}
extension OSSUploadService: OSSUploadServing {}
enum OSSUploadPolicy {
static let maxFileSize = 2_048 * 1_024 * 1_024
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
static func validate(dataSize: Int, fileName: String) throws {
guard dataSize > 0 else { throw OSSUploadError.emptyFile }
guard dataSize <= maxFileSize else { throw OSSUploadError.fileTooLarge }
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
guard allowedExtensions.contains(ext) else { throw OSSUploadError.unsupportedFileType }
}
static func objectKey(
fileName: String,
scenicId: Int,
moduleType: String,
date: Date = Date(),
uuid: UUID = UUID(),
timeZone: TimeZone = .current
) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.timeZone = timeZone
formatter.dateFormat = "yyyyMMdd"
let currentDate = formatter.string(from: date)
let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "")
let safeName = sanitizedFileName(fileName)
switch moduleType {
case "user_avatar":
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "real_name":
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "bank_card":
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
default:
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
}
}
static func contentType(for fileName: String, fileType: Int) -> String {
let ext = URL(fileURLWithPath: fileName).pathExtension
if let type = UTType(filenameExtension: ext)?.preferredMIMEType {
return type
}
return fileType == 1 ? "video/mp4" : "image/jpeg"
}
static func joinURL(baseURL: String, objectKey: String) -> String {
if baseURL.hasSuffix("/") {
return baseURL + objectKey
}
return baseURL + "/" + objectKey
}
private static func sanitizedFileName(_ fileName: String) -> String {
let trimmedName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
var unsafeCharacters = CharacterSet.controlCharacters
unsafeCharacters.formUnion(CharacterSet(charactersIn: "/\\"))
let safeName = trimmedName.unicodeScalars.reduce(into: "") { result, scalar in
result += unsafeCharacters.contains(scalar) ? "_" : String(scalar)
}
return safeName.isEmpty ? "upload" : safeName
}
}
enum OSSUploadError: LocalizedError, Equatable {
case sdkUnavailable
case emptyFile
case fileTooLarge
case unsupportedFileType
var errorDescription: String? {
switch self {
case .sdkUnavailable:
"阿里云 OSS Swift SDK 尚未链接到当前 iOS target"
case .emptyFile:
"文件内容不能为空"
case .fileTooLarge:
"文件大小不能超过2048MB"
case .unsupportedFileType:
"仅支持.mp4,.mov,.m4v,.avi,.png,.jpg,.jpeg,.heic,.heif格式"
}
}
}

View File

@ -0,0 +1,36 @@
//
// UploadAPI.swift
// suixinkan
//
import Foundation
/// OSS STS token 便
@MainActor
protocol OSSConfigServing {
/// bucket OSS
func aliyunOSSBucket(bucket: String) async throws -> AliyunOSSResponse
}
@MainActor
/// API
final class UploadAPI {
private let client: APIClient
init(client: APIClient) {
self.client = client
}
/// bucket OSS
func aliyunOSSBucket(bucket: String = "vipsky") async throws -> AliyunOSSResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/config/get-sts-token",
queryItems: [URLQueryItem(name: "bucket", value: bucket)]
)
)
}
}
extension UploadAPI: OSSConfigServing {}

View File

@ -0,0 +1,139 @@
//
// UploadImageProcessors.swift
// suixinkan
//
import Foundation
import UIKit
import UniformTypeIdentifiers
///
enum AvatarImageProcessor {
static let maxPixelLength: CGFloat = 1024
static let jpegCompressionQuality: CGFloat = 0.82
///
struct ProcessedImage: Equatable {
let data: Data
let fileName: String
let contentType: UTType
}
/// JPEG
static func process(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> ProcessedImage {
try UploadImageRenderer.process(
data: data,
fileNamePrefix: "avatar",
maxPixelLength: maxPixelLength,
jpegCompressionQuality: jpegCompressionQuality,
timestamp: timestamp
)
}
}
///
enum RealNameImageProcessor {
static let maxPixelLength: CGFloat = 1800
static let jpegCompressionQuality: CGFloat = 0.88
/// JPEG
static func process(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> AvatarImageProcessor.ProcessedImage {
try UploadImageRenderer.process(
data: data,
fileNamePrefix: "real_name_\(side.rawValue)",
maxPixelLength: maxPixelLength,
jpegCompressionQuality: jpegCompressionQuality,
timestamp: timestamp
)
}
}
///
enum BankCardImageProcessor {
static let maxPixelLength: CGFloat = 1800
static let jpegCompressionQuality: CGFloat = 0.88
static func process(data: Data, side: BankCardImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> AvatarImageProcessor.ProcessedImage {
try UploadImageRenderer.process(
data: data,
fileNamePrefix: "bank_card_\(side.rawValue)",
maxPixelLength: maxPixelLength,
jpegCompressionQuality: jpegCompressionQuality,
timestamp: timestamp
)
}
}
///
enum RealNameImageSide: String {
case front
case back
}
///
enum BankCardImageSide: String {
case front
case back
}
///
enum UploadImageProcessingError: LocalizedError, Equatable {
case invalidImage
case encodingFailed
var errorDescription: String? {
switch self {
case .invalidImage:
"请选择有效的图片"
case .encodingFailed:
"图片处理失败,请重新选择"
}
}
}
/// JPEG
private enum UploadImageRenderer {
static func process(
data: Data,
fileNamePrefix: String,
maxPixelLength: CGFloat,
jpegCompressionQuality: CGFloat,
timestamp: TimeInterval
) throws -> AvatarImageProcessor.ProcessedImage {
guard let image = UIImage(data: data), image.size.width > 0, image.size.height > 0 else {
throw UploadImageProcessingError.invalidImage
}
let normalized = image.normalizedForUpload(maxPixelLength: maxPixelLength)
guard let jpegData = normalized.jpegData(compressionQuality: jpegCompressionQuality), !jpegData.isEmpty else {
throw UploadImageProcessingError.encodingFailed
}
return AvatarImageProcessor.ProcessedImage(
data: jpegData,
fileName: "\(fileNamePrefix)_\(Int(timestamp)).jpg",
contentType: .jpeg
)
}
}
private extension UIImage {
func normalizedForUpload(maxPixelLength: CGFloat) -> UIImage {
let longestSide = max(size.width, size.height)
let scale = min(1, maxPixelLength / longestSide)
let targetSize = CGSize(
width: max(1, (size.width * scale).rounded()),
height: max(1, (size.height * scale).rounded())
)
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
format.opaque = true
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
return renderer.image { context in
UIColor.white.setFill()
context.fill(CGRect(origin: .zero, size: targetSize))
draw(in: CGRect(origin: .zero, size: targetSize))
}
}
}

View File

@ -0,0 +1,92 @@
//
// UploadModels.swift
// suixinkan
//
import Foundation
/// OSS STS
struct AliyunOSSResponse: Decodable, Equatable {
let baseUrl: String
let endpoint: String
let region: String
let bucket: String
let expireSeconds: Int
let credentials: AliyunOSSCredentials
enum CodingKeys: String, CodingKey {
case baseUrl = "base_url"
case endpoint
case region
case bucket
case expireSeconds = "expire_seconds"
case credentials
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
baseUrl = try container.decodeLossyString(forKey: .baseUrl)
endpoint = try container.decodeLossyString(forKey: .endpoint)
region = try container.decodeLossyString(forKey: .region)
bucket = try container.decodeLossyString(forKey: .bucket)
expireSeconds = try container.decodeLossyInt(forKey: .expireSeconds) ?? 0
credentials = try container.decode(AliyunOSSCredentials.self, forKey: .credentials)
}
}
/// OSS SDK 访 token
struct AliyunOSSCredentials: Decodable, Equatable {
let accessKeyId: String
let accessKeySecret: String
let securityToken: String
enum CodingKeys: String, CodingKey {
case accessKeyId = "access_key_id"
case accessKeySecret = "access_key_secret"
case securityToken = "security_token"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
accessKeyId = try container.decodeLossyString(forKey: .accessKeyId)
accessKeySecret = try container.decodeLossyString(forKey: .accessKeySecret)
securityToken = try container.decodeLossyString(forKey: .securityToken)
}
}
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

@ -6,7 +6,6 @@
import Foundation
/// Android `AppStoreDataSource`
/// Token
final class AppStore {
static let shared = AppStore()
@ -15,6 +14,18 @@ final class AppStore {
static let token = "key_in_token"
static let lastLoginUsername = "key_last_login_username"
static let privacyAgreementAccepted = "key_privacy_agreement_accepted"
static let userId = "key_in_user_id"
static let userName = "key_in_user_name"
static let realName = "key_in_real_name"
static let avatar = "key_in_avatar"
static let phone = "key_in_phone"
static let accountType = "key_in_account_type"
static let accountDisplayName = "key_in_account_display_name"
static let roleCode = "key_in_role_code"
static let roleName = "key_in_role_name"
static let currentScenicId = "key_in_current_scenic_id"
static let currentScenicName = "key_in_current_scenic_name"
static let currentStoreId = "key_in_current_store_id"
}
private let defaults: UserDefaults
@ -47,6 +58,85 @@ final class AppStore {
set { defaults.set(newValue, forKey: Key.privacyAgreementAccepted) }
}
var userId: String {
get { defaults.string(forKey: Key.userId) ?? "" }
set { defaults.set(newValue, forKey: Key.userId) }
}
var userName: String {
get { defaults.string(forKey: Key.userName) ?? "" }
set { defaults.set(newValue, forKey: Key.userName) }
}
var realName: String {
get { defaults.string(forKey: Key.realName) ?? "" }
set { defaults.set(newValue, forKey: Key.realName) }
}
var avatar: String {
get { defaults.string(forKey: Key.avatar) ?? "" }
set { defaults.set(newValue, forKey: Key.avatar) }
}
var phone: String {
get { defaults.string(forKey: Key.phone) ?? "" }
set { defaults.set(newValue, forKey: Key.phone) }
}
var accountType: String {
get { defaults.string(forKey: Key.accountType) ?? "" }
set { defaults.set(newValue, forKey: Key.accountType) }
}
var accountDisplayName: String {
get { defaults.string(forKey: Key.accountDisplayName) ?? "" }
set { defaults.set(newValue, forKey: Key.accountDisplayName) }
}
var roleCode: String {
get { defaults.string(forKey: Key.roleCode) ?? "" }
set { defaults.set(newValue, forKey: Key.roleCode) }
}
var roleName: String {
get { defaults.string(forKey: Key.roleName) ?? "" }
set { defaults.set(newValue, forKey: Key.roleName) }
}
var currentScenicId: Int {
get {
let value = defaults.string(forKey: Key.currentScenicId) ?? ""
return Int(value) ?? 0
}
set {
defaults.set(newValue > 0 ? String(newValue) : "", forKey: Key.currentScenicId)
}
}
var currentScenicName: String {
get { defaults.string(forKey: Key.currentScenicName) ?? "" }
set { defaults.set(newValue, forKey: Key.currentScenicName) }
}
var currentStoreId: Int {
get { defaults.integer(forKey: Key.currentStoreId) }
set { defaults.set(newValue, forKey: Key.currentStoreId) }
}
///
var currentAppRole: AppRoleCode? {
AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName)
}
///
var isPhotographerRole: Bool {
if let role = currentAppRole {
return role.isPhotographer
}
let name = roleName.trimmingCharacters(in: .whitespacesAndNewlines)
return name == "摄影师"
}
/// Token
var isLoggedIn: Bool {
!token.isEmpty
@ -57,8 +147,54 @@ final class AppStore {
self.token = token
}
///
///
func applyAccount(_ account: AccountSwitchAccount) {
userId = String(account.businessUserId)
accountType = account.accountType
accountDisplayName = account.title
userName = account.subtitle
phone = account.phone
if !account.avatar.isEmpty {
avatar = account.avatar
}
currentScenicName = account.scenicName
currentScenicId = account.scenicId ?? 0
currentStoreId = account.storeId ?? 0
}
///
func applyUserInfo(_ info: UserInfoResponse) {
if !info.nickname.isEmpty {
userName = info.nickname
}
if !info.realName.isEmpty {
realName = info.realName
}
if !info.avatar.isEmpty {
avatar = info.avatar
}
if !info.phone.isEmpty {
phone = info.phone
}
if !info.roleName.isEmpty, roleName.isEmpty {
roleName = info.roleName
}
}
///
func logout() {
token = ""
userId = ""
userName = ""
realName = ""
avatar = ""
phone = ""
accountType = ""
accountDisplayName = ""
roleCode = ""
roleName = ""
currentScenicId = 0
currentScenicName = ""
currentStoreId = 0
}
}

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

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以选择头像、身份证和银行卡照片</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@ -0,0 +1,20 @@
//
// UIImageView+Remote.swift
// suixinkan
//
import Kingfisher
import UIKit
extension UIImageView {
/// URL
func loadRemoteImage(urlString: String?, placeholderColor: UIColor = UIColor(hex: 0xEFF6FF)) {
backgroundColor = placeholderColor
let trimmed = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let url = URL(string: trimmed), !trimmed.isEmpty else {
image = nil
return
}
kf.setImage(with: url, placeholder: nil)
}
}

View File

@ -273,8 +273,8 @@ final class LoginViewController: BaseViewController {
do {
let resolution = try await viewModel.login(authAPI: authAPI)
switch resolution {
case let .completed(response):
completeLogin(with: response)
case let .completed(response, account):
completeLogin(with: response, account: account)
case .needsAccountSelection:
break
}
@ -311,7 +311,7 @@ final class LoginViewController: BaseViewController {
do {
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
completeLogin(with: response)
completeLogin(with: response, account: account)
accountSelectionController = nil
dismiss(animated: true)
} catch is CancellationError {
@ -322,11 +322,12 @@ final class LoginViewController: BaseViewController {
}
}
private func completeLogin(with response: V9AuthResponse) {
private func completeLogin(with response: V9AuthResponse, account: AccountSwitchAccount) {
AuthSessionHelper.completeLogin(
with: response,
username: viewModel.normalizedUsername,
privacyAgreementAccepted: viewModel.isPrivacyChecked
privacyAgreementAccepted: viewModel.isPrivacyChecked,
selectedAccount: account
)
}

View File

@ -182,7 +182,7 @@ final class LoginViewModel {
guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw LoginFlowError.missingToken
}
return .completed(finalResponse)
return .completed(finalResponse, account)
}
let payload = AccountSelectionPayload(tempToken: token, accounts: accounts)

View File

@ -0,0 +1,245 @@
//
// AccountSwitchViewController.swift
// suixinkan
//
import SnapKit
import UIKit
private typealias AccountSwitchSection = Int
private typealias AccountSwitchItem = String
///
final class AccountSwitchViewController: BaseViewController, UITableViewDelegate {
private let viewModel = AccountSwitchViewModel()
private let profileAPI = NetworkServices.shared.profileAPI
private let authAPI = NetworkServices.shared.authAPI
private lazy var tableView: UITableView = {
let table = UITableView(frame: .zero, style: .plain)
table.backgroundColor = UIColor(hex: 0xF5F7FB)
table.separatorStyle = .none
table.delegate = self
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
return table
}()
private let confirmButton = AppButton(title: "确认切换")
private var tableDataSource: UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>!
override func setupNavigationBar() {
title = "账号切换"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF5F7FB)
configureTableDataSource()
view.addSubview(tableView)
view.addSubview(confirmButton)
}
override func setupConstraints() {
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)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
self?.applyTableSnapshot(reconfigure: true)
self?.updateConfirmButton()
}
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
Task { await loadAccounts() }
}
private func configureTableDataSource() {
tableDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
guard let self else { return UITableViewCell() }
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
guard let account = self.viewModel.accounts.first(where: { $0.id == item }) else { return cell }
cell.configure(
account: account,
selected: account.id == self.viewModel.selectedAccountId,
isCurrent: account.isCurrent || self.isCurrentAccount(account)
)
return cell
}
applyTableSnapshot(animated: false)
}
private func buildSnapshot() -> NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem> {
var snapshot = NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem>()
snapshot.appendSections([0])
snapshot.appendItems(viewModel.accounts.map(\.id), toSection: 0)
return snapshot
}
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
var snapshot = buildSnapshot()
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
snapshot.reconfigureItems(snapshot.itemIdentifiers)
}
tableDataSource.apply(snapshot, animatingDifferences: animated)
}
private func updateConfirmButton() {
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
confirmButton.isEnabled = enabled
}
private var currentAccountId: String? {
let store = AppStore.shared
if store.currentStoreId > 0 {
return "\(V9StoreUser.accountTypeValue)_\(store.currentStoreId)"
}
if store.currentScenicId > 0 {
return "\(V9ScenicUser.accountTypeValue)_\(store.userId)"
}
return store.userId.isEmpty ? nil : store.userId
}
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
viewModel.isCurrent(account, currentAccountId: currentAccountId)
}
private func loadAccounts(force: Bool = false) async {
showLoading()
defer { hideLoading() }
do {
try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId)
} catch {
showToast(error.localizedDescription)
}
}
@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 {
showLoading()
defer { hideLoading() }
do {
let response = try await viewModel.switchAccount(account, api: authAPI)
AuthSessionHelper.applyAccountSwitch(with: response, account: account)
showToast("账号已切换")
navigationController?.popViewController(animated: true)
} catch {
showToast(error.localizedDescription)
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = tableDataSource.itemIdentifier(for: indexPath),
let account = viewModel.accounts.first(where: { $0.id == item }) else { return }
viewModel.select(account)
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 92 }
}
private final class AccountSwitchCell: UITableViewCell {
static let reuseID = "AccountSwitchCell"
private let card = UIView()
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
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 = AppColor.text666
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.bottom.equalToSuperview().inset(14)
make.width.height.equalTo(22)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
titleLabel.text = account.title
if isCurrent {
titleLabel.text = (account.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 ? AppColor.primary : UIColor(hex: 0xB6BECA)
avatarView.loadRemoteImage(urlString: account.avatar)
}
}

View File

@ -0,0 +1,135 @@
//
// ProfileInfoRowView.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class ProfileInfoRowView: UIControl {
enum AccessoryStyle {
case none
case chevron
case action(String)
}
private let titleLabel = UILabel()
private let valueLabel = UILabel()
private let accessoryLabel = UILabel()
private let chevronView = UIImageView()
private let divider = UIView()
private let badgeContainer = UIView()
private var badgeView: ProfileStatusBadgeView?
var showsDivider = true {
didSet { divider.isHidden = !showsDivider }
}
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(
value: String?,
valueColor: UIColor = AppColor.text333,
accessory: AccessoryStyle = .none,
badge: ProfileStatusBadgeView.Style? = nil
) {
valueLabel.text = value
valueLabel.textColor = valueColor
valueLabel.isHidden = badge != nil
badgeContainer.isHidden = badge == nil
if let badge {
if badgeView == nil {
let view = ProfileStatusBadgeView()
badgeContainer.addSubview(view)
view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
badgeView = view
}
badgeView?.apply(style: badge, text: value ?? "")
}
accessoryLabel.isHidden = true
chevronView.isHidden = true
switch accessory {
case .none:
break
case .chevron:
chevronView.isHidden = false
case .action(let text):
accessoryLabel.text = text
accessoryLabel.isHidden = false
chevronView.isHidden = false
}
}
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 1
accessoryLabel.font = .systemFont(ofSize: 14)
accessoryLabel.textColor = AppColor.primary
chevronView.image = UIImage(systemName: "chevron.right")
chevronView.tintColor = AppColor.text666
chevronView.contentMode = .scaleAspectFit
divider.backgroundColor = UIColor(hex: 0xE5E7EB)
addSubview(titleLabel)
addSubview(valueLabel)
addSubview(badgeContainer)
addSubview(accessoryLabel)
addSubview(chevronView)
addSubview(divider)
snp.makeConstraints { make in
make.height.equalTo(52)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview()
make.centerY.equalToSuperview()
make.width.equalTo(102)
}
chevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview()
make.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
accessoryLabel.snp.makeConstraints { make in
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
make.centerY.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
make.centerY.equalToSuperview()
}
badgeContainer.snp.makeConstraints { make in
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
}
divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(0.5)
}
}
}

View File

@ -0,0 +1,62 @@
//
// ProfileStatusBadgeView.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class ProfileStatusBadgeView: UIView {
enum Style {
case accountActive
case scenic
case auditPending
case auditApproved
case auditRejected
case bankPending
case bankApproved
case bankRejected
}
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
label.font = .systemFont(ofSize: 12, weight: .medium)
label.textAlignment = .center
addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
}
layer.cornerRadius = 4
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(style: Style, text: String) {
label.text = text
switch style {
case .accountActive:
label.textColor = UIColor(hex: 0x22C55E)
backgroundColor = UIColor(hex: 0xF0FDF4)
case .scenic:
label.textColor = AppColor.primary
backgroundColor = UIColor(hex: 0xEFF6FF)
case .auditPending, .bankPending:
label.textColor = UIColor(hex: 0xFF7B00)
backgroundColor = UIColor(hex: 0xFFF0E2)
case .auditApproved, .bankApproved:
label.textColor = AppColor.primary
backgroundColor = UIColor(hex: 0xEFF6FF)
case .auditRejected, .bankRejected:
label.textColor = UIColor(hex: 0xEF4444)
backgroundColor = UIColor(hex: 0xFFE7E7)
}
}
}

View File

@ -3,26 +3,430 @@
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
/// Tab
/// Tab
final class ProfileViewController: BaseViewController {
private let titleLabel = UILabel()
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() {
title = "我的"
titleLabel.text = "我的"
titleLabel.font = .systemFont(ofSize: 24, weight: .medium)
titleLabel.textAlignment = .center
titleLabel.textColor = AppColor.text333
view.addSubview(titleLabel)
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() {
titleLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
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)
}
}
}
}
}

View File

@ -0,0 +1,113 @@
//
// RealNameAuthAuditViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class RealNameAuthAuditViewController: BaseViewController {
private let info: RealNameInfo
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let statusBadge = ProfileStatusBadgeView()
private let rejectLabel = UILabel()
private let frontImageView = UIImageView()
private let backImageView = UIImageView()
init(info: RealNameInfo) {
self.info = info
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "认证状态"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
contentStack.axis = .vertical
contentStack.spacing = 16
rejectLabel.numberOfLines = 0
rejectLabel.font = .systemFont(ofSize: 14)
rejectLabel.textColor = UIColor(hex: 0xEF4444)
[frontImageView, backImageView].forEach {
$0.contentMode = .scaleAspectFit
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
$0.layer.cornerRadius = 8
$0.clipsToBounds = true
$0.snp.makeConstraints { make in make.height.equalTo(160) }
}
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(makeTitle("审核状态"))
contentStack.addArrangedSubview(statusBadge)
if let reason = info.rejectReason, !reason.isEmpty, info.auditStatus == 3 {
rejectLabel.text = "驳回原因:\(reason)"
contentStack.addArrangedSubview(rejectLabel)
}
contentStack.addArrangedSubview(makeInfoRow("姓名", info.realName))
contentStack.addArrangedSubview(makeInfoRow("身份证号", maskID(info.idCardNo)))
contentStack.addArrangedSubview(makeTitle("身份证国徽面"))
contentStack.addArrangedSubview(backImageView)
contentStack.addArrangedSubview(makeTitle("身份证人像面"))
contentStack.addArrangedSubview(frontImageView)
applyStatus()
frontImageView.loadRemoteImage(urlString: info.frontUrl)
backImageView.loadRemoteImage(urlString: info.backUrl)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
}
private func applyStatus() {
switch info.auditStatus {
case 2:
statusBadge.apply(style: .auditApproved, text: "已实名认证")
case 3:
statusBadge.apply(style: .auditRejected, text: "审核不通过")
default:
statusBadge.apply(style: .auditPending, text: info.auditStatusText ?? "审核中")
}
}
private func makeTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 15, weight: .semibold)
return label
}
private func makeInfoRow(_ title: String, _ value: String) -> UILabel {
let label = UILabel()
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.text666
label.text = "\(title)\(value)"
return label
}
private func maskID(_ value: String) -> String {
guard value.count > 8 else { return value }
let prefix = value.prefix(4)
let suffix = value.suffix(4)
return "\(prefix)**********\(suffix)"
}
}

View File

@ -0,0 +1,255 @@
//
// RealNameAuthViewController.swift
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
///
final class RealNameAuthViewController: BaseViewController {
private let viewModel = RealNameAuthViewModel()
private let profileAPI = NetworkServices.shared.profileAPI
private let ossUploadService = NetworkServices.shared.ossUploadService
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 longValidSwitch = UISwitch()
private let startDatePicker = UIDatePicker()
private let endDatePicker = UIDatePicker()
private let submitButton = AppButton(title: "提交审核")
override func setupNavigationBar() {
title = "实名认证"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
contentStack.axis = .vertical
contentStack.spacing = 16
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(submitButton)
[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)
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
contentStack.addArrangedSubview(longValidRow)
contentStack.addArrangedSubview(labeledField("起始日期", startDatePicker))
contentStack.addArrangedSubview(labeledField("结束日期", endDatePicker))
let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton])
smsRow.axis = .horizontal
smsRow.spacing = 8
smsCodeField.snp.makeConstraints { make in make.width.equalTo(200) }
contentStack.addArrangedSubview(labeledField("短信验证码", smsRow))
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(submitButton.snp.top).offset(-12)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
submitButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in self?.applyViewModel() }
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
longValidSwitch.addTarget(self, action: #selector(longValidChanged), for: .valueChanged)
realNameField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged)
idCardField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged)
smsCodeField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged)
startDatePicker.addTarget(self, action: #selector(dateChanged), for: .valueChanged)
endDatePicker.addTarget(self, action: #selector(dateChanged), for: .valueChanged)
Task { await loadInfo() }
}
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
submitButton.isEnabled = !viewModel.submitting
if let data = viewModel.pendingFrontImageData, let image = UIImage(data: data) {
frontImageView.image = image
} else {
frontImageView.loadRemoteImage(urlString: viewModel.frontUrl)
}
if let data = viewModel.pendingBackImageData, let image = UIImage(data: data) {
backImageView.image = image
} else {
backImageView.loadRemoteImage(urlString: viewModel.backUrl)
}
}
@objc private func fieldChanged() {
viewModel.updateRealName(realNameField.text ?? "")
viewModel.updateIdCardNo(idCardField.text ?? "")
viewModel.updateSmsCode(smsCodeField.text ?? "")
}
@objc private func dateChanged() {
viewModel.updateStartDate(startDatePicker.date)
viewModel.updateEndDate(endDatePicker.date)
}
@objc private func longValidChanged() {
viewModel.updateLongValid(longValidSwitch.isOn)
}
@objc private func sendCodeTapped() {
Task {
showLoading()
defer { hideLoading() }
do {
try await viewModel.sendCode(api: profileAPI)
showToast(viewModel.statusMessage ?? "验证码已发送")
} catch {
showToast(error.localizedDescription)
}
}
}
@objc private func submitTapped() {
Task { await submit() }
}
@objc private func pickFront() { pickImage(side: .front) }
@objc private func pickBack() { pickImage(side: .back) }
private var pickingSide: RealNameImageSide = .front
private func pickImage(side: RealNameImageSide) {
pickingSide = side
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
private func loadInfo() async {
showLoading()
defer { hideLoading() }
do {
try await viewModel.load(api: profileAPI)
} catch {
showToast(error.localizedDescription)
}
}
private func submit() async {
let scenicId = AppStore.shared.currentScenicId
guard scenicId > 0 else {
showToast("当前景区信息缺失")
return
}
showLoading()
defer { hideLoading() }
do {
try await viewModel.submit(api: profileAPI, uploader: ossUploadService, scenicId: scenicId)
showToast("提交成功")
navigationController?.popViewController(animated: true)
} catch {
showToast(error.localizedDescription)
}
}
private func makeSectionTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 15, weight: .semibold)
return label
}
private func labeledField(_ title: String, _ field: UIView) -> UIStackView {
let titleLabel = UILabel(text: title)
titleLabel.font = .systemFont(ofSize: 14)
let stack = UIStackView(arrangedSubviews: [titleLabel, field])
stack.axis = .vertical
stack.spacing = 8
return stack
}
}
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 }
Task { @MainActor in
do {
try self.viewModel.prepareIdentityImage(data: data, side: self.pickingSide)
} catch {
self.showToast(error.localizedDescription)
}
}
}
}
}
private extension UILabel {
convenience init(text: String) {
self.init()
self.text = text
}
}

View File

@ -0,0 +1,109 @@
//
// WithdrawalSettingsAuditViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class WithdrawalSettingsAuditViewController: BaseViewController {
private let bankCard: BankCardInfo
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let statusBadge = ProfileStatusBadgeView()
private let rejectLabel = UILabel()
private let frontImageView = UIImageView()
private let backImageView = UIImageView()
init(bankCard: BankCardInfo) {
self.bankCard = bankCard
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "提现设置"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
contentStack.axis = .vertical
contentStack.spacing = 16
rejectLabel.numberOfLines = 0
rejectLabel.font = .systemFont(ofSize: 14)
rejectLabel.textColor = UIColor(hex: 0xEF4444)
[frontImageView, backImageView].forEach {
$0.contentMode = .scaleAspectFit
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
$0.layer.cornerRadius = 8
$0.clipsToBounds = true
$0.snp.makeConstraints { make in make.height.equalTo(140) }
}
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(makeTitle("审核状态"))
contentStack.addArrangedSubview(statusBadge)
if !bankCard.rejectReason.isEmpty, bankCard.auditStatus == 3 {
rejectLabel.text = "驳回原因:\(bankCard.rejectReason)"
contentStack.addArrangedSubview(rejectLabel)
}
contentStack.addArrangedSubview(makeInfoRow("持卡人", bankCard.realName))
contentStack.addArrangedSubview(makeInfoRow("开户银行", bankCard.bankName))
contentStack.addArrangedSubview(makeInfoRow("支行", bankCard.branchName))
contentStack.addArrangedSubview(makeInfoRow("卡号", ProfileViewModel.maskCardNumber(bankCard.cardNumber)))
contentStack.addArrangedSubview(makeTitle("银行卡正面"))
contentStack.addArrangedSubview(frontImageView)
contentStack.addArrangedSubview(makeTitle("银行卡反面"))
contentStack.addArrangedSubview(backImageView)
applyStatus()
frontImageView.loadRemoteImage(urlString: bankCard.frontUrl)
backImageView.loadRemoteImage(urlString: bankCard.backUrl)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
}
private func applyStatus() {
let text = bankCard.auditStatusLabel.isEmpty ? "审核中" : bankCard.auditStatusLabel
switch bankCard.auditStatus {
case 2:
statusBadge.apply(style: .bankApproved, text: text)
case 3:
statusBadge.apply(style: .bankRejected, text: text)
default:
statusBadge.apply(style: .bankPending, text: text)
}
}
private func makeTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 15, weight: .semibold)
return label
}
private func makeInfoRow(_ title: String, _ value: String) -> UILabel {
let label = UILabel()
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.text666
label.text = "\(title)\(value)"
return label
}
}

View File

@ -0,0 +1,294 @@
//
// WithdrawalSettingsViewController.swift
// suixinkan
//
import PhotosUI
import SnapKit
import UIKit
///
final class WithdrawalSettingsViewController: BaseViewController {
private let viewModel = WithdrawalSettingsViewModel()
private let profileAPI = NetworkServices.shared.profileAPI
private let ossUploadService = NetworkServices.shared.ossUploadService
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let stepLabel = UILabel()
private let holderNameField = UITextField()
private let bankNoField = UITextField()
private let frontImageView = UIImageView()
private let backImageView = UIImageView()
private let bankNameField = UITextField()
private let provinceButton = UIButton(type: .system)
private let cityButton = UIButton(type: .system)
private let branchField = UITextField()
private let smsCodeField = UITextField()
private let agreeSwitch = UISwitch()
private let actionButton = AppButton(title: "下一步")
private var pickingSide: BankCardImageSide = .front
private var stepTwoStack = UIStackView()
override func setupNavigationBar() {
title = "提现设置"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
contentStack.axis = .vertical
contentStack.spacing = 16
stepLabel.font = .systemFont(ofSize: 14, weight: .medium)
stepLabel.textColor = AppColor.text666
[holderNameField, bankNoField, bankNameField, branchField, smsCodeField].forEach {
$0.borderStyle = .roundedRect
$0.font = .systemFont(ofSize: 16)
}
holderNameField.placeholder = "持卡人姓名"
bankNoField.placeholder = "银行卡号"
bankNoField.keyboardType = .numberPad
bankNameField.placeholder = "开户银行"
branchField.placeholder = "支行名称"
smsCodeField.placeholder = "短信验证码"
smsCodeField.keyboardType = .numberPad
provinceButton.contentHorizontalAlignment = .leading
cityButton.contentHorizontalAlignment = .leading
provinceButton.setTitle("请选择省份", for: .normal)
cityButton.setTitle("请选择城市", for: .normal)
[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(140) }
}
frontImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickFront)))
backImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickBack)))
stepTwoStack.axis = .vertical
stepTwoStack.spacing = 16
stepTwoStack.isHidden = true
stepTwoStack.addArrangedSubview(labeled("开户银行", bankNameField))
stepTwoStack.addArrangedSubview(labeled("所在省份", provinceButton))
stepTwoStack.addArrangedSubview(labeled("所在城市", cityButton))
stepTwoStack.addArrangedSubview(labeled("支行名称", branchField))
let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton()])
smsRow.axis = .horizontal
smsRow.spacing = 8
stepTwoStack.addArrangedSubview(labeled("短信验证码", smsRow))
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(actionButton)
contentStack.addArrangedSubview(stepLabel)
contentStack.addArrangedSubview(labeled("持卡人姓名", holderNameField))
contentStack.addArrangedSubview(labeled("银行卡号", bankNoField))
contentStack.addArrangedSubview(labeled("银行卡正面", frontImageView))
contentStack.addArrangedSubview(labeled("银行卡反面", backImageView))
contentStack.addArrangedSubview(agreeRow())
contentStack.addArrangedSubview(stepTwoStack)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
make.bottom.equalTo(actionButton.snp.top).offset(-12)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
actionButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in self?.applyViewModel() }
actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
provinceButton.addTarget(self, action: #selector(selectProvince), for: .touchUpInside)
cityButton.addTarget(self, action: #selector(selectCity), for: .touchUpInside)
agreeSwitch.addTarget(self, action: #selector(agreeChanged), for: .valueChanged)
holderNameField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
bankNoField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
bankNameField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
branchField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
smsCodeField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
Task { await loadData() }
}
private func applyViewModel() {
stepLabel.text = viewModel.currentStep == 1 ? "步骤 1/2填写银行卡信息" : "步骤 2/2完善开户信息"
holderNameField.text = viewModel.holderName
bankNoField.text = viewModel.bankNo
bankNameField.text = viewModel.bankName
branchField.text = viewModel.branchName
smsCodeField.text = viewModel.verificationCode
agreeSwitch.isOn = viewModel.isAgree
stepTwoStack.isHidden = viewModel.currentStep == 1
actionButton.setTitle(viewModel.currentStep == 1 ? "下一步" : "提交", for: .normal)
actionButton.isEnabled = viewModel.currentStep == 1 ? viewModel.canGoNextStep : viewModel.canSubmit
provinceButton.setTitle(viewModel.provinceName.isEmpty ? "请选择省份" : viewModel.provinceName, for: .normal)
cityButton.setTitle(viewModel.cityName.isEmpty ? "请选择城市" : viewModel.cityName, for: .normal)
updateImageView(frontImageView, pending: viewModel.pendingFrontImageData, url: viewModel.bankCardFrontURL)
updateImageView(backImageView, pending: viewModel.pendingBackImageData, url: viewModel.bankCardBackURL)
}
private func updateImageView(_ imageView: UIImageView, pending: Data?, url: String) {
if let pending, let image = UIImage(data: pending) {
imageView.image = image
} else if url != "pending" {
imageView.loadRemoteImage(urlString: url)
}
}
@objc private func fieldsChanged() {
viewModel.updateHolderName(holderNameField.text ?? "")
viewModel.updateBankNo(bankNoField.text ?? "")
viewModel.updateBankName(bankNameField.text ?? "")
viewModel.updateBranchName(branchField.text ?? "")
viewModel.updateVerificationCode(smsCodeField.text ?? "")
}
@objc private func agreeChanged() {
viewModel.updateAgree(agreeSwitch.isOn)
}
@objc private func actionTapped() {
if viewModel.currentStep == 1 {
viewModel.goNextStep()
} else {
Task { await submit() }
}
}
@objc private func pickFront() { pickImage(side: .front) }
@objc private func pickBack() { pickImage(side: .back) }
private func pickImage(side: BankCardImageSide) {
pickingSide = side
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 selectProvince() {
presentAreaPicker(title: "选择省份", options: viewModel.provinceOptions) { [weak self] area in
self?.viewModel.selectProvince(area)
}
}
@objc private func selectCity() {
presentAreaPicker(title: "选择城市", options: viewModel.cityOptions) { [weak self] area in
self?.viewModel.selectCity(area)
}
}
private func presentAreaPicker(title: String, options: [AreasResponse], onSelect: @escaping (AreasResponse) -> Void) {
let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
options.forEach { area in
alert.addAction(UIAlertAction(title: area.name, style: .default) { _ in
onSelect(area)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func loadData() async {
showLoading()
defer { hideLoading() }
do {
try await viewModel.load(api: profileAPI)
} catch {
showToast(error.localizedDescription)
}
}
private func submit() async {
let scenicId = AppStore.shared.currentScenicId
guard scenicId > 0 else {
showToast("当前景区信息缺失")
return
}
showLoading()
defer { hideLoading() }
do {
try await viewModel.submit(api: profileAPI, uploader: ossUploadService, scenicId: scenicId)
showToast("提交成功")
navigationController?.popViewController(animated: true)
} catch {
showToast(error.localizedDescription)
}
}
private func labeled(_ title: String, _ view: UIView) -> UIStackView {
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 14)
let stack = UIStackView(arrangedSubviews: [label, view])
stack.axis = .vertical
stack.spacing = 8
return stack
}
private func agreeRow() -> UIStackView {
let label = UILabel()
label.text = "我已阅读并同意相关协议"
label.font = .systemFont(ofSize: 14)
let row = UIStackView(arrangedSubviews: [label, agreeSwitch])
row.axis = .horizontal
return row
}
private func sendCodeButton() -> UIButton {
let button = UIButton(type: .system)
button.setTitle("获取验证码", for: .normal)
button.addAction(UIAction { [weak self] _ in
Task {
guard let self else { return }
self.showLoading()
defer { self.hideLoading() }
do {
try await self.viewModel.sendCode(api: self.profileAPI)
self.showToast("验证码已发送")
} catch {
self.showToast(error.localizedDescription)
}
}
}, for: .touchUpInside)
return button
}
}
extension WithdrawalSettingsViewController: 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.prepareBankCardImage(data: data, side: self.pickingSide)
} catch {
self.showToast(error.localizedDescription)
}
}
}
}
}