新增 OSS 上传与 Kingfisher 图片加载支持
This commit is contained in:
@ -6,6 +6,11 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */; };
|
||||||
|
93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
A00000072FE9000000000007 /* PBXContainerItemProxy */ = {
|
A00000072FE9000000000007 /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
@ -39,6 +44,8 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */,
|
||||||
|
93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -90,6 +97,8 @@
|
|||||||
);
|
);
|
||||||
name = suixinkan;
|
name = suixinkan;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
93DAED0D2FE8E50000B9E2B1 /* Kingfisher */,
|
||||||
|
93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */,
|
||||||
);
|
);
|
||||||
productName = suixinkan;
|
productName = suixinkan;
|
||||||
productReference = 939AC7962FE3F832004B22E4 /* suixinkan.app */;
|
productReference = 939AC7962FE3F832004B22E4 /* suixinkan.app */;
|
||||||
@ -515,6 +524,19 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
93DAED0D2FE8E50000B9E2B1 /* Kingfisher */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 93DAED092FE8E14D00B9E2B1 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||||
|
productName = Kingfisher;
|
||||||
|
};
|
||||||
|
93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */;
|
||||||
|
productName = AlibabaCloudOSS;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 939AC78E2FE3F832004B22E4 /* Project object */;
|
rootObject = 939AC78E2FE3F832004B22E4 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,8 @@ struct RootView: View {
|
|||||||
@State private var apiClient: APIClient
|
@State private var apiClient: APIClient
|
||||||
@State private var authAPI: AuthAPI
|
@State private var authAPI: AuthAPI
|
||||||
@State private var profileAPI: ProfileAPI
|
@State private var profileAPI: ProfileAPI
|
||||||
|
@State private var uploadAPI: UploadAPI
|
||||||
|
@State private var ossUploadService: OSSUploadService
|
||||||
@State private var accountContextAPI: AccountContextAPI
|
@State private var accountContextAPI: AccountContextAPI
|
||||||
@State private var ordersAPI: OrdersAPI
|
@State private var ordersAPI: OrdersAPI
|
||||||
@State private var statisticsAPI: StatisticsAPI
|
@State private var statisticsAPI: StatisticsAPI
|
||||||
@ -32,6 +34,9 @@ struct RootView: View {
|
|||||||
_apiClient = State(initialValue: apiClient)
|
_apiClient = State(initialValue: apiClient)
|
||||||
_authAPI = State(initialValue: AuthAPI(client: apiClient))
|
_authAPI = State(initialValue: AuthAPI(client: apiClient))
|
||||||
_profileAPI = State(initialValue: ProfileAPI(client: apiClient))
|
_profileAPI = State(initialValue: ProfileAPI(client: apiClient))
|
||||||
|
let uploadAPI = UploadAPI(client: apiClient)
|
||||||
|
_uploadAPI = State(initialValue: uploadAPI)
|
||||||
|
_ossUploadService = State(initialValue: OSSUploadService(configService: uploadAPI))
|
||||||
_accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient))
|
_accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient))
|
||||||
_ordersAPI = State(initialValue: OrdersAPI(client: apiClient))
|
_ordersAPI = State(initialValue: OrdersAPI(client: apiClient))
|
||||||
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
|
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
|
||||||
@ -62,6 +67,8 @@ struct RootView: View {
|
|||||||
.environment(apiClient)
|
.environment(apiClient)
|
||||||
.environment(authAPI)
|
.environment(authAPI)
|
||||||
.environment(profileAPI)
|
.environment(profileAPI)
|
||||||
|
.environment(uploadAPI)
|
||||||
|
.environment(ossUploadService)
|
||||||
.environment(accountContextAPI)
|
.environment(accountContextAPI)
|
||||||
.environment(ordersAPI)
|
.environment(ordersAPI)
|
||||||
.environment(statisticsAPI)
|
.environment(statisticsAPI)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
|
|||||||
- `Networking`:统一 API 请求、响应解析、错误处理和 token 注入。
|
- `Networking`:统一 API 请求、响应解析、错误处理和 token 注入。
|
||||||
- `Storage`:统一登录 token、账号快照和 App 偏好的本地存储。
|
- `Storage`:统一登录 token、账号快照和 App 偏好的本地存储。
|
||||||
- `Design`:统一颜色、字号、间距、控件尺寸和圆角。
|
- `Design`:统一颜色、字号、间距、控件尺寸和圆角。
|
||||||
|
- `Upload`:统一阿里云 OSS 上传、图片压缩、上传策略和 Kingfisher 网络图片展示。
|
||||||
|
|
||||||
## Networking
|
## Networking
|
||||||
|
|
||||||
@ -49,7 +50,7 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
|
|||||||
- 正式 token 只放 Keychain。
|
- 正式 token 只放 Keychain。
|
||||||
- 临时 token 只放内存。
|
- 临时 token 只放内存。
|
||||||
- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。
|
- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。
|
||||||
- 头像图片缓存交给 Kingfisher,Core 不保存图片 Data。
|
- 头像、证件照等图片缓存交给 Kingfisher,Core 不保存图片 Data。
|
||||||
|
|
||||||
`AccountSnapshot` 保存可重建的账号展示和业务上下文:
|
`AccountSnapshot` 保存可重建的账号展示和业务上下文:
|
||||||
- `AccountProfile`
|
- `AccountProfile`
|
||||||
@ -63,3 +64,14 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存
|
|||||||
`AppDesign` 管理跨页面颜色。`AppMetrics` 管理常用字号、间距、控件尺寸、行距和圆角。
|
`AppDesign` 管理跨页面颜色。`AppMetrics` 管理常用字号、间距、控件尺寸、行距和圆角。
|
||||||
|
|
||||||
新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。
|
新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。
|
||||||
|
|
||||||
|
## Upload
|
||||||
|
|
||||||
|
`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。
|
||||||
|
|
||||||
|
上传模块只保存内存状态:
|
||||||
|
- STS token 不写入 Keychain 或 UserDefaults。
|
||||||
|
- 用户选择的本地图片 Data 不落盘。
|
||||||
|
- 上传进度只用于当前页面展示。
|
||||||
|
|
||||||
|
网络图片统一使用 `RemoteImage` / `RemoteAvatarImage`,内部由 Kingfisher 负责下载和缓存。业务页面不要再直接使用 `AsyncImage` 加载网络图片。
|
||||||
|
|||||||
81
suixinkan/Core/UI/RemoteImage.swift
Normal file
81
suixinkan/Core/UI/RemoteImage.swift
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// RemoteImage.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 网络图片组件,统一使用 Kingfisher 加载和缓存远程图片。
|
||||||
|
struct RemoteImage<Placeholder: View>: View {
|
||||||
|
let urlString: String?
|
||||||
|
let contentMode: SwiftUI.ContentMode
|
||||||
|
@ViewBuilder let placeholder: () -> Placeholder
|
||||||
|
|
||||||
|
@State private var didFail = false
|
||||||
|
|
||||||
|
/// 初始化网络图片组件。
|
||||||
|
init(
|
||||||
|
urlString: String?,
|
||||||
|
contentMode: SwiftUI.ContentMode = .fill,
|
||||||
|
@ViewBuilder placeholder: @escaping () -> Placeholder
|
||||||
|
) {
|
||||||
|
self.urlString = urlString
|
||||||
|
self.contentMode = contentMode
|
||||||
|
self.placeholder = placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let url = imageURL, !didFail {
|
||||||
|
KFImage(url)
|
||||||
|
.placeholder { placeholder() }
|
||||||
|
.onFailure { _ in didFail = true }
|
||||||
|
.resizable()
|
||||||
|
.cacheOriginalImage()
|
||||||
|
.aspectRatio(contentMode: contentMode)
|
||||||
|
} else {
|
||||||
|
placeholder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: normalizedURLString) { _, _ in
|
||||||
|
didFail = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var imageURL: URL? {
|
||||||
|
guard let normalizedURLString else { return nil }
|
||||||
|
return URL(string: normalizedURLString)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var normalizedURLString: String? {
|
||||||
|
let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return text.isEmpty ? nil : text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 远程头像组件,提供统一的圆形头像占位和 Kingfisher 加载逻辑。
|
||||||
|
struct RemoteAvatarImage: View {
|
||||||
|
let urlString: String
|
||||||
|
let systemImageName: String
|
||||||
|
let iconSize: CGFloat
|
||||||
|
|
||||||
|
/// 初始化远程头像组件。
|
||||||
|
init(urlString: String, systemImageName: String = "person.fill", iconSize: CGFloat = 44) {
|
||||||
|
self.urlString = urlString
|
||||||
|
self.systemImageName = systemImageName
|
||||||
|
self.iconSize = iconSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
RemoteImage(urlString: urlString, contentMode: .fill) {
|
||||||
|
Image(systemName: systemImageName)
|
||||||
|
.font(.system(size: iconSize, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppDesign.primarySoft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
245
suixinkan/Core/Upload/OSSUploadService.swift
Normal file
245
suixinkan/Core/Upload/OSSUploadService.swift
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
//
|
||||||
|
// OSSUploadService.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
#if canImport(AlibabaCloudOSS)
|
||||||
|
import AlibabaCloudOSS
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// OSS 上传服务协议,定义业务模块可复用的上传入口。
|
||||||
|
@MainActor
|
||||||
|
protocol OSSUploadServing {
|
||||||
|
/// 上传用户头像,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
|
|
||||||
|
/// 上传实名认证证件图片,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
|
|
||||||
|
/// 上传云盘文件,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
|
|
||||||
|
/// 上传相册文件,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
|
|
||||||
|
/// 上传任务附件,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
|
|
||||||
|
/// 上传打卡点图片,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
|
|
||||||
|
/// 上传景区申请图片,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
/// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。
|
||||||
|
final class OSSUploadService {
|
||||||
|
@ObservationIgnored private let configService: any OSSConfigServing
|
||||||
|
|
||||||
|
/// 初始化 OSS 上传服务,并注入 STS 配置服务。
|
||||||
|
init(configService: any OSSConfigServing) {
|
||||||
|
self.configService = configService
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传用户头像,并返回可访问的 OSS 文件 URL。
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传实名认证证件图片,并返回可访问的 OSS 文件 URL。
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传云盘文件,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "cloud_driver", onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传相册文件,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "album_upload", onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传任务附件,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "task_upload", onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传打卡点图片,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadPunchPointImage(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: "punch_point", onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传景区申请图片,并返回可访问的 OSS 文件 URL。
|
||||||
|
func uploadScenicApplicationImage(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: "scenic_apply", onProgress: onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行通用 OSS 上传流程。
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
/// OSS 上传策略实体,集中管理文件限制、路径生成、MIME 类型和 URL 拼接规则。
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据业务模块、景区和文件名生成 OSS objectKey。
|
||||||
|
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 "task_upload":
|
||||||
|
return "task_upload/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "cloud_driver":
|
||||||
|
return "cloud_driver/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "album_upload":
|
||||||
|
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "punch_point":
|
||||||
|
return "punch_point/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "scenic_apply":
|
||||||
|
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "user_avatar":
|
||||||
|
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "real_name":
|
||||||
|
return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
default:
|
||||||
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据文件名扩展名和业务文件类型推断 MIME 类型。
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拼接 OSS 访问域名和 objectKey。
|
||||||
|
static func joinURL(baseURL: String, objectKey: String) -> String {
|
||||||
|
if baseURL.hasSuffix("/") {
|
||||||
|
return baseURL + objectKey
|
||||||
|
}
|
||||||
|
return baseURL + "/" + objectKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理文件名中的危险字符,避免生成非法 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSS 上传错误实体,表示本地校验或 SDK 链接状态异常。
|
||||||
|
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格式"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
suixinkan/Core/Upload/Upload.md
Normal file
51
suixinkan/Core/Upload/Upload.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Upload 模块业务逻辑
|
||||||
|
|
||||||
|
## 模块职责
|
||||||
|
|
||||||
|
Upload 模块负责 App 内通用文件上传能力,当前主要服务个人头像和实名认证证件图片,后续云盘、相册、任务、打卡点等模块迁移时复用同一套 OSS 上传入口。
|
||||||
|
|
||||||
|
该模块不负责业务表单提交,只负责:
|
||||||
|
- 获取阿里云 OSS STS 临时配置。
|
||||||
|
- 校验待上传文件大小和扩展名。
|
||||||
|
- 生成按业务模块隔离的 OSS objectKey。
|
||||||
|
- 调用阿里云 OSS Swift SDK 上传文件。
|
||||||
|
- 返回最终可访问的文件 URL。
|
||||||
|
|
||||||
|
## 核心对象
|
||||||
|
|
||||||
|
- `UploadAPI`:封装 `/api/app/config/get-sts-token`,只负责获取 STS 临时上传配置。
|
||||||
|
- `OSSUploadService`:统一上传服务,封装 SDK 调用和进度回调。
|
||||||
|
- `OSSUploadPolicy`:上传策略,管理大小限制、扩展名白名单、路径规则、MIME 类型和 URL 拼接。
|
||||||
|
- `AvatarImageProcessor`:头像图片处理器,上传前把图片压缩为 JPEG。
|
||||||
|
- `RealNameImageProcessor`:实名认证证件图片处理器,上传前把证件图压缩为 JPEG。
|
||||||
|
- `RemoteImage`:Kingfisher 网络图片组件,统一远程图片加载、缓存和失败占位。
|
||||||
|
|
||||||
|
## 上传流程
|
||||||
|
|
||||||
|
1. 页面或 ViewModel 将用户选择的本地图片处理成上传数据。
|
||||||
|
2. ViewModel 调用 `OSSUploadService` 的业务上传方法。
|
||||||
|
3. `OSSUploadService` 调用 `UploadAPI.aliyunOSSBucket(bucket:)` 获取 STS 配置。
|
||||||
|
4. `OSSUploadPolicy` 校验文件并生成 objectKey。
|
||||||
|
5. `OSSUploadService` 使用 `AlibabaCloudOSS` SDK 上传数据。
|
||||||
|
6. 上传成功后返回 `base_url + objectKey`。
|
||||||
|
7. 业务 ViewModel 再把 URL 提交给对应业务接口。
|
||||||
|
|
||||||
|
## 路径规则
|
||||||
|
|
||||||
|
当前模块路径:
|
||||||
|
- `user_avatar`:`avatar/yyyyMMdd/scenicId/uuid_fileName`
|
||||||
|
- `real_name`:`real_name/yyyyMMdd/scenicId/uuid_fileName`
|
||||||
|
- `task_upload`:`task_upload/yyyyMMdd/scenicId/uuid_fileName`
|
||||||
|
- `cloud_driver`:`cloud_driver/yyyyMMdd/scenicId/uuid_fileName`
|
||||||
|
- `album_upload`:`album/yyyyMMdd/scenicId/uuid_fileName`
|
||||||
|
- `punch_point`:`punch_point/yyyyMMdd/scenicId/uuid_fileName`
|
||||||
|
- `scenic_apply`:`scenic_apply/yyyyMMdd/scenicId/uuid_fileName`
|
||||||
|
|
||||||
|
文件名会清理控制字符、`/` 和 `\`,避免生成非法 objectKey。
|
||||||
|
|
||||||
|
## 缓存边界
|
||||||
|
|
||||||
|
- OSS STS token 不落盘,只在一次上传流程中临时使用。
|
||||||
|
- 原始图片 Data、压缩后图片 Data、上传进度不落盘。
|
||||||
|
- 图片展示缓存交给 Kingfisher,业务代码不自行保存网络图片文件。
|
||||||
|
- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。
|
||||||
41
suixinkan/Core/Upload/UploadAPI.swift
Normal file
41
suixinkan/Core/Upload/UploadAPI.swift
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// UploadAPI.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// OSS 配置服务协议,抽象 STS token 获取能力,便于上传服务测试替换。
|
||||||
|
@MainActor
|
||||||
|
protocol OSSConfigServing {
|
||||||
|
/// 获取指定 bucket 的阿里云 OSS 临时上传配置。
|
||||||
|
func aliyunOSSBucket(bucket: String) async throws -> AliyunOSSResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
/// 上传 API,封装文件上传前需要的服务端配置接口。
|
||||||
|
final class UploadAPI {
|
||||||
|
@ObservationIgnored private let client: APIClient
|
||||||
|
|
||||||
|
/// 初始化上传 API,并注入共享网络客户端。
|
||||||
|
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 {}
|
||||||
121
suixinkan/Core/Upload/UploadImageProcessors.swift
Normal file
121
suixinkan/Core/Upload/UploadImageProcessors.swift
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
//
|
||||||
|
// UploadImageProcessors.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
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 RealNameImageSide: 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 {
|
||||||
|
/// 将原始图片渲染为指定大小限制的 JPEG 上传数据。
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
suixinkan/Core/Upload/UploadModels.swift
Normal file
100
suixinkan/Core/Upload/UploadModels.swift
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
//
|
||||||
|
// UploadModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/// 阿里云 OSS STS 配置响应 JSON 字段映射。
|
||||||
|
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
|
||||||
|
|
||||||
|
/// 阿里云 OSS 临时凭证 JSON 字段映射。
|
||||||
|
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 {
|
||||||
|
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -144,18 +144,7 @@ struct HomeMoreFunctionsView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func menuIconView(for item: HomeMenuItem) -> some View {
|
private func menuIconView(for item: HomeMenuItem) -> some View {
|
||||||
if let src = item.iconSrc,
|
RemoteImage(urlString: item.iconSrc, contentMode: .fit) {
|
||||||
let url = URL(string: src),
|
|
||||||
!src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
AsyncImage(url: url) { phase in
|
|
||||||
switch phase {
|
|
||||||
case .success(let image):
|
|
||||||
image.resizable().scaledToFit()
|
|
||||||
default:
|
|
||||||
fallbackMenuIcon(for: item.uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fallbackMenuIcon(for: item.uri)
|
fallbackMenuIcon(for: item.uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -363,20 +363,7 @@ struct HomeView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func menuIconView(for item: HomeMenuItem) -> some View {
|
private func menuIconView(for item: HomeMenuItem) -> some View {
|
||||||
if let src = item.iconSrc,
|
RemoteImage(urlString: item.iconSrc, contentMode: .fit) {
|
||||||
let url = URL(string: src),
|
|
||||||
!src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
AsyncImage(url: url) { phase in
|
|
||||||
switch phase {
|
|
||||||
case .success(let image):
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
default:
|
|
||||||
fallbackMenuIcon(for: item.uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fallbackMenuIcon(for: item.uri)
|
fallbackMenuIcon(for: item.uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
|
|||||||
- 拉取用户基础资料。
|
- 拉取用户基础资料。
|
||||||
- 拉取实名认证信息。
|
- 拉取实名认证信息。
|
||||||
- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。
|
- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。
|
||||||
- 修改昵称。
|
- 修改昵称和头像。
|
||||||
- 修改密码。
|
- 修改密码。
|
||||||
- 切换当前景区/门店业务账号。
|
- 切换当前景区/门店业务账号。
|
||||||
- 提交实名认证基础资料。
|
- 提交实名认证基础资料。
|
||||||
@ -26,6 +26,7 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
|
|||||||
- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。
|
- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。
|
||||||
- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。
|
- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。
|
||||||
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
|
- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。
|
||||||
|
- `OSSUploadService`:上传头像和实名认证证件图片,返回可提交给业务接口的 OSS URL。
|
||||||
- `UserInfoResponse`:用户基础资料。
|
- `UserInfoResponse`:用户基础资料。
|
||||||
- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。
|
- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。
|
||||||
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
|
- `UpdateInfoRequest`:昵称、密码或头像更新请求体。
|
||||||
@ -42,15 +43,18 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
|
|||||||
5. `ProfileView` 将最新 `userInfo` 交给 `AuthSessionCoordinator.refreshCachedProfile`。
|
5. `ProfileView` 将最新 `userInfo` 交给 `AuthSessionCoordinator.refreshCachedProfile`。
|
||||||
6. 协调器更新 `AccountContext.profile`,并同步刷新账号快照。
|
6. 协调器更新 `AccountContext.profile`,并同步刷新账号快照。
|
||||||
|
|
||||||
## 昵称编辑流程
|
## 资料编辑流程
|
||||||
|
|
||||||
1. 用户点击编辑按钮。
|
1. 用户点击编辑按钮。
|
||||||
2. `ProfileViewModel.beginEditing` 进入编辑状态,并把当前昵称写入编辑框。
|
2. `ProfileViewModel.beginEditing` 进入编辑状态,并把当前昵称写入编辑框。
|
||||||
3. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。
|
3. 用户点击头像时通过 `PhotosPicker` 选择本地图片。
|
||||||
4. 昵称未变化时直接退出编辑状态。
|
4. `AvatarImageProcessor` 将头像压缩为 JPEG,并暂存在内存中。
|
||||||
5. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。
|
5. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。
|
||||||
6. 接口成功后本地更新 `userInfo.nickname`。
|
6. 头像变化时先调用 `OSSUploadService.uploadUserAvatar` 上传到 OSS。
|
||||||
7. `ProfileView` 刷新全局账号资料和账号快照。
|
7. 头像上传成功后调用 `ProfileAPI.updateUserAvatarURL` 回写头像 URL。
|
||||||
|
8. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。
|
||||||
|
9. 接口成功后本地更新 `userInfo.nickname` 和 `userInfo.avatar`。
|
||||||
|
10. `ProfileView` 刷新全局账号资料和账号快照。
|
||||||
|
|
||||||
## 密码修改流程
|
## 密码修改流程
|
||||||
|
|
||||||
@ -76,11 +80,15 @@ Profile 模块负责“我的/个人信息”页面及其二级页面,包括
|
|||||||
1. 用户点击“认证状态”进入 `RealNameAuthView`。
|
1. 用户点击“认证状态”进入 `RealNameAuthView`。
|
||||||
2. `RealNameAuthViewModel.load` 调用 `ProfileAPI.realNameInfo` 并回填姓名、身份证号、证件有效期、图片 URL 和审核状态。
|
2. `RealNameAuthViewModel.load` 调用 `ProfileAPI.realNameInfo` 并回填姓名、身份证号、证件有效期、图片 URL 和审核状态。
|
||||||
3. 用户可发送短信验证码,接口为 `/api/yf-handset-app/photog/real-name/sms-verify-code`。
|
3. 用户可发送短信验证码,接口为 `/api/yf-handset-app/photog/real-name/sms-verify-code`。
|
||||||
4. 提交前校验姓名、身份证号、短信验证码、证件图片 URL 和有效期。
|
4. 用户通过图片卡片选择身份证人像面和国徽面。
|
||||||
5. 校验通过后调用 `/api/yf-handset-app/photog/real-name/submit`。
|
5. `RealNameImageProcessor` 将证件图片压缩为 JPEG,并暂存在内存中。
|
||||||
6. 提交成功后重新加载实名认证信息。
|
6. 提交前校验姓名、身份证号、短信验证码、证件图片和有效期。
|
||||||
|
7. 校验通过后先调用 `OSSUploadService.uploadRealNameImage` 上传证件图片。
|
||||||
|
8. 上传成功后把 OSS URL 写入 `RealNameAuthRequest.frontUrl/backUrl`。
|
||||||
|
9. 调用 `/api/yf-handset-app/photog/real-name/submit` 提交实名资料。
|
||||||
|
10. 提交成功后重新加载实名认证信息。
|
||||||
|
|
||||||
本轮证件图片先保留 URL 输入和网络预览,阿里云 OSS 图片选择上传后续接入时替换该输入区。
|
证件图片的原始 Data、压缩 Data 和上传进度只保存在内存中,不写入本地缓存。
|
||||||
|
|
||||||
## 设置和协议流程
|
## 设置和协议流程
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,9 @@ final class ProfileViewModel {
|
|||||||
var isSaving = false
|
var isSaving = false
|
||||||
var isEditingProfile = false
|
var isEditingProfile = false
|
||||||
var editingNickname = ""
|
var editingNickname = ""
|
||||||
|
private(set) var pendingAvatarData: Data?
|
||||||
|
private(set) var pendingAvatarFileName: String?
|
||||||
|
private(set) var avatarUploadProgress: Int?
|
||||||
|
|
||||||
var displayNickname: String {
|
var displayNickname: String {
|
||||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||||
@ -75,29 +78,51 @@ final class ProfileViewModel {
|
|||||||
func cancelEditing() {
|
func cancelEditing() {
|
||||||
editingNickname = ""
|
editingNickname = ""
|
||||||
isEditingProfile = false
|
isEditingProfile = false
|
||||||
|
clearPendingAvatar()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存昵称修改,并同步更新本地 userInfo。
|
/// 准备待上传头像,并进入资料编辑状态。
|
||||||
func saveProfile(api: ProfileAPI) async throws {
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存昵称和头像修改,并同步更新本地 userInfo。
|
||||||
|
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||||
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !nextNickname.isEmpty else {
|
guard !nextNickname.isEmpty else {
|
||||||
throw ProfileValidationError.emptyNickname
|
throw ProfileValidationError.emptyNickname
|
||||||
}
|
}
|
||||||
|
|
||||||
let nicknameChanged = nextNickname != displayNickname
|
let nicknameChanged = nextNickname != displayNickname
|
||||||
guard nicknameChanged else {
|
let avatarChanged = pendingAvatarData != nil
|
||||||
|
guard nicknameChanged || avatarChanged else {
|
||||||
cancelEditing()
|
cancelEditing()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !isSaving else { return }
|
guard !isSaving else { return }
|
||||||
isSaving = true
|
isSaving = true
|
||||||
defer { isSaving = false }
|
avatarUploadProgress = avatarChanged ? 1 : nil
|
||||||
|
defer {
|
||||||
|
isSaving = false
|
||||||
|
avatarUploadProgress = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
try await api.updateUserInfo(nickname: nextNickname)
|
||||||
|
}
|
||||||
var nextUser = userInfo ?? UserInfoResponse()
|
var nextUser = userInfo ?? UserInfoResponse()
|
||||||
nextUser = UserInfoResponse(
|
nextUser = UserInfoResponse(
|
||||||
avatar: nextUser.avatar,
|
avatar: uploadedAvatarURL ?? nextUser.avatar,
|
||||||
realName: nextUser.realName,
|
realName: nextUser.realName,
|
||||||
phone: nextUser.phone,
|
phone: nextUser.phone,
|
||||||
nickname: nextNickname,
|
nickname: nextNickname,
|
||||||
@ -106,6 +131,7 @@ final class ProfileViewModel {
|
|||||||
statusName: nextUser.statusName
|
statusName: nextUser.statusName
|
||||||
)
|
)
|
||||||
userInfo = nextUser
|
userInfo = nextUser
|
||||||
|
clearPendingAvatar()
|
||||||
cancelEditing()
|
cancelEditing()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,6 +165,24 @@ final class ProfileViewModel {
|
|||||||
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return text.isEmpty ? nil : text
|
return text.isEmpty ? nil : text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 上传待保存头像;没有待上传图片时返回 nil。
|
||||||
|
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
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.avatarUploadProgress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空本地待上传头像数据。
|
||||||
|
private func clearPendingAvatar() {
|
||||||
|
pendingAvatarData = nil
|
||||||
|
pendingAvatarFileName = nil
|
||||||
|
avatarUploadProgress = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 个人信息页校验错误实体,表示昵称和密码输入不符合要求。
|
/// 个人信息页校验错误实体,表示昵称和密码输入不符合要求。
|
||||||
|
|||||||
@ -21,6 +21,12 @@ final class RealNameAuthViewModel {
|
|||||||
var isLongValid = false
|
var isLongValid = false
|
||||||
var frontUrl = ""
|
var frontUrl = ""
|
||||||
var backUrl = ""
|
var backUrl = ""
|
||||||
|
private(set) var pendingFrontImageData: Data?
|
||||||
|
private(set) var pendingBackImageData: Data?
|
||||||
|
private(set) var frontUploadProgress: Int?
|
||||||
|
private(set) var backUploadProgress: Int?
|
||||||
|
private var pendingFrontFileName: String?
|
||||||
|
private var pendingBackFileName: String?
|
||||||
private(set) var loading = false
|
private(set) var loading = false
|
||||||
private(set) var sendingCode = false
|
private(set) var sendingCode = false
|
||||||
private(set) var submitting = false
|
private(set) var submitting = false
|
||||||
@ -46,8 +52,23 @@ final class RealNameAuthViewModel {
|
|||||||
statusMessage = "验证码已发送"
|
statusMessage = "验证码已发送"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 准备待上传证件图片,并更新对应面的本地预览。
|
||||||
|
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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 提交实名认证资料,成功后刷新审核状态。
|
/// 提交实名认证资料,成功后刷新审核状态。
|
||||||
func submit(api: ProfileAPI) async throws {
|
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||||
if info?.verified == true {
|
if info?.verified == true {
|
||||||
statusMessage = "已完成实名认证"
|
statusMessage = "已完成实名认证"
|
||||||
return
|
return
|
||||||
@ -58,6 +79,7 @@ final class RealNameAuthViewModel {
|
|||||||
|
|
||||||
submitting = true
|
submitting = true
|
||||||
defer { submitting = false }
|
defer { submitting = false }
|
||||||
|
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
|
||||||
try await api.realNameSubmit(makeRequest())
|
try await api.realNameSubmit(makeRequest())
|
||||||
statusMessage = "已提交审核"
|
statusMessage = "已提交审核"
|
||||||
try await load(api: api)
|
try await load(api: api)
|
||||||
@ -80,8 +102,8 @@ final class RealNameAuthViewModel {
|
|||||||
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
|
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
|
||||||
if info?.verified != true {
|
if info?.verified != true {
|
||||||
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
|
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
|
||||||
if frontUrl.trimmed.isEmpty { return "请填写身份证人像面图片 URL" }
|
if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" }
|
||||||
if backUrl.trimmed.isEmpty { return "请填写身份证国徽面图片 URL" }
|
if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" }
|
||||||
}
|
}
|
||||||
if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" }
|
if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" }
|
||||||
return nil
|
return nil
|
||||||
@ -95,6 +117,10 @@ final class RealNameAuthViewModel {
|
|||||||
idCardNo = info.idCardNo
|
idCardNo = info.idCardNo
|
||||||
frontUrl = info.frontUrl ?? ""
|
frontUrl = info.frontUrl ?? ""
|
||||||
backUrl = info.backUrl ?? ""
|
backUrl = info.backUrl ?? ""
|
||||||
|
pendingFrontImageData = nil
|
||||||
|
pendingBackImageData = nil
|
||||||
|
pendingFrontFileName = nil
|
||||||
|
pendingBackFileName = nil
|
||||||
isLongValid = info.isLongValid
|
isLongValid = info.isLongValid
|
||||||
startDate = Self.date(from: info.startDate) ?? startDate
|
startDate = Self.date(from: info.startDate) ?? startDate
|
||||||
endDate = Self.date(from: info.endDate) ?? endDate
|
endDate = Self.date(from: info.endDate) ?? endDate
|
||||||
@ -148,6 +174,41 @@ final class RealNameAuthViewModel {
|
|||||||
guard let value, !value.isEmpty else { return nil }
|
guard let value, !value.isEmpty else { return nil }
|
||||||
return dateFormatter.date(from: value)
|
return dateFormatter.date(from: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 上传待提交证件图片,并把返回 URL 写回表单字段。
|
||||||
|
private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
|
||||||
|
if let pendingFrontImageData {
|
||||||
|
frontUploadProgress = 1
|
||||||
|
defer { frontUploadProgress = nil }
|
||||||
|
frontUrl = try await uploader.uploadRealNameImage(
|
||||||
|
data: pendingFrontImageData,
|
||||||
|
fileName: pendingFrontFileName ?? "real_name_front_\(Int(Date().timeIntervalSince1970)).jpg",
|
||||||
|
scenicId: scenicId
|
||||||
|
) { [weak self] progress in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.frontUploadProgress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.pendingFrontImageData = nil
|
||||||
|
pendingFrontFileName = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pendingBackImageData {
|
||||||
|
backUploadProgress = 1
|
||||||
|
defer { backUploadProgress = nil }
|
||||||
|
backUrl = try await uploader.uploadRealNameImage(
|
||||||
|
data: pendingBackImageData,
|
||||||
|
fileName: pendingBackFileName ?? "real_name_back_\(Int(Date().timeIntervalSince1970)).jpg",
|
||||||
|
scenicId: scenicId
|
||||||
|
) { [weak self] progress in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.backUploadProgress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.pendingBackImageData = nil
|
||||||
|
pendingBackFileName = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。
|
/// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。
|
||||||
|
|||||||
@ -156,21 +156,11 @@ struct AccountSwitchView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
/// 构造账号头像,网络图加载失败时显示账号类型首字。
|
/// 构造账号头像,网络图加载失败时显示账号类型首字。
|
||||||
private func accountAvatar(_ account: AccountSwitchAccount) -> some View {
|
private func accountAvatar(_ account: AccountSwitchAccount) -> some View {
|
||||||
if let url = URL(string: account.avatar), !account.avatar.isEmpty {
|
RemoteImage(urlString: account.avatar) {
|
||||||
AsyncImage(url: url) { phase in
|
|
||||||
switch phase {
|
|
||||||
case .success(let image):
|
|
||||||
image.resizable().scaledToFill()
|
|
||||||
default:
|
|
||||||
avatarFallback(account)
|
avatarFallback(account)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.frame(width: 50, height: 50)
|
.frame(width: 50, height: 50)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
} else {
|
|
||||||
avatarFallback(account)
|
|
||||||
.frame(width: 50, height: 50)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构造账号头像占位。
|
/// 构造账号头像占位。
|
||||||
|
|||||||
@ -5,7 +5,9 @@
|
|||||||
// Created by Codex on 2026/6/20.
|
// Created by Codex on 2026/6/20.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// 个人信息页视图,展示用户资料、账号状态、实名认证状态和退出登录入口。
|
/// 个人信息页视图,展示用户资料、账号状态、实名认证状态和退出登录入口。
|
||||||
struct ProfileView: View {
|
struct ProfileView: View {
|
||||||
@ -16,6 +18,7 @@ struct ProfileView: View {
|
|||||||
@Environment(AppRouter.self) private var appRouter
|
@Environment(AppRouter.self) private var appRouter
|
||||||
@Environment(RouterPath.self) private var router
|
@Environment(RouterPath.self) private var router
|
||||||
@Environment(ProfileAPI.self) private var profileAPI
|
@Environment(ProfileAPI.self) private var profileAPI
|
||||||
|
@Environment(OSSUploadService.self) private var ossUploadService
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@ -23,6 +26,7 @@ struct ProfileView: View {
|
|||||||
@State private var viewModel = ProfileViewModel()
|
@State private var viewModel = ProfileViewModel()
|
||||||
@State private var showsPasswordSheet = false
|
@State private var showsPasswordSheet = false
|
||||||
@State private var showsLogoutConfirm = false
|
@State private var showsLogoutConfirm = false
|
||||||
|
@State private var pickedAvatarItem: PhotosPickerItem?
|
||||||
@FocusState private var nicknameFocused: Bool
|
@FocusState private var nicknameFocused: Bool
|
||||||
|
|
||||||
private var contentMaxWidth: CGFloat {
|
private var contentMaxWidth: CGFloat {
|
||||||
@ -83,6 +87,9 @@ struct ProfileView: View {
|
|||||||
nicknameFocused = true
|
nicknameFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: pickedAvatarItem) { _, item in
|
||||||
|
Task { await prepareAvatarImage(from: item) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var profileBackground: some View {
|
private var profileBackground: some View {
|
||||||
@ -201,10 +208,8 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var avatarImage: some View {
|
private var avatarImage: some View {
|
||||||
Button {
|
PhotosPicker(selection: $pickedAvatarItem, matching: .images) {
|
||||||
toastCenter.show("头像上传待接入阿里云 OSS")
|
avatarPreview
|
||||||
} label: {
|
|
||||||
AsyncAvatarImage(urlString: viewModel.displayAvatarURL)
|
|
||||||
.frame(width: 86, height: 86)
|
.frame(width: 86, height: 86)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.overlay {
|
.overlay {
|
||||||
@ -221,11 +226,34 @@ struct ProfileView: View {
|
|||||||
Circle().stroke(.white, lineWidth: 2)
|
Circle().stroke(.white, lineWidth: 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if let progress = viewModel.avatarUploadProgress {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(.black.opacity(0.34))
|
||||||
|
Text("\(progress)%")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.disabled(viewModel.isSaving)
|
||||||
.accessibilityLabel("头像")
|
.accessibilityLabel("头像")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var avatarPreview: some View {
|
||||||
|
if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else {
|
||||||
|
RemoteAvatarImage(urlString: viewModel.displayAvatarURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var infoCard: some View {
|
private var infoCard: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
infoRow(title: "姓名") {
|
infoRow(title: "姓名") {
|
||||||
@ -503,7 +531,11 @@ struct ProfileView: View {
|
|||||||
/// 保存昵称编辑内容,并在成功后刷新全局账号展示。
|
/// 保存昵称编辑内容,并在成功后刷新全局账号展示。
|
||||||
private func saveProfileEdits() async {
|
private func saveProfileEdits() async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.saveProfile(api: profileAPI)
|
try await viewModel.saveProfile(
|
||||||
|
api: profileAPI,
|
||||||
|
uploader: ossUploadService,
|
||||||
|
scenicId: accountContext.currentScenic?.id ?? 0
|
||||||
|
)
|
||||||
if let userInfo = viewModel.userInfo {
|
if let userInfo = viewModel.userInfo {
|
||||||
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext)
|
||||||
} else {
|
} else {
|
||||||
@ -515,6 +547,21 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理用户选择的头像图片,并生成待上传数据。
|
||||||
|
private func prepareAvatarImage(from item: PhotosPickerItem?) async {
|
||||||
|
guard let item else { return }
|
||||||
|
defer { pickedAvatarItem = nil }
|
||||||
|
do {
|
||||||
|
guard let data = try await item.loadTransferable(type: Data.self) else {
|
||||||
|
toastCenter.show("请选择有效的头像图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try viewModel.prepareAvatarImage(data: data)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 提交新密码,并在成功后关闭密码弹窗。
|
/// 提交新密码,并在成功后关闭密码弹窗。
|
||||||
private func updatePassword(_ password: String) async {
|
private func updatePassword(_ password: String) async {
|
||||||
do {
|
do {
|
||||||
@ -533,38 +580,6 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 异步头像视图,负责加载网络头像并在失败或空地址时展示占位图。
|
|
||||||
private struct AsyncAvatarImage: View {
|
|
||||||
let urlString: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if let url = URL(string: urlString), !urlString.isEmpty {
|
|
||||||
AsyncImage(url: url) { phase in
|
|
||||||
switch phase {
|
|
||||||
case let .success(image):
|
|
||||||
image
|
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
case .failure, .empty:
|
|
||||||
placeholder
|
|
||||||
@unknown default:
|
|
||||||
placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var placeholder: some View {
|
|
||||||
Image(systemName: "person.fill")
|
|
||||||
.font(.system(size: 44, weight: .semibold))
|
|
||||||
.foregroundStyle(AppDesign.primary)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.background(AppDesign.primarySoft)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 个人信息头部背景波浪形状,用于还原旧版蓝白卡片视觉。
|
/// 个人信息头部背景波浪形状,用于还原旧版蓝白卡片视觉。
|
||||||
private struct ProfileHeaderWaveShape: Shape {
|
private struct ProfileHeaderWaveShape: Shape {
|
||||||
var phase: CGFloat = 0
|
var phase: CGFloat = 0
|
||||||
@ -643,6 +658,7 @@ private struct PasswordUpdateSheet: View {
|
|||||||
.environment(AppRouter())
|
.environment(AppRouter())
|
||||||
.environment(ToastCenter())
|
.environment(ToastCenter())
|
||||||
.environment(ProfileAPI(client: APIClient()))
|
.environment(ProfileAPI(client: APIClient()))
|
||||||
|
.environment(OSSUploadService(configService: UploadAPI(client: APIClient())))
|
||||||
.environment(AuthSessionCoordinator())
|
.environment(AuthSessionCoordinator())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,13 +5,19 @@
|
|||||||
// Created by Codex on 2026/6/22.
|
// Created by Codex on 2026/6/22.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// 实名认证页面,展示审核状态并允许提交基础实名资料。
|
/// 实名认证页面,展示审核状态并允许提交基础实名资料。
|
||||||
struct RealNameAuthView: View {
|
struct RealNameAuthView: View {
|
||||||
|
@Environment(AccountContext.self) private var accountContext
|
||||||
@Environment(ProfileAPI.self) private var profileAPI
|
@Environment(ProfileAPI.self) private var profileAPI
|
||||||
|
@Environment(OSSUploadService.self) private var ossUploadService
|
||||||
@Environment(ToastCenter.self) private var toastCenter
|
@Environment(ToastCenter.self) private var toastCenter
|
||||||
@State private var viewModel = RealNameAuthViewModel()
|
@State private var viewModel = RealNameAuthViewModel()
|
||||||
|
@State private var pickedFrontItem: PhotosPickerItem?
|
||||||
|
@State private var pickedBackItem: PhotosPickerItem?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@ -39,6 +45,12 @@ struct RealNameAuthView: View {
|
|||||||
.background(Color.white.opacity(0.35))
|
.background(Color.white.opacity(0.35))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: pickedFrontItem) { _, item in
|
||||||
|
Task { await prepareIdentityImage(from: item, side: .front) }
|
||||||
|
}
|
||||||
|
.onChange(of: pickedBackItem) { _, item in
|
||||||
|
Task { await prepareIdentityImage(from: item, side: .back) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var auditStatusPanel: some View {
|
private var auditStatusPanel: some View {
|
||||||
@ -69,16 +81,28 @@ struct RealNameAuthView: View {
|
|||||||
|
|
||||||
private var identityCard: some View {
|
private var identityCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
identityImageSection(title: "身份证国徽面", url: viewModel.backUrl)
|
identityImageSection(
|
||||||
identityImageSection(title: "身份证人像面", url: viewModel.frontUrl)
|
title: "身份证国徽面",
|
||||||
|
side: .back,
|
||||||
|
pickedItem: $pickedBackItem,
|
||||||
|
pendingData: viewModel.pendingBackImageData,
|
||||||
|
remoteURL: viewModel.backUrl,
|
||||||
|
progress: viewModel.backUploadProgress
|
||||||
|
)
|
||||||
|
identityImageSection(
|
||||||
|
title: "身份证人像面",
|
||||||
|
side: .front,
|
||||||
|
pickedItem: $pickedFrontItem,
|
||||||
|
pendingData: viewModel.pendingFrontImageData,
|
||||||
|
remoteURL: viewModel.frontUrl,
|
||||||
|
progress: viewModel.frontUploadProgress
|
||||||
|
)
|
||||||
formField("姓名", text: realNameBinding, placeholder: "请输入姓名")
|
formField("姓名", text: realNameBinding, placeholder: "请输入姓名")
|
||||||
formField("身份证号码", text: idCardNoBinding, placeholder: "请输入身份证号码")
|
formField("身份证号码", text: idCardNoBinding, placeholder: "请输入身份证号码")
|
||||||
validitySection
|
validitySection
|
||||||
if shouldShowSmsSection {
|
if shouldShowSmsSection {
|
||||||
smsSection
|
smsSection
|
||||||
}
|
}
|
||||||
urlField("身份证人像面图片 URL", text: frontURLBinding)
|
|
||||||
urlField("身份证国徽面图片 URL", text: backURLBinding)
|
|
||||||
submitButton
|
submitButton
|
||||||
statusBanner
|
statusBanner
|
||||||
}
|
}
|
||||||
@ -88,12 +112,23 @@ struct RealNameAuthView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 构造证件图片预览区域。
|
/// 构造证件图片预览区域。
|
||||||
private func identityImageSection(title: String, url: String) -> some View {
|
private func identityImageSection(
|
||||||
|
title: String,
|
||||||
|
side: RealNameImageSide,
|
||||||
|
pickedItem: Binding<PhotosPickerItem?>,
|
||||||
|
pendingData: Data?,
|
||||||
|
remoteURL: String,
|
||||||
|
progress: Int?
|
||||||
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundStyle(Color(hex: 0x222222))
|
.foregroundStyle(Color(hex: 0x222222))
|
||||||
imagePreview(url: url)
|
PhotosPicker(selection: pickedItem, matching: .images) {
|
||||||
|
imagePreview(side: side, pendingData: pendingData, remoteURL: remoteURL, progress: progress)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(viewModel.submitting || viewModel.info?.verified == true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,21 +272,27 @@ struct RealNameAuthView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 构造证件图片预览。
|
/// 构造证件图片预览。
|
||||||
private func imagePreview(url: String) -> some View {
|
private func imagePreview(side: RealNameImageSide, pendingData: Data?, remoteURL: String, progress: Int?) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if let imageURL = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)), !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let pendingData, let image = UIImage(data: pendingData) {
|
||||||
AsyncImage(url: imageURL) { phase in
|
Image(uiImage: image)
|
||||||
switch phase {
|
.resizable()
|
||||||
case .success(let image):
|
.scaledToFit()
|
||||||
image.resizable().scaledToFit()
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
case .empty:
|
|
||||||
ProgressView().tint(AppDesign.primary)
|
|
||||||
default:
|
|
||||||
placeholderImageContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
placeholderImageContent
|
RemoteImage(urlString: remoteURL, contentMode: .fit) {
|
||||||
|
placeholderImageContent(side: side)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let progress {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(.black.opacity(0.32))
|
||||||
|
Text("\(progress)%")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -264,12 +305,18 @@ struct RealNameAuthView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var placeholderImageContent: some View {
|
/// 构造证件图片空状态内容。
|
||||||
|
private func placeholderImageContent(side: RealNameImageSide) -> some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: 0xF3F6FA)
|
Color(hex: 0xF3F6FA)
|
||||||
Image(systemName: "photo")
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "photo.badge.plus")
|
||||||
.font(.system(size: 26, weight: .semibold))
|
.font(.system(size: 26, weight: .semibold))
|
||||||
.foregroundStyle(Color(hex: 0x8A94A6))
|
.foregroundStyle(Color(hex: 0x8A94A6))
|
||||||
|
Text(side == .front ? "选择身份证人像面" : "选择身份证国徽面")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Color(hex: 0x8A94A6))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,11 +333,6 @@ struct RealNameAuthView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构造 URL 输入行。
|
|
||||||
private func urlField(_ title: String, text: Binding<String>) -> some View {
|
|
||||||
formField(title, text: text, placeholder: "OSS 上传接入前可先填写图片 URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构造日期选择器。
|
/// 构造日期选择器。
|
||||||
private func dateField(selection: Binding<Date>) -> some View {
|
private func dateField(selection: Binding<Date>) -> some View {
|
||||||
DatePicker("", selection: selection, displayedComponents: .date)
|
DatePicker("", selection: selection, displayedComponents: .date)
|
||||||
@ -327,14 +369,6 @@ struct RealNameAuthView: View {
|
|||||||
Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 })
|
Binding(get: { viewModel.smsCode }, set: { viewModel.smsCode = $0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
private var frontURLBinding: Binding<String> {
|
|
||||||
Binding(get: { viewModel.frontUrl }, set: { viewModel.frontUrl = $0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
private var backURLBinding: Binding<String> {
|
|
||||||
Binding(get: { viewModel.backUrl }, set: { viewModel.backUrl = $0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
private var startDateBinding: Binding<Date> {
|
private var startDateBinding: Binding<Date> {
|
||||||
Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 })
|
Binding(get: { viewModel.startDate }, set: { viewModel.startDate = $0 })
|
||||||
}
|
}
|
||||||
@ -366,12 +400,38 @@ struct RealNameAuthView: View {
|
|||||||
/// 提交实名资料。
|
/// 提交实名资料。
|
||||||
private func submit() async {
|
private func submit() async {
|
||||||
do {
|
do {
|
||||||
try await viewModel.submit(api: profileAPI)
|
try await viewModel.submit(
|
||||||
|
api: profileAPI,
|
||||||
|
uploader: ossUploadService,
|
||||||
|
scenicId: accountContext.currentScenic?.id ?? 0
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
viewModel.statusMessage = error.localizedDescription
|
viewModel.statusMessage = error.localizedDescription
|
||||||
toastCenter.show(error.localizedDescription)
|
toastCenter.show(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理用户选择的证件图片,并生成待上传数据。
|
||||||
|
private func prepareIdentityImage(from item: PhotosPickerItem?, side: RealNameImageSide) async {
|
||||||
|
guard let item else { return }
|
||||||
|
defer {
|
||||||
|
switch side {
|
||||||
|
case .front:
|
||||||
|
pickedFrontItem = nil
|
||||||
|
case .back:
|
||||||
|
pickedBackItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
guard let data = try await item.loadTransferable(type: Data.self) else {
|
||||||
|
toastCenter.show("请选择有效的证件图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try viewModel.prepareIdentityImage(data: data, side: side)
|
||||||
|
} catch {
|
||||||
|
toastCenter.show(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension String {
|
private extension String {
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
// Created by Codex on 2026/6/22.
|
// Created by Codex on 2026/6/22.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
@ -170,6 +171,114 @@ final class ProfileSecondaryPagesTests: XCTestCase {
|
|||||||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 测试头像变更时先上传 OSS,再回写头像 URL。
|
||||||
|
func testProfileAvatarUploadUpdatesAvatarURL() async throws {
|
||||||
|
let session = ProfileSecondaryURLSession(responses: [
|
||||||
|
try TestFixture.data(named: "empty_success")
|
||||||
|
])
|
||||||
|
let api = ProfileAPI(client: APIClient(session: session))
|
||||||
|
let uploader = MockOSSUploader()
|
||||||
|
let viewModel = ProfileViewModel()
|
||||||
|
viewModel.userInfo = UserInfoResponse(avatar: "", nickname: "旧昵称")
|
||||||
|
viewModel.beginEditing()
|
||||||
|
try viewModel.prepareAvatarImage(data: makeJPEGData(), timestamp: 1_767_225_600)
|
||||||
|
|
||||||
|
try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10)
|
||||||
|
|
||||||
|
XCTAssertEqual(uploader.userAvatarUploads.count, 1)
|
||||||
|
XCTAssertEqual(uploader.userAvatarUploads.first?.scenicId, 10)
|
||||||
|
XCTAssertEqual(viewModel.userInfo?.avatar, uploader.avatarURL)
|
||||||
|
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update-avatar-url"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试头像未变更时不会调用 OSS 上传。
|
||||||
|
func testProfileNicknameOnlySaveDoesNotUploadAvatar() async throws {
|
||||||
|
let session = ProfileSecondaryURLSession(responses: [
|
||||||
|
try TestFixture.data(named: "empty_success")
|
||||||
|
])
|
||||||
|
let api = ProfileAPI(client: APIClient(session: session))
|
||||||
|
let uploader = MockOSSUploader()
|
||||||
|
let viewModel = ProfileViewModel()
|
||||||
|
viewModel.userInfo = UserInfoResponse(avatar: "https://cdn.example.com/old.jpg", nickname: "旧昵称")
|
||||||
|
viewModel.beginEditing()
|
||||||
|
viewModel.editingNickname = "新昵称"
|
||||||
|
|
||||||
|
try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10)
|
||||||
|
|
||||||
|
XCTAssertTrue(uploader.userAvatarUploads.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.userInfo?.nickname, "新昵称")
|
||||||
|
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试实名认证提交前会上传正反面图片并提交最终 URL。
|
||||||
|
func testRealNameSubmitUploadsImagesBeforeSubmitting() async throws {
|
||||||
|
let session = ProfileSecondaryURLSession(responses: [
|
||||||
|
try TestFixture.data(named: "empty_success"),
|
||||||
|
try TestFixture.data(named: "real_name_info_success")
|
||||||
|
])
|
||||||
|
let api = ProfileAPI(client: APIClient(session: session))
|
||||||
|
let uploader = MockOSSUploader()
|
||||||
|
let viewModel = RealNameAuthViewModel()
|
||||||
|
viewModel.realName = "测试摄影师"
|
||||||
|
viewModel.idCardNo = "11010519491231002X"
|
||||||
|
viewModel.smsCode = "123456"
|
||||||
|
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front, timestamp: 1_767_225_600)
|
||||||
|
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back, timestamp: 1_767_225_601)
|
||||||
|
|
||||||
|
try await viewModel.submit(api: api, uploader: uploader, scenicId: 10)
|
||||||
|
|
||||||
|
XCTAssertEqual(uploader.realNameUploads.map(\.fileName), ["real_name_front_1767225600.jpg", "real_name_back_1767225601.jpg"])
|
||||||
|
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||||
|
"/api/yf-handset-app/photog/real-name/submit",
|
||||||
|
"/api/yf-handset-app/photog/real-name/info"
|
||||||
|
])
|
||||||
|
let body = try XCTUnwrap(session.requests.first?.httpBody)
|
||||||
|
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||||
|
XCTAssertEqual(json["front_url"] as? String, uploader.realNameURLs[0])
|
||||||
|
XCTAssertEqual(json["back_url"] as? String, uploader.realNameURLs[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试实名认证缺少图片时阻止提交。
|
||||||
|
func testRealNameSubmitRequiresImages() async throws {
|
||||||
|
let api = ProfileAPI(client: APIClient(session: ProfileSecondaryURLSession(responses: [])))
|
||||||
|
let uploader = MockOSSUploader()
|
||||||
|
let viewModel = RealNameAuthViewModel()
|
||||||
|
viewModel.realName = "测试摄影师"
|
||||||
|
viewModel.idCardNo = "11010519491231002X"
|
||||||
|
viewModel.smsCode = "123456"
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await viewModel.submit(api: api, uploader: uploader, scenicId: 10)
|
||||||
|
XCTFail("缺少证件图片时应阻止提交")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(error as? RealNameValidationError, .message("请选择身份证人像面图片"))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(uploader.realNameUploads.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试实名认证图片上传失败时不会提交资料。
|
||||||
|
func testRealNameSubmitStopsWhenUploadFails() async throws {
|
||||||
|
let session = ProfileSecondaryURLSession(responses: [])
|
||||||
|
let api = ProfileAPI(client: APIClient(session: session))
|
||||||
|
let uploader = MockOSSUploader(error: OSSUploadError.unsupportedFileType)
|
||||||
|
let viewModel = RealNameAuthViewModel()
|
||||||
|
viewModel.realName = "测试摄影师"
|
||||||
|
viewModel.idCardNo = "11010519491231002X"
|
||||||
|
viewModel.smsCode = "123456"
|
||||||
|
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front)
|
||||||
|
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await viewModel.submit(api: api, uploader: uploader, scenicId: 10)
|
||||||
|
XCTFail("上传失败时应阻止提交")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(session.requests.isEmpty)
|
||||||
|
XCTAssertFalse(viewModel.submitting)
|
||||||
|
}
|
||||||
|
|
||||||
/// 测试首页系统设置 URI 已接入真实设置页。
|
/// 测试首页系统设置 URI 已接入真实设置页。
|
||||||
func testHomeSystemSettingsRouteResolvesToSettingsPage() {
|
func testHomeSystemSettingsRouteResolvesToSettingsPage() {
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
@ -199,3 +308,76 @@ private final class ProfileSecondaryURLSession: URLSessionProtocol {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 个人中心测试用 OSS 上传替身,记录上传请求并返回固定 URL。
|
||||||
|
private final class MockOSSUploader: OSSUploadServing {
|
||||||
|
struct UploadRecord: Equatable {
|
||||||
|
let fileName: String
|
||||||
|
let scenicId: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
let avatarURL = "https://cdn.example.com/avatar-new.jpg"
|
||||||
|
let realNameURLs = [
|
||||||
|
"https://cdn.example.com/front-new.jpg",
|
||||||
|
"https://cdn.example.com/back-new.jpg"
|
||||||
|
]
|
||||||
|
private let error: Error?
|
||||||
|
private(set) var userAvatarUploads: [UploadRecord] = []
|
||||||
|
private(set) var realNameUploads: [UploadRecord] = []
|
||||||
|
|
||||||
|
/// 初始化上传替身,可选指定要抛出的错误。
|
||||||
|
init(error: Error? = nil) {
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟上传用户头像。
|
||||||
|
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
if let error { throw error }
|
||||||
|
userAvatarUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId))
|
||||||
|
onProgress(100)
|
||||||
|
return avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟上传实名认证证件图片。
|
||||||
|
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
if let error { throw error }
|
||||||
|
realNameUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId))
|
||||||
|
onProgress(100)
|
||||||
|
return realNameURLs[min(realNameUploads.count - 1, realNameURLs.count - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟上传云盘文件。
|
||||||
|
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟上传相册文件。
|
||||||
|
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟上传任务附件。
|
||||||
|
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟上传打卡点图片。
|
||||||
|
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
avatarURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟上传景区申请图片。
|
||||||
|
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||||
|
avatarURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成测试用 JPEG 图片数据。
|
||||||
|
private func makeJPEGData() throws -> Data {
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 12))
|
||||||
|
let image = renderer.image { context in
|
||||||
|
UIColor.systemBlue.setFill()
|
||||||
|
context.fill(CGRect(x: 0, y: 0, width: 12, height: 12))
|
||||||
|
}
|
||||||
|
return try XCTUnwrap(image.jpegData(compressionQuality: 0.9))
|
||||||
|
}
|
||||||
|
|||||||
113
suixinkanTests/UploadTests.swift
Normal file
113
suixinkanTests/UploadTests.swift
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
//
|
||||||
|
// UploadTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 上传模块测试,覆盖 OSS 上传策略和 STS 配置接口。
|
||||||
|
final class UploadTests: XCTestCase {
|
||||||
|
/// 测试文件校验能识别空文件、超大文件和不支持扩展名。
|
||||||
|
func testOSSUploadPolicyValidatesFileConstraints() {
|
||||||
|
XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 0, fileName: "avatar.jpg")) { error in
|
||||||
|
XCTAssertEqual(error as? OSSUploadError, .emptyFile)
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: OSSUploadPolicy.maxFileSize + 1, fileName: "avatar.jpg")) { error in
|
||||||
|
XCTAssertEqual(error as? OSSUploadError, .fileTooLarge)
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.txt")) { error in
|
||||||
|
XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType)
|
||||||
|
}
|
||||||
|
XCTAssertNoThrow(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.heic"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 objectKey 按业务模块生成稳定路径并清洗文件名。
|
||||||
|
func testOSSUploadPolicyBuildsObjectKeys() {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_767_225_600)
|
||||||
|
let uuid = UUID(uuidString: "12345678-1234-1234-1234-1234567890ab")!
|
||||||
|
let timeZone = TimeZone(secondsFromGMT: 0)!
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
OSSUploadPolicy.objectKey(fileName: "a/b.jpg", scenicId: 9, moduleType: "user_avatar", date: date, uuid: uuid, timeZone: timeZone),
|
||||||
|
"avatar/20260101/9/123456781234123412341234567890AB_a_b.jpg"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
OSSUploadPolicy.objectKey(fileName: "id.jpg", scenicId: 9, moduleType: "real_name", date: date, uuid: uuid, timeZone: timeZone),
|
||||||
|
"real_name/20260101/9/123456781234123412341234567890AB_id.jpg"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone),
|
||||||
|
"task_upload/20260101/9/123456781234123412341234567890AB_video.mp4"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 MIME 类型推断和 URL 拼接规则。
|
||||||
|
func testOSSUploadPolicyContentTypeAndURLJoin() {
|
||||||
|
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.jpg", fileType: 2), "image/jpeg")
|
||||||
|
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.png", fileType: 2), "image/png")
|
||||||
|
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.mp4", fileType: 1), "video/mp4")
|
||||||
|
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 1), "video/mp4")
|
||||||
|
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 2), "image/jpeg")
|
||||||
|
XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg")
|
||||||
|
XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com/", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 STS token 接口使用正确路径、bucket 参数并兼容宽松字段类型。
|
||||||
|
func testUploadAPIRequestsSTSConfig() async throws {
|
||||||
|
let response = """
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"base_url": 123,
|
||||||
|
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
|
||||||
|
"region": "cn-hangzhou",
|
||||||
|
"bucket": "vipsky",
|
||||||
|
"expire_seconds": "900",
|
||||||
|
"credentials": {
|
||||||
|
"access_key_id": 456,
|
||||||
|
"access_key_secret": "secret",
|
||||||
|
"security_token": "token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let session = UploadURLSession(responses: [response])
|
||||||
|
let api = UploadAPI(client: APIClient(session: session))
|
||||||
|
|
||||||
|
let config = try await api.aliyunOSSBucket()
|
||||||
|
|
||||||
|
XCTAssertEqual(config.baseUrl, "123")
|
||||||
|
XCTAssertEqual(config.expireSeconds, 900)
|
||||||
|
XCTAssertEqual(config.credentials.accessKeyId, "456")
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "GET")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/app/config/get-sts-token")
|
||||||
|
XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "vipsky")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传测试用 URLSession 替身,按顺序返回预设响应。
|
||||||
|
private final class UploadURLSession: URLSessionProtocol {
|
||||||
|
private var responses: [Data]
|
||||||
|
private(set) var requests: [URLRequest] = []
|
||||||
|
|
||||||
|
/// 初始化顺序响应。
|
||||||
|
init(responses: [Data]) {
|
||||||
|
self.responses = responses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录请求并返回下一份响应。
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
requests.append(request)
|
||||||
|
let data = responses.isEmpty ? Data() : responses.removeFirst()
|
||||||
|
return (
|
||||||
|
data,
|
||||||
|
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
135
功能同步Checklist.md
Normal file
135
功能同步Checklist.md
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
# 功能同步 Checklist
|
||||||
|
|
||||||
|
更新时间:2026-06-22
|
||||||
|
|
||||||
|
## 状态说明
|
||||||
|
|
||||||
|
- 完成:UI、核心功能、文档和必要单元测试已同步,并通过编译验证。
|
||||||
|
- 部分完成:已有入口、壳、路由或部分主流程,但旧工程完整业务尚未迁移。
|
||||||
|
- 未开始:new 工程尚无对应真实业务页面或服务。
|
||||||
|
- 不迁移:已明确不纳入 iOS 迁移范围。
|
||||||
|
|
||||||
|
## 基础架构
|
||||||
|
|
||||||
|
| 状态 | 模块 | 同步内容 | 剩余事项 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab;主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 |
|
||||||
|
| 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tab;push 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 |
|
||||||
|
| 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 |
|
||||||
|
| 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 |
|
||||||
|
| 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 |
|
||||||
|
| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像和实名图片压缩处理。 | 后续云盘、相册、任务等模块迁移时复用。 |
|
||||||
|
| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 |
|
||||||
|
| 完成 | 设计常量 | `AppDesign`、`AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 |
|
||||||
|
| 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 |
|
||||||
|
|
||||||
|
## 登录与账号上下文
|
||||||
|
|
||||||
|
| 状态 | 模块 | 同步内容 | 剩余事项 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 完成 | 登录页 | 登录 UI、手机号/密码登录、协议勾选、上次手机号恢复。 | 后续如有验证码登录再迁移。 |
|
||||||
|
| 完成 | 多账号选择 | 登录后多账号选择、临时 token 内存保存、选择后写正式 token。 | 继续跟随后端账号结构变化维护。 |
|
||||||
|
| 完成 | 账号上下文 | 用户信息、角色权限、景区列表、门店列表、当前景区/门店选择和恢复。 | 后续业务模块按当前上下文取数。 |
|
||||||
|
| 完成 | 景点/打卡点上下文 | 当前景区下景点/打卡点懒加载,失败不影响登录态。 | 具体打卡点管理页面尚未迁移。 |
|
||||||
|
| 完成 | 权限菜单数据 | 递归解析角色权限、URI 去重、按旧工程顺序排序。 | 新 URI 出现时补路由映射或标记不支持。 |
|
||||||
|
|
||||||
|
## 首页
|
||||||
|
|
||||||
|
| 状态 | 模块 | 同步内容 | 剩余事项 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 完成 | 首页主 UI | 顶部景区、工作状态、位置上报卡、快捷操作、门店卡、常用应用网格。 | 视觉可随旧工程细节继续微调。 |
|
||||||
|
| 完成 | 首页权限菜单 | 从 `PermissionContext` 构建菜单,支持 URI 别名去重和旧工程排序。 | 未知 URI 继续通过诊断报告跟踪。 |
|
||||||
|
| 完成 | 常用应用 | 默认常用应用、添加/移除、UserDefaults 持久化、按权限过滤。 | 后续可加入拖拽排序。 |
|
||||||
|
| 完成 | 更多功能 | 全部功能页、常用应用增删、首页入口跳转。 | 未迁移功能仍进入占位页。 |
|
||||||
|
| 完成 | 首页路由壳 | 已迁移入口跳真实页面;订单/数据入口切换 Tab;未知入口安全占位。 | 按模块逐步替换占位页。 |
|
||||||
|
| 部分完成 | 景区选择入口 | 首页轻量景区选择,可调用 `AccountContext.selectScenic(id:)`。 | 如旧工程有完整筛选/搜索/权限细节,后续补齐。 |
|
||||||
|
|
||||||
|
## 订单 Tab
|
||||||
|
|
||||||
|
| 状态 | 模块 | 同步内容 | 剩余事项 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 完成 | 订单 Tab 根页面 | 订单管理/核销订单分段入口。 | 后续补完整详情流程。 |
|
||||||
|
| 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 订单详情仍为占位。 |
|
||||||
|
| 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 核销详情仍为占位。 |
|
||||||
|
| 部分完成 | 扫码核销 | 入口存在,进入安全占位。 | 迁移相机权限、扫码识别、核销闭环。 |
|
||||||
|
| 部分完成 | 订单详情 | 列表点击可进入占位详情。 | 迁移完整详情、退款、历史拍摄、任务上传、视频预告等长尾流程。 |
|
||||||
|
| 未开始 | 退款流程 | 暂未迁移。 | 需要同步旧工程退款 UI、接口和状态流。 |
|
||||||
|
| 未开始 | 历史拍摄 | 暂未迁移。 | 需要同步旧工程相关页面和接口。 |
|
||||||
|
|
||||||
|
## 数据 Tab
|
||||||
|
|
||||||
|
| 状态 | 模块 | 同步内容 | 剩余事项 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 完成 | 数据看板 | 今日、昨日、7 日、本月时间段切换;统计汇总卡;每日明细。 | 后续按旧工程继续补图表细节。 |
|
||||||
|
| 完成 | 数据分页 | 日数据分页加载、切换时间段重置分页、加载失败保留当前数据。 | 持续跟随后端字段变化。 |
|
||||||
|
| 完成 | 角色接口分流 | 摄影师/景区管理员两套统计接口。 | 其他角色如有特殊逻辑再补。 |
|
||||||
|
|
||||||
|
## 个人中心
|
||||||
|
|
||||||
|
| 状态 | 模块 | 同步内容 | 剩余事项 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 完成 | 个人信息首页 | 头像、昵称、UID、姓名、手机号、账号状态、当前景区、退出登录。 | 后续可继续细化旧工程视觉。 |
|
||||||
|
| 完成 | 头像上传 | `PhotosPicker` 选图、压缩、OSS 上传、回写头像 URL、刷新账号快照。 | 后续可补裁剪体验。 |
|
||||||
|
| 完成 | 昵称编辑 | 昵称校验、保存、同步 `AccountContext`。 | 无。 |
|
||||||
|
| 完成 | 修改密码 | 密码弹窗、最小长度校验、接口提交。 | 无。 |
|
||||||
|
| 完成 | 账号切换 | 可切换账号列表、默认选中、set-user、刷新登录上下文。 | 无。 |
|
||||||
|
| 完成 | 实名认证 | 信息加载、短信验证码、身份证校验、证件图片选择、OSS 上传、提交审核。 | 后续可补示例图或拍照入口。 |
|
||||||
|
| 完成 | 系统设置 | 关于我们、版本号、下载链接、协议入口。 | 若旧工程有更多设置项需继续核对。 |
|
||||||
|
| 完成 | 协议页 | 关于我们、用户协议、隐私政策、钱包协议页面。 | 无。 |
|
||||||
|
|
||||||
|
## 首页子业务模块
|
||||||
|
|
||||||
|
| 状态 | 模块 | 当前情况 | 后续事项 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 部分完成 | 钱包 | 首页入口已识别,进入占位页。 | 迁移钱包首页、提现、流水、余额等流程。 |
|
||||||
|
| 部分完成 | 提现审核 | 首页入口已识别,进入占位页。 | 迁移审核列表、详情、操作接口。 |
|
||||||
|
| 部分完成 | 消息中心 | 首页入口已识别,进入占位页。 | 迁移消息列表、已读状态和详情。 |
|
||||||
|
| 部分完成 | 排队管理 | `/scenic-queue` / `queue_management` 已识别,进入占位页。 | 迁移队列列表、叫号、设置等流程。 |
|
||||||
|
| 部分完成 | 立即收款/收款码 | 入口已识别,进入占位页。 | 迁移收款码、收款记录、支付结果。 |
|
||||||
|
| 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 |
|
||||||
|
| 部分完成 | 任务管理 | 入口已识别,进入占位页。 | 迁移任务列表、筛选、详情、状态操作。 |
|
||||||
|
| 部分完成 | 发布任务 | 入口已识别,进入占位页。 | 迁移任务创建、选择订单、附件上传。 |
|
||||||
|
| 部分完成 | 日程管理 | 入口已识别,进入占位页。 | 迁移日程列表、日历和详情。 |
|
||||||
|
| 部分完成 | 打卡点管理 | 入口已识别,景点上下文已具备。 | 迁移打卡点列表、创建/编辑、图片上传。 |
|
||||||
|
| 部分完成 | 项目管理 | `pm` / `pm_manager` / `project_edit` 已识别,进入占位页。 | 迁移项目列表、详情、编辑、新建。 |
|
||||||
|
| 部分完成 | 相册云盘 | 入口已识别,OSS 上传服务已具备。 | 迁移云盘列表、文件上传、传输管理。 |
|
||||||
|
| 部分完成 | 相册管理 | 入口已识别,进入占位页。 | 迁移相册文件夹、相册内容、预览上传。 |
|
||||||
|
| 部分完成 | 素材管理 | 入口已识别,OSS 上传服务已具备。 | 迁移素材列表、上传素材。 |
|
||||||
|
| 部分完成 | 样片管理 | 入口已识别,OSS 上传服务已具备。 | 迁移样片列表、上传样片。 |
|
||||||
|
| 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 |
|
||||||
|
| 部分完成 | 景区申请 | 入口已识别,OSS 上传服务已具备。 | 迁移申请表单、图片上传、提交状态。 |
|
||||||
|
| 部分完成 | 权限申请 | 入口已识别,进入占位页。 | 迁移权限申请、申请状态。 |
|
||||||
|
| 部分完成 | 景区结算 | 入口已识别,进入占位页。 | 迁移结算列表、审核、详情。 |
|
||||||
|
| 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 |
|
||||||
|
| 部分完成 | 位置上报 | 首页已有位置上报卡;具体页面入口进入占位。 | 迁移上报提交、历史记录、定位权限。 |
|
||||||
|
| 部分完成 | 注册邀请 | 入口已识别,进入占位页。 | 迁移邀请摄影师、邀请记录。 |
|
||||||
|
| 部分完成 | 飞手认证 | 入口已识别,进入占位页。 | 迁移飞手认证页面。 |
|
||||||
|
| 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 |
|
||||||
|
|
||||||
|
## 旧工程能力尚未系统迁移
|
||||||
|
|
||||||
|
| 状态 | 模块 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 未开始 | Assets 模块 | 旧工程 `Feature/Assets` 下相册内容、素材内容尚未完整迁移。 |
|
||||||
|
| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 |
|
||||||
|
| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 |
|
||||||
|
| 未开始 | Core/Queue | 旧工程队列基础能力尚未迁移。 |
|
||||||
|
| 未开始 | Core/Validation | 旧工程独立校验能力尚未系统迁移;当前只迁移了实名身份证校验等必要逻辑。 |
|
||||||
|
| 未开始 | UIKit 旧代码 | 旧工程 UIKit 目录下的兼容/旧版页面尚未迁移,除非后续明确仍需保留。 |
|
||||||
|
|
||||||
|
## 最近验证记录
|
||||||
|
|
||||||
|
已通过:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild test -project suixinkan.xcodeproj -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17'
|
||||||
|
xcodebuild build -project suixinkan.xcodeproj -scheme suixinkan -destination 'generic/platform=iOS Simulator'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一批建议迁移顺序
|
||||||
|
|
||||||
|
1. 订单详情与扫码核销:订单 Tab 已具备入口和列表,继续补闭环收益最大。
|
||||||
|
2. 任务管理/发布任务:已具备 OSS 上传服务,可复用当前上传层。
|
||||||
|
3. 相册云盘/素材管理:上传能力已准备好,适合接着迁移文件类模块。
|
||||||
|
4. 钱包/提现/结算:涉及资金流,建议单独一轮迁移并补足单元测试。
|
||||||
|
5. 打卡点管理/位置上报:依赖当前景区和景点上下文,适合在账号上下文稳定后推进。
|
||||||
Reference in New Issue
Block a user