From b7bc62e94b853812f1a5596123bd0a5d7aa890d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 6 Jul 2026 16:01:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=88=91=E7=9A=84=20Tab?= =?UTF-8?q?=E3=80=81=E5=AF=B9=E9=BD=90=20Android=20=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E5=B9=B6=E6=8E=A5=E5=85=A5=20OSS=20=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suixinkan.xcodeproj/project.pbxproj | 79 ++-- .../xcshareddata/swiftpm/Package.resolved | 38 +- suixinkan/App/NetworkServices.swift | 12 +- suixinkan/Common/NotificationName.swift | 3 + suixinkan/Core/Upload/OSSUploadService.swift | 178 ++++++++ suixinkan/Core/Upload/UploadAPI.swift | 36 ++ .../Core/Upload/UploadImageProcessors.swift | 139 ++++++ suixinkan/Core/Upload/UploadModels.swift | 92 ++++ suixinkan/DataStore/AppStore.swift | 140 +++++- .../Features/Auth/AuthSessionHelper.swift | 98 +++- .../Features/Auth/Models/AppRoleCode.swift | 88 ++++ .../Features/Auth/Models/AuthModels.swift | 14 +- .../Features/Profile/API/ProfileAPI.swift | 125 ++++++ .../Profile/Models/ProfileModels.swift | 353 +++++++++++++++ .../ViewModels/AccountSwitchViewModel.swift | 77 ++++ .../Profile/ViewModels/ProfileViewModel.swift | 249 ++++++++++ .../ViewModels/RealNameAuthViewModel.swift | 237 ++++++++++ .../WithdrawalSettingsViewModel.swift | 219 +++++++++ suixinkan/Info.plist | 2 + suixinkan/UI/Common/UIImageView+Remote.swift | 20 + suixinkan/UI/Login/LoginViewController.swift | 11 +- suixinkan/UI/Login/LoginViewModel.swift | 2 +- .../Profile/AccountSwitchViewController.swift | 245 ++++++++++ suixinkan/UI/Profile/ProfileInfoRowView.swift | 135 ++++++ .../UI/Profile/ProfileStatusBadgeView.swift | 62 +++ .../UI/Profile/ProfileViewController.swift | 424 +++++++++++++++++- .../RealNameAuthAuditViewController.swift | 113 +++++ .../Profile/RealNameAuthViewController.swift | 255 +++++++++++ ...ithdrawalSettingsAuditViewController.swift | 109 +++++ .../WithdrawalSettingsViewController.swift | 294 ++++++++++++ .../AccountSwitchViewModelTests.swift | 77 ++++ suixinkanTests/LoginViewModelTests.swift | 3 +- suixinkanTests/ProfileModelsTests.swift | 65 +++ suixinkanTests/ProfileViewModelTests.swift | 52 +++ .../RealNameAuthViewModelTests.swift | 30 ++ .../WithdrawalSettingsViewModelTests.swift | 58 +++ 36 files changed, 4077 insertions(+), 57 deletions(-) create mode 100644 suixinkan/Core/Upload/OSSUploadService.swift create mode 100644 suixinkan/Core/Upload/UploadAPI.swift create mode 100644 suixinkan/Core/Upload/UploadImageProcessors.swift create mode 100644 suixinkan/Core/Upload/UploadModels.swift create mode 100644 suixinkan/Features/Auth/Models/AppRoleCode.swift create mode 100644 suixinkan/Features/Profile/API/ProfileAPI.swift create mode 100644 suixinkan/Features/Profile/Models/ProfileModels.swift create mode 100644 suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift create mode 100644 suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift create mode 100644 suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift create mode 100644 suixinkan/Features/Profile/ViewModels/WithdrawalSettingsViewModel.swift create mode 100644 suixinkan/UI/Common/UIImageView+Remote.swift create mode 100644 suixinkan/UI/Profile/AccountSwitchViewController.swift create mode 100644 suixinkan/UI/Profile/ProfileInfoRowView.swift create mode 100644 suixinkan/UI/Profile/ProfileStatusBadgeView.swift create mode 100644 suixinkan/UI/Profile/RealNameAuthAuditViewController.swift create mode 100644 suixinkan/UI/Profile/RealNameAuthViewController.swift create mode 100644 suixinkan/UI/Profile/WithdrawalSettingsAuditViewController.swift create mode 100644 suixinkan/UI/Profile/WithdrawalSettingsViewController.swift create mode 100644 suixinkanTests/AccountSwitchViewModelTests.swift create mode 100644 suixinkanTests/ProfileModelsTests.swift create mode 100644 suixinkanTests/ProfileViewModelTests.swift create mode 100644 suixinkanTests/RealNameAuthViewModelTests.swift create mode 100644 suixinkanTests/WithdrawalSettingsViewModelTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 01c721c..77e6228 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 93FF17E52FFB84950033C304 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E32FFB84950033C304 /* Kingfisher */; }; A1B2C3D12FFB84950033C305 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E22FFB84950033C304 /* SnapKit */; }; A1B2C3D22FFB84950033C305 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E32FFB84950033C304 /* Kingfisher */; }; + B2C3D4E12FFB84950033C306 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = B2C3D4E32FFB84950033C306 /* AlibabaCloudOSS */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -61,6 +62,7 @@ files = ( 93FF17E42FFB84950033C304 /* SnapKit in Frameworks */, 93FF17E52FFB84950033C304 /* Kingfisher in Frameworks */, + B2C3D4E12FFB84950033C306 /* AlibabaCloudOSS in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -116,6 +118,7 @@ packageProductDependencies = ( 93FF17E22FFB84950033C304 /* SnapKit */, 93FF17E32FFB84950033C304 /* Kingfisher */, + B2C3D4E32FFB84950033C306 /* AlibabaCloudOSS */, ); productName = suixinkan; productReference = 93FF17C52FFB84950033C304 /* suixinkan.app */; @@ -148,14 +151,6 @@ }; /* End PBXNativeTarget section */ -/* Begin PBXTargetDependency section */ - A1B2C3D92FFB84950033C305 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 93FF17C42FFB84950033C304 /* suixinkan */; - targetProxy = A1B2C3D82FFB84950033C305 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - /* Begin PBXProject section */ 93FF17BD2FFB84950033C304 /* Project object */ = { isa = PBXProject; @@ -185,6 +180,7 @@ packageReferences = ( 93FF17E02FFB84950033C304 /* XCRemoteSwiftPackageReference "SnapKit" */, 93FF17E12FFB84950033C304 /* XCRemoteSwiftPackageReference "Kingfisher" */, + B2C3D4E22FFB84950033C306 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */, ); preferredProjectObjectVersion = 77; productRefGroup = 93FF17C62FFB84950033C304 /* Products */; @@ -231,6 +227,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A1B2C3D92FFB84950033C305 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 93FF17C42FFB84950033C304 /* suixinkan */; + targetProxy = A1B2C3D82FFB84950033C305 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 93FF17D92FFB84950033C304 /* Debug */ = { isa = XCBuildConfiguration; @@ -425,29 +429,6 @@ }; name = Release; }; - A1B2C3DC2FFB84950033C305 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 56GVN5RNVN; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/suixinkan.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan"; - }; - name = Debug; - }; A1B2C3DA2FFB84950033C305 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -471,6 +452,29 @@ }; name = Release; }; + A1B2C3DC2FFB84950033C305 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/suixinkan.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan"; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -520,6 +524,14 @@ minimumVersion = 8.0.0; }; }; + B2C3D4E22FFB84950033C306 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/aliyun/alibabacloud-oss-swift-sdk-v2.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.3.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -533,6 +545,11 @@ package = 93FF17E12FFB84950033C304 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + B2C3D4E32FFB84950033C306 /* AlibabaCloudOSS */ = { + isa = XCSwiftPackageProductDependency; + package = B2C3D4E22FFB84950033C306 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */; + productName = AlibabaCloudOSS; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 93FF17BD2FFB84950033C304 /* Project object */; diff --git a/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 71b5a7f..5aca56a 100644 --- a/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "6b6ce0f12bbd7c3a70ad4db9892c7afaef3ba38abf1df2e0ab744794af7d3b86", + "originHash" : "bdf5586d6171744520b6e9c884c2dbe9a16818e8af708876240fda75c94cb2f0", "pins" : [ + { + "identity" : "alibabacloud-oss-swift-sdk-v2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aliyun/alibabacloud-oss-swift-sdk-v2.git", + "state" : { + "revision" : "e208a6c3c55847f90944a5bb4453c2e2ce00f1b1", + "version" : "0.3.0" + } + }, { "identity" : "kingfisher", "kind" : "remoteSourceControl", @@ -18,6 +27,33 @@ "revision" : "2842e6e84e82eb9a8dac0100ca90d9444b0307f4", "version" : "5.7.1" } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "xmlcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CoreOffice/XMLCoder.git", + "state" : { + "revision" : "42f62383dbcd074440cb1f6a750b9c02df9e7325", + "version" : "0.18.2" + } } ], "version" : 3 diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 545d164..8309ee6 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -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) } } diff --git a/suixinkan/Common/NotificationName.swift b/suixinkan/Common/NotificationName.swift index 37e45b5..e45700f 100644 --- a/suixinkan/Common/NotificationName.swift +++ b/suixinkan/Common/NotificationName.swift @@ -23,6 +23,9 @@ enum NotificationName { /// 用户主动退出登录 static let userDidLogout = name("userDidLogout") + /// 已登录状态下切换景区/门店账号 + static let accountDidSwitch = name("accountDidSwitch") + /// Token 失效或鉴权失败,需重新登录 static let sessionDidExpire = name("sessionDidExpire") diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift new file mode 100644 index 0000000..7a2a7a0 --- /dev/null +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -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 = ["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格式" + } + } +} diff --git a/suixinkan/Core/Upload/UploadAPI.swift b/suixinkan/Core/Upload/UploadAPI.swift new file mode 100644 index 0000000..da041e8 --- /dev/null +++ b/suixinkan/Core/Upload/UploadAPI.swift @@ -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 {} diff --git a/suixinkan/Core/Upload/UploadImageProcessors.swift b/suixinkan/Core/Upload/UploadImageProcessors.swift new file mode 100644 index 0000000..4fc4b0c --- /dev/null +++ b/suixinkan/Core/Upload/UploadImageProcessors.swift @@ -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)) + } + } +} diff --git a/suixinkan/Core/Upload/UploadModels.swift b/suixinkan/Core/Upload/UploadModels.swift new file mode 100644 index 0000000..5004e1c --- /dev/null +++ b/suixinkan/Core/Upload/UploadModels.swift @@ -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 + } +} diff --git a/suixinkan/DataStore/AppStore.swift b/suixinkan/DataStore/AppStore.swift index 35d08d0..c47dc0c 100644 --- a/suixinkan/DataStore/AppStore.swift +++ b/suixinkan/DataStore/AppStore.swift @@ -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 } } diff --git a/suixinkan/Features/Auth/AuthSessionHelper.swift b/suixinkan/Features/Auth/AuthSessionHelper.swift index 2c3aa08..4754dbe 100644 --- a/suixinkan/Features/Auth/AuthSessionHelper.swift +++ b/suixinkan/Features/Auth/AuthSessionHelper.swift @@ -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 } ?? "" + } } diff --git a/suixinkan/Features/Auth/Models/AppRoleCode.swift b/suixinkan/Features/Auth/Models/AppRoleCode.swift new file mode 100644 index 0000000..e33bbf2 --- /dev/null +++ b/suixinkan/Features/Auth/Models/AppRoleCode.swift @@ -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) } + } +} diff --git a/suixinkan/Features/Auth/Models/AuthModels.swift b/suixinkan/Features/Auth/Models/AuthModels.swift index 7d04dc1..54f909d 100644 --- a/suixinkan/Features/Auth/Models/AuthModels.swift +++ b/suixinkan/Features/Auth/Models/AuthModels.swift @@ -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 } } diff --git a/suixinkan/Features/Profile/API/ProfileAPI.swift b/suixinkan/Features/Profile/API/ProfileAPI.swift new file mode 100644 index 0000000..ad4b091 --- /dev/null +++ b/suixinkan/Features/Profile/API/ProfileAPI.swift @@ -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 + ) + ) + } +} diff --git a/suixinkan/Features/Profile/Models/ProfileModels.swift b/suixinkan/Features/Profile/Models/ProfileModels.swift new file mode 100644 index 0000000..be88d58 --- /dev/null +++ b/suixinkan/Features/Profile/Models/ProfileModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift b/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift new file mode 100644 index 0000000..181c495 --- /dev/null +++ b/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift @@ -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: "账号信息异常,请重新选择账号" + } + } +} diff --git a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..a5c2fca --- /dev/null +++ b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift @@ -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 + } +} diff --git a/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift new file mode 100644 index 0000000..ac4aa84 --- /dev/null +++ b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift @@ -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) + } +} diff --git a/suixinkan/Features/Profile/ViewModels/WithdrawalSettingsViewModel.swift b/suixinkan/Features/Profile/ViewModels/WithdrawalSettingsViewModel.swift new file mode 100644 index 0000000..c77086e --- /dev/null +++ b/suixinkan/Features/Profile/ViewModels/WithdrawalSettingsViewModel.swift @@ -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) + } +} diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index 0eb786d..d76da8f 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -2,6 +2,8 @@ + NSPhotoLibraryUsageDescription + 需要访问相册以选择头像、身份证和银行卡照片 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/suixinkan/UI/Common/UIImageView+Remote.swift b/suixinkan/UI/Common/UIImageView+Remote.swift new file mode 100644 index 0000000..a2fe121 --- /dev/null +++ b/suixinkan/UI/Common/UIImageView+Remote.swift @@ -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) + } +} diff --git a/suixinkan/UI/Login/LoginViewController.swift b/suixinkan/UI/Login/LoginViewController.swift index fb8c52c..4f5566d 100644 --- a/suixinkan/UI/Login/LoginViewController.swift +++ b/suixinkan/UI/Login/LoginViewController.swift @@ -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 ) } diff --git a/suixinkan/UI/Login/LoginViewModel.swift b/suixinkan/UI/Login/LoginViewModel.swift index f912a00..54f1c6b 100644 --- a/suixinkan/UI/Login/LoginViewModel.swift +++ b/suixinkan/UI/Login/LoginViewModel.swift @@ -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) diff --git a/suixinkan/UI/Profile/AccountSwitchViewController.swift b/suixinkan/UI/Profile/AccountSwitchViewController.swift new file mode 100644 index 0000000..807cf1c --- /dev/null +++ b/suixinkan/UI/Profile/AccountSwitchViewController.swift @@ -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! + + 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 { + var snapshot = NSDiffableDataSourceSnapshot() + 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) + } +} diff --git a/suixinkan/UI/Profile/ProfileInfoRowView.swift b/suixinkan/UI/Profile/ProfileInfoRowView.swift new file mode 100644 index 0000000..078ffc7 --- /dev/null +++ b/suixinkan/UI/Profile/ProfileInfoRowView.swift @@ -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) + } + } +} diff --git a/suixinkan/UI/Profile/ProfileStatusBadgeView.swift b/suixinkan/UI/Profile/ProfileStatusBadgeView.swift new file mode 100644 index 0000000..639c7a5 --- /dev/null +++ b/suixinkan/UI/Profile/ProfileStatusBadgeView.swift @@ -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) + } + } +} diff --git a/suixinkan/UI/Profile/ProfileViewController.swift b/suixinkan/UI/Profile/ProfileViewController.swift index dcead50..d60a61d 100644 --- a/suixinkan/UI/Profile/ProfileViewController.swift +++ b/suixinkan/UI/Profile/ProfileViewController.swift @@ -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) + } + } } } } diff --git a/suixinkan/UI/Profile/RealNameAuthAuditViewController.swift b/suixinkan/UI/Profile/RealNameAuthAuditViewController.swift new file mode 100644 index 0000000..502c90c --- /dev/null +++ b/suixinkan/UI/Profile/RealNameAuthAuditViewController.swift @@ -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)" + } +} diff --git a/suixinkan/UI/Profile/RealNameAuthViewController.swift b/suixinkan/UI/Profile/RealNameAuthViewController.swift new file mode 100644 index 0000000..a0d6024 --- /dev/null +++ b/suixinkan/UI/Profile/RealNameAuthViewController.swift @@ -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 + } +} diff --git a/suixinkan/UI/Profile/WithdrawalSettingsAuditViewController.swift b/suixinkan/UI/Profile/WithdrawalSettingsAuditViewController.swift new file mode 100644 index 0000000..d03058e --- /dev/null +++ b/suixinkan/UI/Profile/WithdrawalSettingsAuditViewController.swift @@ -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 + } +} diff --git a/suixinkan/UI/Profile/WithdrawalSettingsViewController.swift b/suixinkan/UI/Profile/WithdrawalSettingsViewController.swift new file mode 100644 index 0000000..591a73a --- /dev/null +++ b/suixinkan/UI/Profile/WithdrawalSettingsViewController.swift @@ -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) + } + } + } + } +} diff --git a/suixinkanTests/AccountSwitchViewModelTests.swift b/suixinkanTests/AccountSwitchViewModelTests.swift new file mode 100644 index 0000000..7eca497 --- /dev/null +++ b/suixinkanTests/AccountSwitchViewModelTests.swift @@ -0,0 +1,77 @@ +// +// AccountSwitchViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 账号切换 ViewModel 测试。 +final class AccountSwitchViewModelTests: XCTestCase { + func testLoadFiltersInvalidAccounts() async throws { + let json = """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "token", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 1, + "user_id": 1, + "scenic_user_id": 1, + "ss_user_id": 9001, + "username": "u1", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "景区A", + "is_current": true + } + ], + "store_users": [] + } + } + """.data(using: .utf8)! + + let session = MockURLSession(responses: [json]) + let api = ProfileAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = AccountSwitchViewModel() + + try await viewModel.load(api: api, force: true) + + XCTAssertEqual(viewModel.accounts.count, 1) + XCTAssertEqual(viewModel.selectedAccount?.businessUserId, 9001) + } + + func testSwitchAccountThrowsWhenBusinessUserIdInvalid() async { + let viewModel = AccountSwitchViewModel() + let account = AccountSwitchAccount( + accountType: V9ScenicUser.accountTypeValue, + businessUserId: 0, + title: "景区A", + subtitle: "张三", + phone: "13800138000", + avatar: "", + scenicName: "景区A", + storeId: nil, + storeName: "", + scenicId: 10, + isCurrent: false + ) + let authAPI = AuthAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: []))) + + do { + _ = try await viewModel.switchAccount(account, api: authAPI) + XCTFail("无效账号应失败") + } catch { + guard let switchError = error as? AccountSwitchError else { + return XCTFail("期望 AccountSwitchError,实际为 \(error)") + } + XCTAssertEqual(switchError, .invalidAccount) + } + } +} diff --git a/suixinkanTests/LoginViewModelTests.swift b/suixinkanTests/LoginViewModelTests.swift index 12b0e6d..c077891 100644 --- a/suixinkanTests/LoginViewModelTests.swift +++ b/suixinkanTests/LoginViewModelTests.swift @@ -93,10 +93,11 @@ final class LoginViewModelTests: XCTestCase { let resolution = try await viewModel.login(authAPI: api) - guard case .completed(let response) = resolution else { + guard case .completed(let response, let account) = resolution else { return XCTFail("单账号登录应直接完成") } XCTAssertEqual(response.token, "business-token-scenic") + XCTAssertEqual(account.businessUserId, 101) XCTAssertFalse(viewModel.isLoading) XCTAssertNil(viewModel.pendingAccountSelection) XCTAssertEqual(session.requests.map { $0.url?.path }, [ diff --git a/suixinkanTests/ProfileModelsTests.swift b/suixinkanTests/ProfileModelsTests.swift new file mode 100644 index 0000000..7b2d457 --- /dev/null +++ b/suixinkanTests/ProfileModelsTests.swift @@ -0,0 +1,65 @@ +// +// ProfileModelsTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// Profile 模型解码测试。 +final class ProfileModelsTests: XCTestCase { + func testUserInfoResponseDecodesSnakeCaseFields() throws { + let json = """ + { + "avatar": "https://cdn.example.com/a.jpg", + "real_name": "张三", + "phone": "13800138000", + "nickname": "小张", + "role_name": "摄影师", + "status": 1, + "status_name": "正常" + } + """.data(using: .utf8)! + + let info = try JSONDecoder().decode(UserInfoResponse.self, from: json) + + XCTAssertEqual(info.realName, "张三") + XCTAssertEqual(info.nickname, "小张") + XCTAssertEqual(info.roleName, "摄影师") + XCTAssertEqual(info.statusName, "正常") + } + + func testBankCardInfoDecodesAuditStatusAsString() throws { + let json = """ + { + "real_name": "张三", + "bank_name": "中国银行", + "branch_name": "南京支行", + "card_number": "6222021234567890", + "audit_status": "2", + "audit_status_label": "审核通过", + "reject_reason": "" + } + """.data(using: .utf8)! + + let info = try JSONDecoder().decode(BankCardInfo.self, from: json) + + XCTAssertEqual(info.auditStatus, 2) + XCTAssertEqual(info.bankName, "中国银行") + } + + func testRealNameInfoVerifiedWhenAuditStatusIsTwo() throws { + let json = """ + { + "real_name": "张三", + "id_card_no": "320102199001011234", + "audit_status": 2, + "is_long_valid": 1 + } + """.data(using: .utf8)! + + let info = try JSONDecoder().decode(RealNameInfo.self, from: json) + + XCTAssertTrue(info.verified) + } +} diff --git a/suixinkanTests/ProfileViewModelTests.swift b/suixinkanTests/ProfileViewModelTests.swift new file mode 100644 index 0000000..3e968ef --- /dev/null +++ b/suixinkanTests/ProfileViewModelTests.swift @@ -0,0 +1,52 @@ +// +// ProfileViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 个人信息页 ViewModel 测试。 +final class ProfileViewModelTests: XCTestCase { + func testDisplayNicknameUsesFallbackWhenEmpty() { + let viewModel = ProfileViewModel() + XCTAssertEqual(viewModel.displayNickname, "未设置昵称") + } + + func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() { + AppStore.shared.roleCode = AppRoleCode.photographer.rawValue + let viewModel = ProfileViewModel() + XCTAssertTrue(viewModel.showPhotographerFields) + AppStore.shared.roleCode = "" + } + + func testMaskCardNumberShowsLastFourDigits() { + XCTAssertEqual( + ProfileViewModel.maskCardNumber("6222021234567890"), + "**** **** **** 7890" + ) + } + + func testUpdatePasswordRejectsShortPassword() async { + let session = MockURLSession(responses: []) + let api = ProfileAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = ProfileViewModel() + + do { + try await viewModel.updatePassword("12345", api: api) + XCTFail("短密码应抛出校验错误") + } catch { + guard let validationError = error as? ProfileValidationError else { + return XCTFail("期望 ProfileValidationError,实际为 \(error)") + } + XCTAssertEqual(validationError, .shortPassword) + } + } + + func testBeginEditingPrefillsNickname() { + let viewModel = ProfileViewModel() + viewModel.beginEditing() + XCTAssertTrue(viewModel.isEditingProfile) + } +} diff --git a/suixinkanTests/RealNameAuthViewModelTests.swift b/suixinkanTests/RealNameAuthViewModelTests.swift new file mode 100644 index 0000000..4184e82 --- /dev/null +++ b/suixinkanTests/RealNameAuthViewModelTests.swift @@ -0,0 +1,30 @@ +// +// RealNameAuthViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 实名认证 ViewModel 测试。 +final class RealNameAuthViewModelTests: XCTestCase { + func testValidationRequiresSmsCodeWhenNotVerified() { + let viewModel = RealNameAuthViewModel() + viewModel.updateRealName("张三") + viewModel.updateIdCardNo("11010519491231002X") + + XCTAssertEqual(viewModel.validationMessage, "请输入短信验证码") + } + + func testIsValidMainlandIDCardNumberChecksChecksum() { + XCTAssertTrue(RealNameAuthViewModel.isValidMainlandIDCardNumber("11010519491231002X")) + XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123")) + } + + func testNormalizedIDCardNumberUppercasesSuffix() { + XCTAssertEqual( + RealNameAuthViewModel.normalizedIDCardNumber("11010519491231002x"), + "11010519491231002X" + ) + } +} diff --git a/suixinkanTests/WithdrawalSettingsViewModelTests.swift b/suixinkanTests/WithdrawalSettingsViewModelTests.swift new file mode 100644 index 0000000..05f2f2a --- /dev/null +++ b/suixinkanTests/WithdrawalSettingsViewModelTests.swift @@ -0,0 +1,58 @@ +// +// WithdrawalSettingsViewModelTests.swift +// suixinkanTests +// + +import UIKit +import XCTest +@testable import suixinkan + +/// 提现设置 ViewModel 测试。 +final class WithdrawalSettingsViewModelTests: XCTestCase { + func testCanGoNextStepRequiresImagesAndAgreement() { + let viewModel = WithdrawalSettingsViewModel() + viewModel.updateHolderName("张三") + viewModel.updateBankNo("6222021234567890") + viewModel.updateAgree(true) + + XCTAssertFalse(viewModel.canGoNextStep) + + try? viewModel.prepareBankCardImage( + data: UIImage(systemName: "photo")!.pngData()!, + side: .front + ) + try? viewModel.prepareBankCardImage( + data: UIImage(systemName: "photo")!.pngData()!, + side: .back + ) + + XCTAssertTrue(viewModel.canGoNextStep) + } + + func testCanSubmitRequiresStepTwoFields() throws { + let viewModel = WithdrawalSettingsViewModel() + viewModel.updateHolderName("张三") + viewModel.updateBankNo("6222021234567890") + viewModel.updateAgree(true) + try viewModel.prepareBankCardImage( + data: XCTUnwrap(UIImage(systemName: "photo")?.pngData()), + side: .front + ) + try viewModel.prepareBankCardImage( + data: XCTUnwrap(UIImage(systemName: "photo")?.pngData()), + side: .back + ) + viewModel.goNextStep() + + viewModel.updateBankName("中国银行") + viewModel.updateBranchName("南京支行") + viewModel.updateVerificationCode("123456") + + XCTAssertFalse(viewModel.canSubmit) + + viewModel.selectProvince(AreasResponse(id: 1, code: "32", name: "江苏", children: [])) + viewModel.selectCity(AreasResponse(id: 2, code: "3201", name: "南京", children: [])) + + XCTAssertTrue(viewModel.canSubmit) + } +}