实现我的 Tab、对齐 Android 流程并接入 OSS 上传
This commit is contained in:
@ -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 */;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,6 +23,9 @@ enum NotificationName {
|
||||
/// 用户主动退出登录
|
||||
static let userDidLogout = name("userDidLogout")
|
||||
|
||||
/// 已登录状态下切换景区/门店账号
|
||||
static let accountDidSwitch = name("accountDidSwitch")
|
||||
|
||||
/// Token 失效或鉴权失败,需重新登录
|
||||
static let sessionDidExpire = name("sessionDidExpire")
|
||||
|
||||
|
||||
178
suixinkan/Core/Upload/OSSUploadService.swift
Normal file
178
suixinkan/Core/Upload/OSSUploadService.swift
Normal file
@ -0,0 +1,178 @@
|
||||
//
|
||||
// OSSUploadService.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
#if canImport(AlibabaCloudOSS)
|
||||
import AlibabaCloudOSS
|
||||
#endif
|
||||
|
||||
/// OSS 上传服务协议,定义 Profile 模块可复用的上传入口。
|
||||
@MainActor
|
||||
protocol OSSUploadServing {
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。
|
||||
final class OSSUploadService {
|
||||
private let configService: any OSSConfigServing
|
||||
|
||||
init(configService: any OSSConfigServing) {
|
||||
self.configService = configService
|
||||
}
|
||||
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "user_avatar", onProgress: onProgress)
|
||||
}
|
||||
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name", onProgress: onProgress)
|
||||
}
|
||||
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
|
||||
}
|
||||
|
||||
private func uploadFile(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
scenicId: Int,
|
||||
moduleType: String,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
onProgress(1)
|
||||
try OSSUploadPolicy.validate(dataSize: data.count, fileName: fileName)
|
||||
|
||||
let config = try await configService.aliyunOSSBucket(bucket: "vipsky")
|
||||
let objectKey = OSSUploadPolicy.objectKey(fileName: fileName, scenicId: scenicId, moduleType: moduleType)
|
||||
onProgress(12)
|
||||
|
||||
#if canImport(AlibabaCloudOSS)
|
||||
let credentialsProvider = StaticCredentialsProvider(
|
||||
accessKeyId: config.credentials.accessKeyId,
|
||||
accessKeySecret: config.credentials.accessKeySecret,
|
||||
securityToken: config.credentials.securityToken
|
||||
)
|
||||
let clientConfig = Configuration.default()
|
||||
.withRegion(config.region)
|
||||
.withCredentialsProvider(credentialsProvider)
|
||||
if !config.endpoint.isEmpty {
|
||||
clientConfig.withEndpoint(config.endpoint)
|
||||
}
|
||||
onProgress(25)
|
||||
let client = Client(clientConfig)
|
||||
_ = try await client.putObject(
|
||||
PutObjectRequest(
|
||||
bucket: config.bucket,
|
||||
key: objectKey,
|
||||
contentType: OSSUploadPolicy.contentType(for: fileName, fileType: fileType),
|
||||
body: .data(data),
|
||||
progress: ProgressClosure { _, transferred, expected in
|
||||
guard expected > 0 else { return }
|
||||
let uploadProgress = Double(transferred) / Double(expected)
|
||||
let progress = max(26, min(99, 25 + Int(uploadProgress * 74)))
|
||||
onProgress(progress)
|
||||
}
|
||||
)
|
||||
)
|
||||
onProgress(100)
|
||||
return OSSUploadPolicy.joinURL(baseURL: config.baseUrl, objectKey: objectKey)
|
||||
#else
|
||||
throw OSSUploadError.sdkUnavailable
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension OSSUploadService: OSSUploadServing {}
|
||||
|
||||
enum OSSUploadPolicy {
|
||||
static let maxFileSize = 2_048 * 1_024 * 1_024
|
||||
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
|
||||
|
||||
static func validate(dataSize: Int, fileName: String) throws {
|
||||
guard dataSize > 0 else { throw OSSUploadError.emptyFile }
|
||||
guard dataSize <= maxFileSize else { throw OSSUploadError.fileTooLarge }
|
||||
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
||||
guard allowedExtensions.contains(ext) else { throw OSSUploadError.unsupportedFileType }
|
||||
}
|
||||
|
||||
static func objectKey(
|
||||
fileName: String,
|
||||
scenicId: Int,
|
||||
moduleType: String,
|
||||
date: Date = Date(),
|
||||
uuid: UUID = UUID(),
|
||||
timeZone: TimeZone = .current
|
||||
) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.timeZone = timeZone
|
||||
formatter.dateFormat = "yyyyMMdd"
|
||||
let currentDate = formatter.string(from: date)
|
||||
let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let safeName = sanitizedFileName(fileName)
|
||||
switch moduleType {
|
||||
case "user_avatar":
|
||||
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "real_name":
|
||||
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
case "bank_card":
|
||||
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
default:
|
||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||
}
|
||||
}
|
||||
|
||||
static func contentType(for fileName: String, fileType: Int) -> String {
|
||||
let ext = URL(fileURLWithPath: fileName).pathExtension
|
||||
if let type = UTType(filenameExtension: ext)?.preferredMIMEType {
|
||||
return type
|
||||
}
|
||||
return fileType == 1 ? "video/mp4" : "image/jpeg"
|
||||
}
|
||||
|
||||
static func joinURL(baseURL: String, objectKey: String) -> String {
|
||||
if baseURL.hasSuffix("/") {
|
||||
return baseURL + objectKey
|
||||
}
|
||||
return baseURL + "/" + objectKey
|
||||
}
|
||||
|
||||
private static func sanitizedFileName(_ fileName: String) -> String {
|
||||
let trimmedName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var unsafeCharacters = CharacterSet.controlCharacters
|
||||
unsafeCharacters.formUnion(CharacterSet(charactersIn: "/\\"))
|
||||
|
||||
let safeName = trimmedName.unicodeScalars.reduce(into: "") { result, scalar in
|
||||
result += unsafeCharacters.contains(scalar) ? "_" : String(scalar)
|
||||
}
|
||||
return safeName.isEmpty ? "upload" : safeName
|
||||
}
|
||||
}
|
||||
|
||||
enum OSSUploadError: LocalizedError, Equatable {
|
||||
case sdkUnavailable
|
||||
case emptyFile
|
||||
case fileTooLarge
|
||||
case unsupportedFileType
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .sdkUnavailable:
|
||||
"阿里云 OSS Swift SDK 尚未链接到当前 iOS target"
|
||||
case .emptyFile:
|
||||
"文件内容不能为空"
|
||||
case .fileTooLarge:
|
||||
"文件大小不能超过2048MB"
|
||||
case .unsupportedFileType:
|
||||
"仅支持.mp4,.mov,.m4v,.avi,.png,.jpg,.jpeg,.heic,.heif格式"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
suixinkan/Core/Upload/UploadAPI.swift
Normal file
36
suixinkan/Core/Upload/UploadAPI.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// UploadAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// OSS 配置服务协议,抽象 STS token 获取能力,便于上传服务测试替换。
|
||||
@MainActor
|
||||
protocol OSSConfigServing {
|
||||
/// 获取指定 bucket 的阿里云 OSS 临时上传配置。
|
||||
func aliyunOSSBucket(bucket: String) async throws -> AliyunOSSResponse
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 上传 API,封装文件上传前需要的服务端配置接口。
|
||||
final class UploadAPI {
|
||||
private let client: APIClient
|
||||
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取指定 bucket 的阿里云 OSS 临时上传配置。
|
||||
func aliyunOSSBucket(bucket: String = "vipsky") async throws -> AliyunOSSResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/config/get-sts-token",
|
||||
queryItems: [URLQueryItem(name: "bucket", value: bucket)]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension UploadAPI: OSSConfigServing {}
|
||||
139
suixinkan/Core/Upload/UploadImageProcessors.swift
Normal file
139
suixinkan/Core/Upload/UploadImageProcessors.swift
Normal file
@ -0,0 +1,139 @@
|
||||
//
|
||||
// UploadImageProcessors.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 头像图片处理器,负责压缩和规范化用户选择的头像图片。
|
||||
enum AvatarImageProcessor {
|
||||
static let maxPixelLength: CGFloat = 1024
|
||||
static let jpegCompressionQuality: CGFloat = 0.82
|
||||
|
||||
/// 处理后的上传图片实体,包含数据、文件名和内容类型。
|
||||
struct ProcessedImage: Equatable {
|
||||
let data: Data
|
||||
let fileName: String
|
||||
let contentType: UTType
|
||||
}
|
||||
|
||||
/// 将原始图片数据转换为适合头像上传的 JPEG 数据。
|
||||
static func process(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> ProcessedImage {
|
||||
try UploadImageRenderer.process(
|
||||
data: data,
|
||||
fileNamePrefix: "avatar",
|
||||
maxPixelLength: maxPixelLength,
|
||||
jpegCompressionQuality: jpegCompressionQuality,
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名证件图片处理器,负责压缩和规范化身份证正反面图片。
|
||||
enum RealNameImageProcessor {
|
||||
static let maxPixelLength: CGFloat = 1800
|
||||
static let jpegCompressionQuality: CGFloat = 0.88
|
||||
|
||||
/// 将原始图片数据转换为适合实名认证上传的 JPEG 数据。
|
||||
static func process(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> AvatarImageProcessor.ProcessedImage {
|
||||
try UploadImageRenderer.process(
|
||||
data: data,
|
||||
fileNamePrefix: "real_name_\(side.rawValue)",
|
||||
maxPixelLength: maxPixelLength,
|
||||
jpegCompressionQuality: jpegCompressionQuality,
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行卡图片处理器,负责压缩银行卡正反面图片。
|
||||
enum BankCardImageProcessor {
|
||||
static let maxPixelLength: CGFloat = 1800
|
||||
static let jpegCompressionQuality: CGFloat = 0.88
|
||||
|
||||
static func process(data: Data, side: BankCardImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> AvatarImageProcessor.ProcessedImage {
|
||||
try UploadImageRenderer.process(
|
||||
data: data,
|
||||
fileNamePrefix: "bank_card_\(side.rawValue)",
|
||||
maxPixelLength: maxPixelLength,
|
||||
jpegCompressionQuality: jpegCompressionQuality,
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证证件图片面向实体,区分人像面和国徽面。
|
||||
enum RealNameImageSide: String {
|
||||
case front
|
||||
case back
|
||||
}
|
||||
|
||||
/// 银行卡图片面向实体。
|
||||
enum BankCardImageSide: String {
|
||||
case front
|
||||
case back
|
||||
}
|
||||
|
||||
/// 上传图片处理错误实体,表示图片无效或编码失败。
|
||||
enum UploadImageProcessingError: LocalizedError, Equatable {
|
||||
case invalidImage
|
||||
case encodingFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidImage:
|
||||
"请选择有效的图片"
|
||||
case .encodingFailed:
|
||||
"图片处理失败,请重新选择"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传图片渲染器,提供通用图片缩放、铺底和 JPEG 编码能力。
|
||||
private enum UploadImageRenderer {
|
||||
static func process(
|
||||
data: Data,
|
||||
fileNamePrefix: String,
|
||||
maxPixelLength: CGFloat,
|
||||
jpegCompressionQuality: CGFloat,
|
||||
timestamp: TimeInterval
|
||||
) throws -> AvatarImageProcessor.ProcessedImage {
|
||||
guard let image = UIImage(data: data), image.size.width > 0, image.size.height > 0 else {
|
||||
throw UploadImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
let normalized = image.normalizedForUpload(maxPixelLength: maxPixelLength)
|
||||
guard let jpegData = normalized.jpegData(compressionQuality: jpegCompressionQuality), !jpegData.isEmpty else {
|
||||
throw UploadImageProcessingError.encodingFailed
|
||||
}
|
||||
|
||||
return AvatarImageProcessor.ProcessedImage(
|
||||
data: jpegData,
|
||||
fileName: "\(fileNamePrefix)_\(Int(timestamp)).jpg",
|
||||
contentType: .jpeg
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIImage {
|
||||
func normalizedForUpload(maxPixelLength: CGFloat) -> UIImage {
|
||||
let longestSide = max(size.width, size.height)
|
||||
let scale = min(1, maxPixelLength / longestSide)
|
||||
let targetSize = CGSize(
|
||||
width: max(1, (size.width * scale).rounded()),
|
||||
height: max(1, (size.height * scale).rounded())
|
||||
)
|
||||
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
format.opaque = true
|
||||
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
|
||||
return renderer.image { context in
|
||||
UIColor.white.setFill()
|
||||
context.fill(CGRect(origin: .zero, size: targetSize))
|
||||
draw(in: CGRect(origin: .zero, size: targetSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
92
suixinkan/Core/Upload/UploadModels.swift
Normal file
92
suixinkan/Core/Upload/UploadModels.swift
Normal file
@ -0,0 +1,92 @@
|
||||
//
|
||||
// UploadModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 阿里云 OSS STS 配置响应实体,表示一次临时上传授权。
|
||||
struct AliyunOSSResponse: Decodable, Equatable {
|
||||
let baseUrl: String
|
||||
let endpoint: String
|
||||
let region: String
|
||||
let bucket: String
|
||||
let expireSeconds: Int
|
||||
let credentials: AliyunOSSCredentials
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case baseUrl = "base_url"
|
||||
case endpoint
|
||||
case region
|
||||
case bucket
|
||||
case expireSeconds = "expire_seconds"
|
||||
case credentials
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
baseUrl = try container.decodeLossyString(forKey: .baseUrl)
|
||||
endpoint = try container.decodeLossyString(forKey: .endpoint)
|
||||
region = try container.decodeLossyString(forKey: .region)
|
||||
bucket = try container.decodeLossyString(forKey: .bucket)
|
||||
expireSeconds = try container.decodeLossyInt(forKey: .expireSeconds) ?? 0
|
||||
credentials = try container.decode(AliyunOSSCredentials.self, forKey: .credentials)
|
||||
}
|
||||
}
|
||||
|
||||
/// 阿里云 OSS 临时凭证实体,表示 SDK 上传所需的访问密钥和安全 token。
|
||||
struct AliyunOSSCredentials: Decodable, Equatable {
|
||||
let accessKeyId: String
|
||||
let accessKeySecret: String
|
||||
let securityToken: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessKeyId = "access_key_id"
|
||||
case accessKeySecret = "access_key_secret"
|
||||
case securityToken = "security_token"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accessKeyId = try container.decodeLossyString(forKey: .accessKeyId)
|
||||
accessKeySecret = try container.decodeLossyString(forKey: .accessKeySecret)
|
||||
securityToken = try container.decodeLossyString(forKey: .securityToken)
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 } ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
88
suixinkan/Features/Auth/Models/AppRoleCode.swift
Normal file
88
suixinkan/Features/Auth/Models/AppRoleCode.swift
Normal file
@ -0,0 +1,88 @@
|
||||
//
|
||||
// AppRoleCode.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 业务角色编码,与 Android `AppRoleCode` 对齐。
|
||||
enum AppRoleCode: String, CaseIterable, Equatable {
|
||||
case driver = "driver"
|
||||
case floristBuilder = "florist_builder"
|
||||
case clerk = "clerk"
|
||||
case liveStream = "live_stream"
|
||||
case frontDesk = "front_desk"
|
||||
case makeupArtist = "makeup_artist"
|
||||
case photographerAssistant = "photographer_assistant"
|
||||
case assistantManager = "assistant_manager"
|
||||
case scenicOperation = "scenic_operation"
|
||||
case scenicAdmin = "scenic_admin"
|
||||
case scenicCS = "scenic_cs"
|
||||
case editor = "editor"
|
||||
case storeAdmin = "store_admin"
|
||||
case photographer = "photographer"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .driver: "司机"
|
||||
case .floristBuilder: "搭建花艺师"
|
||||
case .clerk: "店员"
|
||||
case .liveStream: "直播"
|
||||
case .frontDesk: "前台"
|
||||
case .makeupArtist: "化妆师"
|
||||
case .photographerAssistant: "摄影师助理"
|
||||
case .assistantManager: "副店长"
|
||||
case .scenicOperation: "景区运营"
|
||||
case .scenicAdmin: "景区管理员"
|
||||
case .scenicCS: "飞手"
|
||||
case .editor: "剪辑师"
|
||||
case .storeAdmin: "店铺管理员"
|
||||
case .photographer: "摄影师"
|
||||
}
|
||||
}
|
||||
|
||||
var isPhotographer: Bool {
|
||||
self == .photographer
|
||||
}
|
||||
|
||||
/// 从后端 role_code 解析角色。
|
||||
static func fromCode(_ code: String?) -> AppRoleCode? {
|
||||
let normalized = code?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return AppRoleCode(rawValue: normalized)
|
||||
}
|
||||
|
||||
/// 从旧版 role_id 推断角色。
|
||||
static func fromLegacyRoleId(_ roleId: Int) -> AppRoleCode? {
|
||||
switch roleId {
|
||||
case 41: .photographer
|
||||
case 46: .storeAdmin
|
||||
case 47: .editor
|
||||
case 52: .scenicCS
|
||||
case 53: .scenicAdmin
|
||||
case 54: .scenicOperation
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 role_name 文本推断角色,避免「摄影师助理」误判为摄影师。
|
||||
static func fromRoleName(_ roleName: String?) -> AppRoleCode? {
|
||||
let name = roleName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !name.isEmpty else { return nil }
|
||||
if name.contains("摄影师助理") { return .photographerAssistant }
|
||||
if name.contains("副店长") { return .assistantManager }
|
||||
if name.contains("搭建花艺") || name.contains("花艺师") { return .floristBuilder }
|
||||
if name.contains("店铺管理") || name.contains("店长") { return .storeAdmin }
|
||||
if name.contains("景区管理") { return .scenicAdmin }
|
||||
if name.contains("景区运营") { return .scenicOperation }
|
||||
if name.contains("景区客服") || name.contains("飞手") { return .scenicCS }
|
||||
if name.contains("剪辑") { return .editor }
|
||||
if name.contains("化妆师") { return .makeupArtist }
|
||||
if name.contains("摄影师") { return .photographer }
|
||||
if name.contains("司机") { return .driver }
|
||||
if name.contains("店员") { return .clerk }
|
||||
if name.contains("直播") { return .liveStream }
|
||||
if name.contains("前台") { return .frontDesk }
|
||||
return AppRoleCode.allCases.first { name.contains($0.displayName) }
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
125
suixinkan/Features/Profile/API/ProfileAPI.swift
Normal file
125
suixinkan/Features/Profile/API/ProfileAPI.swift
Normal file
@ -0,0 +1,125 @@
|
||||
//
|
||||
// ProfileAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。
|
||||
final class ProfileAPI {
|
||||
private let client: APIClient
|
||||
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的基础资料。
|
||||
func userInfo() async throws -> UserInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/userinfo")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录账号可切换的景区账号和门店账号。
|
||||
func switchableAccounts() async throws -> V9AuthResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/v9/accounts")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的实名认证状态。
|
||||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/real-name/info")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前登录用户的银行卡信息。
|
||||
func bankCardInfo() async throws -> BankCardInfoResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-card/info")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取开户银行列表。
|
||||
func bankList() async throws -> BankListResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-list")
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取省市区列表。
|
||||
func areas() async throws -> [AreasResponse] {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/config/areas")
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新用户昵称、密码或头像地址。
|
||||
func updateUserInfo(nickname: String? = nil, password: String? = nil, avatar: String? = nil) async throws {
|
||||
let request = UpdateInfoRequest(nickname: nickname, password: password, avatar: avatar)
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/userinfo-update",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 单独更新用户头像 URL,供 OSS 上传完成后回写服务端。
|
||||
func updateUserAvatarURL(_ fileURL: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/userinfo-update-avatar-url",
|
||||
queryItems: [URLQueryItem(name: "file_url", value: fileURL)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 发送实名认证短信验证码。
|
||||
func realNameSmsVerifyCode() async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/real-name/sms-verify-code",
|
||||
body: EmptyPayload()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交实名认证资料。
|
||||
func realNameSubmit(_ request: RealNameAuthRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/real-name/submit",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 发送银行卡绑定短信验证码。
|
||||
func bankCardSmsVerifyCode() async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/wallet/bank-card/sms-verify-code",
|
||||
body: EmptyPayload()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交或更新银行卡信息。
|
||||
func updateBankCard(_ request: UpdateBankInfoRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/wallet/bank-card/update",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
353
suixinkan/Features/Profile/Models/ProfileModels.swift
Normal file
353
suixinkan/Features/Profile/Models/ProfileModels.swift
Normal file
@ -0,0 +1,353 @@
|
||||
//
|
||||
// ProfileModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 用户资料响应实体,表示“我的”页面展示的账号基础信息。
|
||||
struct UserInfoResponse: Decodable, Equatable {
|
||||
let avatar: String
|
||||
let realName: String
|
||||
let phone: String
|
||||
let nickname: String
|
||||
let roleName: String
|
||||
let status: Int
|
||||
let statusName: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case avatar
|
||||
case realName = "real_name"
|
||||
case phone
|
||||
case nickname
|
||||
case roleName = "role_name"
|
||||
case status
|
||||
case statusName = "status_name"
|
||||
}
|
||||
|
||||
init(
|
||||
avatar: String = "",
|
||||
realName: String = "",
|
||||
phone: String = "",
|
||||
nickname: String = "",
|
||||
roleName: String = "",
|
||||
status: Int = 0,
|
||||
statusName: String = ""
|
||||
) {
|
||||
self.avatar = avatar
|
||||
self.realName = realName
|
||||
self.phone = phone
|
||||
self.nickname = nickname
|
||||
self.roleName = roleName
|
||||
self.status = status
|
||||
self.statusName = statusName
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
avatar = try container.decodeLossyString(forKey: .avatar)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
nickname = try container.decodeLossyString(forKey: .nickname)
|
||||
roleName = try container.decodeLossyString(forKey: .roleName)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusName = try container.decodeLossyString(forKey: .statusName)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户资料更新请求实体,表示昵称、密码和头像修改参数。
|
||||
struct UpdateInfoRequest: Encodable {
|
||||
let nickname: String?
|
||||
let password: String?
|
||||
let avatar: String?
|
||||
}
|
||||
|
||||
/// 实名认证响应实体,包裹当前用户的认证详情。
|
||||
struct RealNameInfoResponse: Decodable, Equatable {
|
||||
let realNameInfo: RealNameInfo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realNameInfo = "real_name_info"
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证信息实体,表示当前用户实名审核状态和失败原因。
|
||||
struct RealNameInfo: Decodable, Equatable {
|
||||
let realName: String
|
||||
let idCardNo: String
|
||||
let auditStatus: Int
|
||||
let auditStatusText: String?
|
||||
let rejectReason: String?
|
||||
let startDate: String?
|
||||
let endDate: String?
|
||||
let isLongValid: Bool
|
||||
let frontUrl: String?
|
||||
let backUrl: String?
|
||||
let auditorId: Int?
|
||||
let auditor: RealNameAuditor?
|
||||
let auditAt: String?
|
||||
|
||||
var verified: Bool {
|
||||
auditStatus == 2
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case idCardNo = "id_card_no"
|
||||
case auditStatus = "audit_status"
|
||||
case auditStatusText = "audit_status_text"
|
||||
case rejectReason = "reject_reason"
|
||||
case startDate = "start_date"
|
||||
case endDate = "end_date"
|
||||
case isLongValid = "is_long_valid"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case auditorId = "auditor_id"
|
||||
case auditor
|
||||
case auditAt = "audit_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
idCardNo = try container.decodeLossyString(forKey: .idCardNo)
|
||||
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
|
||||
auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText)
|
||||
rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason)
|
||||
startDate = try container.decodeIfPresent(String.self, forKey: .startDate)
|
||||
endDate = try container.decodeIfPresent(String.self, forKey: .endDate)
|
||||
isLongValid = (try container.decodeLossyInt(forKey: .isLongValid) ?? 0) == 1
|
||||
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
|
||||
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
|
||||
auditorId = try container.decodeLossyInt(forKey: .auditorId)
|
||||
auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor)
|
||||
auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证审核人实体,表示后台审核人的基础展示信息。
|
||||
struct RealNameAuditor: Decodable, Equatable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证提交请求实体,表示用户填写的姓名、证件号、短信和证件图片。
|
||||
struct RealNameAuthRequest: Encodable, Equatable {
|
||||
let realName: String
|
||||
let idCardNo: String
|
||||
let smsVerifyCode: String
|
||||
let startDate: String
|
||||
let endDate: String
|
||||
let isLongValid: Int
|
||||
let frontUrl: String
|
||||
let backUrl: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case idCardNo = "id_card_no"
|
||||
case smsVerifyCode = "sms_verify_code"
|
||||
case startDate = "start_date"
|
||||
case endDate = "end_date"
|
||||
case isLongValid = "is_long_valid"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行卡信息响应实体。
|
||||
struct BankCardInfoResponse: Decodable, Equatable {
|
||||
let bankCard: BankCardInfo?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case bankCard = "bank_card"
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行卡信息实体,表示提现设置与审核状态。
|
||||
struct BankCardInfo: Decodable, Equatable {
|
||||
let realName: String
|
||||
let bankName: String
|
||||
let branchName: String
|
||||
let cardNumber: String
|
||||
let frontUrl: String?
|
||||
let backUrl: String?
|
||||
let provinceCode: String?
|
||||
let cityCode: String?
|
||||
let rejectReason: String
|
||||
let auditStatus: Int
|
||||
let auditStatusLabel: String
|
||||
let auditAt: String?
|
||||
let auditorId: Int?
|
||||
let auditor: RealNameAuditor?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case bankName = "bank_name"
|
||||
case branchName = "branch_name"
|
||||
case cardNumber = "card_number"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case provinceCode = "province_code"
|
||||
case cityCode = "city_code"
|
||||
case rejectReason = "reject_reason"
|
||||
case auditStatus = "audit_status"
|
||||
case auditStatusLabel = "audit_status_label"
|
||||
case auditAt = "audit_at"
|
||||
case auditorId = "auditor_id"
|
||||
case auditor
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
bankName = try container.decodeLossyString(forKey: .bankName)
|
||||
branchName = try container.decodeLossyString(forKey: .branchName)
|
||||
cardNumber = try container.decodeLossyString(forKey: .cardNumber)
|
||||
frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl)
|
||||
backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl)
|
||||
provinceCode = try container.decodeIfPresent(String.self, forKey: .provinceCode)
|
||||
cityCode = try container.decodeIfPresent(String.self, forKey: .cityCode)
|
||||
rejectReason = try container.decodeLossyString(forKey: .rejectReason)
|
||||
auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0
|
||||
auditStatusLabel = try container.decodeLossyString(forKey: .auditStatusLabel)
|
||||
auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt)
|
||||
auditorId = try container.decodeLossyInt(forKey: .auditorId)
|
||||
auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor)
|
||||
}
|
||||
}
|
||||
|
||||
/// 银行列表响应实体。
|
||||
struct BankListResponse: Decodable, Equatable {
|
||||
let banks: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case banks
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
banks = try container.decodeIfPresent([String].self, forKey: .banks) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 省市区响应实体。
|
||||
struct AreasResponse: Decodable, Equatable, Identifiable {
|
||||
let id: Int
|
||||
let code: String
|
||||
let name: String
|
||||
let children: [AreasResponse]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case code
|
||||
case name
|
||||
case children
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id)
|
||||
?? container.decodeLossyInt(forKey: .code)
|
||||
?? 0
|
||||
let decodedCode = try container.decodeLossyString(forKey: .code)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
code = decodedCode.isEmpty ? "\(id)" : decodedCode
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
children = try container.decodeIfPresent([AreasResponse].self, forKey: .children) ?? []
|
||||
}
|
||||
|
||||
init(id: Int, code: String, name: String, children: [AreasResponse] = []) {
|
||||
self.id = id
|
||||
self.code = code
|
||||
self.name = name
|
||||
self.children = children
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新银行卡信息请求实体。
|
||||
struct UpdateBankInfoRequest: Encodable, Equatable {
|
||||
let realName: String
|
||||
let cardNumber: String
|
||||
let bankName: String
|
||||
let branchName: String
|
||||
let frontUrl: String
|
||||
let backUrl: String
|
||||
let provinceCode: String
|
||||
let cityCode: String
|
||||
let smsVerifyCode: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case realName = "real_name"
|
||||
case cardNumber = "card_number"
|
||||
case bankName = "bank_name"
|
||||
case branchName = "branch_name"
|
||||
case frontUrl = "front_url"
|
||||
case backUrl = "back_url"
|
||||
case provinceCode = "province_code"
|
||||
case cityCode = "city_code"
|
||||
case smsVerifyCode = "sms_verify_code"
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人信息页校验错误实体。
|
||||
enum ProfileValidationError: LocalizedError, Equatable {
|
||||
case emptyNickname
|
||||
case shortPassword
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyNickname:
|
||||
"请输入昵称"
|
||||
case .shortPassword:
|
||||
"密码至少 6 位"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -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: "账号信息异常,请重新选择账号"
|
||||
}
|
||||
}
|
||||
}
|
||||
249
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
249
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
@ -0,0 +1,249 @@
|
||||
//
|
||||
// ProfileViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
||||
final class ProfileViewModel {
|
||||
|
||||
private(set) var userInfo: UserInfoResponse?
|
||||
private(set) var realNameInfo: RealNameInfo?
|
||||
private(set) var bankCardInfo: BankCardInfo?
|
||||
private(set) var isLoading = false
|
||||
private(set) var isSaving = false
|
||||
private(set) var isEditingProfile = false
|
||||
private(set) var editingNickname = ""
|
||||
private(set) var pendingAvatarData: Data?
|
||||
private(set) var pendingAvatarFileName: String?
|
||||
private(set) var avatarUploadProgress: Int?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
}
|
||||
|
||||
var displayRealName: String {
|
||||
nonEmpty(userInfo?.realName) ?? "--"
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
nonEmpty(userInfo?.phone) ?? AppStore.shared.phone.nonEmpty ?? "--"
|
||||
}
|
||||
|
||||
var displayAvatarURL: String {
|
||||
nonEmpty(userInfo?.avatar) ?? AppStore.shared.avatar.nonEmpty ?? ""
|
||||
}
|
||||
|
||||
var displayUID: String {
|
||||
let uid = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return uid.isEmpty ? "--" : uid
|
||||
}
|
||||
|
||||
var accountDisplayName: String {
|
||||
let name = AppStore.shared.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "--" : name
|
||||
}
|
||||
|
||||
var currentScenicName: String {
|
||||
let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "--" : name
|
||||
}
|
||||
|
||||
var accountStatusText: String {
|
||||
nonEmpty(userInfo?.statusName) ?? "--"
|
||||
}
|
||||
|
||||
var showPhotographerFields: Bool {
|
||||
if AppStore.shared.isPhotographerRole {
|
||||
return true
|
||||
}
|
||||
if let roleName = nonEmpty(userInfo?.roleName) {
|
||||
return AppRoleCode.fromRoleName(roleName)?.isPhotographer == true || roleName == "摄影师"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var realNameStatusText: String {
|
||||
guard let realNameInfo else { return "点击去实名认证" }
|
||||
switch realNameInfo.auditStatus {
|
||||
case 2: return "已实名认证"
|
||||
case 3: return "审核不通过"
|
||||
default: return "审核中"
|
||||
}
|
||||
}
|
||||
|
||||
var bankCardDisplayText: String {
|
||||
guard let bankCardInfo else { return "点击绑定银行卡" }
|
||||
if bankCardInfo.auditStatus == 2 {
|
||||
let masked = Self.maskCardNumber(bankCardInfo.cardNumber)
|
||||
return "\(bankCardInfo.bankName) \(masked)"
|
||||
}
|
||||
let label = bankCardInfo.auditStatusLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return label.isEmpty ? "审核中" : label
|
||||
}
|
||||
|
||||
func updateEditingNickname(_ value: String) {
|
||||
editingNickname = value
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 重新加载用户资料,摄影师额外拉取实名与银行卡信息。
|
||||
func reload(api: ProfileAPI) async throws {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let info = try await api.userInfo()
|
||||
userInfo = info
|
||||
AppStore.shared.applyUserInfo(info)
|
||||
if !info.roleName.isEmpty {
|
||||
AppStore.shared.roleName = info.roleName
|
||||
}
|
||||
|
||||
if showPhotographerFields {
|
||||
async let realName = api.realNameInfo()
|
||||
async let bankCard = api.bankCardInfo()
|
||||
let extra = try await (realName, bankCard)
|
||||
realNameInfo = extra.0.realNameInfo
|
||||
bankCardInfo = extra.1.bankCard
|
||||
} else {
|
||||
realNameInfo = nil
|
||||
bankCardInfo = nil
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func beginEditing() {
|
||||
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
|
||||
isEditingProfile = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
editingNickname = ""
|
||||
isEditingProfile = false
|
||||
clearPendingAvatar()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
|
||||
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
|
||||
pendingAvatarData = processed.data
|
||||
pendingAvatarFileName = processed.fileName
|
||||
if !isEditingProfile {
|
||||
beginEditing()
|
||||
} else {
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !nextNickname.isEmpty else {
|
||||
throw ProfileValidationError.emptyNickname
|
||||
}
|
||||
|
||||
let nicknameChanged = nextNickname != displayNickname
|
||||
let avatarChanged = pendingAvatarData != nil
|
||||
guard nicknameChanged || avatarChanged else {
|
||||
cancelEditing()
|
||||
return
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
avatarUploadProgress = avatarChanged ? 1 : nil
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSaving = false
|
||||
avatarUploadProgress = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
|
||||
if let uploadedAvatarURL {
|
||||
try await api.updateUserAvatarURL(uploadedAvatarURL)
|
||||
}
|
||||
if nicknameChanged {
|
||||
try await api.updateUserInfo(nickname: nextNickname)
|
||||
}
|
||||
|
||||
let previous = userInfo ?? UserInfoResponse()
|
||||
userInfo = UserInfoResponse(
|
||||
avatar: uploadedAvatarURL ?? previous.avatar,
|
||||
realName: previous.realName,
|
||||
phone: previous.phone,
|
||||
nickname: nextNickname,
|
||||
roleName: previous.roleName,
|
||||
status: previous.status,
|
||||
statusName: previous.statusName
|
||||
)
|
||||
AppStore.shared.userName = nextNickname
|
||||
if let uploadedAvatarURL {
|
||||
AppStore.shared.avatar = uploadedAvatarURL
|
||||
}
|
||||
clearPendingAvatar()
|
||||
cancelEditing()
|
||||
}
|
||||
|
||||
func updatePassword(_ password: String, api: ProfileAPI) async throws {
|
||||
let nextPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard nextPassword.count >= 6 else {
|
||||
throw ProfileValidationError.shortPassword
|
||||
}
|
||||
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSaving = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
try await api.updateUserInfo(password: nextPassword)
|
||||
}
|
||||
|
||||
static func maskCardNumber(_ cardNumber: String) -> String {
|
||||
let digits = cardNumber.filter(\.isNumber)
|
||||
guard digits.count >= 4 else { return cardNumber }
|
||||
return "**** **** **** \(digits.suffix(4))"
|
||||
}
|
||||
|
||||
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
|
||||
guard let pendingAvatarData else { return nil }
|
||||
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
|
||||
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
|
||||
self?.avatarUploadProgress = progress
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func clearPendingAvatar() {
|
||||
pendingAvatarData = nil
|
||||
pendingAvatarFileName = nil
|
||||
avatarUploadProgress = nil
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要访问相册以选择头像、身份证和银行卡照片</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
20
suixinkan/UI/Common/UIImageView+Remote.swift
Normal file
20
suixinkan/UI/Common/UIImageView+Remote.swift
Normal file
@ -0,0 +1,20 @@
|
||||
//
|
||||
// UIImageView+Remote.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import UIKit
|
||||
|
||||
extension UIImageView {
|
||||
/// 加载远程头像,空 URL 时显示占位背景。
|
||||
func loadRemoteImage(urlString: String?, placeholderColor: UIColor = UIColor(hex: 0xEFF6FF)) {
|
||||
backgroundColor = placeholderColor
|
||||
let trimmed = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard let url = URL(string: trimmed), !trimmed.isEmpty else {
|
||||
image = nil
|
||||
return
|
||||
}
|
||||
kf.setImage(with: url, placeholder: nil)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
245
suixinkan/UI/Profile/AccountSwitchViewController.swift
Normal file
245
suixinkan/UI/Profile/AccountSwitchViewController.swift
Normal file
@ -0,0 +1,245 @@
|
||||
//
|
||||
// AccountSwitchViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
private typealias AccountSwitchSection = Int
|
||||
private typealias AccountSwitchItem = String
|
||||
|
||||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||||
final class AccountSwitchViewController: BaseViewController, UITableViewDelegate {
|
||||
|
||||
private let viewModel = AccountSwitchViewModel()
|
||||
private let profileAPI = NetworkServices.shared.profileAPI
|
||||
private let authAPI = NetworkServices.shared.authAPI
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let table = UITableView(frame: .zero, style: .plain)
|
||||
table.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
table.separatorStyle = .none
|
||||
table.delegate = self
|
||||
table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID)
|
||||
return table
|
||||
}()
|
||||
|
||||
private let confirmButton = AppButton(title: "确认切换")
|
||||
|
||||
private var tableDataSource: UITableViewDiffableDataSource<AccountSwitchSection, AccountSwitchItem>!
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "账号切换"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF5F7FB)
|
||||
configureTableDataSource()
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(confirmButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(confirmButton.snp.top).offset(-12)
|
||||
}
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
self?.applyTableSnapshot(reconfigure: true)
|
||||
self?.updateConfirmButton()
|
||||
}
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
Task { await loadAccounts() }
|
||||
}
|
||||
|
||||
private func configureTableDataSource() {
|
||||
tableDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||
guard let self else { return UITableViewCell() }
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell
|
||||
guard let account = self.viewModel.accounts.first(where: { $0.id == item }) else { return cell }
|
||||
cell.configure(
|
||||
account: account,
|
||||
selected: account.id == self.viewModel.selectedAccountId,
|
||||
isCurrent: account.isCurrent || self.isCurrentAccount(account)
|
||||
)
|
||||
return cell
|
||||
}
|
||||
applyTableSnapshot(animated: false)
|
||||
}
|
||||
|
||||
private func buildSnapshot() -> NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<AccountSwitchSection, AccountSwitchItem>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.accounts.map(\.id), toSection: 0)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) {
|
||||
var snapshot = buildSnapshot()
|
||||
if reconfigure, !snapshot.itemIdentifiers.isEmpty {
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
}
|
||||
tableDataSource.apply(snapshot, animatingDifferences: animated)
|
||||
}
|
||||
|
||||
private func updateConfirmButton() {
|
||||
let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching
|
||||
confirmButton.isEnabled = enabled
|
||||
}
|
||||
|
||||
private var currentAccountId: String? {
|
||||
let store = AppStore.shared
|
||||
if store.currentStoreId > 0 {
|
||||
return "\(V9StoreUser.accountTypeValue)_\(store.currentStoreId)"
|
||||
}
|
||||
if store.currentScenicId > 0 {
|
||||
return "\(V9ScenicUser.accountTypeValue)_\(store.userId)"
|
||||
}
|
||||
return store.userId.isEmpty ? nil : store.userId
|
||||
}
|
||||
|
||||
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
|
||||
viewModel.isCurrent(account, currentAccountId: currentAccountId)
|
||||
}
|
||||
|
||||
private func loadAccounts(force: Bool = false) async {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.load(api: profileAPI, force: force, currentAccountId: currentAccountId)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard let account = viewModel.selectedAccount else { return }
|
||||
if account.isCurrent || isCurrentAccount(account) {
|
||||
navigationController?.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
Task { await switchAccount(account) }
|
||||
}
|
||||
|
||||
private func switchAccount(_ account: AccountSwitchAccount) async {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
let response = try await viewModel.switchAccount(account, api: authAPI)
|
||||
AuthSessionHelper.applyAccountSwitch(with: response, account: account)
|
||||
showToast("账号已切换")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let item = tableDataSource.itemIdentifier(for: indexPath),
|
||||
let account = viewModel.accounts.first(where: { $0.id == item }) else { return }
|
||||
viewModel.select(account)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 92 }
|
||||
}
|
||||
|
||||
private final class AccountSwitchCell: UITableViewCell {
|
||||
static let reuseID = "AccountSwitchCell"
|
||||
|
||||
private let card = UIView()
|
||||
private let avatarView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let tagLabel = UILabel()
|
||||
private let checkView = UIImageView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 12
|
||||
contentView.addSubview(card)
|
||||
|
||||
avatarView.layer.cornerRadius = 25
|
||||
avatarView.clipsToBounds = true
|
||||
avatarView.contentMode = .scaleAspectFill
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
subtitleLabel.font = .systemFont(ofSize: 13)
|
||||
subtitleLabel.textColor = AppColor.text666
|
||||
phoneLabel.font = .systemFont(ofSize: 12)
|
||||
phoneLabel.textColor = UIColor(hex: 0x9AA1AA)
|
||||
tagLabel.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
tagLabel.textAlignment = .center
|
||||
tagLabel.layer.cornerRadius = 4
|
||||
tagLabel.clipsToBounds = true
|
||||
|
||||
card.addSubview(avatarView)
|
||||
card.addSubview(titleLabel)
|
||||
card.addSubview(subtitleLabel)
|
||||
card.addSubview(phoneLabel)
|
||||
card.addSubview(tagLabel)
|
||||
card.addSubview(checkView)
|
||||
|
||||
card.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
|
||||
}
|
||||
avatarView.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview().inset(14)
|
||||
make.width.height.equalTo(50)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(avatarView.snp.trailing).offset(13)
|
||||
make.top.equalTo(avatarView).offset(2)
|
||||
make.trailing.lessThanOrEqualTo(tagLabel.snp.leading).offset(-8)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||
}
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(4)
|
||||
}
|
||||
tagLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.top.equalToSuperview().offset(14)
|
||||
make.height.equalTo(22)
|
||||
make.width.greaterThanOrEqualTo(40)
|
||||
}
|
||||
checkView.snp.makeConstraints { make in
|
||||
make.trailing.bottom.equalToSuperview().inset(14)
|
||||
make.width.height.equalTo(22)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) {
|
||||
titleLabel.text = account.title
|
||||
if isCurrent {
|
||||
titleLabel.text = (account.title) + " (当前)"
|
||||
}
|
||||
subtitleLabel.text = account.subtitle
|
||||
phoneLabel.text = account.phone
|
||||
tagLabel.text = account.isStoreUser ? "门店" : "景区"
|
||||
tagLabel.textColor = account.isStoreUser ? UIColor(hex: 0x0F9F6E) : UIColor(hex: 0x7C3AED)
|
||||
tagLabel.backgroundColor = account.isStoreUser ? UIColor(hex: 0xE8F8F1) : UIColor(hex: 0xF3ECFF)
|
||||
checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
checkView.tintColor = selected ? AppColor.primary : UIColor(hex: 0xB6BECA)
|
||||
avatarView.loadRemoteImage(urlString: account.avatar)
|
||||
}
|
||||
}
|
||||
135
suixinkan/UI/Profile/ProfileInfoRowView.swift
Normal file
135
suixinkan/UI/Profile/ProfileInfoRowView.swift
Normal file
@ -0,0 +1,135 @@
|
||||
//
|
||||
// ProfileInfoRowView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 个人信息卡片行,展示左侧标题与右侧内容,可选点击与箭头。
|
||||
final class ProfileInfoRowView: UIControl {
|
||||
|
||||
enum AccessoryStyle {
|
||||
case none
|
||||
case chevron
|
||||
case action(String)
|
||||
}
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let valueLabel = UILabel()
|
||||
private let accessoryLabel = UILabel()
|
||||
private let chevronView = UIImageView()
|
||||
private let divider = UIView()
|
||||
private let badgeContainer = UIView()
|
||||
private var badgeView: ProfileStatusBadgeView?
|
||||
|
||||
var showsDivider = true {
|
||||
didSet { divider.isHidden = !showsDivider }
|
||||
}
|
||||
|
||||
init(title: String) {
|
||||
super.init(frame: .zero)
|
||||
titleLabel.text = title
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(
|
||||
value: String?,
|
||||
valueColor: UIColor = AppColor.text333,
|
||||
accessory: AccessoryStyle = .none,
|
||||
badge: ProfileStatusBadgeView.Style? = nil
|
||||
) {
|
||||
valueLabel.text = value
|
||||
valueLabel.textColor = valueColor
|
||||
valueLabel.isHidden = badge != nil
|
||||
badgeContainer.isHidden = badge == nil
|
||||
|
||||
if let badge {
|
||||
if badgeView == nil {
|
||||
let view = ProfileStatusBadgeView()
|
||||
badgeContainer.addSubview(view)
|
||||
view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
badgeView = view
|
||||
}
|
||||
badgeView?.apply(style: badge, text: value ?? "")
|
||||
}
|
||||
|
||||
accessoryLabel.isHidden = true
|
||||
chevronView.isHidden = true
|
||||
switch accessory {
|
||||
case .none:
|
||||
break
|
||||
case .chevron:
|
||||
chevronView.isHidden = false
|
||||
case .action(let text):
|
||||
accessoryLabel.text = text
|
||||
accessoryLabel.isHidden = false
|
||||
chevronView.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
titleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
valueLabel.font = .systemFont(ofSize: 14)
|
||||
valueLabel.textAlignment = .right
|
||||
valueLabel.numberOfLines = 1
|
||||
|
||||
accessoryLabel.font = .systemFont(ofSize: 14)
|
||||
accessoryLabel.textColor = AppColor.primary
|
||||
|
||||
chevronView.image = UIImage(systemName: "chevron.right")
|
||||
chevronView.tintColor = AppColor.text666
|
||||
chevronView.contentMode = .scaleAspectFit
|
||||
|
||||
divider.backgroundColor = UIColor(hex: 0xE5E7EB)
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(valueLabel)
|
||||
addSubview(badgeContainer)
|
||||
addSubview(accessoryLabel)
|
||||
addSubview(chevronView)
|
||||
addSubview(divider)
|
||||
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(52)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview()
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.equalTo(102)
|
||||
}
|
||||
chevronView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview()
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(16)
|
||||
}
|
||||
accessoryLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
|
||||
make.trailing.equalTo(chevronView.snp.leading).offset(-4)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
badgeContainer.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
|
||||
}
|
||||
divider.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
62
suixinkan/UI/Profile/ProfileStatusBadgeView.swift
Normal file
62
suixinkan/UI/Profile/ProfileStatusBadgeView.swift
Normal file
@ -0,0 +1,62 @@
|
||||
//
|
||||
// ProfileStatusBadgeView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 个人信息页状态标签,用于账号状态、审核状态等展示。
|
||||
final class ProfileStatusBadgeView: UIView {
|
||||
|
||||
enum Style {
|
||||
case accountActive
|
||||
case scenic
|
||||
case auditPending
|
||||
case auditApproved
|
||||
case auditRejected
|
||||
case bankPending
|
||||
case bankApproved
|
||||
case bankRejected
|
||||
}
|
||||
|
||||
private let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
label.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
label.textAlignment = .center
|
||||
addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
|
||||
}
|
||||
layer.cornerRadius = 4
|
||||
clipsToBounds = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(style: Style, text: String) {
|
||||
label.text = text
|
||||
switch style {
|
||||
case .accountActive:
|
||||
label.textColor = UIColor(hex: 0x22C55E)
|
||||
backgroundColor = UIColor(hex: 0xF0FDF4)
|
||||
case .scenic:
|
||||
label.textColor = AppColor.primary
|
||||
backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
case .auditPending, .bankPending:
|
||||
label.textColor = UIColor(hex: 0xFF7B00)
|
||||
backgroundColor = UIColor(hex: 0xFFF0E2)
|
||||
case .auditApproved, .bankApproved:
|
||||
label.textColor = AppColor.primary
|
||||
backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
case .auditRejected, .bankRejected:
|
||||
label.textColor = UIColor(hex: 0xEF4444)
|
||||
backgroundColor = UIColor(hex: 0xFFE7E7)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
113
suixinkan/UI/Profile/RealNameAuthAuditViewController.swift
Normal file
113
suixinkan/UI/Profile/RealNameAuthAuditViewController.swift
Normal file
@ -0,0 +1,113 @@
|
||||
//
|
||||
// RealNameAuthAuditViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 实名认证审核状态页,展示审核结果与证件预览。
|
||||
final class RealNameAuthAuditViewController: BaseViewController {
|
||||
|
||||
private let info: RealNameInfo
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let statusBadge = ProfileStatusBadgeView()
|
||||
private let rejectLabel = UILabel()
|
||||
private let frontImageView = UIImageView()
|
||||
private let backImageView = UIImageView()
|
||||
|
||||
init(info: RealNameInfo) {
|
||||
self.info = info
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "认证状态"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
|
||||
rejectLabel.numberOfLines = 0
|
||||
rejectLabel.font = .systemFont(ofSize: 14)
|
||||
rejectLabel.textColor = UIColor(hex: 0xEF4444)
|
||||
|
||||
[frontImageView, backImageView].forEach {
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.clipsToBounds = true
|
||||
$0.snp.makeConstraints { make in make.height.equalTo(160) }
|
||||
}
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
|
||||
contentStack.addArrangedSubview(makeTitle("审核状态"))
|
||||
contentStack.addArrangedSubview(statusBadge)
|
||||
if let reason = info.rejectReason, !reason.isEmpty, info.auditStatus == 3 {
|
||||
rejectLabel.text = "驳回原因:\(reason)"
|
||||
contentStack.addArrangedSubview(rejectLabel)
|
||||
}
|
||||
contentStack.addArrangedSubview(makeInfoRow("姓名", info.realName))
|
||||
contentStack.addArrangedSubview(makeInfoRow("身份证号", maskID(info.idCardNo)))
|
||||
contentStack.addArrangedSubview(makeTitle("身份证国徽面"))
|
||||
contentStack.addArrangedSubview(backImageView)
|
||||
contentStack.addArrangedSubview(makeTitle("身份证人像面"))
|
||||
contentStack.addArrangedSubview(frontImageView)
|
||||
|
||||
applyStatus()
|
||||
frontImageView.loadRemoteImage(urlString: info.frontUrl)
|
||||
backImageView.loadRemoteImage(urlString: info.backUrl)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyStatus() {
|
||||
switch info.auditStatus {
|
||||
case 2:
|
||||
statusBadge.apply(style: .auditApproved, text: "已实名认证")
|
||||
case 3:
|
||||
statusBadge.apply(style: .auditRejected, text: "审核不通过")
|
||||
default:
|
||||
statusBadge.apply(style: .auditPending, text: info.auditStatusText ?? "审核中")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeInfoRow(_ title: String, _ value: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.textColor = AppColor.text666
|
||||
label.text = "\(title):\(value)"
|
||||
return label
|
||||
}
|
||||
|
||||
private func maskID(_ value: String) -> String {
|
||||
guard value.count > 8 else { return value }
|
||||
let prefix = value.prefix(4)
|
||||
let suffix = value.suffix(4)
|
||||
return "\(prefix)**********\(suffix)"
|
||||
}
|
||||
}
|
||||
255
suixinkan/UI/Profile/RealNameAuthViewController.swift
Normal file
255
suixinkan/UI/Profile/RealNameAuthViewController.swift
Normal file
@ -0,0 +1,255 @@
|
||||
//
|
||||
// RealNameAuthViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 实名认证页面,展示表单并允许提交基础实名资料。
|
||||
final class RealNameAuthViewController: BaseViewController {
|
||||
|
||||
private let viewModel = RealNameAuthViewModel()
|
||||
private let profileAPI = NetworkServices.shared.profileAPI
|
||||
private let ossUploadService = NetworkServices.shared.ossUploadService
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let realNameField = UITextField()
|
||||
private let idCardField = UITextField()
|
||||
private let smsCodeField = UITextField()
|
||||
private let frontImageView = UIImageView()
|
||||
private let backImageView = UIImageView()
|
||||
private let longValidSwitch = UISwitch()
|
||||
private let startDatePicker = UIDatePicker()
|
||||
private let endDatePicker = UIDatePicker()
|
||||
private let submitButton = AppButton(title: "提交审核")
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "实名认证"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
view.addSubview(submitButton)
|
||||
|
||||
[realNameField, idCardField, smsCodeField].forEach {
|
||||
$0.borderStyle = .roundedRect
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
}
|
||||
realNameField.placeholder = "请输入姓名"
|
||||
idCardField.placeholder = "请输入身份证号码"
|
||||
smsCodeField.placeholder = "请输入短信验证码"
|
||||
smsCodeField.keyboardType = .numberPad
|
||||
|
||||
[frontImageView, backImageView].forEach {
|
||||
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.clipsToBounds = true
|
||||
$0.isUserInteractionEnabled = true
|
||||
$0.snp.makeConstraints { make in make.height.equalTo(160) }
|
||||
}
|
||||
frontImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickFront)))
|
||||
backImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickBack)))
|
||||
|
||||
startDatePicker.datePickerMode = .date
|
||||
endDatePicker.datePickerMode = .date
|
||||
if #available(iOS 17.0, *) {
|
||||
startDatePicker.preferredDatePickerStyle = .compact
|
||||
endDatePicker.preferredDatePickerStyle = .compact
|
||||
}
|
||||
|
||||
let sendCodeButton = UIButton(type: .system)
|
||||
sendCodeButton.setTitle("获取验证码", for: .normal)
|
||||
sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside)
|
||||
|
||||
contentStack.addArrangedSubview(makeSectionTitle("身份证国徽面"))
|
||||
contentStack.addArrangedSubview(backImageView)
|
||||
contentStack.addArrangedSubview(makeSectionTitle("身份证人像面"))
|
||||
contentStack.addArrangedSubview(frontImageView)
|
||||
contentStack.addArrangedSubview(labeledField("姓名", realNameField))
|
||||
contentStack.addArrangedSubview(labeledField("身份证号码", idCardField))
|
||||
|
||||
let longValidRow = UIStackView(arrangedSubviews: [UILabel(text: "长期有效"), longValidSwitch])
|
||||
longValidRow.axis = .horizontal
|
||||
contentStack.addArrangedSubview(longValidRow)
|
||||
contentStack.addArrangedSubview(labeledField("起始日期", startDatePicker))
|
||||
contentStack.addArrangedSubview(labeledField("结束日期", endDatePicker))
|
||||
|
||||
let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton])
|
||||
smsRow.axis = .horizontal
|
||||
smsRow.spacing = 8
|
||||
smsCodeField.snp.makeConstraints { make in make.width.equalTo(200) }
|
||||
contentStack.addArrangedSubview(labeledField("短信验证码", smsRow))
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(submitButton.snp.top).offset(-12)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
submitButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in self?.applyViewModel() }
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
longValidSwitch.addTarget(self, action: #selector(longValidChanged), for: .valueChanged)
|
||||
realNameField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged)
|
||||
idCardField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged)
|
||||
smsCodeField.addTarget(self, action: #selector(fieldChanged), for: .editingChanged)
|
||||
startDatePicker.addTarget(self, action: #selector(dateChanged), for: .valueChanged)
|
||||
endDatePicker.addTarget(self, action: #selector(dateChanged), for: .valueChanged)
|
||||
Task { await loadInfo() }
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
realNameField.text = viewModel.realName
|
||||
idCardField.text = viewModel.idCardNo
|
||||
smsCodeField.text = viewModel.smsCode
|
||||
longValidSwitch.isOn = viewModel.isLongValid
|
||||
startDatePicker.date = viewModel.startDate
|
||||
endDatePicker.date = viewModel.endDate
|
||||
endDatePicker.isEnabled = !viewModel.isLongValid
|
||||
submitButton.isEnabled = !viewModel.submitting
|
||||
|
||||
if let data = viewModel.pendingFrontImageData, let image = UIImage(data: data) {
|
||||
frontImageView.image = image
|
||||
} else {
|
||||
frontImageView.loadRemoteImage(urlString: viewModel.frontUrl)
|
||||
}
|
||||
if let data = viewModel.pendingBackImageData, let image = UIImage(data: data) {
|
||||
backImageView.image = image
|
||||
} else {
|
||||
backImageView.loadRemoteImage(urlString: viewModel.backUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func fieldChanged() {
|
||||
viewModel.updateRealName(realNameField.text ?? "")
|
||||
viewModel.updateIdCardNo(idCardField.text ?? "")
|
||||
viewModel.updateSmsCode(smsCodeField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func dateChanged() {
|
||||
viewModel.updateStartDate(startDatePicker.date)
|
||||
viewModel.updateEndDate(endDatePicker.date)
|
||||
}
|
||||
|
||||
@objc private func longValidChanged() {
|
||||
viewModel.updateLongValid(longValidSwitch.isOn)
|
||||
}
|
||||
|
||||
@objc private func sendCodeTapped() {
|
||||
Task {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.sendCode(api: profileAPI)
|
||||
showToast(viewModel.statusMessage ?? "验证码已发送")
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
Task { await submit() }
|
||||
}
|
||||
|
||||
@objc private func pickFront() { pickImage(side: .front) }
|
||||
@objc private func pickBack() { pickImage(side: .back) }
|
||||
|
||||
private var pickingSide: RealNameImageSide = .front
|
||||
|
||||
private func pickImage(side: RealNameImageSide) {
|
||||
pickingSide = side
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
private func loadInfo() async {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.load(api: profileAPI)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
let scenicId = AppStore.shared.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
showToast("当前景区信息缺失")
|
||||
return
|
||||
}
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.submit(api: profileAPI, uploader: ossUploadService, scenicId: scenicId)
|
||||
showToast("提交成功")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSectionTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
return label
|
||||
}
|
||||
|
||||
private func labeledField(_ title: String, _ field: UIView) -> UIStackView {
|
||||
let titleLabel = UILabel(text: title)
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, field])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
return stack
|
||||
}
|
||||
}
|
||||
|
||||
extension RealNameAuthViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try self.viewModel.prepareIdentityImage(data: data, side: self.pickingSide)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UILabel {
|
||||
convenience init(text: String) {
|
||||
self.init()
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
109
suixinkan/UI/Profile/WithdrawalSettingsAuditViewController.swift
Normal file
109
suixinkan/UI/Profile/WithdrawalSettingsAuditViewController.swift
Normal file
@ -0,0 +1,109 @@
|
||||
//
|
||||
// WithdrawalSettingsAuditViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 提现设置审核状态页,展示银行卡审核结果。
|
||||
final class WithdrawalSettingsAuditViewController: BaseViewController {
|
||||
|
||||
private let bankCard: BankCardInfo
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let statusBadge = ProfileStatusBadgeView()
|
||||
private let rejectLabel = UILabel()
|
||||
private let frontImageView = UIImageView()
|
||||
private let backImageView = UIImageView()
|
||||
|
||||
init(bankCard: BankCardInfo) {
|
||||
self.bankCard = bankCard
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "提现设置"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
|
||||
rejectLabel.numberOfLines = 0
|
||||
rejectLabel.font = .systemFont(ofSize: 14)
|
||||
rejectLabel.textColor = UIColor(hex: 0xEF4444)
|
||||
|
||||
[frontImageView, backImageView].forEach {
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.clipsToBounds = true
|
||||
$0.snp.makeConstraints { make in make.height.equalTo(140) }
|
||||
}
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
|
||||
contentStack.addArrangedSubview(makeTitle("审核状态"))
|
||||
contentStack.addArrangedSubview(statusBadge)
|
||||
if !bankCard.rejectReason.isEmpty, bankCard.auditStatus == 3 {
|
||||
rejectLabel.text = "驳回原因:\(bankCard.rejectReason)"
|
||||
contentStack.addArrangedSubview(rejectLabel)
|
||||
}
|
||||
contentStack.addArrangedSubview(makeInfoRow("持卡人", bankCard.realName))
|
||||
contentStack.addArrangedSubview(makeInfoRow("开户银行", bankCard.bankName))
|
||||
contentStack.addArrangedSubview(makeInfoRow("支行", bankCard.branchName))
|
||||
contentStack.addArrangedSubview(makeInfoRow("卡号", ProfileViewModel.maskCardNumber(bankCard.cardNumber)))
|
||||
contentStack.addArrangedSubview(makeTitle("银行卡正面"))
|
||||
contentStack.addArrangedSubview(frontImageView)
|
||||
contentStack.addArrangedSubview(makeTitle("银行卡反面"))
|
||||
contentStack.addArrangedSubview(backImageView)
|
||||
|
||||
applyStatus()
|
||||
frontImageView.loadRemoteImage(urlString: bankCard.frontUrl)
|
||||
backImageView.loadRemoteImage(urlString: bankCard.backUrl)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyStatus() {
|
||||
let text = bankCard.auditStatusLabel.isEmpty ? "审核中" : bankCard.auditStatusLabel
|
||||
switch bankCard.auditStatus {
|
||||
case 2:
|
||||
statusBadge.apply(style: .bankApproved, text: text)
|
||||
case 3:
|
||||
statusBadge.apply(style: .bankRejected, text: text)
|
||||
default:
|
||||
statusBadge.apply(style: .bankPending, text: text)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTitle(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
return label
|
||||
}
|
||||
|
||||
private func makeInfoRow(_ title: String, _ value: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.textColor = AppColor.text666
|
||||
label.text = "\(title):\(value)"
|
||||
return label
|
||||
}
|
||||
}
|
||||
294
suixinkan/UI/Profile/WithdrawalSettingsViewController.swift
Normal file
294
suixinkan/UI/Profile/WithdrawalSettingsViewController.swift
Normal file
@ -0,0 +1,294 @@
|
||||
//
|
||||
// WithdrawalSettingsViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 提现设置页面,两步表单绑定银行卡。
|
||||
final class WithdrawalSettingsViewController: BaseViewController {
|
||||
|
||||
private let viewModel = WithdrawalSettingsViewModel()
|
||||
private let profileAPI = NetworkServices.shared.profileAPI
|
||||
private let ossUploadService = NetworkServices.shared.ossUploadService
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let stepLabel = UILabel()
|
||||
private let holderNameField = UITextField()
|
||||
private let bankNoField = UITextField()
|
||||
private let frontImageView = UIImageView()
|
||||
private let backImageView = UIImageView()
|
||||
private let bankNameField = UITextField()
|
||||
private let provinceButton = UIButton(type: .system)
|
||||
private let cityButton = UIButton(type: .system)
|
||||
private let branchField = UITextField()
|
||||
private let smsCodeField = UITextField()
|
||||
private let agreeSwitch = UISwitch()
|
||||
private let actionButton = AppButton(title: "下一步")
|
||||
|
||||
private var pickingSide: BankCardImageSide = .front
|
||||
private var stepTwoStack = UIStackView()
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "提现设置"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
|
||||
stepLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
stepLabel.textColor = AppColor.text666
|
||||
|
||||
[holderNameField, bankNoField, bankNameField, branchField, smsCodeField].forEach {
|
||||
$0.borderStyle = .roundedRect
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
}
|
||||
holderNameField.placeholder = "持卡人姓名"
|
||||
bankNoField.placeholder = "银行卡号"
|
||||
bankNoField.keyboardType = .numberPad
|
||||
bankNameField.placeholder = "开户银行"
|
||||
branchField.placeholder = "支行名称"
|
||||
smsCodeField.placeholder = "短信验证码"
|
||||
smsCodeField.keyboardType = .numberPad
|
||||
|
||||
provinceButton.contentHorizontalAlignment = .leading
|
||||
cityButton.contentHorizontalAlignment = .leading
|
||||
provinceButton.setTitle("请选择省份", for: .normal)
|
||||
cityButton.setTitle("请选择城市", for: .normal)
|
||||
|
||||
[frontImageView, backImageView].forEach {
|
||||
$0.backgroundColor = UIColor(hex: 0xF8FAFC)
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.clipsToBounds = true
|
||||
$0.isUserInteractionEnabled = true
|
||||
$0.snp.makeConstraints { make in make.height.equalTo(140) }
|
||||
}
|
||||
frontImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickFront)))
|
||||
backImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickBack)))
|
||||
|
||||
stepTwoStack.axis = .vertical
|
||||
stepTwoStack.spacing = 16
|
||||
stepTwoStack.isHidden = true
|
||||
stepTwoStack.addArrangedSubview(labeled("开户银行", bankNameField))
|
||||
stepTwoStack.addArrangedSubview(labeled("所在省份", provinceButton))
|
||||
stepTwoStack.addArrangedSubview(labeled("所在城市", cityButton))
|
||||
stepTwoStack.addArrangedSubview(labeled("支行名称", branchField))
|
||||
|
||||
let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton()])
|
||||
smsRow.axis = .horizontal
|
||||
smsRow.spacing = 8
|
||||
stepTwoStack.addArrangedSubview(labeled("短信验证码", smsRow))
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
view.addSubview(actionButton)
|
||||
|
||||
contentStack.addArrangedSubview(stepLabel)
|
||||
contentStack.addArrangedSubview(labeled("持卡人姓名", holderNameField))
|
||||
contentStack.addArrangedSubview(labeled("银行卡号", bankNoField))
|
||||
contentStack.addArrangedSubview(labeled("银行卡正面", frontImageView))
|
||||
contentStack.addArrangedSubview(labeled("银行卡反面", backImageView))
|
||||
contentStack.addArrangedSubview(agreeRow())
|
||||
contentStack.addArrangedSubview(stepTwoStack)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(actionButton.snp.top).offset(-12)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
actionButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in self?.applyViewModel() }
|
||||
actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside)
|
||||
provinceButton.addTarget(self, action: #selector(selectProvince), for: .touchUpInside)
|
||||
cityButton.addTarget(self, action: #selector(selectCity), for: .touchUpInside)
|
||||
agreeSwitch.addTarget(self, action: #selector(agreeChanged), for: .valueChanged)
|
||||
holderNameField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
|
||||
bankNoField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
|
||||
bankNameField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
|
||||
branchField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
|
||||
smsCodeField.addTarget(self, action: #selector(fieldsChanged), for: .editingChanged)
|
||||
Task { await loadData() }
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
stepLabel.text = viewModel.currentStep == 1 ? "步骤 1/2:填写银行卡信息" : "步骤 2/2:完善开户信息"
|
||||
holderNameField.text = viewModel.holderName
|
||||
bankNoField.text = viewModel.bankNo
|
||||
bankNameField.text = viewModel.bankName
|
||||
branchField.text = viewModel.branchName
|
||||
smsCodeField.text = viewModel.verificationCode
|
||||
agreeSwitch.isOn = viewModel.isAgree
|
||||
stepTwoStack.isHidden = viewModel.currentStep == 1
|
||||
actionButton.setTitle(viewModel.currentStep == 1 ? "下一步" : "提交", for: .normal)
|
||||
actionButton.isEnabled = viewModel.currentStep == 1 ? viewModel.canGoNextStep : viewModel.canSubmit
|
||||
|
||||
provinceButton.setTitle(viewModel.provinceName.isEmpty ? "请选择省份" : viewModel.provinceName, for: .normal)
|
||||
cityButton.setTitle(viewModel.cityName.isEmpty ? "请选择城市" : viewModel.cityName, for: .normal)
|
||||
|
||||
updateImageView(frontImageView, pending: viewModel.pendingFrontImageData, url: viewModel.bankCardFrontURL)
|
||||
updateImageView(backImageView, pending: viewModel.pendingBackImageData, url: viewModel.bankCardBackURL)
|
||||
}
|
||||
|
||||
private func updateImageView(_ imageView: UIImageView, pending: Data?, url: String) {
|
||||
if let pending, let image = UIImage(data: pending) {
|
||||
imageView.image = image
|
||||
} else if url != "pending" {
|
||||
imageView.loadRemoteImage(urlString: url)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func fieldsChanged() {
|
||||
viewModel.updateHolderName(holderNameField.text ?? "")
|
||||
viewModel.updateBankNo(bankNoField.text ?? "")
|
||||
viewModel.updateBankName(bankNameField.text ?? "")
|
||||
viewModel.updateBranchName(branchField.text ?? "")
|
||||
viewModel.updateVerificationCode(smsCodeField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func agreeChanged() {
|
||||
viewModel.updateAgree(agreeSwitch.isOn)
|
||||
}
|
||||
|
||||
@objc private func actionTapped() {
|
||||
if viewModel.currentStep == 1 {
|
||||
viewModel.goNextStep()
|
||||
} else {
|
||||
Task { await submit() }
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pickFront() { pickImage(side: .front) }
|
||||
@objc private func pickBack() { pickImage(side: .back) }
|
||||
|
||||
private func pickImage(side: BankCardImageSide) {
|
||||
pickingSide = side
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
@objc private func selectProvince() {
|
||||
presentAreaPicker(title: "选择省份", options: viewModel.provinceOptions) { [weak self] area in
|
||||
self?.viewModel.selectProvince(area)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func selectCity() {
|
||||
presentAreaPicker(title: "选择城市", options: viewModel.cityOptions) { [weak self] area in
|
||||
self?.viewModel.selectCity(area)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAreaPicker(title: String, options: [AreasResponse], onSelect: @escaping (AreasResponse) -> Void) {
|
||||
let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
|
||||
options.forEach { area in
|
||||
alert.addAction(UIAlertAction(title: area.name, style: .default) { _ in
|
||||
onSelect(area)
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func loadData() async {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.load(api: profileAPI)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
let scenicId = AppStore.shared.currentScenicId
|
||||
guard scenicId > 0 else {
|
||||
showToast("当前景区信息缺失")
|
||||
return
|
||||
}
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
try await viewModel.submit(api: profileAPI, uploader: ossUploadService, scenicId: scenicId)
|
||||
showToast("提交成功")
|
||||
navigationController?.popViewController(animated: true)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func labeled(_ title: String, _ view: UIView) -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
let stack = UIStackView(arrangedSubviews: [label, view])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
return stack
|
||||
}
|
||||
|
||||
private func agreeRow() -> UIStackView {
|
||||
let label = UILabel()
|
||||
label.text = "我已阅读并同意相关协议"
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
let row = UIStackView(arrangedSubviews: [label, agreeSwitch])
|
||||
row.axis = .horizontal
|
||||
return row
|
||||
}
|
||||
|
||||
private func sendCodeButton() -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle("获取验证码", for: .normal)
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
Task {
|
||||
guard let self else { return }
|
||||
self.showLoading()
|
||||
defer { self.hideLoading() }
|
||||
do {
|
||||
try await self.viewModel.sendCode(api: self.profileAPI)
|
||||
self.showToast("验证码已发送")
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
extension WithdrawalSettingsViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try self.viewModel.prepareBankCardImage(data: data, side: self.pickingSide)
|
||||
} catch {
|
||||
self.showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
suixinkanTests/AccountSwitchViewModelTests.swift
Normal file
77
suixinkanTests/AccountSwitchViewModelTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 }, [
|
||||
|
||||
65
suixinkanTests/ProfileModelsTests.swift
Normal file
65
suixinkanTests/ProfileModelsTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
52
suixinkanTests/ProfileViewModelTests.swift
Normal file
52
suixinkanTests/ProfileViewModelTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
30
suixinkanTests/RealNameAuthViewModelTests.swift
Normal file
30
suixinkanTests/RealNameAuthViewModelTests.swift
Normal file
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
58
suixinkanTests/WithdrawalSettingsViewModelTests.swift
Normal file
58
suixinkanTests/WithdrawalSettingsViewModelTests.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user